- golem15.seo.robots singleton (UI-SPEC C10): staging callout and the served file (P4); the plugin's own extension adds the editor, the tester and the AI bots tab, saved to the robots row with history - field type robotstxt (E4, live lint, size meter; Decode refuses over 500 KB) and widget golem15-seo-robotstester (E5) on robots_test, which tests the unsaved text and switches through grobotstxt - saving rules that block a noindex address answers robots.blocks_noindex
167 lines
5.7 KiB
Go
167 lines
5.7 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 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, "//")
|
|
}
|
|
u, err := url.Parse(v)
|
|
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
|
}
|