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

View File

@@ -0,0 +1,98 @@
// Package controllers holds the SEO plugin's admin controllers.
package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// PermissionManage is the one SEO permission (D-15): the SEO tab, URL
// rules, settings and history.
const PermissionManage = "golem15.seo.manage"
// The admin controller IDs.
const (
SettingsControllerID = "golem15.seo.settings"
RulesControllerID = "golem15.seo.rules"
)
// AdminControllers is the plugin's admin controller list. app returns the
// booted application; it is asked on every call, so a controller never
// keeps a database handle of its own.
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
settingsAdminController{app: app},
rulesAdminController{app: app},
}
}
// appOf returns the booted application, nil before boot.
func appOf(app func() *backpack.App) *backpack.App {
if app == nil {
return nil
}
return app()
}
// translator returns the application's translator, nil when none.
func translator(app func() *backpack.App) *phrasebook.Translator {
a := appOf(app)
if a == nil {
return nil
}
tr, _ := a.Lookup[*phrasebook.Translator]()
return tr
}
// database returns the write transaction of ctx, else the app database.
func database(ctx context.Context, app func() *backpack.App) (*gorm.DB, error) {
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return tx, nil
}
a := appOf(app)
if a == nil {
return nil, errors.New("golem15.seo: app is not booted")
}
db, ok := a.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
return db.WithContext(ctx), nil
}
// Phrase translates key with params in the request locale; without a
// translator it returns the key.
func Phrase(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string {
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
// problemsError turns field problems into a 422 with the messages
// translated in the request locale; nil when there are none.
func problemsError(ctx context.Context, tr *phrasebook.Translator, problems map[string][]models.Problem) error {
if len(problems) == 0 {
return nil
}
details := make(map[string]any, len(problems))
for field, list := range problems {
msgs := make([]string, 0, len(list))
for _, p := range list {
if len(p.Params) == 0 {
msgs = append(msgs, p.Key)
continue
}
msgs = append(msgs, Phrase(ctx, tr, p.Key, p.Params))
}
details[field] = msgs
}
return &cabana.ValidationError{Details: details}
}

90
controllers/rules.go Normal file
View File

@@ -0,0 +1,90 @@
package controllers
import (
"context"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// rulesAdminController lists and edits URL rules (D-02, UI-SPEC C7).
type rulesAdminController struct {
app func() *backpack.App
}
func (rulesAdminController) ID() string { return RulesControllerID }
func (rulesAdminController) ModelName() string { return `Golem15\SEO\Models\Rule` }
func (rulesAdminController) ConfigDir() string { return "controllers/rules" }
func (rulesAdminController) NewRecord() any { return &models.Rule{} }
func (rulesAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate trims the address, normalises the match and refuses an
// invalid or taken address.
func (c rulesAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
// FormBeforeUpdate checks an update like a create.
func (c rulesAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
func (c rulesAdminController) check(ctx context.Context, model any) error {
r, ok := model.(*models.Rule)
if !ok || r == nil {
return nil
}
r.URL = strings.TrimSpace(r.URL)
r.Match = models.NormalizeMatch(r.Match)
db, err := database(ctx, c.app)
if err != nil {
return err
}
return problemsError(ctx, translator(c.app), r.Problems(db))
}
// ListExtendRecords fills the overrides column: the translated labels of
// the properties each rule sets, comma-joined.
func (c rulesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
tr := translator(c.app)
for _, record := range records {
r, ok := record.(*models.Rule)
if !ok || r == nil {
continue
}
var labels []string
for _, p := range models.RuleProperties {
if strings.TrimSpace(r.Value(p.Column)) != "" {
labels = append(labels, Phrase(ctx, tr, p.Label, nil))
}
}
r.Overrides = strings.Join(labels, ", ")
}
return nil
}
// FilterOptions serves the match filter's two choices.
func (rulesAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterMatch {
return nil
}
return []pact.Option{
{Value: models.MatchExact, Label: "golem15.seo::lang.rules.match_exact"},
{Value: models.MatchPattern, Label: "golem15.seo::lang.rules.match_pattern"},
}
}
var (
_ pact.AdminController = rulesAdminController{}
_ pact.AdminPermissioned = rulesAdminController{}
_ pact.AdminRecordSource = rulesAdminController{}
_ pact.FormBeforeCreate = rulesAdminController{}
_ pact.FormBeforeUpdate = rulesAdminController{}
_ pact.ListExtendRecords = rulesAdminController{}
_ pact.FilterOptions = rulesAdminController{}
)

View File

@@ -0,0 +1,7 @@
# The match filter (UI-SPEC C7): exact addresses or patterns.
scopes:
match:
label: golem15.seo::lang.rules.match
modelClass: Golem15\SEO\Models\Rule
nameFrom: match
scope: FilterMatch

View File

@@ -0,0 +1,18 @@
# URL rule form (UI-SPEC C7).
name: golem15.seo::lang.rules.name
form: ~/plugins/golem15/seo/models/rule/fields.yaml
modelClass: Golem15\SEO\Models\Rule
defaultRedirect: golem15/seo/rules
create:
redirect: golem15/seo/rules/update/:id
redirectClose: golem15/seo/rules
update:
redirect: golem15/seo/rules
redirectClose: golem15/seo/rules
messages:
create: golem15.seo::lang.rules.new
update: golem15.seo::lang.rules.name
deleteConfirm: golem15.seo::lang.rules.delete_confirm

View File

@@ -0,0 +1,23 @@
# URL rules list (UI-SPEC C7).
list: ~/plugins/golem15/seo/models/rule/columns.yaml
modelClass: Golem15\SEO\Models\Rule
title: golem15.seo::lang.rules.list_title
recordUrl: golem15/seo/rules/update/:id
noRecordsMessage: golem15.seo::lang.rules.list_empty
recordsPerPage: 20
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
defaultSort:
column: url
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
empty: golem15.seo::lang.rules.list_empty
deleteConfirm: golem15.seo::lang.rules.delete_selected_confirm

99
controllers/settings.go Normal file
View File

@@ -0,0 +1,99 @@
package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// settingsAdminController edits the SEO settings singleton (D-104), linked
// from the SEO settings tile. Its per-content-type tabs are added by the
// plugin's own form extension on this controller.
type settingsAdminController struct {
app func() *backpack.App
}
func (settingsAdminController) ID() string { return SettingsControllerID }
func (settingsAdminController) ModelName() string { return `Golem15\SEO\Models\Settings` }
func (settingsAdminController) ConfigDir() string { return "controllers/settings" }
func (settingsAdminController) NewRecord() any { return &models.Settings{} }
func (settingsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// SingletonRecord returns settings row 1, creating it with the defaults
// when the migration's row is gone (first or create, safe against a
// concurrent first request through ON CONFLICT DO NOTHING).
func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) > 0 {
return &rows[0], nil
}
row := models.DefaultSettings()
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return nil, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, errors.New("golem15.seo: settings row is missing")
}
return &rows[0], nil
}
// FormBeforeUpdate refuses templates with unknown placeholders and
// malformed or duplicate verification file names.
func (c settingsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
s, ok := model.(*models.Settings)
if !ok || s == nil {
return nil
}
return problemsError(ctx, translator(c.app), s.Problems())
}
// FormBeforeCreate checks a created row like an update.
func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.FormBeforeUpdate(ctx, model)
}
// stagingView is the view model of the staging partial.
type stagingView struct {
Staging bool
}
// PartialData serves the staging callout: shown outside production, where
// every page is noindex whatever the settings say (D-22).
func (c settingsAdminController) PartialData(_ context.Context, name string, _ any) (any, error) {
if name != "staging" {
return nil, nil
}
return stagingView{Staging: IsStaging(appOf(c.app))}, nil
}
// IsStaging reports whether the application runs outside production. It
// depends on the environment only (D-22): no setting can change it.
func IsStaging(app *backpack.App) bool {
if app == nil || app.Config == nil {
return true
}
return app.Config.Environment() != "production"
}
var (
_ pact.AdminController = settingsAdminController{}
_ pact.AdminPermissioned = settingsAdminController{}
_ pact.AdminRecordSource = settingsAdminController{}
_ pact.AdminSingleton = settingsAdminController{}
_ pact.AdminPartialData = settingsAdminController{}
_ pact.FormBeforeCreate = settingsAdminController{}
_ pact.FormBeforeUpdate = settingsAdminController{}
)

View File

@@ -0,0 +1,6 @@
{{- if .Data.Staging -}}
<div class="summer-callout summer-callout--warning" role="status">
<p class="summer-callout__title">{{ trans "golem15.seo::lang.robots.staging_title" }}</p>
<p class="summer-callout__text">{{ trans "golem15.seo::lang.status.staging" }}</p>
</div>
{{- end -}}

View File

@@ -0,0 +1,19 @@
# ===================================
# Form Behavior Config
# ===================================
#
# The SEO settings singleton (D-104), linked from the SEO settings tile.
# The plugin's form extension adds one tab per content type after General.
name: golem15.seo::lang.settings.label
form: ~/plugins/golem15/seo/models/settings/fields.yaml
modelClass: Golem15\SEO\Models\Settings
defaultRedirect: system/settings
update:
redirect: golem15/seo/settings
redirectClose: system/settings
messages:
update: golem15.seo::lang.settings.label
saved: backend::lang.messages.form.saved