diff --git a/admin.go b/admin.go
new file mode 100644
index 0000000..3db6a92
--- /dev/null
+++ b/admin.go
@@ -0,0 +1,53 @@
+package seo
+
+import (
+ "embed"
+ "io/fs"
+
+ "git.golem15.com/golem15/sm-seo-plugin/controllers"
+ "git.golem15.com/golem15/summercms/modules/backpack"
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// adminFS holds the admin YAML and partial templates by pattern (the
+// controllers directory also holds Go sources), so a new screen or partial
+// needs no edit here.
+//
+//go:embed controllers/*/*.yaml controllers/*/_*.htm models/*/*.yaml
+var adminFS embed.FS
+
+// AdminFS serves the admin YAML and partials.
+func (p *Plugin) AdminFS() fs.FS { return adminFS }
+
+// AdminControllers hands the controllers a lazy accessor of the booted
+// app, so they read the database at request time.
+func (p *Plugin) AdminControllers() []pact.AdminController {
+ return controllers.AdminControllers(func() *backpack.App {
+ if p == nil {
+ return nil
+ }
+ return p.app
+ })
+}
+
+// Settings registers the SEO settings tile (D-104): a link to the
+// settings singleton controller, behind golem15.seo.manage.
+func (p *Plugin) Settings() []pact.SettingsItem {
+ return []pact.SettingsItem{{
+ Code: "seo_settings",
+ Label: "golem15.seo::lang.settings.label",
+ Description: "golem15.seo::lang.settings.description",
+ Category: "golem15.seo::lang.settings.category",
+ Icon: "search",
+ Order: 100,
+ Keywords: []string{"seo", "meta", "canonical", "robots", "verification"},
+ Permissions: []string{controllers.PermissionManage},
+ Controller: controllers.SettingsControllerID,
+ }}
+}
+
+var (
+ _ pact.AdminAssets = (*Plugin)(nil)
+ _ pact.HasAdminControllers = (*Plugin)(nil)
+ _ pact.HasSettings = (*Plugin)(nil)
+)
diff --git a/admin_navigation.go b/admin_navigation.go
new file mode 100644
index 0000000..eec38f1
--- /dev/null
+++ b/admin_navigation.go
@@ -0,0 +1,31 @@
+package seo
+
+import (
+ "git.golem15.com/golem15/sm-seo-plugin/controllers"
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// Navigation registers the SEO main item (UI-SPEC C2) behind
+// golem15.seo.manage. It lands on URL rules until the redirects screen
+// exists; each side-menu entry is added when its controller exists.
+func (*Plugin) Navigation() []pact.NavigationItem {
+ return []pact.NavigationItem{{
+ Code: "seo",
+ Label: "golem15.seo::lang.nav.seo",
+ Icon: "search",
+ Permissions: []string{controllers.PermissionManage},
+ Order: 600,
+ Controller: controllers.RulesControllerID,
+ SideMenu: []pact.NavigationItem{
+ {
+ Code: "rules",
+ Label: "golem15.seo::lang.nav.rules",
+ Icon: "link",
+ Permissions: []string{controllers.PermissionManage},
+ Controller: controllers.RulesControllerID,
+ },
+ },
+ }}
+}
+
+var _ pact.HasNavigation = (*Plugin)(nil)
diff --git a/admin_permissions.go b/admin_permissions.go
new file mode 100644
index 0000000..b0fbb66
--- /dev/null
+++ b/admin_permissions.go
@@ -0,0 +1,19 @@
+package seo
+
+import (
+ "git.golem15.com/golem15/sm-seo-plugin/controllers"
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// Permissions declares the one SEO permission (D-15): the SEO tab of every
+// content type, URL rules, the settings and history, in the admin and
+// through MCP.
+func (*Plugin) Permissions() []pact.Permission {
+ return []pact.Permission{{
+ Code: controllers.PermissionManage,
+ Tab: "golem15.seo::lang.permissions.tab",
+ Label: "golem15.seo::lang.permissions.manage",
+ }}
+}
+
+var _ pact.HasPermissions = (*Plugin)(nil)
diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go
new file mode 100644
index 0000000..07b6e28
--- /dev/null
+++ b/controllers/admin_registry.go
@@ -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}
+}
diff --git a/controllers/rules.go b/controllers/rules.go
new file mode 100644
index 0000000..b876f80
--- /dev/null
+++ b/controllers/rules.go
@@ -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{}
+)
diff --git a/controllers/rules/config_filter.yaml b/controllers/rules/config_filter.yaml
new file mode 100644
index 0000000..56ea05a
--- /dev/null
+++ b/controllers/rules/config_filter.yaml
@@ -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
diff --git a/controllers/rules/config_form.yaml b/controllers/rules/config_form.yaml
new file mode 100644
index 0000000..1fe61c0
--- /dev/null
+++ b/controllers/rules/config_form.yaml
@@ -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
diff --git a/controllers/rules/config_list.yaml b/controllers/rules/config_list.yaml
new file mode 100644
index 0000000..2236e32
--- /dev/null
+++ b/controllers/rules/config_list.yaml
@@ -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
diff --git a/controllers/settings.go b/controllers/settings.go
new file mode 100644
index 0000000..7d568f6
--- /dev/null
+++ b/controllers/settings.go
@@ -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{}
+)
diff --git a/controllers/settings/_staging.htm b/controllers/settings/_staging.htm
new file mode 100644
index 0000000..c6c8169
--- /dev/null
+++ b/controllers/settings/_staging.htm
@@ -0,0 +1,6 @@
+{{- if .Data.Staging -}}
+
+
{{ trans "golem15.seo::lang.robots.staging_title" }}
+
{{ trans "golem15.seo::lang.status.staging" }}
+
+{{- end -}}
diff --git a/controllers/settings/config_form.yaml b/controllers/settings/config_form.yaml
new file mode 100644
index 0000000..d8b2c2f
--- /dev/null
+++ b/controllers/settings/config_form.yaml
@@ -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
diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml
index 66acba3..8cf9973 100644
--- a/lang/en/lang.yaml
+++ b/lang/en/lang.yaml
@@ -1,8 +1,109 @@
plugin:
name: SEO
description: Search engine metadata for every content type, URL rules and global SEO settings.
+nav:
+ seo: SEO
+ rules: URL rules
+permissions:
+ tab: SEO
+ manage: Manage SEO
+ manage_comment: Edit the SEO tab of every content type, URL rules, redirects, robots.txt and the SEO settings, in the admin and through MCP.
tab:
seo: SEO
field:
title: SEO title
title_comment: Leave empty to use the title template from the SEO settings.
+ title_comment_rule: Leave empty to keep the title of the page or record at this address.
+ description: Meta description
+ canonical: Canonical URL
+ canonical_comment: Leave empty for this page's own address. Accepts a path (/blog/post) or a full URL, also on another domain.
+ robots: Search engine indexing
+ robots_comment_rule: “Inherit” keeps the setting of the record at this address, then the content type default.
+ og_title: Social title
+ og_description: Social description
+ og_image: Social image
+robots:
+ inherit: Inherit
+ site_default: Site default
+ staging_title: This is not the production environment
+status:
+ staging: On this environment every page is served with noindex and robots.txt blocks everything (non-production lock).
+settings:
+ category: SEO
+ label: SEO settings
+ description: Title templates, default descriptions, indexing rules, address format and search engine verification.
+ tab_general: General
+ tab_indexing: Indexing
+ tab_urls: Addresses
+ tab_verification: Verification
+ site_name: Site name
+ site_name_comment: Replaces %site% in title templates.
+ title_template: Title template
+ title_template_comment: 'Used when a page has no SEO title. Default: the page title, a vertical bar and the site name (%title% followed by %site%).'
+ home_template: Home page title template
+ default_description: Default meta description
+ default_description_comment: Used when neither the page, its template nor its content provides a description.
+ default_og_image: Default social image
+ twitter_site: X (Twitter) account
+ twitter_site_comment: Your handle with @, e.g. @summercms. Sent as twitter:site.
+ query_params: Query parameters
+ query_params_comment: Decides which parameters stay in the canonical URL. A trailing * matches a prefix, e.g. utm_*.
+ param_name: Parameter
+ param_kind: Kind
+ param_path: Only under path
+ param_content: 'Content: stays in the canonical'
+ param_filter: 'Filter or sort: dropped, page gets noindex, follow'
+ param_tracking: 'Tracking: dropped'
+ param_path_comment: 'Empty: the whole site. Example: /katalog/*'
+ param_add: Add parameter
+ noindex_paths: Addresses never indexed
+ noindex_paths_comment: One address or pattern per line, e.g. /konto/* or /logowanie. These pages get noindex, nofollow. Pages behind sign-in are marked automatically.
+ search_paths: Internal search results
+ search_paths_comment: One address or pattern per line, e.g. /szukaj. These pages get noindex, follow.
+ url_host: Domain form
+ url_host_keep: Do not change
+ url_host_www: Always with www
+ url_host_bare: Always without www
+ url_slash: Trailing slash
+ url_slash_keep: Do not change
+ url_slash_add: Always add (/blog/)
+ url_slash_remove: Always remove (/blog)
+ url_lowercase: Lowercase addresses
+ url_change_comment: A change sends every non-matching address to the new form with a 301. Check the site's current addresses first.
+ url_fixed_comment: 'Always on: HTTPS, removal of double slashes and /index.html, a single 301 hop to the final address, and strict addresses (every segment must match the record).'
+ verify_google: Google verification code
+ verify_bing: Bing verification code
+ verify_yandex: Yandex verification code
+ verify_code_comment: Paste only the content value of the meta tag. It is added to the home page.
+ verify_other: Other verification meta tags
+ verify_other_name: Meta name
+ verify_other_content: Content
+ verify_other_add: Add meta tag
+ verify_files: Verification files
+ verify_files_filename: File name
+ verify_files_body: File content
+ verify_files_add: Add file
+ verify_files_comment: Served from the site root, e.g. /google1234.html.
+ verify_filename_invalid: Use a plain file name ending in .html, .txt or .xml, without slashes, e.g. google1234.html.
+ verify_filename_taken: Another verification file already uses this name.
+ template_unknown: Unknown placeholder “:placeholder”. Use one from the table below the templates.
+rules:
+ new: New URL rule
+ list_title: URL rules
+ name: URL rule
+ url: Address
+ match: Match
+ match_exact: Exact address
+ match_pattern: Pattern
+ note: Note
+ note_comment: Why this rule exists, e.g. the audit it comes from. Not shown on the site.
+ col_overrides: Sets
+ col_updated: Updated
+ url_comment_full: A URL rule wins over the record's SEO tab and the SEO settings. A path such as / or /blog?tag=*. In a pattern, * matches any text.
+ url_taken: A rule for this address and match already exists.
+ url_invalid: Start the address with /. Use * only in a pattern.
+ list_empty: No URL rules yet. A rule sets SEO for an address that has no record behind it, such as the home page or a filter URL.
+ delete_confirm: Delete this URL rule? The address falls back to its record and the SEO settings. You can undo it in History.
+ delete_selected_confirm:
+ one: Delete :count URL rule? You can undo it in History.
+ other: Delete :count URL rules? You can undo it in History.
diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml
index 3204ce6..d8b1096 100644
--- a/lang/pl/lang.yaml
+++ b/lang/pl/lang.yaml
@@ -1,8 +1,111 @@
plugin:
name: SEO
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO.
+nav:
+ seo: SEO
+ rules: Reguły adresów
+permissions:
+ tab: SEO
+ manage: Zarządzanie SEO
+ manage_comment: Edycja zakładki SEO we wszystkich typach treści, reguł adresów, przekierowań, robots.txt i ustawień SEO, w panelu i przez MCP.
tab:
seo: SEO
field:
title: Tytuł SEO
title_comment: Zostaw puste, aby użyć szablonu tytułu z ustawień SEO.
+ title_comment_rule: Zostaw puste, aby zachować tytuł strony lub rekordu pod tym adresem.
+ description: Opis meta
+ canonical: Adres kanoniczny
+ canonical_comment: Zostaw puste dla własnego adresu strony. Przyjmuje ścieżkę (/blog/wpis) lub pełny adres, także w innej domenie.
+ robots: Indeksowanie w wyszukiwarkach
+ robots_comment_rule: „Dziedzicz” zachowuje ustawienie rekordu pod tym adresem, a potem domyślne ustawienie typu treści.
+ og_title: Tytuł w mediach społecznościowych
+ og_description: Opis w mediach społecznościowych
+ og_image: Obraz w mediach społecznościowych
+robots:
+ inherit: Dziedzicz
+ site_default: Domyślne
+ staging_title: To nie jest środowisko produkcyjne
+status:
+ staging: W tym środowisku każda strona ma noindex, a robots.txt blokuje wszystko (blokada środowiska nieprodukcyjnego).
+settings:
+ category: SEO
+ label: Ustawienia SEO
+ description: Szablony tytułów, domyślne opisy, zasady indeksowania, format adresów i weryfikacja w wyszukiwarkach.
+ tab_general: Ogólne
+ tab_indexing: Indeksowanie
+ tab_urls: Adresy
+ tab_verification: Weryfikacja
+ site_name: Nazwa witryny
+ site_name_comment: Zastępuje %site% w szablonach tytułów.
+ title_template: Szablon tytułu
+ title_template_comment: 'Używany, gdy strona nie ma tytułu SEO. Domyślnie: tytuł strony, pionowa kreska i nazwa witryny (%title%, a po nim %site%).'
+ home_template: Szablon tytułu strony głównej
+ default_description: Domyślny opis meta
+ default_description_comment: Używany, gdy ani strona, ani jej szablon, ani treść nie dostarczają opisu.
+ default_og_image: Domyślny obraz w mediach społecznościowych
+ twitter_site: Konto X (Twitter)
+ twitter_site_comment: Nazwa konta z @, np. @summercms. Wysyłana jako twitter:site.
+ query_params: Parametry adresu
+ query_params_comment: Decyduje, które parametry zostają w adresie kanonicznym. Gwiazdka na końcu pasuje do prefiksu, np. utm_*.
+ param_name: Parametr
+ param_kind: Rodzaj
+ param_path: Tylko pod ścieżką
+ param_content: 'Treść: zostaje w adresie kanonicznym'
+ param_filter: 'Filtr lub sortowanie: usuwany, strona dostaje noindex, follow'
+ param_tracking: 'Śledzenie: usuwany'
+ param_path_comment: 'Puste: cała witryna. Przykład: /katalog/*'
+ param_add: Dodaj parametr
+ noindex_paths: Adresy nigdy nieindeksowane
+ noindex_paths_comment: Jeden adres lub wzorzec w wierszu, np. /konto/* lub /logowanie. Te strony dostają noindex, nofollow. Strony wymagające logowania są oznaczane automatycznie.
+ search_paths: Wyniki wyszukiwania wewnętrznego
+ search_paths_comment: Jeden adres lub wzorzec w wierszu, np. /szukaj. Te strony dostają noindex, follow.
+ url_host: Forma domeny
+ url_host_keep: Nie zmieniaj
+ url_host_www: Zawsze z www
+ url_host_bare: Zawsze bez www
+ url_slash: Ukośnik na końcu
+ url_slash_keep: Nie zmieniaj
+ url_slash_add: Zawsze dodawaj (/blog/)
+ url_slash_remove: Zawsze usuwaj (/blog)
+ url_lowercase: Małe litery w adresach
+ url_change_comment: Zmiana przekierowuje (301) każdy niepasujący adres na nową formę. Najpierw sprawdź obecne adresy witryny.
+ url_fixed_comment: 'Zawsze włączone: HTTPS, usuwanie podwójnych ukośników i /index.html, jedno przekierowanie 301 do adresu docelowego oraz ścisłe adresy (każdy segment musi pasować do rekordu).'
+ verify_google: Kod weryfikacyjny Google
+ verify_bing: Kod weryfikacyjny Bing
+ verify_yandex: Kod weryfikacyjny Yandex
+ verify_code_comment: Wklej tylko wartość content znacznika meta. Zostanie dodany na stronie głównej.
+ verify_other: Inne znaczniki weryfikacyjne
+ verify_other_name: Nazwa meta
+ verify_other_content: Wartość
+ verify_other_add: Dodaj znacznik
+ verify_files: Pliki weryfikacyjne
+ verify_files_filename: Nazwa pliku
+ verify_files_body: Zawartość pliku
+ verify_files_add: Dodaj plik
+ verify_files_comment: Serwowane z katalogu głównego witryny, np. /google1234.html.
+ verify_filename_invalid: Użyj samej nazwy pliku z końcówką .html, .txt lub .xml, bez ukośników, np. google1234.html.
+ verify_filename_taken: Ten plik weryfikacyjny już istnieje.
+ template_unknown: Nieznany znacznik „:placeholder”. Użyj jednego z tabeli pod szablonami.
+rules:
+ new: Nowa reguła adresu
+ list_title: Reguły adresów
+ name: Reguła adresu
+ url: Adres
+ match: Dopasowanie
+ match_exact: Dokładny adres
+ match_pattern: Wzorzec
+ note: Notatka
+ note_comment: Dlaczego ta reguła istnieje, np. z jakiego audytu pochodzi. Nie jest wyświetlana na stronie.
+ col_overrides: Ustawia
+ col_updated: Zaktualizowano
+ url_comment_full: Reguła adresu ma pierwszeństwo przed zakładką SEO rekordu i ustawieniami SEO. Ścieżka, np. / lub /blog?tag=*. We wzorcu * pasuje do dowolnego tekstu.
+ url_taken: Reguła dla tego adresu i dopasowania już istnieje.
+ url_invalid: Zacznij adres od /. Używaj * tylko we wzorcu.
+ list_empty: Nie ma jeszcze reguł adresów. Reguła ustawia SEO dla adresu, za którym nie stoi żaden rekord, np. strony głównej lub adresu z filtrem.
+ delete_confirm: Usunąć tę regułę adresu? Adres wróci do ustawień rekordu i ustawień SEO. Możesz to cofnąć w Historii zmian.
+ delete_selected_confirm:
+ one: Usunąć :count regułę adresu? Możesz to cofnąć w Historii zmian.
+ few: Usunąć :count reguły adresów? Możesz to cofnąć w Historii zmian.
+ many: Usunąć :count reguł adresów? Możesz to cofnąć w Historii zmian.
+ other: Usunąć :count reguły adresu? Możesz to cofnąć w Historii zmian.
diff --git a/models/rule.go b/models/rule.go
new file mode 100644
index 0000000..9ab7c04
--- /dev/null
+++ b/models/rule.go
@@ -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)
+}
diff --git a/models/rule/columns.yaml b/models/rule/columns.yaml
new file mode 100644
index 0000000..1fb7429
--- /dev/null
+++ b/models/rule/columns.yaml
@@ -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
diff --git a/models/rule/fields.yaml b/models/rule/fields.yaml
new file mode 100644
index 0000000..b7a58a7
--- /dev/null
+++ b/models/rule/fields.yaml
@@ -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
diff --git a/models/settings.go b/models/settings.go
new file mode 100644
index 0000000..e5ef136
--- /dev/null
+++ b/models/settings.go
@@ -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..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{})
+}
diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml
new file mode 100644
index 0000000..4fba07a
--- /dev/null
+++ b/models/settings/fields.yaml
@@ -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
diff --git a/updates/202610270003_create_golem15_seo_settings.go b/updates/202610270003_create_golem15_seo_settings.go
new file mode 100644
index 0000000..19612f2
--- /dev/null
+++ b/updates/202610270003_create_golem15_seo_settings.go
@@ -0,0 +1,63 @@
+package updates
+
+import (
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+// createSettings creates the settings singleton golem15_seo_settings (row
+// id 1) with the default templates, address options and the seeded D-07
+// query-parameter list. The robots.txt columns are added by a later
+// migration.
+var createSettings = []*gormigrate.Migration{
+ {
+ ID: "202610270003_create_golem15_seo_settings",
+ Migrate: func(tx *gorm.DB) error {
+ for _, stmt := range []string{
+ `CREATE TABLE golem15_seo_settings (
+ id INTEGER PRIMARY KEY,
+ site_name TEXT NOT NULL DEFAULT '',
+ twitter_site TEXT NOT NULL DEFAULT '',
+ title_template TEXT NOT NULL DEFAULT '%title% | %site%',
+ home_template TEXT NOT NULL DEFAULT '%site%',
+ default_description TEXT NOT NULL DEFAULT '',
+ default_og_image TEXT NOT NULL DEFAULT '',
+ query_params jsonb NULL,
+ noindex_paths TEXT NOT NULL DEFAULT '',
+ search_paths TEXT NOT NULL DEFAULT '',
+ url_host TEXT NOT NULL DEFAULT 'keep',
+ url_slash TEXT NOT NULL DEFAULT 'keep',
+ url_lowercase BOOLEAN NOT NULL DEFAULT FALSE,
+ verify_google TEXT NOT NULL DEFAULT '',
+ verify_bing TEXT NOT NULL DEFAULT '',
+ verify_yandex TEXT NOT NULL DEFAULT '',
+ verify_other jsonb NULL,
+ verify_files jsonb NULL,
+ csv_delimiter TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMP(0) NULL,
+ updated_at TIMESTAMP(0) NULL
+)`,
+ `INSERT INTO golem15_seo_settings (id, query_params, created_at, updated_at) VALUES (1, '[
+ {"name": "page", "kind": "content", "path": ""},
+ {"name": "utm_*", "kind": "tracking", "path": ""},
+ {"name": "fbclid", "kind": "tracking", "path": ""},
+ {"name": "gclid", "kind": "tracking", "path": ""},
+ {"name": "msclkid", "kind": "tracking", "path": ""},
+ {"name": "sort", "kind": "filter", "path": ""}
+]'::jsonb, NOW(), NOW())`,
+ } {
+ if err := tx.Exec(stmt).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_settings`).Error
+ },
+ },
+}
+
+func init() {
+ Register(createSettings...)
+}
diff --git a/updates/202610270005_create_golem15_seo_rules.go b/updates/202610270005_create_golem15_seo_rules.go
new file mode 100644
index 0000000..c2eab17
--- /dev/null
+++ b/updates/202610270005_create_golem15_seo_rules.go
@@ -0,0 +1,39 @@
+package updates
+
+import (
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+// createRules creates golem15_seo_rules: per-URL SEO for exact addresses
+// and patterns (D-02), one rule per address and match.
+var createRules = []*gormigrate.Migration{
+ {
+ ID: "202610270005_create_golem15_seo_rules",
+ Migrate: func(tx *gorm.DB) error {
+ return tx.Exec(`CREATE TABLE golem15_seo_rules (
+ id BIGSERIAL PRIMARY KEY,
+ url TEXT NOT NULL,
+ match TEXT NOT NULL DEFAULT 'exact',
+ title TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ canonical TEXT NOT NULL DEFAULT '',
+ robots TEXT NOT NULL DEFAULT '',
+ og_title TEXT NOT NULL DEFAULT '',
+ og_description TEXT NOT NULL DEFAULT '',
+ og_image TEXT NOT NULL DEFAULT '',
+ note TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMP(0) NULL,
+ updated_at TIMESTAMP(0) NULL,
+ CONSTRAINT golem15_seo_rules_url_match UNIQUE (url, match)
+)`).Error
+ },
+ Rollback: func(tx *gorm.DB) error {
+ return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_rules`).Error
+ },
+ },
+}
+
+func init() {
+ Register(createRules...)
+}