- 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.
320 lines
12 KiB
Go
320 lines
12 KiB
Go
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"
|
|
)
|
|
|
|
// The elements of the SEO field types and widgets (assets/src/elements).
|
|
const (
|
|
ElementText = "golem15-seo-text"
|
|
ElementRobots = "golem15-seo-robots"
|
|
ElementSERP = "golem15-seo-serp"
|
|
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.
|
|
var (
|
|
elementScripts = []string{"assets/dist/seo.js"}
|
|
elementStyles = []string{"assets/dist/seo.css"}
|
|
)
|
|
|
|
// errRobotsInvalid is the refusal of a malformed robots value.
|
|
const errRobotsInvalid = "golem15.seo::lang.robots.invalid"
|
|
|
|
// errRobotsTooLarge refuses a robots.txt over MaxRobotsBytes.
|
|
const errRobotsTooLarge = "golem15.seo::lang.robots.too_large"
|
|
|
|
// FieldTypes returns the SEO admin field types:
|
|
// - seotext and mlseotext (E1): text with a live length counter; option
|
|
// measure: title (input) or description (textarea);
|
|
// - seorobots and mlseorobots (E2): the robots directives editor; option
|
|
// scope: record, rule or type; Decode refuses a malformed value;
|
|
// - seoimage (E7): an uploaded social image stored in the media bucket,
|
|
// its URL as the value (D-100); uploads need permission;
|
|
// - robotstxt (E4): the robots.txt editor with live lint; Decode refuses
|
|
// a file over 500 KB (D-22).
|
|
//
|
|
// app is asked for the booted application at request time.
|
|
func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldType {
|
|
text := func(name string, translatable bool) pact.FormFieldType {
|
|
return pact.FormFieldType{
|
|
Name: name, Element: ElementText, Translatable: translatable,
|
|
Scripts: elementScripts, Styles: elementStyles,
|
|
Options: []string{"measure"}, CheckOptions: checkMeasure, Decode: decodeText,
|
|
}
|
|
}
|
|
robots := func(name string, translatable bool) pact.FormFieldType {
|
|
return pact.FormFieldType{
|
|
Name: name, Element: ElementRobots, Translatable: translatable,
|
|
Scripts: elementScripts, Styles: elementStyles,
|
|
Options: []string{"scope"}, CheckOptions: checkScope, Decode: decodeRobots,
|
|
}
|
|
}
|
|
return []pact.FormFieldType{
|
|
text("seotext", false),
|
|
text("mlseotext", true),
|
|
robots("seorobots", false),
|
|
robots("mlseorobots", true),
|
|
{
|
|
Name: "seoimage", Element: ElementImage,
|
|
Scripts: elementScripts, Styles: elementStyles,
|
|
Decode: decodeImage, Upload: UploadImage(app, permission),
|
|
},
|
|
{
|
|
Name: "robotstxt", Element: ElementRobotsTxt,
|
|
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"}}
|
|
}
|
|
}
|
|
|
|
// decodeRobotsTxt stores a robots.txt with Unix line ends and refuses one
|
|
// over MaxRobotsBytes (robots.too_large); lint findings are warnings in
|
|
// the element and never block a save.
|
|
func decodeRobotsTxt(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
|
s, ok := stringValue(in.Value)
|
|
if !ok {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}}
|
|
}
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
if len(s) > MaxRobotsBytes {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsTooLarge}}
|
|
}
|
|
return pact.FormFieldDecoded{Value: s}, nil
|
|
}
|
|
|
|
func checkMeasure(options map[string]any) error {
|
|
if raw, ok := options["measure"]; ok {
|
|
if s, _ := raw.(string); s != "title" && s != "description" {
|
|
return fmt.Errorf("measure must be title or description, got %v", raw)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkScope(options map[string]any) error {
|
|
if raw, ok := options["scope"]; ok {
|
|
if s, _ := raw.(string); s != "record" && s != "rule" && s != "type" {
|
|
return fmt.Errorf("scope must be record, rule or type, got %v", raw)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stringValue reads a submitted value as text; nil is "".
|
|
func stringValue(v any) (string, bool) {
|
|
switch s := v.(type) {
|
|
case nil:
|
|
return "", true
|
|
case string:
|
|
return s, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func decodeText(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
|
s, ok := stringValue(in.Value)
|
|
if !ok {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}}
|
|
}
|
|
return pact.FormFieldDecoded{Value: strings.TrimSpace(s)}, nil
|
|
}
|
|
|
|
// decodeRobots stores a robots value in the fixed order; a malformed value
|
|
// (reachable only through MCP or an import) is refused.
|
|
func decodeRobots(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
|
s, ok := stringValue(in.Value)
|
|
if !ok {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsInvalid}}
|
|
}
|
|
normal, err := NormalizeRobots(s)
|
|
if err != nil {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsInvalid}}
|
|
}
|
|
return pact.FormFieldDecoded{Value: normal}, nil
|
|
}
|
|
|
|
// decodeImage accepts an empty value, a path or an http(s) URL.
|
|
func decodeImage(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
|
s, ok := stringValue(in.Value)
|
|
s = strings.TrimSpace(s)
|
|
if !ok || !ValidImageURL(s) {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errImageURL}}
|
|
}
|
|
return pact.FormFieldDecoded{Value: s}, nil
|
|
}
|
|
|
|
// ValidURLRef reports whether v is a path ("/x", not "//x" or "/\x",
|
|
// which browsers read as another host) or an absolute http(s) URL with a
|
|
// host, without spaces or control characters.
|
|
func ValidURLRef(v string) bool {
|
|
if v == "" || strings.ContainsFunc(v, func(r rune) bool { return r <= ' ' || r == 0x7f }) {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(v, "/") {
|
|
return !strings.HasPrefix(v, "//") && !strings.HasPrefix(v, `/\`)
|
|
}
|
|
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"}
|
|
}
|