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

@@ -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"}
}