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

48
controllers/api/graph.go Normal file
View File

@@ -0,0 +1,48 @@
package api
import (
"context"
"errors"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
)
// GetGraphInput is the query of GET /_seo/api/v1/graph.
type GetGraphInput struct {
URL string `json:"url" query:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
Schema *string `json:"schema,omitempty" query:"schema" jsonschema:"an unsaved structured data value (the seo_set_url_seo schema string) to preview instead of the stored one; omitted: the stored value"`
}
// Graph serves GET /_seo/api/v1/graph?url=&schema= (D-46, D-10): the
// JSON-LD @graph the resolve answer of the URL carries, built with the
// given unsaved schema value when sent, and every node with its missing
// required and recommended properties (rules false for a type Google has
// no rules for). It answers {locale, url, graph, nodes}; url and graph are
// null for an address that is not an ok page. 422 for a URL that is not a
// path or http(s) URL, or for a schema value the SEO tab would refuse.
func Graph(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
q := r.URL.Query()
var override *string
if q.Has("schema") {
v := q.Get("schema")
override = &v
}
answer, res, err := svc.GraphOfURL(ctx, q.Get("url"), override)
if errors.Is(err, classes.ErrBadURL) {
return 0, nil, Fail(http.StatusUnprocessableEntity, err.Error())
}
if err != nil {
return 0, nil, err
}
if override != nil && res.TypeKey != "" {
if typ, ok := svc.Registry().ContentType(res.TypeKey); ok && typ.Content != nil {
if err := svc.CheckSchema(ctx, typ.Content.Kind, res.TypeKey, res.RecordID, *override); err != nil {
return 0, nil, err
}
}
}
return http.StatusOK, answer, nil
})
}

View File

@@ -20,6 +20,8 @@ type SEOValues struct {
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image: a path or an absolute URL"`
PrimaryParent *string `json:"primary_parent,omitempty" jsonschema:"records only: the primary category of the breadcrumb trail and the article section, one of the record's parents as <type>:<id> (seo_get_url_seo and the graph tool name them); empty for the first parent. Shared by every locale"`
Schema *string `json:"schema,omitempty" jsonschema:"records only: the structured data as a JSON string {\"type\": schema type or empty for the default, \"fields\": {serviceType, areaServed, price, currency} for Service, \"custom\": custom JSON-LD text (an object or a list, at most 16 KB, no script tag; a node with a generated @id is merged into it)}; empty resets it. Shared by every locale"`
}
// Map returns the values that are set, keyed by column.
@@ -29,6 +31,7 @@ func (v SEOValues) Map() map[string]string {
"title": v.Title, "description": v.Description, "keyphrase": v.Keyphrase,
"canonical": v.Canonical, "robots": v.Robots, "og_title": v.OGTitle,
"og_description": v.OGDescription, "og_image": v.OGImage,
"primary_parent": v.PrimaryParent, "schema": v.Schema,
} {
if p != nil {
out[column] = *p