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

285
classes/schema/custom.go Normal file
View File

@@ -0,0 +1,285 @@
package schema
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
)
// The caps of custom JSON-LD (UI-SPEC C17, T-33-24).
const (
MaxCustomBytes = 16 * 1024
MaxCustomDepth = 8
)
// The codes of a refused value; each is a phrase key under schema.
const (
CodeInvalidJSON = "invalid_json"
CodeNotObject = "not_object"
CodeTooLarge = "too_large"
CodeTooDeep = "too_deep"
CodeScriptTag = "script_tag"
CodeTypeUnknown = "type_unknown"
CodeFieldInvalid = "field_invalid"
)
// Error is a refused structured data value: a code, and for invalid JSON
// the parser's message with its line and column; for a field the field
// name.
type Error struct {
Code string
Detail string
Field string
}
func (e *Error) Error() string {
if e.Detail != "" {
return "schema: " + e.Code + ": " + e.Detail
}
return "schema: " + e.Code
}
// Value is the stored seo_schema value (UI-SPEC C17): the editor's type
// ("" for the kind's default), the Service fields and the custom JSON-LD.
type Value struct {
Type string `json:"type"`
Fields ServiceFields `json:"fields"`
Custom string `json:"custom"`
}
// ParseValue reads a stored or submitted value; "" is the zero value.
func ParseValue(raw string) (Value, error) {
var v Value
if strings.TrimSpace(raw) == "" {
return v, nil
}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return Value{}, &Error{Code: CodeInvalidJSON, Detail: err.Error()}
}
return v, nil
}
// scriptTag finds a literal <script in any letter case.
var scriptTag = regexp.MustCompile(`(?i)<\s*/?\s*script`)
// currencyPattern is an ISO 4217 code.
var currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
// Normalize checks a submitted value for kind and returns it in its
// stored form: the type checked against the kind's list, Service fields
// kept only for Service (price and currency checked), the custom JSON-LD
// decoded with the C17 caps and re-serialised, never echoed as typed. The
// empty value stays "".
func Normalize(kind, variant, raw string) (string, Value, error) {
v, err := ParseValue(raw)
if err != nil {
return "", Value{}, err
}
v.Type = strings.TrimSpace(v.Type)
if !Allowed(kind, v.Type) {
return "", Value{}, &Error{Code: CodeTypeUnknown}
}
if EffectiveType(kind, variant, v.Type) == TypeService {
f := v.Fields
f.ServiceType, f.AreaServed = strings.TrimSpace(f.ServiceType), strings.TrimSpace(f.AreaServed)
f.Price, f.Currency = strings.TrimSpace(f.Price), strings.ToUpper(strings.TrimSpace(f.Currency))
if f.Price != "" {
if NormalizePrice(f.Price) == "" {
return "", Value{}, &Error{Code: CodeFieldInvalid, Field: "price"}
}
f.Price = NormalizePrice(f.Price)
}
if f.Currency != "" && !currencyPattern.MatchString(f.Currency) {
return "", Value{}, &Error{Code: CodeFieldInvalid, Field: "currency"}
}
v.Fields = f
} else {
v.Fields = ServiceFields{}
}
v.Custom = strings.TrimSpace(v.Custom)
if v.Custom != "" {
nodes, err := DecodeCustom(v.Custom)
if err != nil {
return "", Value{}, err
}
var out any = nodes
if len(nodes) == 1 {
out = nodes[0]
}
pretty, err := json.MarshalIndent(out, "", " ")
if err != nil {
return "", Value{}, &Error{Code: CodeInvalidJSON, Detail: err.Error()}
}
v.Custom = string(pretty)
}
if v.Type == "" && v.Custom == "" && v.Fields == (ServiceFields{}) {
return "", v, nil
}
stored, err := json.Marshal(v)
if err != nil {
return "", Value{}, err
}
return string(stored), v, nil
}
// position turns a byte offset of text into its line and column.
func position(text string, offset int64) (int, int) {
if offset > int64(len(text)) {
offset = int64(len(text))
}
line, col := 1, 1
for _, r := range text[:offset] {
if r == '\n' {
line, col = line+1, 1
} else {
col++
}
}
return line, col
}
// depth is the nesting depth of a decoded JSON value (an object or a list
// counts one level).
func depth(v any) int {
switch x := v.(type) {
case map[string]any:
d := 0
for _, c := range x {
d = max(d, depth(c))
}
return d + 1
case []any:
d := 0
for _, c := range x {
d = max(d, depth(c))
}
return d + 1
}
return 0
}
// DecodeCustom decodes custom JSON-LD (UI-SPEC C17): an object or a list
// of objects, at most 16 KB and 8 levels deep, without a literal <script.
// Invalid JSON is refused with the parser's line and column.
func DecodeCustom(text string) ([]map[string]any, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
if len(text) > MaxCustomBytes {
return nil, &Error{Code: CodeTooLarge}
}
if scriptTag.MatchString(text) {
return nil, &Error{Code: CodeScriptTag}
}
dec := json.NewDecoder(strings.NewReader(text))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
var syn *json.SyntaxError
offset := dec.InputOffset()
if errors.As(err, &syn) {
offset = syn.Offset
}
line, col := position(text, offset)
msg := err.Error()
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
msg = "unexpected end of JSON input"
}
return nil, &Error{Code: CodeInvalidJSON, Detail: fmt.Sprintf("line %d, column %d: %s", line, col, msg)}
}
if dec.More() {
line, col := position(text, dec.InputOffset())
return nil, &Error{Code: CodeInvalidJSON, Detail: fmt.Sprintf("line %d, column %d: unexpected data after the JSON value", line, col)}
}
if depth(v) > MaxCustomDepth {
return nil, &Error{Code: CodeTooDeep}
}
var out []map[string]any
switch x := v.(type) {
case map[string]any:
out = []map[string]any{x}
case []any:
if len(x) == 0 {
return nil, &Error{Code: CodeNotObject}
}
for _, item := range x {
m, ok := item.(map[string]any)
if !ok {
return nil, &Error{Code: CodeNotObject}
}
out = append(out, m)
}
default:
return nil, &Error{Code: CodeNotObject}
}
for _, n := range out {
delete(n, "@context")
}
return out, nil
}
// Merged is one generated node a custom node was merged into, with the
// generated properties the custom values replace.
type Merged struct {
ID string
Type string
Props []string
}
// Merge adds custom nodes to a graph (UI-SPEC C17): a node whose @id is a
// generated node's is merged into it, custom values first; any other node
// is appended. It answers the merges that replaced generated values.
func Merge(graph map[string]any, custom []map[string]any) (map[string]any, []Merged) {
nodes := Nodes(graph)
byID := map[string]map[string]any{}
for _, n := range nodes {
if id, _ := n["@id"].(string); id != "" {
byID[id] = n
}
}
var merged []Merged
for _, c := range custom {
id, _ := c["@id"].(string)
target, ok := byID[id]
if id == "" || !ok {
nodes = append(nodes, c)
if id != "" {
byID[id] = c
}
continue
}
var props []string
for k, v := range c {
if k == "@id" {
continue
}
if old, had := target[k]; had && !sameJSON(old, v) {
props = append(props, k)
}
target[k] = v
}
if len(props) > 0 {
sort.Strings(props)
merged = append(merged, Merged{ID: id, Type: NodeType(target), Props: props})
}
}
list := make([]any, len(nodes))
for i, n := range nodes {
list[i] = n
}
out := map[string]any{"@context": Context, "@graph": list}
return out, merged
}
// sameJSON reports whether two values encode alike.
func sameJSON(a, b any) bool {
x, err1 := json.Marshal(a)
y, err2 := json.Marshal(b)
return err1 == nil && err2 == nil && bytes.Equal(x, y)
}