feat: SEO field types and the complete SEO tab
This commit is contained in:
139
classes/field_types.go
Normal file
139
classes/field_types.go
Normal file
@@ -0,0 +1,139 @@
|
||||
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 (assets/src/elements).
|
||||
const (
|
||||
ElementText = "golem15-seo-text"
|
||||
ElementRobots = "golem15-seo-robots"
|
||||
ElementSERP = "golem15-seo-serp"
|
||||
ElementImage = "golem15-seo-image"
|
||||
)
|
||||
|
||||
// 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"
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 != ""
|
||||
}
|
||||
Reference in New Issue
Block a user