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

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