From ce60c8a1eeb7acd2e6acd4eef8a57cb63fe85059 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 10:34:39 +0100 Subject: [PATCH] feat: URL rules and the settings singleton --- admin.go | 53 +++++ admin_navigation.go | 31 +++ admin_permissions.go | 19 ++ controllers/admin_registry.go | 98 ++++++++ controllers/rules.go | 90 ++++++++ controllers/rules/config_filter.yaml | 7 + controllers/rules/config_form.yaml | 18 ++ controllers/rules/config_list.yaml | 23 ++ controllers/settings.go | 99 ++++++++ controllers/settings/_staging.htm | 6 + controllers/settings/config_form.yaml | 19 ++ lang/en/lang.yaml | 101 +++++++++ lang/pl/lang.yaml | 103 +++++++++ models/rule.go | 144 ++++++++++++ models/rule/columns.yaml | 24 ++ models/rule/fields.yaml | 71 ++++++ models/settings.go | 211 ++++++++++++++++++ models/settings/fields.yaml | 193 ++++++++++++++++ ...02610270003_create_golem15_seo_settings.go | 63 ++++++ .../202610270005_create_golem15_seo_rules.go | 39 ++++ 20 files changed, 1412 insertions(+) create mode 100644 admin.go create mode 100644 admin_navigation.go create mode 100644 admin_permissions.go create mode 100644 controllers/admin_registry.go create mode 100644 controllers/rules.go create mode 100644 controllers/rules/config_filter.yaml create mode 100644 controllers/rules/config_form.yaml create mode 100644 controllers/rules/config_list.yaml create mode 100644 controllers/settings.go create mode 100644 controllers/settings/_staging.htm create mode 100644 controllers/settings/config_form.yaml create mode 100644 models/rule.go create mode 100644 models/rule/columns.yaml create mode 100644 models/rule/fields.yaml create mode 100644 models/settings.go create mode 100644 models/settings/fields.yaml create mode 100644 updates/202610270003_create_golem15_seo_settings.go create mode 100644 updates/202610270005_create_golem15_seo_rules.go 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...) +}