feat: URL rules and the settings singleton
This commit is contained in:
53
admin.go
Normal file
53
admin.go
Normal 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
31
admin_navigation.go
Normal 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
19
admin_permissions.go
Normal 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)
|
||||
98
controllers/admin_registry.go
Normal file
98
controllers/admin_registry.go
Normal 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
90
controllers/rules.go
Normal 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{}
|
||||
)
|
||||
7
controllers/rules/config_filter.yaml
Normal file
7
controllers/rules/config_filter.yaml
Normal 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
|
||||
18
controllers/rules/config_form.yaml
Normal file
18
controllers/rules/config_form.yaml
Normal 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
|
||||
23
controllers/rules/config_list.yaml
Normal file
23
controllers/rules/config_list.yaml
Normal 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
99
controllers/settings.go
Normal 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{}
|
||||
)
|
||||
6
controllers/settings/_staging.htm
Normal file
6
controllers/settings/_staging.htm
Normal 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 -}}
|
||||
19
controllers/settings/config_form.yaml
Normal file
19
controllers/settings/config_form.yaml
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
144
models/rule.go
Normal file
144
models/rule.go
Normal 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
24
models/rule/columns.yaml
Normal 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
71
models/rule/fields.yaml
Normal 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
211
models/settings.go
Normal 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
193
models/settings/fields.yaml
Normal 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
|
||||
63
updates/202610270003_create_golem15_seo_settings.go
Normal file
63
updates/202610270003_create_golem15_seo_settings.go
Normal 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...)
|
||||
}
|
||||
39
updates/202610270005_create_golem15_seo_rules.go
Normal file
39
updates/202610270005_create_golem15_seo_rules.go
Normal 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...)
|
||||
}
|
||||
Reference in New Issue
Block a user