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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user