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