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

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