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
}