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

@@ -160,3 +160,39 @@ func (s *Service) Trail(ctx context.Context, db *gorm.DB, settings models.Settin
}
return append(out, schema.Crumb{Name: name, URL: site + path, Path: path})
}
// ParentOption is one choice of the primary category field (P33-23).
type ParentOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ParentOptions lists a record's parents as primary category choices, in
// descriptor order, labelled with their title in locale (the default
// locale when "").
func (s *Service) ParentOptions(ctx context.Context, key, id, locale string) []ParentOption {
out := []ParentOption{}
reg := s.Registry()
if reg == nil || key == "" || id == "" {
return out
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return out
}
if locale == "" {
if db, err := s.DB(ctx); err == nil {
locale = s.DefaultLocale(ctx, db)
}
}
for _, ref := range rec.Parents {
label := ParentValue(ref)
if parent, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale()); err == nil && parent != nil {
if l := contentLocale(parent, locale); strings.TrimSpace(l.Title) != "" {
label = l.Title
}
}
out = append(out, ParentOption{Value: ParentValue(ref), Label: label})
}
return out
}

View File

@@ -22,6 +22,30 @@ var EntryColumns = []string{
// every locale row (Q-5): the social image.
const SharedColumn = "og_image"
// DataColumns are the SEO tab values stored in an entry's data (P33-23):
// the primary parent and the structured data value. Like the social image
// they are one per record, written on every locale row.
var DataColumns = []string{DataPrimaryParent, DataSchema}
// sharedColumns are every value shared by the locale rows of a record.
var sharedColumns = append([]string{SharedColumn}, DataColumns...)
// SharedData returns a record's shared data value: the first non-empty
// value in locale order.
func SharedData(entries map[string]models.Entry, key string) string {
locales := make([]string, 0, len(entries))
for locale := range entries {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
if v := entryData(entries[locale], key); v != "" {
return v
}
}
return ""
}
// SharedValue returns a record's shared social image: the first non-empty
// value in locale order.
func SharedValue(entries map[string]models.Entry) string {
@@ -61,6 +85,8 @@ func entryValue(e models.Entry, column string) string {
return e.OGDescription
case "og_image":
return e.OGImage
case DataPrimaryParent, DataSchema:
return entryData(e, column)
}
return ""
}
@@ -85,6 +111,17 @@ func setEntryValue(e *models.Entry, column, value string) {
e.OGDescription = value
case "og_image":
e.OGImage = value
case DataPrimaryParent, DataSchema:
data := models.JSONMap{}
for k, v := range e.Data {
data[k] = v
}
if value == "" {
delete(data, column)
} else {
data[column] = value
}
e.Data = data
}
}
@@ -152,7 +189,11 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
}
described := s.recordLocales(ctx, in.Type.Key, in.ID)
values := in.Values
if shared, ok := values[SharedColumn]; ok {
for _, column := range sharedColumns {
shared, ok := values[column]
if !ok {
continue
}
// One value for every locale of the record (Q-5).
v := ""
for _, text := range shared {
@@ -166,10 +207,10 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
all[locale] = v
}
copied := RecordValues{}
for column, perLocale := range values {
copied[column] = perLocale
for c, perLocale := range values {
copied[c] = perLocale
}
copied[SharedColumn] = all
copied[column] = all
values = copied
}
touched := map[string]models.Entry{}

View File

@@ -2,10 +2,13 @@ package classes
import (
"context"
"errors"
"fmt"
"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/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -18,6 +21,9 @@ const (
ElementImage = "golem15-seo-image"
ElementRobotsTxt = "golem15-seo-robotstxt"
ElementRobotsTester = "golem15-seo-robotstester"
ElementSchema = "golem15-seo-schema"
ElementGraph = "golem15-seo-graph"
ElementParent = "golem15-seo-parent"
)
// The element bundle every SEO field type loads.
@@ -73,6 +79,126 @@ func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldTyp
Scripts: elementScripts, Styles: elementStyles,
Decode: decodeRobotsTxt,
},
{
Name: "seoschema", Element: ElementSchema,
Scripts: elementScripts, Styles: elementStyles,
Options: []string{"kind", "contentType"}, CheckOptions: checkKind,
Decode: decodeSchema(app),
},
{
Name: "seoparent", Element: ElementParent,
Scripts: elementScripts, Styles: elementStyles,
Options: []string{"contentType"},
Decode: decodeParent(app),
},
}
}
// checkKind accepts the content kinds of pact.ContentDescriptor.
func checkKind(options map[string]any) error {
if raw, ok := options["kind"]; ok {
switch s, _ := raw.(string); s {
case "", schema.KindArticle, schema.KindCollection, schema.KindProfile, schema.KindPage:
default:
return fmt.Errorf("kind must be article, collection, profile or page, got %v", raw)
}
}
return nil
}
// optionString reads a string option.
func optionString(options map[string]any, key string) string {
s, _ := options[key].(string)
return s
}
// SchemaRefusal renders a refused seo_schema value with the schema.* copy
// in the request locale (UI-SPEC C17).
func (s *Service) SchemaRefusal(ctx context.Context, kind string, err error) string {
var se *schema.Error
if !errors.As(err, &se) {
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": err.Error()})
}
switch se.Code {
case schema.CodeInvalidJSON:
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": se.Detail})
case schema.CodeTypeUnknown:
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.type_unknown", map[string]string{"type": kind})
case schema.CodeFieldInvalid:
label, message := "golem15.seo::lang.schema.field_price", "golem15.seo::lang.schema.price_invalid"
if se.Field == "currency" {
label, message = "golem15.seo::lang.schema.field_currency", "golem15.seo::lang.schema.currency_invalid"
}
return s.PhraseCtx(ctx, "golem15.seo::lang.schema.field_invalid", map[string]string{
"field": s.PhraseCtx(ctx, label, nil),
"message": s.PhraseCtx(ctx, message, nil),
})
}
keys := map[string]string{
schema.CodeNotObject: "golem15.seo::lang.schema.not_object",
schema.CodeTooLarge: "golem15.seo::lang.schema.too_large",
schema.CodeTooDeep: "golem15.seo::lang.schema.too_deep",
schema.CodeScriptTag: "golem15.seo::lang.schema.script_tag",
}
return s.PhraseCtx(ctx, keys[se.Code], nil)
}
// SchemaVariant is the registry kind of a record of contentType, "" for
// other types.
func SchemaVariant(record any) string {
if p, ok := record.(*models.Page); ok && p != nil {
return p.Kind
}
return ""
}
// decodeSchema checks and normalises the seoschema value (E8): a JSON
// string or "" (UI-SPEC C17); every refusal answers the schema.* copy.
func decodeSchema(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
raw, ok := stringValue(in.Value)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.schema.not_object"}}
}
kind := optionString(in.Options, "kind")
stored, _, err := schema.Normalize(kind, SchemaVariant(in.Record), raw)
if err != nil {
svc, serr := NewService(app())
if serr != nil {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{err.Error()}}
}
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{svc.SchemaRefusal(ctx, kind, err)}}
}
return pact.FormFieldDecoded{Value: stored}, nil
}
}
// decodeParent accepts "" (the first parent) or one of the record's
// parents as <type>:<id>.
func decodeParent(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
raw, ok := stringValue(in.Value)
raw = strings.TrimSpace(raw)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}}
}
if raw == "" {
return pact.FormFieldDecoded{Value: ""}, nil
}
svc, err := NewService(app())
if err != nil {
return pact.FormFieldDecoded{}, err
}
id := ""
if in.Record != nil && in.DB != nil {
id, _ = RecordKey(in.DB, in.Record)
}
for _, opt := range svc.ParentOptions(ctx, optionString(in.Options, "contentType"), id, "") {
if opt.Value == raw {
return pact.FormFieldDecoded{Value: raw}, nil
}
}
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}}
}
}
@@ -165,3 +291,29 @@ func ValidURLRef(v string) bool {
u, err := url.Parse(v)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// SchemaProblem is a refused seo_schema value as an API problem.
func SchemaProblem(kind string, err error) models.Problem {
var se *schema.Error
if !errors.As(err, &se) {
return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": err.Error()}}
}
switch se.Code {
case schema.CodeInvalidJSON:
return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": se.Detail}}
case schema.CodeTypeUnknown:
return models.Problem{Key: "golem15.seo::lang.schema.type_unknown", Params: map[string]string{"type": kind}}
case schema.CodeFieldInvalid:
if se.Field == "currency" {
return models.Problem{Key: "golem15.seo::lang.schema.currency_invalid"}
}
return models.Problem{Key: "golem15.seo::lang.schema.price_invalid"}
case schema.CodeNotObject:
return models.Problem{Key: "golem15.seo::lang.schema.not_object"}
case schema.CodeTooLarge:
return models.Problem{Key: "golem15.seo::lang.schema.too_large"}
case schema.CodeTooDeep:
return models.Problem{Key: "golem15.seo::lang.schema.too_deep"}
}
return models.Problem{Key: "golem15.seo::lang.schema.script_tag"}
}

View File

@@ -342,6 +342,11 @@ func entryValues(e models.Entry) map[string]any {
for _, column := range EntryColumns {
out[column] = entryValue(e, column)
}
for _, column := range DataColumns {
if v := entryData(e, column); v != "" {
out[column] = v
}
}
return out
}

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}

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
}

View File

@@ -83,6 +83,8 @@ type Result struct {
// TypeKey and RecordID name the record behind the URL, "" when none.
TypeKey string `json:"-"`
RecordID string `json:"-"`
// Merged names the generated values custom JSON-LD replaced.
Merged []schema.Merged `json:"-"`
}
// SEO is the head metadata of a resolved page.
@@ -157,6 +159,9 @@ type Resolver struct {
// non-production lock (D-22) is not applied. The sitemap filter decides
// on these, so staging lists the same addresses as production.
production bool
// schema replaces the stored seo_schema value of the page when set
// (the graph preview of unsaved changes and the MCP graph tool).
schema *string
}
// Resolver returns the service's resolver.
@@ -799,6 +804,7 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S
if err := svc.structuredData(ctx, db, settings, &res, structuredIn{
key: key, id: res.RecordID, rec: rec, entry: entry, content: content,
locale: locale, path: path, url: self, home: home, siteName: svc.SiteName(ctx, settings, locale), seo: seo,
schemaValue: r.schema, sources: src,
}); err != nil {
return Result{}, err
}

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

View File

@@ -8,6 +8,7 @@ import (
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
@@ -111,6 +112,19 @@ type structuredIn struct {
home bool
siteName string
seo *SEO
// schemaValue replaces the stored seo_schema value when set (the
// admin preview of unsaved changes, the MCP graph tool).
schemaValue *string
// sources is the answer's value sources (the schema type's is set).
sources map[string]string
}
// schemaRaw is the seo_schema value a graph is built with.
func (in structuredIn) schemaRaw() string {
if in.schemaValue != nil {
return *in.schemaValue
}
return entryData(in.entry, DataSchema)
}
// graphInput composes the builder's input of a page.
@@ -139,6 +153,12 @@ func (s *Service) graphInput(ctx context.Context, db *gorm.DB, settings models.S
if in.seo != nil {
gi.Description = in.seo.Description
}
if value, err := schema.ParseValue(in.schemaRaw()); err == nil {
gi.Type, gi.Service = value.Type, value.Fields
if value.Type != "" && in.sources != nil {
in.sources["schema"] = SourceRecord
}
}
if in.key != "" {
if typ, ok := s.Registry().ContentType(in.key); ok && typ.Content != nil {
gi.Kind = typ.Content.Kind
@@ -214,14 +234,34 @@ func (s *Service) structuredData(ctx context.Context, db *gorm.DB, settings mode
name = res.SEO.Title
}
res.Breadcrumbs = s.Trail(ctx, db, settings, in.rec, entryData(in.entry, DataPrimaryParent), in.locale, in.path, name)
graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, res.Breadcrumbs))
graph, merged, err := s.buildGraph(ctx, db, settings, in, res.Breadcrumbs)
if err != nil {
return err
}
res.JSONLD = graph
res.JSONLD, res.Merged = graph, merged
return nil
}
// buildGraph builds a page's graph with its custom JSON-LD merged in
// (D-44); merged names the generated values the custom nodes replace. A
// stored custom value that no longer decodes is left out.
func (s *Service) buildGraph(ctx context.Context, db *gorm.DB, settings models.Settings, in structuredIn, crumbs []schema.Crumb) (map[string]any, []schema.Merged, error) {
graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, crumbs))
if err != nil {
return nil, nil, err
}
value, err := schema.ParseValue(in.schemaRaw())
if err != nil || value.Custom == "" {
return graph, nil, nil
}
custom, err := schema.DecodeCustom(value.Custom)
if err != nil || len(custom) == 0 {
return graph, nil, nil
}
graph, merged := schema.Merge(graph, custom)
return graph, merged, nil
}
// PublisherPreview is the view model of the settings' P8 partial: the
// generated publisher node as indented JSON and its findings as lines, or
// the all-good line.
@@ -268,3 +308,135 @@ func (s *Service) PublisherPreview(ctx context.Context) (PublisherPreview, error
}
return out, nil
}
// GraphAnswer is the structured data of one page (E9, the MCP graph tool):
// the locale, the page's absolute URL (nil when it has none yet), the
// graph and every node with its findings (D-46).
type GraphAnswer struct {
Locale string `json:"locale"`
URL *string `json:"url"`
Graph map[string]any `json:"graph"`
Nodes []schema.Report `json:"nodes"`
}
// graphAnswer turns a resolve answer into a graph answer.
func graphAnswer(res Result, locale string) GraphAnswer {
out := GraphAnswer{Locale: locale, Nodes: []schema.Report{}}
if res.Action != ActionOK || res.JSONLD == nil {
return out
}
u := res.URL
out.URL, out.Graph, out.Locale = &u, res.JSONLD, res.Locale
out.Nodes = schema.Validate(res.JSONLD)
return out
}
// GraphOfURL builds the graph of a public URL; schemaValue (nil: the
// stored one) replaces the page's seo_schema value. A URL that is not an
// ok page answers no graph.
func (s *Service) GraphOfURL(ctx context.Context, rawURL string, schemaValue *string) (GraphAnswer, Result, error) {
r := &Resolver{Service: s, schema: schemaValue}
res, err := r.Resolve(ctx, rawURL)
if err != nil {
return GraphAnswer{}, Result{}, err
}
return graphAnswer(res, res.Locale), res, nil
}
// GraphOfRecord builds the graph of a record in locale at its own
// address, published or not; schemaValue (nil: the stored one) replaces
// its seo_schema value. A record without an address in locale answers no
// URL and no graph.
func (s *Service) GraphOfRecord(ctx context.Context, key, id, locale string, schemaValue *string) (GraphAnswer, Result, error) {
r := &Resolver{Service: s, schema: schemaValue}
res, ok, err := r.ResolveRecord(ctx, key, id, locale)
if err != nil {
return GraphAnswer{}, Result{}, err
}
if !ok {
return GraphAnswer{Locale: locale, Nodes: []schema.Report{}}, res, nil
}
return graphAnswer(res, locale), res, nil
}
// GraphActionName is the SEO tab's graph preview widget action (E9).
const GraphActionName = "seo_graph"
// graphPayload is the E9 action payload.
type graphPayload struct {
Op string `json:"op"`
Locale string `json:"locale"`
}
// RecordGraphAction is the E9 widget action of content type key (UI-SPEC
// C18): op graph answers the graph of the saved record in locale with the
// unsaved seo_schema value of the form, and its findings.
func RecordGraphAction(svc func() *Service, key, label, permission string) pact.AdminAction {
return pact.AdminAction{
Name: GraphActionName,
Label: label,
Permissions: []string{permission},
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
s := svc()
if s == nil {
return pact.AdminActionResult{}, ErrNotBooted
}
var p graphPayload
if len(in.Payload) > 0 {
if err := json.Unmarshal(in.Payload, &p); err != nil || (p.Op != "" && p.Op != "graph") {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.api.value_invalid"}}}
}
}
db, err := s.DB(ctx)
if err != nil {
return pact.AdminActionResult{}, err
}
locale := p.Locale
if locale == "" {
locale = s.DefaultLocale(ctx, db)
}
var override *string
if raw, ok := in.Values[FieldPrefix+DataSchema]; ok {
text, _ := raw.(string)
override = &text
}
if in.RecordID == nil {
return pact.AdminActionResult{Data: GraphAnswer{Locale: locale, Nodes: []schema.Report{}}}, nil
}
answer, _, err := s.GraphOfRecord(ctx, key, strconv.FormatUint(*in.RecordID, 10), locale, override)
return pact.AdminActionResult{Data: answer}, err
},
}
}
// SchemaMerges are the schema.merged save notices of a record (UI-SPEC
// C17): one per generated node whose values its custom JSON-LD replaces,
// in the default locale.
func (s *Service) SchemaMerges(ctx context.Context, key, id string) ([]pact.FormFieldNotice, error) {
db, err := s.DB(ctx)
if err != nil {
return nil, err
}
_, res, err := s.GraphOfRecord(ctx, key, id, s.DefaultLocale(ctx, db), nil)
if err != nil {
return nil, err
}
var out []pact.FormFieldNotice
for _, m := range res.Merged {
out = append(out, pact.FormFieldNotice{Message: "golem15.seo::lang.schema.merged", Params: map[string]string{"props": strings.Join(m.Props, ", "), "node": m.Type}})
}
return out, nil
}
// CheckSchema refuses a structured data value the SEO tab of record id of
// content type key would refuse, as a *ProblemsError on schema.
func (s *Service) CheckSchema(ctx context.Context, kind, key, id, raw string) error {
db, err := s.DB(ctx)
if err != nil {
return err
}
if _, _, err := schema.Normalize(kind, pageVariant(db, key, id), raw); err != nil {
return &ProblemsError{Problems: map[string][]models.Problem{DataSchema: {SchemaProblem(kind, err)}}}
}
return nil
}

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

View File

@@ -3,6 +3,7 @@ package seo
import (
"context"
"fmt"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
@@ -34,7 +35,7 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
}
out = append(out, pact.AdminActionExtension{
Controller: typ.Content.Controller,
Form: p.seoTab(typ.Key, typ.Label),
Form: p.seoTab(typ.Key, typ.Content.Kind),
})
}
if form := p.typeSettingsTabs(types); form != nil {
@@ -205,16 +206,60 @@ fields:
tab: golem15.seo::lang.tab.seo
`
// seoTab is the form extension of content type key.
func (p *Plugin) seoTab(key, label string) *pact.AdminFormExtension {
// seoTabFor is the SEO tab's fields document of a content type (UI-SPEC
// C16): seoTabYAML with the primary category after seo_robots for article
// types (whose records have parents, P33-23), and the structured data
// field (E8) and its preview (E9) after seo_og_image.
func seoTabFor(key, kind string) string {
doc := seoTabYAML
quoted := strconv.Quote(key)
if kind == pact.ContentKindArticle {
doc = strings.Replace(doc, " seo_og_title:\n", ` seo_primary_parent:
label: golem15.seo::lang.field.primary_category
comment: golem15.seo::lang.field.primary_category_comment
type: seoparent
contentType: `+quoted+`
span: full
context: [create, update]
tab: golem15.seo::lang.tab.seo
seo_og_title:
`, 1)
}
return strings.Replace(doc, " seo_effective:\n", ` seo_schema:
label: golem15.seo::lang.field.schema
comment: golem15.seo::lang.field.schema_comment
type: seoschema
kind: `+strconv.Quote(kind)+`
contentType: `+quoted+`
span: full
context: [create, update]
tab: golem15.seo::lang.tab.seo
seo_graph:
label: golem15.seo::lang.field.graph
type: widget
widget: golem15-seo-graph
action: seo_graph
fill: [seo_schema]
span: full
context: update
tab: golem15.seo::lang.tab.seo
seo_effective:
`, 1)
}
// seoTab is the form extension of content type key of kind.
func (p *Plugin) seoTab(key, kind string) *pact.AdminFormExtension {
return &pact.AdminFormExtension{
FieldsYAML: []byte(seoTabYAML),
FieldsYAML: []byte(seoTabFor(key, kind)),
Permissions: []string{controllers.PermissionManage},
Visible: func(ctx context.Context) bool {
return p.injected(ctx, key)
},
Dir: "extension",
Actions: []pact.AdminAction{classes.RecordSnippetAction(p.Service, key, "golem15.seo::lang.snippet.action", controllers.PermissionManage)},
Dir: "extension",
Actions: []pact.AdminAction{
classes.RecordSnippetAction(p.Service, key, "golem15.seo::lang.snippet.action", controllers.PermissionManage),
classes.RecordGraphAction(p.Service, key, "golem15.seo::lang.graph.action", controllers.PermissionManage),
},
PartialData: func(ctx context.Context, name string, record any) (any, error) {
return p.seoTabPartial(ctx, key, name, record)
},
@@ -289,6 +334,13 @@ func (p *Plugin) loadSEOTab(ctx context.Context, key string, in pact.AdminFormLo
}
out[classes.FieldPrefix+column] = perLocale
}
out[classes.FieldPrefix+classes.DataSchema] = classes.SharedData(entries, classes.DataSchema)
if typ.Content.Kind == pact.ContentKindArticle {
out[classes.FieldPrefix+classes.DataPrimaryParent] = map[string]any{
"selected": classes.SharedData(entries, classes.DataPrimaryParent),
"options": svc.ParentOptions(ctx, key, in.ID, ""),
}
}
return out, nil
}
@@ -307,7 +359,7 @@ func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSa
}
values := classes.RecordValues{}
refusals := pact.AdminFormErrors{}
for _, column := range classes.EntryColumns {
for _, column := range append(append([]string{}, classes.EntryColumns...), classes.DataColumns...) {
raw, ok := in.Values[classes.FieldPrefix+column]
if !ok {
continue
@@ -349,7 +401,21 @@ func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSa
if _, _, err := svc.SaveRecord(ctx, in.DB, classes.RecordWrite{Type: typ, ID: in.ID, Values: values}); err != nil {
return pact.AdminFormSaveResult{}, err
}
return pact.AdminFormSaveResult{Notices: classes.AddressNotices(typ, in.Changes, moved, "seo_status")}, nil
notices := classes.AddressNotices(typ, in.Changes, moved, "seo_status")
if _, sent := values[classes.DataSchema]; sent {
merged, err := svc.SchemaMerges(ctx, key, in.ID)
if err != nil {
return pact.AdminFormSaveResult{}, err
}
if len(merged) > 0 {
if notices == nil {
notices = map[string][]pact.FormFieldNotice{}
}
field := classes.FieldPrefix + classes.DataSchema
notices[field] = append(notices[field], merged...)
}
}
return pact.AdminFormSaveResult{Notices: notices}, nil
}
// localeMap reads a submitted value as locale -> text: a translatable

View File

@@ -36,6 +36,12 @@ field:
og_title: Social title
og_description: Social description
og_image: Social image
primary_category: "Primary category (breadcrumb trail)"
primary_category_comment: "Sets the breadcrumb trail and the article section. Default: the first category."
primary_category_default: "Default (first category)"
schema: "Structured data"
schema_comment: "The schema.org type of this page in its JSON-LD graph. Shared by all languages."
graph: "Structured data preview"
robots:
llms_enabled: Serve llms.txt
llms_enabled_comment: Lists the site's indexable pages from the sitemaps at /llms.txt, for AI tools that read it.
@@ -73,6 +79,7 @@ status:
canonical_noindex: The canonical URL “:url” is set to noindex, so search engines may ignore both pages.
canonical_redirected: The canonical URL “:url” redirects to “:target”. Point the canonical to the final address.
staging: On this environment every page is served with noindex and robots.txt blocks everything (non-production lock).
primary_gone: "The primary category “:name” is no longer assigned, so the first category is used. Choose another one below."
settings:
placeholders: Placeholders
placeholders_code: Placeholder
@@ -317,6 +324,8 @@ effective:
source_content: Content
source_default: Site default
no_url: This language has no address yet. Fill in its slug and save.
schema: "Schema type"
trail: "Breadcrumb trail"
placeholder:
title: The content's title
description: The content's own description or excerpt
@@ -422,6 +431,7 @@ api:
change_not_found: The record this change names does not exist.
op_unknown: "Unknown operation “:op”."
conflict_noindex_published: "“:url” is a published page listed in the sitemaps; noindex removes it from search results."
record_only: "Only the SEO of a record has this value."
transfer:
title: Import and export
delimiter: CSV delimiter
@@ -497,3 +507,16 @@ graph:
recommended: "Missing recommended property “:property” (:type)"
no_rules: ":type has no rich-result rules at Google; it is checked as valid schema.org only."
all_good: "No problems: every node has its required and recommended properties."
schema:
invalid_json: "The custom JSON-LD is not valid JSON: :error"
not_object: "The custom JSON-LD must be an object or a list of objects."
too_large: "The custom JSON-LD may be at most 16 KB."
too_deep: "The custom JSON-LD is nested more than 8 levels deep."
script_tag: "Remove the script tag and paste only the JSON."
type_unknown: "This schema type is not available for :type."
field_invalid: ":field: :message"
field_price: "Price"
field_currency: "Currency"
price_invalid: "Enter a price such as 150 or 149.99."
currency_invalid: "Use a three-letter currency code, e.g. PLN."
merged: "Saved. Your custom JSON-LD replaces :props in the generated :node node."

View File

@@ -36,6 +36,12 @@ field:
og_title: Tytuł w mediach społecznościowych
og_description: Opis w mediach społecznościowych
og_image: Obraz w mediach społecznościowych
primary_category: "Kategoria główna (ścieżka nawigacji)"
primary_category_comment: "Ustala ścieżkę nawigacji i sekcję artykułu. Domyślnie: pierwsza kategoria."
primary_category_default: "Domyślna (pierwsza kategoria)"
schema: "Dane strukturalne"
schema_comment: "Typ schema.org tej strony w jej grafie JSON-LD. Wspólny dla wszystkich wersji językowych."
graph: "Podgląd danych strukturalnych"
robots:
llms_enabled: Udostępniaj llms.txt
llms_enabled_comment: Wypisuje indeksowalne strony witryny z map witryny pod /llms.txt, dla narzędzi AI, które go czytają.
@@ -75,6 +81,7 @@ status:
canonical_noindex: Adres kanoniczny „:url” ma ustawione noindex, więc wyszukiwarki mogą zignorować obie strony.
canonical_redirected: Adres kanoniczny „:url” przekierowuje na „:target”. Ustaw adres kanoniczny na adres docelowy.
staging: W tym środowisku każda strona ma noindex, a robots.txt blokuje wszystko (blokada środowiska nieprodukcyjnego).
primary_gone: "Kategoria główna „:name” nie jest już przypisana, więc używana jest pierwsza kategoria. Wybierz inną poniżej."
settings:
placeholders: Znaczniki
placeholders_code: Znacznik
@@ -323,6 +330,8 @@ effective:
source_content: Treść
source_default: Domyślne dla witryny
no_url: Ta wersja językowa nie ma jeszcze adresu. Uzupełnij jej slug i zapisz.
schema: "Typ schema"
trail: "Ścieżka nawigacji"
placeholder:
title: Tytuł treści
description: Własny opis lub zajawka treści
@@ -432,6 +441,7 @@ api:
change_not_found: Rekord wskazany w tej zmianie nie istnieje.
op_unknown: "Nieznana operacja „:op”."
conflict_noindex_published: "„:url” to opublikowana strona z map witryny; noindex usunie ją z wyników wyszukiwania."
record_only: "Tę wartość ma tylko SEO rekordu."
transfer:
title: Import i eksport
delimiter: Separator CSV
@@ -511,3 +521,16 @@ graph:
recommended: "Brak zalecanej właściwości „:property” (:type)"
no_rules: ":type nie ma reguł wyników z elementami rozszerzonymi w Google; sprawdzana jest tylko poprawność schema.org."
all_good: "Brak problemów: każdy węzeł ma wymagane i zalecane właściwości."
schema:
invalid_json: "Własny JSON-LD nie jest poprawnym JSON-em: :error"
not_object: "Własny JSON-LD musi być obiektem lub listą obiektów."
too_large: "Własny JSON-LD może mieć najwyżej 16 KB."
too_deep: "Własny JSON-LD jest zagnieżdżony głębiej niż 8 poziomów."
script_tag: "Usuń znacznik script i wklej sam JSON."
type_unknown: "Ten typ schema jest niedostępny dla: :type."
field_invalid: ":field: :message"
field_price: "Cena"
field_currency: "Waluta"
price_invalid: "Wpisz cenę, np. 150 lub 149,99."
currency_invalid: "Użyj trzyliterowego kodu waluty, np. PLN."
merged: "Zapisano. Twój JSON-LD zastępuje :props w wygenerowanym węźle :node."

11
mcp.go
View File

@@ -48,6 +48,17 @@ func (p *Plugin) MCP() pact.MCP {
Permissions: []string{mcpPermission},
},
{
Name: "get_graph",
Title: "Get the structured data of a URL",
Description: "Shows the JSON-LD @graph a page addressed by URL emits (the nodes the CMS generates, the custom JSON-LD merged in) and every node's missing required and recommended properties per Google's rules; rules false marks a type Google has no rich-result rules for. With schema, previews an unsaved structured data value. Fix through seo_set_url_seo (schema, primary_parent) or seo_update_settings (publisher, local business).",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_seo/api/v1/graph",
Input: apicontrollers.GetGraphInput{},
Permissions: []string{mcpPermission},
},
// URL rules (D-02).
{
Name: "list_rules",

View File

@@ -51,6 +51,7 @@ var AdminOperations = []AdminOperation{
{ContentController, OpShow, "get_url_seo"},
{ContentController, OpExtensionSave, "set_url_seo"},
{ContentController, OpWidgetAction + ":seo_snippet", "get_url_seo"},
{ContentController, OpWidgetAction + ":seo_graph", "get_graph"},
// The social image is set by URL through MCP (uploads are no tool).
{ContentController, OpUpload, "set_url_seo"},

View File

@@ -49,6 +49,7 @@ func (p *Plugin) Routes(r pact.Router) error {
api := "throttle:" + BucketAPI
g.Get("/seo", apicontrollers.GetSEO(p.Service), api)
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
g.Get("/graph", apicontrollers.Graph(p.Service), api)
g.Get("/rules", apicontrollers.ListRules(p.Service), api)
g.Post("/rules", apicontrollers.CreateRule(p.Service), api)
g.Get("/rules/{id}", apicontrollers.ShowRule(p.Service), api)

View File

@@ -370,7 +370,7 @@ func TestSEOTab(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_effective"
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_schema seo_graph seo_effective"
fields := env.formSchema(t, token, "acme/pages/pages")
var seoFields []string
for _, f := range fields {

View File

@@ -598,3 +598,264 @@ func TestPagedAlternates(t *testing.T) {
t.Fatalf("paged alternates = %s", got)
}
}
// schemaJSON encodes a seo_schema value.
func schemaJSON(t *testing.T, typ string, fields map[string]string, custom string) string {
t.Helper()
return asJSON(t, map[string]any{"type": typ, "fields": fields, "custom": custom})
}
// TestCustomJSONLD covers E8's server rules (UI-SPEC C17, D-44, T-33-24):
// every refusal answers 422 with its schema.* copy, a custom node with a
// generated @id merges into it with the schema.merged notice, a new node
// is appended, and the stored value is re-serialised.
func TestCustomJSONLD(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true})
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_schema")
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_graph")
form := adminAPI("/acme/pages/pages/" + id)
save := func(value string) *httptest.ResponseRecorder {
return env.call(t, http.MethodPut, form, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, "seo_schema": value})
}
deep := strings.Repeat(`{"a":`, 9) + "1" + strings.Repeat("}", 9)
for _, c := range []struct{ name, value, message string }{
{"invalid", schemaJSON(t, "", nil, `{"@type": "Thing",`), "The custom JSON-LD is not valid JSON: line 1, column"},
{"not an object", schemaJSON(t, "", nil, `42`), "must be an object or a list of objects"},
{"list of scalars", schemaJSON(t, "", nil, `[1, 2]`), "must be an object or a list of objects"},
{"too large", schemaJSON(t, "", nil, `{"description": "`+strings.Repeat("x", 17000)+`"}`), "may be at most 16 KB"},
{"too deep", schemaJSON(t, "", nil, deep), "nested more than 8 levels deep"},
{"script", schemaJSON(t, "", nil, `{"name": "</script><script>alert(1)</script>"}`), "Remove the script tag"},
{"type", schemaJSON(t, "BlogPosting", nil, ""), "This schema type is not available"},
{"price", schemaJSON(t, "Service", map[string]string{"price": "abc"}, ""), "Enter a price such as 150"},
{"currency", schemaJSON(t, "Service", map[string]string{"currency": "ZLOTY"}, ""), "three-letter currency code"},
} {
t.Run(c.name, func(t *testing.T) { refusal(t, save(c.value), "seo_schema", c.message) })
}
page := siteURL + "/pages/about"
custom := `[{"@id": "` + page + `#webpage", "name": "Custom name", "about": "a<b"}, {"@id": "` + page + `#faq", "@type": "FAQPage", "name": "FAQ"}]`
rec := save(schemaJSON(t, "AboutPage", nil, custom))
if rec.Code != http.StatusOK {
t.Fatalf("save = %d %s", rec.Code, rec.Body.String())
}
var merged string
for _, n := range saveNotices(t, rec.Body.Bytes()) {
if n.Field == "seo_schema" {
merged = n.Message
}
}
if merged != "Saved. Your custom JSON-LD replaces about, name in the generated AboutPage node." {
t.Fatalf("merge notice = %q (%s)", merged, rec.Body.String())
}
nodes := graphNodes(t, env.resolve(t, "/pages/about"))
if p := nodes["AboutPage"]; p == nil || p["name"] != "Custom name" || p["about"] != "a<b" || idOf(p["isPartOf"]) != siteURL+"/#website" {
t.Fatalf("merged page node = %v", nodes)
}
if faq := nodes["FAQPage"]; faq == nil || faq["@id"] != page+"#faq" {
t.Fatalf("appended node = %v", nodes)
}
var stored string
if err := env.db.Raw(`SELECT data->>'schema' FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'en'`, pagesModel, id).Scan(&stored).Error; err != nil {
t.Fatal(err)
}
if !strings.Contains(stored, `\u003c`) || strings.Contains(stored, "a<b") || !strings.Contains(stored, `"type":"AboutPage"`) {
t.Fatalf("stored value = %s, want re-serialised JSON with < escaped", stored)
}
var pl string
if err := env.db.Raw(`SELECT COALESCE(data->>'schema', '') FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'pl'`, pagesModel, id).Scan(&pl).Error; err != nil || pl != stored {
t.Fatalf("pl row schema = %q err=%v, want the shared value", pl, err)
}
// The SEO tab loads the stored value; an empty value resets it.
if show := env.expect(t, http.StatusOK, http.MethodGet, form, token, nil).Body.String(); !strings.Contains(show, `"seo_schema":"{`) {
t.Fatalf("show lacks seo_schema: %s", show)
}
env.expect(t, http.StatusOK, http.MethodPut, form, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, "seo_schema": ""})
if nodes := graphNodes(t, env.resolve(t, "/pages/about")); nodes["WebPage"] == nil || nodes["FAQPage"] != nil {
t.Fatalf("reset graph = %v", nodes)
}
}
// TestGraphPerKind covers the node of every kind (D-43, D-94): a
// collection, a profile, a registry offer with Service fields, a registry
// contact page, and the home page's SearchAction.
func TestGraphPerKind(t *testing.T) {
env := bootBlog(t, "SUMMER_GOLEM15__SEO__SEARCH_URL=/szukaj?q={search_term_string}")
token := env.admin(t, true)
nodes := graphNodes(t, env.resolve(t, "/blog/category/recipes"))
if p := nodes["CollectionPage"]; p == nil || idOf(p["mainEntity"]) != siteURL+"/blog/category/recipes#itemlist" {
t.Fatalf("collection = %v", nodes)
}
nodes = graphNodes(t, env.resolve(t, "/blog/author/jan"))
if p, person := nodes["ProfilePage"], nodes["Person"]; p == nil || person == nil || idOf(p["mainEntity"]) != siteURL+"/blog/author/jan#profile" || person["@id"] != siteURL+"/blog/author/jan#profile" || person["name"] != "Jan Kowalski" {
t.Fatalf("profile = %v", nodes)
}
api := "/_seo/api/v1/pages"
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/oferta", "title": "Warsztaty", "kind": "offer"})
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/kontakt", "title": "Kontakt", "kind": "page"})
set := func(url, value string) {
t.Helper()
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": url, "values": map[string]any{"schema": value}})
}
set("/oferta", schemaJSON(t, "", map[string]string{"serviceType": "Warsztaty grzybowe", "areaServed": "Warszawa, Kraków", "price": "149,99", "currency": "pln"}, ""))
nodes = graphNodes(t, env.resolve(t, "/oferta"))
svc := nodes["Service"]
if svc == nil || idOf(svc["provider"]) != siteURL+"/#organization" || asJSON(t, svc["areaServed"]) != `["Warszawa","Kraków"]` || svc["serviceType"] != "Warsztaty grzybowe" {
t.Fatalf("service = %v", nodes)
}
if got := asJSON(t, svc["offers"]); got != `{"@type":"Offer","price":"149.99","priceCurrency":"PLN","url":"https://www.example.test/oferta"}` {
t.Fatalf("offer = %s", got)
}
set("/kontakt", schemaJSON(t, "ContactPage", nil, ""))
nodes = graphNodes(t, env.resolve(t, "/kontakt"))
if p := nodes["ContactPage"]; p == nil || idOf(p["mainEntity"]) != siteURL+"/#organization" {
t.Fatalf("contact = %v", nodes)
}
// The API refuses what the SEO tab refuses; a rule-only URL takes no
// structured data.
rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/kontakt", "values": map[string]any{"schema": schemaJSON(t, "NewsArticle", nil, "")}})
refusal(t, rec, "schema", "This schema type is not available")
home := graphNodes(t, env.resolve(t, "/"))
action, _ := home["WebSite"]["potentialAction"].(map[string]any)
if action["@type"] != "SearchAction" || asJSON(t, action["target"]) != `{"@type":"EntryPoint","urlTemplate":"https://www.example.test/szukaj?q={search_term_string}"}` {
t.Fatalf("SearchAction = %v", home["WebSite"])
}
for _, n := range []string{"Product", "Offer"} {
if home[n] != nil {
t.Fatalf("e-commerce node %s", n)
}
}
}
// TestGraphTool covers GET /_seo/api/v1/graph (seo_get_graph, D-46,
// D-10, T-33-27): the graph and findings, the unsaved schema preview, the
// permission, and primary_parent through seo_set_url_seo and apply_batch.
func TestGraphTool(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
other := env.admin(t, false)
target := "/_seo/api/v1/graph?url=" + urlQueryEscape("/blog/first-post")
env.expect(t, http.StatusForbidden, http.MethodGet, target, other, nil)
env.expect(t, http.StatusUnauthorized, http.MethodGet, target, "", nil)
var answer struct {
Locale string `json:"locale"`
URL *string `json:"url"`
Graph map[string]any `json:"graph"`
Nodes []struct {
ID string `json:"id"`
Type string `json:"type"`
Rules bool `json:"rules"`
Findings []struct {
Level string `json:"level"`
Property string `json:"property"`
} `json:"findings"`
} `json:"nodes"`
}
body := env.expect(t, http.StatusOK, http.MethodGet, target, token, nil).Body.Bytes()
if err := json.Unmarshal(body, &answer); err != nil || answer.Locale != "en" || answer.URL == nil || *answer.URL != siteURL+"/blog/first-post" || answer.Graph["@context"] != "https://schema.org" {
t.Fatalf("graph answer = %s err=%v", body, err)
}
rules := map[string]bool{}
findings := map[string]string{}
for _, n := range answer.Nodes {
rules[n.Type] = n.Rules
for _, f := range n.Findings {
findings[n.Type+"."+f.Property] = f.Level
}
}
if !rules["BlogPosting"] || rules["WebPage"] || !rules["VideoObject"] || !rules["BreadcrumbList"] {
t.Fatalf("rules = %v", rules)
}
if findings["Organization.logo"] != "recommended" || len(findings) != 1 {
t.Fatalf("findings = %v (%s)", findings, body)
}
// The unsaved schema preview.
preview := target + "&schema=" + urlQueryEscape(schemaJSON(t, "NewsArticle", nil, ""))
if b := env.expect(t, http.StatusOK, http.MethodGet, preview, token, nil).Body.String(); !strings.Contains(b, `"type":"NewsArticle"`) {
t.Fatalf("preview = %s", b)
}
refusal(t, env.call(t, http.MethodGet, target+"&schema="+urlQueryEscape(schemaJSON(t, "ContactPage", nil, "")), token, nil), "schema", "This schema type is not available")
if b := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/graph?url=%2Fnowhere", token, nil).Body.String(); !jsonContains(b, `"url":null`) {
t.Fatalf("missing page = %s", b)
}
// The validator on its own: missing image, uploadDate and address.
resetBlog()
blogMu.Lock()
blogRecords[postTypeKey][0].PublishedAt = nil
blogRecords[postTypeKey][0].Locales[0].Images = nil
blogRecords[postTypeKey][0].Locales[0].Image = ""
blogMu.Unlock()
body = env.expect(t, http.StatusOK, http.MethodGet, target, token, nil).Body.Bytes()
_ = json.Unmarshal(body, &answer)
findings = map[string]string{}
for _, n := range answer.Nodes {
for _, f := range n.Findings {
findings[n.Type+"."+f.Property] = f.Level
}
}
if findings["BlogPosting.image"] != "recommended" || findings["VideoObject.uploadDate"] != "required" || findings["BlogPosting.datePublished"] != "recommended" {
t.Fatalf("findings = %v", findings)
}
resetBlog()
// primary_parent through seo_set_url_seo (every locale row) and
// apply_batch with dry_run.
rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:9"}})
refusal(t, rec, "primary_parent", "")
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:2"}})
var rows int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE model_type = ? AND record_id = '1' AND data->>'primary_parent' = 'acme-section:2'`, postModel).Scan(&rows).Error; err != nil || rows != 2 {
t.Fatalf("rows with primary_parent = %d err=%v, want both locales", rows, err)
}
if got := crumbNames(env.resolve(t, "/pl/blog/pierwszy-wpis")); got != "Strona główna > Pierwszy wpis" {
t.Fatalf("pl trail = %s (Mushrooms has no pl page)", got)
}
batch := map[string]any{"dry_run": true, "changes": []map[string]any{{"op": "set_seo", "url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:1"}}}}
env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", token, batch)
if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Mushrooms > First post" {
t.Fatalf("trail after a dry run = %s", got)
}
batch["dry_run"] = false
env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", token, batch)
if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Recipes > First post" {
t.Fatalf("trail after the batch = %s", got)
}
// P1: a stored primary parent the record no longer has.
blogMu.Lock()
blogRecords[postTypeKey][0].Parents = []pact.ContentRef{{Type: categoryTypeKey, ID: "2"}}
blogMu.Unlock()
defer resetBlog()
status, err := env.seoPlugin(t).Service().RecordStatus(t.Context(), postTypeKey, "1")
if err != nil {
t.Fatal(err)
}
found := false
for _, c := range status.Callouts {
found = found || strings.Contains(c.Title, "The primary category “Recipes” is no longer assigned")
}
if !found {
t.Fatalf("P1 callouts = %v", status.Callouts)
}
if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Mushrooms > First post" {
t.Fatalf("trail with a gone primary = %s", got)
}
eff, err := env.seoPlugin(t).Service().RecordEffective(t.Context(), postTypeKey, "1")
if err != nil {
t.Fatal(err)
}
rows2 := map[string]string{}
for _, r := range eff.Locales[0].Rows {
rows2[r.Property] = r.Value
}
if rows2["Schema type"] != "BlogPosting" || rows2["Breadcrumb trail"] != "Home › Mushrooms › First post" {
t.Fatalf("P2 rows = %v", rows2)
}
}