Files
sm-seo-plugin/classes/field_types.go
Jakub Zych 10ff628f6a feat: structured data, graph preview and primary category in the SEO tab
- E8 golem15-seo-schema: the type per content kind, the Service grid with
  price and currency checks, custom JSON-LD with a 300 ms parse status
- E9 golem15-seo-graph: nodes and findings from the seo_graph action, the
  JSON, the Rich Results Test link, 800 ms re-check on fill changes, the
  ML locale switch, one queued request, the failure line
- golem15-seo-parent: the primary category select over the record's
  parents; the seoparent Decode accepts the loaded value sent back
- the rebuilt element bundle and vitest smoke tests
2026-11-04 14:37:49 +01:00

325 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) {
value := in.Value
if m, isMap := value.(map[string]any); isMap {
// The loaded {selected, options} sent back unchanged.
value = m["selected"]
}
raw, ok := stringValue(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"}
}