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:
285
classes/schema/custom.go
Normal file
285
classes/schema/custom.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user