feat: graph validation and custom JSON-LD

- seo_schema (seoschema, E8) with the C17 server rules: invalid JSON with
  line and column, non-objects, 16 KB, depth 8, a literal <script and a
  type outside the kind's list refused; the value re-serialised
- a custom node with a generated @id merges into it (schema.merged save
  notice), other nodes are appended to the graph
- seo_primary_parent (seoparent) for article types; both values stored
  in the entry data on every locale row
- the seo_graph widget action (E9 data), P1 primary_gone and the P2
  schema type and breadcrumb trail rows
- GET /_seo/api/v1/graph and MCP seo_get_graph; seo_set_url_seo and
  apply_batch accept primary_parent and schema

The graph tool lands with the validation: the operations parity test
binds the seo_graph widget action to its tool.
This commit is contained in:
Jakub Zych
2026-11-04 14:33:10 +01:00
parent 032d3dae45
commit 17da132b00
19 changed files with 1259 additions and 16 deletions

View File

@@ -160,3 +160,39 @@ func (s *Service) Trail(ctx context.Context, db *gorm.DB, settings models.Settin
}
return append(out, schema.Crumb{Name: name, URL: site + path, Path: path})
}
// ParentOption is one choice of the primary category field (P33-23).
type ParentOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ParentOptions lists a record's parents as primary category choices, in
// descriptor order, labelled with their title in locale (the default
// locale when "").
func (s *Service) ParentOptions(ctx context.Context, key, id, locale string) []ParentOption {
out := []ParentOption{}
reg := s.Registry()
if reg == nil || key == "" || id == "" {
return out
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return out
}
if locale == "" {
if db, err := s.DB(ctx); err == nil {
locale = s.DefaultLocale(ctx, db)
}
}
for _, ref := range rec.Parents {
label := ParentValue(ref)
if parent, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale()); err == nil && parent != nil {
if l := contentLocale(parent, locale); strings.TrimSpace(l.Title) != "" {
label = l.Title
}
}
out = append(out, ParentOption{Value: ParentValue(ref), Label: label})
}
return out
}

View File

@@ -22,6 +22,30 @@ var EntryColumns = []string{
// every locale row (Q-5): the social image.
const SharedColumn = "og_image"
// DataColumns are the SEO tab values stored in an entry's data (P33-23):
// the primary parent and the structured data value. Like the social image
// they are one per record, written on every locale row.
var DataColumns = []string{DataPrimaryParent, DataSchema}
// sharedColumns are every value shared by the locale rows of a record.
var sharedColumns = append([]string{SharedColumn}, DataColumns...)
// SharedData returns a record's shared data value: the first non-empty
// value in locale order.
func SharedData(entries map[string]models.Entry, key string) string {
locales := make([]string, 0, len(entries))
for locale := range entries {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
if v := entryData(entries[locale], key); v != "" {
return v
}
}
return ""
}
// SharedValue returns a record's shared social image: the first non-empty
// value in locale order.
func SharedValue(entries map[string]models.Entry) string {
@@ -61,6 +85,8 @@ func entryValue(e models.Entry, column string) string {
return e.OGDescription
case "og_image":
return e.OGImage
case DataPrimaryParent, DataSchema:
return entryData(e, column)
}
return ""
}
@@ -85,6 +111,17 @@ func setEntryValue(e *models.Entry, column, value string) {
e.OGDescription = value
case "og_image":
e.OGImage = value
case DataPrimaryParent, DataSchema:
data := models.JSONMap{}
for k, v := range e.Data {
data[k] = v
}
if value == "" {
delete(data, column)
} else {
data[column] = value
}
e.Data = data
}
}
@@ -152,7 +189,11 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
}
described := s.recordLocales(ctx, in.Type.Key, in.ID)
values := in.Values
if shared, ok := values[SharedColumn]; ok {
for _, column := range sharedColumns {
shared, ok := values[column]
if !ok {
continue
}
// One value for every locale of the record (Q-5).
v := ""
for _, text := range shared {
@@ -166,10 +207,10 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
all[locale] = v
}
copied := RecordValues{}
for column, perLocale := range values {
copied[column] = perLocale
for c, perLocale := range values {
copied[c] = perLocale
}
copied[SharedColumn] = all
copied[column] = all
values = copied
}
touched := map[string]models.Entry{}

View File

@@ -2,10 +2,13 @@ package classes
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -18,6 +21,9 @@ const (
ElementImage = "golem15-seo-image"
ElementRobotsTxt = "golem15-seo-robotstxt"
ElementRobotsTester = "golem15-seo-robotstester"
ElementSchema = "golem15-seo-schema"
ElementGraph = "golem15-seo-graph"
ElementParent = "golem15-seo-parent"
)
// The element bundle every SEO field type loads.
@@ -73,6 +79,126 @@ func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldTyp
Scripts: elementScripts, Styles: elementStyles,
Decode: decodeRobotsTxt,
},
{
Name: "seoschema", Element: ElementSchema,
Scripts: elementScripts, Styles: elementStyles,
Options: []string{"kind", "contentType"}, CheckOptions: checkKind,
Decode: decodeSchema(app),
},
{
Name: "seoparent", Element: ElementParent,
Scripts: elementScripts, Styles: elementStyles,
Options: []string{"contentType"},
Decode: decodeParent(app),
},
}
}
// checkKind accepts the content kinds of pact.ContentDescriptor.
func checkKind(options map[string]any) error {
if raw, ok := options["kind"]; ok {
switch s, _ := raw.(string); s {
case "", schema.KindArticle, schema.KindCollection, schema.KindProfile, schema.KindPage:
default:
return fmt.Errorf("kind must be article, collection, profile or page, got %v", raw)
}
}
return nil
}
// optionString reads a string option.
func optionString(options map[string]any, key string) string {
s, _ := options[key].(string)
return s
}
// SchemaRefusal renders a refused seo_schema value with the schema.* copy
// in the request locale (UI-SPEC C17).
func (s *Service) SchemaRefusal(ctx context.Context, kind string, err error) string {
var se *schema.Error
if !errors.As(err, &se) {
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": err.Error()})
}
switch se.Code {
case schema.CodeInvalidJSON:
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": se.Detail})
case schema.CodeTypeUnknown:
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.type_unknown", map[string]string{"type": kind})
case schema.CodeFieldInvalid:
label, message := "golem15.seo::lang.schema.field_price", "golem15.seo::lang.schema.price_invalid"
if se.Field == "currency" {
label, message = "golem15.seo::lang.schema.field_currency", "golem15.seo::lang.schema.currency_invalid"
}
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.field_invalid", map[string]string{
"field": s.PhraseCtx(ctx, label, nil),
"message": s.PhraseCtx(ctx, message, nil),
})
}
keys := map[string]string{
schema.CodeNotObject: "golem15.seo::lang.schema.not_object",
schema.CodeTooLarge: "golem15.seo::lang.schema.too_large",
schema.CodeTooDeep: "golem15.seo::lang.schema.too_deep",
schema.CodeScriptTag: "golem15.seo::lang.schema.script_tag",
}
return s.PhraseCtx(ctx, keys[se.Code], nil)
}
// SchemaVariant is the registry kind of a record of contentType, "" for
// other types.
func SchemaVariant(record any) string {
if p, ok := record.(*models.Page); ok && p != nil {
return p.Kind
}
return ""
}
// decodeSchema checks and normalises the seoschema value (E8): a JSON
// string or "" (UI-SPEC C17); every refusal answers the schema.* copy.
func decodeSchema(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
raw, ok := stringValue(in.Value)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.schema.not_object"}}
}
kind := optionString(in.Options, "kind")
stored, _, err := schema.Normalize(kind, SchemaVariant(in.Record), raw)
if err != nil {
svc, serr := NewService(app())
if serr != nil {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{err.Error()}}
}
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{svc.SchemaRefusal(ctx, kind, err)}}
}
return pact.FormFieldDecoded{Value: stored}, nil
}
}
// decodeParent accepts "" (the first parent) or one of the record's
// parents as <type>:<id>.
func decodeParent(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
raw, ok := stringValue(in.Value)
raw = strings.TrimSpace(raw)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}}
}
if raw == "" {
return pact.FormFieldDecoded{Value: ""}, nil
}
svc, err := NewService(app())
if err != nil {
return pact.FormFieldDecoded{}, err
}
id := ""
if in.Record != nil && in.DB != nil {
id, _ = RecordKey(in.DB, in.Record)
}
for _, opt := range svc.ParentOptions(ctx, optionString(in.Options, "contentType"), id, "") {
if opt.Value == raw {
return pact.FormFieldDecoded{Value: raw}, nil
}
}
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}}
}
}
@@ -165,3 +291,29 @@ func ValidURLRef(v string) bool {
u, err := url.Parse(v)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// SchemaProblem is a refused seo_schema value as an API problem.
func SchemaProblem(kind string, err error) models.Problem {
var se *schema.Error
if !errors.As(err, &se) {
return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": err.Error()}}
}
switch se.Code {
case schema.CodeInvalidJSON:
return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": se.Detail}}
case schema.CodeTypeUnknown:
return models.Problem{Key: "golem15.seo::lang.schema.type_unknown", Params: map[string]string{"type": kind}}
case schema.CodeFieldInvalid:
if se.Field == "currency" {
return models.Problem{Key: "golem15.seo::lang.schema.currency_invalid"}
}
return models.Problem{Key: "golem15.seo::lang.schema.price_invalid"}
case schema.CodeNotObject:
return models.Problem{Key: "golem15.seo::lang.schema.not_object"}
case schema.CodeTooLarge:
return models.Problem{Key: "golem15.seo::lang.schema.too_large"}
case schema.CodeTooDeep:
return models.Problem{Key: "golem15.seo::lang.schema.too_deep"}
}
return models.Problem{Key: "golem15.seo::lang.schema.script_tag"}
}

View File

@@ -342,6 +342,11 @@ func entryValues(e models.Entry) map[string]any {
for _, column := range EntryColumns {
out[column] = entryValue(e, column)
}
for _, column := range DataColumns {
if v := entryData(e, column); v != "" {
out[column] = v
}
}
return out
}

View File

@@ -5,6 +5,7 @@ import (
"net/url"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
@@ -126,6 +127,9 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U
for _, column := range EntryColumns {
known[column] = true
}
for _, column := range DataColumns {
known[column] = true
}
for column, raw := range in.Values {
v := strings.TrimSpace(raw)
switch {
@@ -148,6 +152,27 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U
case column == "og_image" && !ValidImageURL(v):
problems[column] = append(problems[column], models.Problem{Key: errImageURL})
continue
case (column == DataPrimaryParent || column == DataSchema) && owner.WrittenTo != WrittenToRecord:
if v != "" {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.record_only"})
}
continue
case column == DataPrimaryParent && v != "":
found := false
for _, opt := range s.ParentOptions(ctx, typ.Key, owner.ID, "") {
found = found || opt.Value == v
}
if !found {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
continue
}
case column == DataSchema:
stored, _, err := schema.Normalize(typ.Content.Kind, pageVariant(tx, typ.Key, owner.ID), v)
if err != nil {
problems[column] = append(problems[column], SchemaProblem(typ.Content.Kind, err))
continue
}
v = stored
}
values[column] = v
}
@@ -181,6 +206,9 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U
for _, column := range EntryColumns {
out[column] = EntryValue(e, column)
}
for _, column := range DataColumns {
out[column] = EntryValue(e, column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}
rule := models.Rule{URL: owner.URL, Match: models.MatchExact}

View File

@@ -7,6 +7,7 @@ import (
"reflect"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
"gorm.io/gorm"
@@ -104,7 +105,7 @@ func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow
"title": res.SEO.Title, "description": res.SEO.Description, "canonical": res.SEO.Canonical,
"robots": res.SEO.Robots, "og_title": res.SEO.OG.Title, "og_description": res.SEO.OG.Description, "og_image": res.SEO.OG.Image,
}
out := make([]EffectiveRow, 0, len(effectiveProperties))
out := make([]EffectiveRow, 0, len(effectiveProperties)+2)
for _, p := range effectiveProperties {
out = append(out, EffectiveRow{
Property: s.PhraseCtx(ctx, p.label, nil),
@@ -113,9 +114,47 @@ func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow
Mono: p.mono,
})
}
// Structured data (UI-SPEC C16): the page's schema type and its
// breadcrumb trail.
if res.JSONLD != nil {
typ, source := effectiveSchemaType(res), SourceDefault
if res.Sources["schema"] != "" {
source = res.Sources["schema"]
}
out = append(out, EffectiveRow{
Property: s.PhraseCtx(ctx, "golem15.seo::lang.effective.schema", nil),
Value: typ,
Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+source, nil),
})
names := make([]string, 0, len(res.Breadcrumbs))
for _, c := range res.Breadcrumbs {
names = append(names, c.Name)
}
out = append(out, EffectiveRow{
Property: s.PhraseCtx(ctx, "golem15.seo::lang.effective.trail", nil),
Value: strings.Join(names, " › "),
Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+SourceContent, nil),
})
}
return out
}
// effectiveSchemaType is the page's main schema type: its article or
// service node, else its page node.
func effectiveSchemaType(res Result) string {
page := ""
for _, n := range schema.Nodes(res.JSONLD) {
id, _ := n["@id"].(string)
switch id {
case res.URL + "#article", res.URL + "#service":
return schema.NodeType(n)
case res.URL + "#webpage":
page = schema.NodeType(n)
}
}
return page
}
// newEffectiveView starts a P2 view with its labels and the staging note.
func (s *Service) newEffectiveView(ctx context.Context) EffectiveView {
return EffectiveView{
@@ -205,6 +244,9 @@ func (s *Service) RecordStatus(ctx context.Context, key, id string) (StatusView,
}
}
}
if c, ok := s.primaryGoneCallout(ctx, db, key, id); ok {
view.Callouts = append(view.Callouts, c)
}
exclusions, err := s.RecordSitemapExclusions(ctx, key, id)
if err != nil {
return view, err
@@ -346,3 +388,42 @@ func RecordKey(db *gorm.DB, record any) (string, error) {
func reflectValue(record any) reflect.Value {
return reflect.Indirect(reflect.ValueOf(record))
}
// primaryGoneCallout warns that a record's stored primary category is no
// longer one of its parents, so the first one is used (UI-SPEC C16).
func (s *Service) primaryGoneCallout(ctx context.Context, db *gorm.DB, key, id string) (Callout, bool) {
reg := s.Registry()
if reg == nil {
return Callout{}, false
}
typ, ok := reg.ContentType(key)
if !ok || typ.Content == nil {
return Callout{}, false
}
entries, err := LoadEntries(db, typ.Content.Model, id)
if err != nil {
return Callout{}, false
}
stored := SharedData(entries, DataPrimaryParent)
if stored == "" {
return Callout{}, false
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return Callout{}, false
}
if _, _, gone := PrimaryParent(rec, stored); !gone {
return Callout{}, false
}
name := stored
if pt, pid, ok := strings.Cut(stored, ":"); ok {
if parent, err := reg.Describe(ctx, pt, pid, s.UnprefixedLocale()); err == nil && parent != nil {
if l := contentLocale(parent, s.DefaultLocale(ctx, db)); strings.TrimSpace(l.Title) != "" {
name = l.Title
}
}
}
return Callout{
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.primary_gone", map[string]string{"name": name}),
}, true
}

View File

@@ -83,6 +83,8 @@ type Result struct {
// TypeKey and RecordID name the record behind the URL, "" when none.
TypeKey string `json:"-"`
RecordID string `json:"-"`
// Merged names the generated values custom JSON-LD replaced.
Merged []schema.Merged `json:"-"`
}
// SEO is the head metadata of a resolved page.
@@ -157,6 +159,9 @@ type Resolver struct {
// non-production lock (D-22) is not applied. The sitemap filter decides
// on these, so staging lists the same addresses as production.
production bool
// schema replaces the stored seo_schema value of the page when set
// (the graph preview of unsaved changes and the MCP graph tool).
schema *string
}
// Resolver returns the service's resolver.
@@ -799,6 +804,7 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S
if err := svc.structuredData(ctx, db, settings, &res, structuredIn{
key: key, id: res.RecordID, rec: rec, entry: entry, content: content,
locale: locale, path: path, url: self, home: home, siteName: svc.SiteName(ctx, settings, locale), seo: seo,
schemaValue: r.schema, sources: src,
}); err != nil {
return Result{}, err
}

285
classes/schema/custom.go Normal file
View File

@@ -0,0 +1,285 @@
package schema
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
)
// The caps of custom JSON-LD (UI-SPEC C17, T-33-24).
const (
MaxCustomBytes = 16 * 1024
MaxCustomDepth = 8
)
// The codes of a refused value; each is a phrase key under schema.
const (
CodeInvalidJSON = "invalid_json"
CodeNotObject = "not_object"
CodeTooLarge = "too_large"
CodeTooDeep = "too_deep"
CodeScriptTag = "script_tag"
CodeTypeUnknown = "type_unknown"
CodeFieldInvalid = "field_invalid"
)
// Error is a refused structured data value: a code, and for invalid JSON
// the parser's message with its line and column; for a field the field
// name.
type Error struct {
Code string
Detail string
Field string
}
func (e *Error) Error() string {
if e.Detail != "" {
return "schema: " + e.Code + ": " + e.Detail
}
return "schema: " + e.Code
}
// Value is the stored seo_schema value (UI-SPEC C17): the editor's type
// ("" for the kind's default), the Service fields and the custom JSON-LD.
type Value struct {
Type string `json:"type"`
Fields ServiceFields `json:"fields"`
Custom string `json:"custom"`
}
// ParseValue reads a stored or submitted value; "" is the zero value.
func ParseValue(raw string) (Value, error) {
var v Value
if strings.TrimSpace(raw) == "" {
return v, nil
}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return Value{}, &Error{Code: CodeInvalidJSON, Detail: err.Error()}
}
return v, nil
}
// scriptTag finds a literal <script in any letter case.
var scriptTag = regexp.MustCompile(`(?i)<\s*/?\s*script`)
// currencyPattern is an ISO 4217 code.
var currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
// Normalize checks a submitted value for kind and returns it in its
// stored form: the type checked against the kind's list, Service fields
// kept only for Service (price and currency checked), the custom JSON-LD
// decoded with the C17 caps and re-serialised, never echoed as typed. The
// empty value stays "".
func Normalize(kind, variant, raw string) (string, Value, error) {
v, err := ParseValue(raw)
if err != nil {
return "", Value{}, err
}
v.Type = strings.TrimSpace(v.Type)
if !Allowed(kind, v.Type) {
return "", Value{}, &Error{Code: CodeTypeUnknown}
}
if EffectiveType(kind, variant, v.Type) == TypeService {
f := v.Fields
f.ServiceType, f.AreaServed = strings.TrimSpace(f.ServiceType), strings.TrimSpace(f.AreaServed)
f.Price, f.Currency = strings.TrimSpace(f.Price), strings.ToUpper(strings.TrimSpace(f.Currency))
if f.Price != "" {
if NormalizePrice(f.Price) == "" {
return "", Value{}, &Error{Code: CodeFieldInvalid, Field: "price"}
}
f.Price = NormalizePrice(f.Price)
}
if f.Currency != "" && !currencyPattern.MatchString(f.Currency) {
return "", Value{}, &Error{Code: CodeFieldInvalid, Field: "currency"}
}
v.Fields = f
} else {
v.Fields = ServiceFields{}
}
v.Custom = strings.TrimSpace(v.Custom)
if v.Custom != "" {
nodes, err := DecodeCustom(v.Custom)
if err != nil {
return "", Value{}, err
}
var out any = nodes
if len(nodes) == 1 {
out = nodes[0]
}
pretty, err := json.MarshalIndent(out, "", " ")
if err != nil {
return "", Value{}, &Error{Code: CodeInvalidJSON, Detail: err.Error()}
}
v.Custom = string(pretty)
}
if v.Type == "" && v.Custom == "" && v.Fields == (ServiceFields{}) {
return "", v, nil
}
stored, err := json.Marshal(v)
if err != nil {
return "", Value{}, err
}
return string(stored), v, nil
}
// position turns a byte offset of text into its line and column.
func position(text string, offset int64) (int, int) {
if offset > int64(len(text)) {
offset = int64(len(text))
}
line, col := 1, 1
for _, r := range text[:offset] {
if r == '\n' {
line, col = line+1, 1
} else {
col++
}
}
return line, col
}
// depth is the nesting depth of a decoded JSON value (an object or a list
// counts one level).
func depth(v any) int {
switch x := v.(type) {
case map[string]any:
d := 0
for _, c := range x {
d = max(d, depth(c))
}
return d + 1
case []any:
d := 0
for _, c := range x {
d = max(d, depth(c))
}
return d + 1
}
return 0
}
// DecodeCustom decodes custom JSON-LD (UI-SPEC C17): an object or a list
// of objects, at most 16 KB and 8 levels deep, without a literal <script.
// Invalid JSON is refused with the parser's line and column.
func DecodeCustom(text string) ([]map[string]any, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
if len(text) > MaxCustomBytes {
return nil, &Error{Code: CodeTooLarge}
}
if scriptTag.MatchString(text) {
return nil, &Error{Code: CodeScriptTag}
}
dec := json.NewDecoder(strings.NewReader(text))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
var syn *json.SyntaxError
offset := dec.InputOffset()
if errors.As(err, &syn) {
offset = syn.Offset
}
line, col := position(text, offset)
msg := err.Error()
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
msg = "unexpected end of JSON input"
}
return nil, &Error{Code: CodeInvalidJSON, Detail: fmt.Sprintf("line %d, column %d: %s", line, col, msg)}
}
if dec.More() {
line, col := position(text, dec.InputOffset())
return nil, &Error{Code: CodeInvalidJSON, Detail: fmt.Sprintf("line %d, column %d: unexpected data after the JSON value", line, col)}
}
if depth(v) > MaxCustomDepth {
return nil, &Error{Code: CodeTooDeep}
}
var out []map[string]any
switch x := v.(type) {
case map[string]any:
out = []map[string]any{x}
case []any:
if len(x) == 0 {
return nil, &Error{Code: CodeNotObject}
}
for _, item := range x {
m, ok := item.(map[string]any)
if !ok {
return nil, &Error{Code: CodeNotObject}
}
out = append(out, m)
}
default:
return nil, &Error{Code: CodeNotObject}
}
for _, n := range out {
delete(n, "@context")
}
return out, nil
}
// Merged is one generated node a custom node was merged into, with the
// generated properties the custom values replace.
type Merged struct {
ID string
Type string
Props []string
}
// Merge adds custom nodes to a graph (UI-SPEC C17): a node whose @id is a
// generated node's is merged into it, custom values first; any other node
// is appended. It answers the merges that replaced generated values.
func Merge(graph map[string]any, custom []map[string]any) (map[string]any, []Merged) {
nodes := Nodes(graph)
byID := map[string]map[string]any{}
for _, n := range nodes {
if id, _ := n["@id"].(string); id != "" {
byID[id] = n
}
}
var merged []Merged
for _, c := range custom {
id, _ := c["@id"].(string)
target, ok := byID[id]
if id == "" || !ok {
nodes = append(nodes, c)
if id != "" {
byID[id] = c
}
continue
}
var props []string
for k, v := range c {
if k == "@id" {
continue
}
if old, had := target[k]; had && !sameJSON(old, v) {
props = append(props, k)
}
target[k] = v
}
if len(props) > 0 {
sort.Strings(props)
merged = append(merged, Merged{ID: id, Type: NodeType(target), Props: props})
}
}
list := make([]any, len(nodes))
for i, n := range nodes {
list[i] = n
}
out := map[string]any{"@context": Context, "@graph": list}
return out, merged
}
// sameJSON reports whether two values encode alike.
func sameJSON(a, b any) bool {
x, err1 := json.Marshal(a)
y, err2 := json.Marshal(b)
return err1 == nil && err2 == nil && bytes.Equal(x, y)
}

View File

@@ -8,6 +8,7 @@ import (
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
@@ -111,6 +112,19 @@ type structuredIn struct {
home bool
siteName string
seo *SEO
// schemaValue replaces the stored seo_schema value when set (the
// admin preview of unsaved changes, the MCP graph tool).
schemaValue *string
// sources is the answer's value sources (the schema type's is set).
sources map[string]string
}
// schemaRaw is the seo_schema value a graph is built with.
func (in structuredIn) schemaRaw() string {
if in.schemaValue != nil {
return *in.schemaValue
}
return entryData(in.entry, DataSchema)
}
// graphInput composes the builder's input of a page.
@@ -139,6 +153,12 @@ func (s *Service) graphInput(ctx context.Context, db *gorm.DB, settings models.S
if in.seo != nil {
gi.Description = in.seo.Description
}
if value, err := schema.ParseValue(in.schemaRaw()); err == nil {
gi.Type, gi.Service = value.Type, value.Fields
if value.Type != "" && in.sources != nil {
in.sources["schema"] = SourceRecord
}
}
if in.key != "" {
if typ, ok := s.Registry().ContentType(in.key); ok && typ.Content != nil {
gi.Kind = typ.Content.Kind
@@ -214,14 +234,34 @@ func (s *Service) structuredData(ctx context.Context, db *gorm.DB, settings mode
name = res.SEO.Title
}
res.Breadcrumbs = s.Trail(ctx, db, settings, in.rec, entryData(in.entry, DataPrimaryParent), in.locale, in.path, name)
graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, res.Breadcrumbs))
graph, merged, err := s.buildGraph(ctx, db, settings, in, res.Breadcrumbs)
if err != nil {
return err
}
res.JSONLD = graph
res.JSONLD, res.Merged = graph, merged
return nil
}
// buildGraph builds a page's graph with its custom JSON-LD merged in
// (D-44); merged names the generated values the custom nodes replace. A
// stored custom value that no longer decodes is left out.
func (s *Service) buildGraph(ctx context.Context, db *gorm.DB, settings models.Settings, in structuredIn, crumbs []schema.Crumb) (map[string]any, []schema.Merged, error) {
graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, crumbs))
if err != nil {
return nil, nil, err
}
value, err := schema.ParseValue(in.schemaRaw())
if err != nil || value.Custom == "" {
return graph, nil, nil
}
custom, err := schema.DecodeCustom(value.Custom)
if err != nil || len(custom) == 0 {
return graph, nil, nil
}
graph, merged := schema.Merge(graph, custom)
return graph, merged, nil
}
// PublisherPreview is the view model of the settings' P8 partial: the
// generated publisher node as indented JSON and its findings as lines, or
// the all-good line.
@@ -268,3 +308,135 @@ func (s *Service) PublisherPreview(ctx context.Context) (PublisherPreview, error
}
return out, nil
}
// GraphAnswer is the structured data of one page (E9, the MCP graph tool):
// the locale, the page's absolute URL (nil when it has none yet), the
// graph and every node with its findings (D-46).
type GraphAnswer struct {
Locale string `json:"locale"`
URL *string `json:"url"`
Graph map[string]any `json:"graph"`
Nodes []schema.Report `json:"nodes"`
}
// graphAnswer turns a resolve answer into a graph answer.
func graphAnswer(res Result, locale string) GraphAnswer {
out := GraphAnswer{Locale: locale, Nodes: []schema.Report{}}
if res.Action != ActionOK || res.JSONLD == nil {
return out
}
u := res.URL
out.URL, out.Graph, out.Locale = &u, res.JSONLD, res.Locale
out.Nodes = schema.Validate(res.JSONLD)
return out
}
// GraphOfURL builds the graph of a public URL; schemaValue (nil: the
// stored one) replaces the page's seo_schema value. A URL that is not an
// ok page answers no graph.
func (s *Service) GraphOfURL(ctx context.Context, rawURL string, schemaValue *string) (GraphAnswer, Result, error) {
r := &Resolver{Service: s, schema: schemaValue}
res, err := r.Resolve(ctx, rawURL)
if err != nil {
return GraphAnswer{}, Result{}, err
}
return graphAnswer(res, res.Locale), res, nil
}
// GraphOfRecord builds the graph of a record in locale at its own
// address, published or not; schemaValue (nil: the stored one) replaces
// its seo_schema value. A record without an address in locale answers no
// URL and no graph.
func (s *Service) GraphOfRecord(ctx context.Context, key, id, locale string, schemaValue *string) (GraphAnswer, Result, error) {
r := &Resolver{Service: s, schema: schemaValue}
res, ok, err := r.ResolveRecord(ctx, key, id, locale)
if err != nil {
return GraphAnswer{}, Result{}, err
}
if !ok {
return GraphAnswer{Locale: locale, Nodes: []schema.Report{}}, res, nil
}
return graphAnswer(res, locale), res, nil
}
// GraphActionName is the SEO tab's graph preview widget action (E9).
const GraphActionName = "seo_graph"
// graphPayload is the E9 action payload.
type graphPayload struct {
Op string `json:"op"`
Locale string `json:"locale"`
}
// RecordGraphAction is the E9 widget action of content type key (UI-SPEC
// C18): op graph answers the graph of the saved record in locale with the
// unsaved seo_schema value of the form, and its findings.
func RecordGraphAction(svc func() *Service, key, label, permission string) pact.AdminAction {
return pact.AdminAction{
Name: GraphActionName,
Label: label,
Permissions: []string{permission},
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
s := svc()
if s == nil {
return pact.AdminActionResult{}, ErrNotBooted
}
var p graphPayload
if len(in.Payload) > 0 {
if err := json.Unmarshal(in.Payload, &p); err != nil || (p.Op != "" && p.Op != "graph") {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.api.value_invalid"}}}
}
}
db, err := s.DB(ctx)
if err != nil {
return pact.AdminActionResult{}, err
}
locale := p.Locale
if locale == "" {
locale = s.DefaultLocale(ctx, db)
}
var override *string
if raw, ok := in.Values[FieldPrefix+DataSchema]; ok {
text, _ := raw.(string)
override = &text
}
if in.RecordID == nil {
return pact.AdminActionResult{Data: GraphAnswer{Locale: locale, Nodes: []schema.Report{}}}, nil
}
answer, _, err := s.GraphOfRecord(ctx, key, strconv.FormatUint(*in.RecordID, 10), locale, override)
return pact.AdminActionResult{Data: answer}, err
},
}
}
// SchemaMerges are the schema.merged save notices of a record (UI-SPEC
// C17): one per generated node whose values its custom JSON-LD replaces,
// in the default locale.
func (s *Service) SchemaMerges(ctx context.Context, key, id string) ([]pact.FormFieldNotice, error) {
db, err := s.DB(ctx)
if err != nil {
return nil, err
}
_, res, err := s.GraphOfRecord(ctx, key, id, s.DefaultLocale(ctx, db), nil)
if err != nil {
return nil, err
}
var out []pact.FormFieldNotice
for _, m := range res.Merged {
out = append(out, pact.FormFieldNotice{Message: "golem15.seo::lang.schema.merged", Params: map[string]string{"props": strings.Join(m.Props, ", "), "node": m.Type}})
}
return out, nil
}
// CheckSchema refuses a structured data value the SEO tab of record id of
// content type key would refuse, as a *ProblemsError on schema.
func (s *Service) CheckSchema(ctx context.Context, kind, key, id, raw string) error {
db, err := s.DB(ctx)
if err != nil {
return err
}
if _, _, err := schema.Normalize(kind, pageVariant(db, key, id), raw); err != nil {
return &ProblemsError{Problems: map[string][]models.Problem{DataSchema: {SchemaProblem(kind, err)}}}
}
return nil
}