feat: URL rules and the settings singleton

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

53
admin.go Normal file
View File

@@ -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)
)

31
admin_navigation.go Normal file
View File

@@ -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)

19
admin_permissions.go Normal file
View File

@@ -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)

View File

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

90
controllers/rules.go Normal file
View File

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

View File

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

View File

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

View File

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

99
controllers/settings.go Normal file
View File

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

View File

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

View File

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

View File

@@ -1,8 +1,109 @@
plugin: plugin:
name: SEO name: SEO
description: Search engine metadata for every content type, URL rules and global SEO settings. 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: tab:
seo: SEO seo: SEO
field: field:
title: SEO title title: SEO title
title_comment: Leave empty to use the title template from the SEO settings. 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.

View File

@@ -1,8 +1,111 @@
plugin: plugin:
name: SEO name: SEO
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia 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: tab:
seo: SEO seo: SEO
field: field:
title: Tytuł SEO title: Tytuł SEO
title_comment: Zostaw puste, aby użyć szablonu tytułu z ustawień 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.

144
models/rule.go Normal file
View File

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

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

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

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

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

211
models/settings.go Normal file
View File

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

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

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

View File

@@ -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...)
}

View File

@@ -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...)
}