140 lines
4.5 KiB
Go
140 lines
4.5 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 (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 != ""
|
|
}
|