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

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