feat: robots.txt editor and tester screen

- 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
This commit is contained in:
2026-10-27 11:51:41 +01:00
parent 3dd936202f
commit 51413fbda4
21 changed files with 1336 additions and 26 deletions

View File

@@ -10,12 +10,14 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
)
// The elements of the SEO field types (assets/src/elements).
// 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"
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.
@@ -27,13 +29,18 @@ var (
// 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.
// 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 {
@@ -61,9 +68,29 @@ func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldTyp
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" {