feat: URL rules and the settings singleton

This commit is contained in:
2026-10-27 10:34:39 +01:00
parent fb9590f099
commit ce60c8a1ee
20 changed files with 1412 additions and 0 deletions

144
models/rule.go Normal file
View File

@@ -0,0 +1,144 @@
package models
import (
"strings"
"time"
"gorm.io/gorm"
)
// The match kinds of a URL rule (D-02).
const (
MatchExact = "exact"
MatchPattern = "pattern"
)
// The phrase keys of the rule refusals.
const (
errRuleURLInvalid = "golem15.seo::lang.rules.url_invalid"
errRuleURLTaken = "golem15.seo::lang.rules.url_taken"
)
// Rule is a per-URL SEO rule on golem15_seo_rules (D-02): the SEO of an
// address with or without a record behind it, which wins over the record's
// SEO tab. URL is a path with an optional query ("/", "/blog?tag=*"); Match
// is exact or pattern, where * matches any text. Empty values inherit.
type Rule struct {
ID uint `gorm:"column:id;primaryKey"`
URL string `gorm:"column:url"`
Match string `gorm:"column:match"`
Title string `gorm:"column:title"`
Description string `gorm:"column:description"`
Canonical string `gorm:"column:canonical"`
Robots string `gorm:"column:robots"`
OGTitle string `gorm:"column:og_title"`
OGDescription string `gorm:"column:og_description"`
OGImage string `gorm:"column:og_image"`
Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
// Overrides is the list column of the properties the rule sets, filled
// by the rules controller for the list only.
Overrides string `gorm:"-:all;column:overrides"`
}
func (Rule) TableName() string { return "golem15_seo_rules" }
// MorphName names a rule in history and attachments.
func (Rule) MorphName() string { return `Golem15\SEO\Models\Rule` }
// Fillable lists the form's columns.
func (Rule) Fillable() []string {
return []string{"url", "match", "title", "description", "canonical", "robots", "og_title", "og_description", "og_image", "note"}
}
// Rules requires the address; its form is checked in Problems.
func (Rule) Rules() map[string]string { return map[string]string{"url": "required"} }
// RuleProperties are the value columns of a rule with their phrase keys, in
// form order.
var RuleProperties = []struct{ Column, Label string }{
{"title", "golem15.seo::lang.field.title"},
{"description", "golem15.seo::lang.field.description"},
{"canonical", "golem15.seo::lang.field.canonical"},
{"robots", "golem15.seo::lang.field.robots"},
{"og_title", "golem15.seo::lang.field.og_title"},
{"og_description", "golem15.seo::lang.field.og_description"},
{"og_image", "golem15.seo::lang.field.og_image"},
}
// Value returns the rule's value of a property column.
func (r Rule) Value(column string) string {
switch column {
case "title":
return r.Title
case "description":
return r.Description
case "canonical":
return r.Canonical
case "robots":
return r.Robots
case "og_title":
return r.OGTitle
case "og_description":
return r.OGDescription
case "og_image":
return r.OGImage
}
return ""
}
// NormalizeMatch returns exact unless m is pattern.
func NormalizeMatch(m string) string {
if strings.TrimSpace(m) == MatchPattern {
return MatchPattern
}
return MatchExact
}
// Problems refuses an address that does not start with "/" or carries * in
// an exact rule, and an address and match another rule already has (db is
// the save transaction; nil skips the uniqueness check).
func (r Rule) Problems(db *gorm.DB) map[string][]Problem {
out := map[string][]Problem{}
url := strings.TrimSpace(r.URL)
match := NormalizeMatch(r.Match)
if !strings.HasPrefix(url, "/") || strings.HasPrefix(url, "//") || strings.ContainsAny(url, " \t\r\n") || (match == MatchExact && strings.Contains(url, "*")) {
out["url"] = []Problem{{Key: errRuleURLInvalid}}
return out
}
if db != nil {
var count int64
q := db.Model(&Rule{}).Where("url = ? AND match = ?", url, match)
if r.ID != 0 {
q = q.Where("id <> ?", r.ID)
}
if err := q.Count(&count).Error; err == nil && count > 0 {
out["url"] = []Problem{{Key: errRuleURLTaken}}
}
}
return out
}
func init() {
Register(Rule{})
}
// FilterMatch is the rules list's match filter scope.
const FilterMatch = "FilterMatch"
// FilterScopes lists the rules list's filter scopes.
func (Rule) FilterScopes() []string { return []string{FilterMatch} }
// FilterScope narrows the rules list to one match kind; an unknown value
// matches nothing.
func (Rule) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != FilterMatch {
return db
}
s, _ := value.(string)
if s != MatchExact && s != MatchPattern {
return db.Where("1 = 0")
}
return db.Where("golem15_seo_rules.match = ?", s)
}

24
models/rule/columns.yaml Normal file
View File

@@ -0,0 +1,24 @@
# URL rules list columns (UI-SPEC C7). overrides is filled by the
# controller's ListExtendRecords.
columns:
url:
label: golem15.seo::lang.rules.url
type: text
searchable: true
sortable: true
match:
label: golem15.seo::lang.rules.match
type: text
overrides:
label: golem15.seo::lang.rules.col_overrides
type: text
sortable: false
note:
label: golem15.seo::lang.rules.note
type: text
searchable: true
invisible: true
updated_at:
label: golem15.seo::lang.rules.col_updated
type: datetime
sortable: true

71
models/rule/fields.yaml Normal file
View File

@@ -0,0 +1,71 @@
# URL rule form (UI-SPEC C7). The SEO field types (seotext, seorobots,
# seoimage) and the preview partials come with the field elements.
fields:
url:
label: golem15.seo::lang.rules.url
comment: golem15.seo::lang.rules.url_comment_full
type: text
span: left
required: true
match:
label: golem15.seo::lang.rules.match
type: dropdown
span: right
default: exact
options:
exact: golem15.seo::lang.rules.match_exact
pattern: golem15.seo::lang.rules.match_pattern
title:
label: golem15.seo::lang.field.title
comment: golem15.seo::lang.field.title_comment_rule
type: text
span: full
description:
label: golem15.seo::lang.field.description
type: textarea
size: small
span: full
canonical:
label: golem15.seo::lang.field.canonical
comment: golem15.seo::lang.field.canonical_comment
type: text
span: left
robots:
label: golem15.seo::lang.field.robots
comment: golem15.seo::lang.field.robots_comment_rule
type: dropdown
span: right
options:
'': golem15.seo::lang.robots.inherit
'index,follow': 'index, follow'
'noindex,follow': 'noindex, follow'
'index,nofollow': 'index, nofollow'
'noindex,nofollow': 'noindex, nofollow'
og_title:
label: golem15.seo::lang.field.og_title
type: text
span: left
og_description:
label: golem15.seo::lang.field.og_description
type: textarea
size: small
span: full
og_image:
label: golem15.seo::lang.field.og_image
type: text
span: full
note:
label: golem15.seo::lang.rules.note
comment: golem15.seo::lang.rules.note_comment
type: textarea
size: tiny
span: full

211
models/settings.go Normal file
View File

@@ -0,0 +1,211 @@
package models
import (
"fmt"
"regexp"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// The query parameter kinds of the D-07 list.
const (
ParamContent = "content"
ParamFilter = "filter"
ParamTracking = "tracking"
)
// The address options of D-20b.
const (
HostKeep = "keep"
HostWWW = "www"
HostBare = "bare"
SlashKeep = "keep"
SlashAdd = "add"
SlashDrop = "remove"
)
// Default templates (D-03).
const (
DefaultTitleTemplate = "%title% | %site%"
DefaultHomeTemplate = "%site%"
)
// The phrase keys of the settings refusals.
const (
errVerifyFilenameInvalid = "golem15.seo::lang.settings.verify_filename_invalid"
errVerifyFilenameTaken = "golem15.seo::lang.settings.verify_filename_taken"
errTemplateUnknown = "golem15.seo::lang.settings.template_unknown"
)
// QueryParam is one row of the D-07 query-parameter list: a name (a
// trailing * matches a prefix), its kind, and an optional path pattern the
// row is limited to.
type QueryParam struct {
Name string `json:"name"`
Kind string `json:"kind"`
Path string `json:"path"`
}
// VerifyMeta is one extra verification meta tag (D-05).
type VerifyMeta struct {
Name string `json:"name"`
Content string `json:"content"`
}
// VerifyFile is one verification file served from the site root (D-05).
type VerifyFile struct {
Filename string `json:"filename"`
Body string `json:"body"`
}
// DefaultQueryParams is the seeded D-07 list.
func DefaultQueryParams() []QueryParam {
return []QueryParam{
{Name: "page", Kind: ParamContent},
{Name: "utm_*", Kind: ParamTracking},
{Name: "fbclid", Kind: ParamTracking},
{Name: "gclid", Kind: ParamTracking},
{Name: "msclkid", Kind: ParamTracking},
{Name: "sort", Kind: ParamFilter},
}
}
// Settings is the SEO settings singleton (row id 1) on golem15_seo_settings
// (D-03, D-05, D-07, D-20b, D-21, D-23). site_name and default_description
// are translatable; the other locales live with the translate plugin.
type Settings struct {
ID uint `gorm:"column:id;primaryKey"`
SiteName string `gorm:"column:site_name"`
TwitterSite string `gorm:"column:twitter_site"`
TitleTemplate string `gorm:"column:title_template"`
HomeTemplate string `gorm:"column:home_template"`
DefaultDescription string `gorm:"column:default_description"`
DefaultOGImage string `gorm:"column:default_og_image"`
QueryParams lagoon.Jsonable[[]QueryParam] `gorm:"column:query_params"`
NoindexPaths string `gorm:"column:noindex_paths"`
SearchPaths string `gorm:"column:search_paths"`
URLHost string `gorm:"column:url_host"`
URLSlash string `gorm:"column:url_slash"`
URLLowercase bool `gorm:"column:url_lowercase"`
VerifyGoogle string `gorm:"column:verify_google"`
VerifyBing string `gorm:"column:verify_bing"`
VerifyYandex string `gorm:"column:verify_yandex"`
VerifyOther lagoon.Jsonable[[]VerifyMeta] `gorm:"column:verify_other"`
VerifyFiles lagoon.Jsonable[[]VerifyFile] `gorm:"column:verify_files"`
CSVDelimiter string `gorm:"column:csv_delimiter"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
func (Settings) TableName() string { return "golem15_seo_settings" }
// MorphName names the settings row for translations.
func (Settings) MorphName() string { return `Golem15\SEO\Models\Settings` }
// Fillable lists the form's columns.
func (Settings) Fillable() []string {
return []string{
"site_name", "twitter_site", "title_template", "home_template",
"default_description", "default_og_image", "query_params",
"noindex_paths", "search_paths", "url_host", "url_slash",
"url_lowercase", "verify_google", "verify_bing", "verify_yandex",
"verify_other", "verify_files",
}
}
// Rules is empty: the settings controller checks Problems before a save.
func (Settings) Rules() map[string]string { return map[string]string{} }
// DefaultSettings is what the resolver reads when the row is missing.
func DefaultSettings() Settings {
return Settings{
ID: 1,
TitleTemplate: DefaultTitleTemplate,
HomeTemplate: DefaultHomeTemplate,
QueryParams: lagoon.Jsonable[[]QueryParam]{Data: DefaultQueryParams(), Valid: true},
URLHost: HostKeep,
URLSlash: SlashKeep,
}
}
// Params returns the query-parameter list.
func (s Settings) Params() []QueryParam { return s.QueryParams.Get() }
// OtherMeta returns the extra verification meta tags.
func (s Settings) OtherMeta() []VerifyMeta { return s.VerifyOther.Get() }
// Files returns the verification files.
func (s Settings) Files() []VerifyFile { return s.VerifyFiles.Get() }
// verifyFilename is the allowed form of a verification file name.
var verifyFilename = regexp.MustCompile(`^[A-Za-z0-9._-]+\.(html|txt|xml)$`)
// placeholderPattern finds %name% placeholders in a template.
var placeholderPattern = regexp.MustCompile(`%[A-Za-z0-9_]*%`)
// KnownPlaceholders are the placeholders a template may use.
var KnownPlaceholders = []string{"%title%", "%description%", "%site%"}
// UnknownPlaceholder returns the first placeholder of tpl that is not
// known, "" when every one is.
func UnknownPlaceholder(tpl string) string {
for _, found := range placeholderPattern.FindAllString(tpl, -1) {
known := false
for _, k := range KnownPlaceholders {
if found == k {
known = true
break
}
}
if !known {
return found
}
}
return ""
}
// Problem is one field refusal: a phrase key and its :name parameters.
// The admin controllers translate it in the request locale.
type Problem struct {
Key string
Params map[string]string
}
// TemplateProblems refuses a template with an unknown placeholder.
func TemplateProblems(tpl string) []Problem {
if p := UnknownPlaceholder(tpl); p != "" {
return []Problem{{Key: errTemplateUnknown, Params: map[string]string{"placeholder": p}}}
}
return nil
}
// Problems lists the settings refusals per field: templates with an unknown
// placeholder, and verification file names that are malformed or used
// twice (verify_files.<i>.filename).
func (s Settings) Problems() map[string][]Problem {
out := map[string][]Problem{}
for field, tpl := range map[string]string{"title_template": s.TitleTemplate, "home_template": s.HomeTemplate} {
if p := TemplateProblems(tpl); p != nil {
out[field] = p
}
}
seen := map[string]bool{}
for i, f := range s.Files() {
name := strings.TrimSpace(f.Filename)
key := fmt.Sprintf("verify_files.%d.filename", i)
switch {
case !verifyFilename.MatchString(name):
out[key] = []Problem{{Key: errVerifyFilenameInvalid}}
case seen[strings.ToLower(name)]:
out[key] = []Problem{{Key: errVerifyFilenameTaken}}
}
seen[strings.ToLower(name)] = true
}
return out
}
func init() {
Register(Settings{})
}

193
models/settings/fields.yaml Normal file
View File

@@ -0,0 +1,193 @@
# ===================================
# Form Field Definitions
# ===================================
#
# SEO settings (UI-SPEC C11): General, one tab per content type (added by
# the plugin's form extension after General), Indexing, Addresses and
# Verification.
fields:
_staging:
type: partial
path: staging
span: full
tab: golem15.seo::lang.settings.tab_general
site_name:
label: golem15.seo::lang.settings.site_name
comment: golem15.seo::lang.settings.site_name_comment
type: mltext
span: left
tab: golem15.seo::lang.settings.tab_general
twitter_site:
label: golem15.seo::lang.settings.twitter_site
comment: golem15.seo::lang.settings.twitter_site_comment
type: text
span: right
tab: golem15.seo::lang.settings.tab_general
title_template:
label: golem15.seo::lang.settings.title_template
comment: golem15.seo::lang.settings.title_template_comment
type: text
span: left
default: '%title% | %site%'
tab: golem15.seo::lang.settings.tab_general
home_template:
label: golem15.seo::lang.settings.home_template
type: text
span: right
default: '%site%'
tab: golem15.seo::lang.settings.tab_general
default_description:
label: golem15.seo::lang.settings.default_description
comment: golem15.seo::lang.settings.default_description_comment
type: mltextarea
size: small
span: full
tab: golem15.seo::lang.settings.tab_general
default_og_image:
label: golem15.seo::lang.settings.default_og_image
type: text
span: full
tab: golem15.seo::lang.settings.tab_general
query_params:
label: golem15.seo::lang.settings.query_params
comment: golem15.seo::lang.settings.query_params_comment
type: repeater
prompt: golem15.seo::lang.settings.param_add
titleFrom: name
span: full
tab: golem15.seo::lang.settings.tab_indexing
form:
fields:
name:
label: golem15.seo::lang.settings.param_name
type: text
span: left
kind:
label: golem15.seo::lang.settings.param_kind
type: dropdown
span: right
default: content
options:
content: golem15.seo::lang.settings.param_content
filter: golem15.seo::lang.settings.param_filter
tracking: golem15.seo::lang.settings.param_tracking
path:
label: golem15.seo::lang.settings.param_path
comment: golem15.seo::lang.settings.param_path_comment
type: text
span: full
noindex_paths:
label: golem15.seo::lang.settings.noindex_paths
comment: golem15.seo::lang.settings.noindex_paths_comment
type: textarea
size: small
span: left
tab: golem15.seo::lang.settings.tab_indexing
search_paths:
label: golem15.seo::lang.settings.search_paths
comment: golem15.seo::lang.settings.search_paths_comment
type: textarea
size: small
span: right
tab: golem15.seo::lang.settings.tab_indexing
url_host:
label: golem15.seo::lang.settings.url_host
comment: golem15.seo::lang.settings.url_fixed_comment
type: dropdown
span: left
default: keep
options:
keep: golem15.seo::lang.settings.url_host_keep
www: golem15.seo::lang.settings.url_host_www
bare: golem15.seo::lang.settings.url_host_bare
tab: golem15.seo::lang.settings.tab_urls
url_slash:
label: golem15.seo::lang.settings.url_slash
comment: golem15.seo::lang.settings.url_change_comment
type: dropdown
span: right
default: keep
options:
keep: golem15.seo::lang.settings.url_slash_keep
add: golem15.seo::lang.settings.url_slash_add
remove: golem15.seo::lang.settings.url_slash_remove
tab: golem15.seo::lang.settings.tab_urls
url_lowercase:
label: golem15.seo::lang.settings.url_lowercase
comment: golem15.seo::lang.settings.url_change_comment
type: switch
span: full
default: false
tab: golem15.seo::lang.settings.tab_urls
verify_google:
label: golem15.seo::lang.settings.verify_google
comment: golem15.seo::lang.settings.verify_code_comment
type: text
span: left
tab: golem15.seo::lang.settings.tab_verification
verify_bing:
label: golem15.seo::lang.settings.verify_bing
comment: golem15.seo::lang.settings.verify_code_comment
type: text
span: right
tab: golem15.seo::lang.settings.tab_verification
verify_yandex:
label: golem15.seo::lang.settings.verify_yandex
comment: golem15.seo::lang.settings.verify_code_comment
type: text
span: left
tab: golem15.seo::lang.settings.tab_verification
verify_other:
label: golem15.seo::lang.settings.verify_other
type: repeater
prompt: golem15.seo::lang.settings.verify_other_add
titleFrom: name
span: full
tab: golem15.seo::lang.settings.tab_verification
form:
fields:
name:
label: golem15.seo::lang.settings.verify_other_name
type: text
span: left
content:
label: golem15.seo::lang.settings.verify_other_content
type: text
span: right
verify_files:
label: golem15.seo::lang.settings.verify_files
comment: golem15.seo::lang.settings.verify_files_comment
type: repeater
prompt: golem15.seo::lang.settings.verify_files_add
titleFrom: filename
span: full
tab: golem15.seo::lang.settings.tab_verification
form:
fields:
filename:
label: golem15.seo::lang.settings.verify_files_filename
type: text
span: left
body:
label: golem15.seo::lang.settings.verify_files_body
type: textarea
size: tiny
span: right