feat: per-type settings tabs and the SEO tab permission

This commit is contained in:
2026-10-27 10:36:32 +01:00
parent ce60c8a1ee
commit 30edb8adee
11 changed files with 560 additions and 6 deletions

43
classes/type_settings.go Normal file
View File

@@ -0,0 +1,43 @@
package classes
import (
"context"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LoadTypeSettings returns the stored per-type settings keyed by type key.
func LoadTypeSettings(db *gorm.DB) (map[string]models.TypeSetting, error) {
var rows []models.TypeSetting
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]models.TypeSetting, len(rows))
for _, row := range rows {
out[row.TypeKey] = row
}
return out, nil
}
// LoadTypeSetting returns the settings of type key, the defaults when none
// are stored.
func LoadTypeSetting(db *gorm.DB, key string) (models.TypeSetting, error) {
var rows []models.TypeSetting
if err := db.Where("type_key = ?", key).Limit(1).Find(&rows).Error; err != nil {
return models.TypeSetting{}, err
}
if len(rows) == 0 {
return models.DefaultTypeSetting(key), nil
}
return rows[0], nil
}
// SaveTypeSetting stores the settings of one content type inside db.
func (s *Service) SaveTypeSetting(_ context.Context, db *gorm.DB, row models.TypeSetting) error {
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "type_key"}},
DoUpdates: clause.AssignmentColumns([]string{"inject", "title_template", "description_template", "robots"}),
}).Create(&row).Error
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
)
@@ -22,7 +23,8 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
return nil
}
var out []pact.AdminActionExtension
for _, typ := range reg.ContentTypes() {
types := reg.ContentTypes()
for _, typ := range types {
if typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" {
continue
}
@@ -31,6 +33,9 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
Form: p.seoTab(typ.Key),
})
}
if form := p.typeSettingsTabs(types); form != nil {
out = append(out, pact.AdminActionExtension{Controller: controllers.SettingsControllerID, Form: form})
}
return out
}
@@ -56,7 +61,11 @@ fields:
// seoTab is the form extension of content type key.
func (p *Plugin) seoTab(key string) *pact.AdminFormExtension {
return &pact.AdminFormExtension{
FieldsYAML: []byte(seoTabYAML),
FieldsYAML: []byte(seoTabYAML),
Permissions: []string{controllers.PermissionManage},
Visible: func(ctx context.Context) bool {
return p.injected(ctx, key)
},
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
return p.loadSEOTab(ctx, key, in)
},
@@ -145,3 +154,22 @@ func localeMap(raw any) map[string]string {
}
return map[string]string{}
}
// injected reports whether the SEO tab of content type key is shown: false
// only when its settings row turns it off. It answers true when it cannot
// decide, so a database error never hides stored values.
func (p *Plugin) injected(ctx context.Context, key string) bool {
svc := p.Service()
if svc == nil {
return true
}
db, err := svc.DB(ctx)
if err != nil {
return true
}
row, err := classes.LoadTypeSetting(db, key)
if err != nil {
return true
}
return row.Inject
}

241
extensions_settings.go Normal file
View File

@@ -0,0 +1,241 @@
package seo
import (
"context"
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml"
)
// TabGeneral is the settings form's General tab key; the per-type tabs
// follow it.
const TabGeneral = "golem15.seo::lang.settings.tab_general"
// typeFieldKey turns a content type key into the part of a field name:
// every character outside [a-z0-9_] becomes "_".
var typeFieldUnsafe = regexp.MustCompile(`[^a-z0-9_]`)
func typeFieldKey(key string) string {
return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_")
}
// typeField is one per-type settings field: its name, the content type and
// the type_settings column it edits.
type typeField struct {
name string
key string
column string
}
// typeSettingsFields lists the per-type fields of types in form order.
func typeSettingsFields(types []pact.MenuItemType) []typeField {
var out []typeField
for _, typ := range types {
base := "type_" + typeFieldKey(typ.Key) + "_"
for _, column := range []string{"inject", "title_template", "description_template", "robots"} {
out = append(out, typeField{name: base + column, key: typ.Key, column: column})
}
}
return out
}
// typeSettingsDocument builds the fields document of the per-type settings
// tabs (D-104): one tab per content type in registration order, the first
// after General and each next after the previous, labelled with the type's
// signpost label.
func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
type tabSpec struct {
Name string `yaml:"name"`
After string `yaml:"after"`
}
tabs := make([]tabSpec, 0, len(types))
fields := yaml.MapSlice{}
prev := TabGeneral
for _, typ := range types {
tab := typ.Label
if strings.TrimSpace(tab) == "" {
tab = typ.Key
}
tabs = append(tabs, tabSpec{Name: tab, After: prev})
prev = tab
base := "type_" + typeFieldKey(typ.Key) + "_"
fields = append(fields,
yaml.MapItem{Key: base + "inject", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_inject"},
{Key: "comment", Value: "golem15.seo::lang.settings.type_inject_comment"},
{Key: "type", Value: "switch"},
{Key: "span", Value: "full"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "title_template", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_title_template"},
{Key: "comment", Value: "golem15.seo::lang.settings.type_title_template_comment"},
{Key: "type", Value: "text"},
{Key: "span", Value: "left"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "description_template", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_description_template"},
{Key: "type", Value: "text"},
{Key: "span", Value: "right"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "robots", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_robots"},
{Key: "type", Value: "dropdown"},
{Key: "span", Value: "full"},
{Key: "tab", Value: tab},
{Key: "options", Value: yaml.MapSlice{
{Key: "", Value: "golem15.seo::lang.robots.site_default"},
{Key: "index,follow", Value: "index, follow"},
{Key: "noindex,follow", Value: "noindex, follow"},
{Key: "index,nofollow", Value: "index, nofollow"},
{Key: "noindex,nofollow", Value: "noindex, nofollow"},
}},
}},
)
}
return yaml.Marshal(yaml.MapSlice{
{Key: "tabs", Value: tabs},
{Key: "fields", Value: fields},
})
}
// typeSettingsTabs is the form extension on the SEO settings controller
// that adds the per-type tabs; nil when no content type is registered.
func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExtension {
if len(types) == 0 {
return nil
}
doc, err := typeSettingsDocument(types)
if err != nil {
pluginLog.Error("golem15.seo: per-type settings tabs", "error", err)
return nil
}
fields := typeSettingsFields(types)
return &pact.AdminFormExtension{
FieldsYAML: doc,
Permissions: []string{controllers.PermissionManage},
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
rows, err := classes.LoadTypeSettings(in.DB.WithContext(ctx))
if err != nil {
return nil, err
}
out := map[string]any{}
for _, f := range fields {
row, ok := rows[f.key]
if !ok {
row = models.DefaultTypeSetting(f.key)
}
out[f.name] = typeSettingValue(row, f.column)
}
return out, nil
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
return pact.AdminFormSaveResult{}, p.saveTypeSettings(ctx, in, fields)
},
}
}
// typeSettingValue reads one column of a type settings row.
func typeSettingValue(row models.TypeSetting, column string) any {
switch column {
case "inject":
return row.Inject
case "title_template":
return row.TitleTemplate
case "description_template":
return row.DescriptionTemplate
case "robots":
return row.Robots
}
return nil
}
// saveTypeSettings stores the submitted per-type values through the
// service; a template with an unknown placeholder refuses the save.
func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fields []typeField) error {
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
rows, err := classes.LoadTypeSettings(in.DB)
if err != nil {
return err
}
changed := map[string]models.TypeSetting{}
refusals := pact.AdminFormErrors{}
var tr *phrasebook.Translator
if p.app != nil {
tr, _ = p.app.Lookup[*phrasebook.Translator]()
}
for _, f := range fields {
raw, ok := in.Values[f.name]
if !ok {
continue
}
row, seen := changed[f.key]
if !seen {
if row, ok = rows[f.key]; !ok {
row = models.DefaultTypeSetting(f.key)
}
}
switch f.column {
case "inject":
row.Inject = truthy(raw)
case "title_template", "description_template":
text := strings.TrimSpace(fmt.Sprint(stringOf(raw)))
for _, problem := range models.TemplateProblems(text) {
refusals[f.name] = append(refusals[f.name], controllers.Phrase(ctx, tr, problem.Key, problem.Params))
}
if f.column == "title_template" {
row.TitleTemplate = text
} else {
row.DescriptionTemplate = text
}
case "robots":
row.Robots = strings.TrimSpace(stringOf(raw))
}
changed[f.key] = row
}
if len(refusals) > 0 {
return refusals
}
for _, row := range changed {
if err := svc.SaveTypeSetting(ctx, in.DB, row); err != nil {
return err
}
}
return nil
}
// truthy reads a submitted switch value.
func truthy(v any) bool {
switch b := v.(type) {
case bool:
return b
case string:
return b == "1" || strings.EqualFold(b, "true") || strings.EqualFold(b, "on")
case fmt.Stringer:
return b.String() == "1"
}
return false
}
// stringOf reads a submitted text value.
func stringOf(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
}
return fmt.Sprint(v)
}

View File

@@ -43,6 +43,7 @@ const (
pagesPermission = "acme.pages.access"
pagesTypeKey = "acme-page"
pagesModel = "acme_pages_pages"
tagsTypeKey = "acme-tag"
adminJWTSecret = "seo-admin-test-only-hs256-secret-key"
siteURL = "https://www.example.test"
)
@@ -153,7 +154,39 @@ func (p *pagesPlugin) describe(row page) pact.ContentRecord {
}
func (p *pagesPlugin) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{{
return []pact.MenuItemType{p.pageType(), p.tagType()}
}
// tagType is acme-tag (/pages/tag/:slug): a content type without an admin
// controller, so it gets per-type settings but no SEO tab.
func (p *pagesPlugin) tagType() pact.MenuItemType {
return pact.MenuItemType{
Key: tagsTypeKey,
Label: "Tags",
DefaultPattern: "/pages/tag/:slug",
Placeholders: []string{"slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return nil, nil
},
Content: &pact.ContentDescriptor{
Model: "acme_pages_tags",
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/tag/" + in.ID, Title: "Tag " + in.ID}}}, nil
},
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
values, ok := signpost.MatchPattern(in.Pattern, in.Path)
if !ok {
return nil, nil
}
return &pact.ContentMatchResult{ID: values["slug"], Locale: "en", Exact: true}, nil
},
},
}
}
// pageType is acme-page (/pages/:slug, /pl/pages/:slug).
func (p *pagesPlugin) pageType() pact.MenuItemType {
return pact.MenuItemType{
Key: pagesTypeKey,
Label: "Page",
DefaultPattern: "/pages/:slug",
@@ -203,7 +236,7 @@ func (p *pagesPlugin) MenuItemTypes() []pact.MenuItemType {
return &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(rows[0].ID), 10), Locale: locale, Exact: true}, nil
},
},
}}
}
}
var (
@@ -233,6 +266,12 @@ func adminAPI(rel string) string { return cabana.DefaultAdminPrefix + "/api/v1"
// bootEnv boots acme.pages and golem15.seo on a fresh database.
func bootEnv(t *testing.T, extraEnv ...string) *testEnv {
t.Helper()
return bootPlugins(t, []string{"acme.pages", "golem15.seo"}, extraEnv...)
}
// bootPlugins boots the plugins ids on a fresh database.
func bootPlugins(t *testing.T, ids []string, extraEnv ...string) *testEnv {
t.Helper()
sqlDB := freshDB(t)
gdb, err := lagoon.Use(t.Context(), sqlDB)
@@ -268,7 +307,7 @@ func bootEnv(t *testing.T, extraEnv ...string) *testEnv {
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"acme.pages", "golem15.seo"})
plugins, err := party.Activate(app, ids)
if err != nil {
t.Fatal(err)
}

2
go.mod
View File

@@ -5,6 +5,7 @@ go 1.27.0
require (
git.golem15.com/golem15/summercms v0.5.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/goccy/go-yaml v1.19.2
github.com/jackc/pgx/v5 v5.10.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
@@ -77,7 +78,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect

View File

@@ -46,6 +46,12 @@ settings:
default_og_image: Default social image
twitter_site: X (Twitter) account
twitter_site_comment: Your handle with @, e.g. @summercms. Sent as twitter:site.
type_inject: Show the SEO tab
type_inject_comment: Off hides the tab. Saved values are kept and still used.
type_title_template: Title template
type_title_template_comment: Leave empty to use the general title template.
type_description_template: Description template
type_robots: Default indexing
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

View File

@@ -46,6 +46,12 @@ settings:
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.
type_inject: Pokazuj zakładkę SEO
type_inject_comment: Wyłączenie ukrywa zakładkę. Zapisane wartości zostają i nadal działają.
type_title_template: Szablon tytułu
type_title_template_comment: Zostaw puste, aby użyć ogólnego szablonu tytułu.
type_description_template: Szablon opisu
type_robots: Domyślne indeksowanie
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

39
models/type_setting.go Normal file
View File

@@ -0,0 +1,39 @@
package models
import "strings"
// TypeSetting is the per-content-type SEO settings row on
// golem15_seo_type_settings (D-104, D-21): whether the SEO tab is shown,
// the type's title and description templates, and its default robots.
type TypeSetting struct {
TypeKey string `gorm:"column:type_key;primaryKey"`
Inject bool `gorm:"column:inject"`
TitleTemplate string `gorm:"column:title_template"`
DescriptionTemplate string `gorm:"column:description_template"`
Robots string `gorm:"column:robots"`
}
func (TypeSetting) TableName() string { return "golem15_seo_type_settings" }
// DefaultTypeRobots is a type's robots before an editor sets one (D-21):
// tag archives noindex,follow, author pages index,follow, every other type
// empty (the site default).
func DefaultTypeRobots(key string) string {
k := strings.ToLower(key)
switch {
case strings.HasSuffix(k, "tag") || strings.HasSuffix(k, "tags"):
return "noindex,follow"
case strings.HasSuffix(k, "author") || strings.HasSuffix(k, "authors"):
return "index,follow"
}
return ""
}
// DefaultTypeSetting is a type's settings before an editor saves them.
func DefaultTypeSetting(key string) TypeSetting {
return TypeSetting{TypeKey: key, Inject: true, Robots: DefaultTypeRobots(key)}
}
func init() {
Register(TypeSetting{})
}

View File

@@ -3,6 +3,7 @@ package seo_test
import (
"net/http"
"net/url"
"strings"
"testing"
seo "git.golem15.com/golem15/sm-seo-plugin"
@@ -89,3 +90,37 @@ func TestSEOTabTracer(t *testing.T) {
t.Fatalf("unknown path = %v, want not_found", got)
}
}
// TestSEOTabPermission checks D-15 on the SEO tab: an administrator
// without golem15.seo.manage gets no SEO fields, and a posted seo_title
// changes nothing.
func TestSEOTabPermission(t *testing.T) {
env := bootEnv(t)
super := env.admin(t, true)
editor := env.admin(t, false, pagesPermission)
for _, f := range env.formSchema(t, editor, "acme/pages/pages") {
if strings.HasPrefix(f.Name, "seo_") {
t.Fatalf("editor without golem15.seo.manage sees %s", f.Name)
}
}
id := env.createPage(t, editor, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Sneaky"}})
var count int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries`).Scan(&count).Error; err != nil || count != 0 {
t.Fatalf("entries after an editor create = %d err=%v, want 0", count, err)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), super, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Custom"},
})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), editor, map[string]any{
"title": "About us", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Hacked"},
})
got := env.resolve(t, "/pages/about")
if seoBody, _ := got["seo"].(map[string]any); seoBody["title"] != "Custom" {
t.Fatalf("resolve = %v, want the title set with the permission", got)
}
show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/"+id), editor, nil)
if strings.Contains(show.Body.String(), "seo_title") {
t.Fatalf("editor show carries SEO values: %s", show.Body.String())
}
}

86
settings_test.go Normal file
View File

@@ -0,0 +1,86 @@
package seo_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// tabsOf returns the schema's tab labels in first-appearance order.
func tabsOf(fields []schemaField) []string {
var out []string
seen := map[string]bool{}
for _, f := range fields {
if !seen[f.Tab] {
seen[f.Tab] = true
out = append(out, f.Tab)
}
}
return out
}
// TestSettingsTypeTabs checks the D-104 per-type tabs on the settings
// singleton: one tab per content type in registration order between
// General and Indexing, their values in golem15_seo_type_settings with the
// D-21 defaults, template checks, and the inject switch hiding the SEO tab.
func TestSettingsTypeTabs(t *testing.T) {
t.Run("two content types", func(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
fields := env.formSchema(t, token, "golem15/seo/settings")
if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Indexing | Addresses | Verification"; got != want {
t.Fatalf("settings tabs = %q, want %q", got, want)
}
findField(t, fields, "type_acme_page_inject")
findField(t, fields, "type_acme_tag_robots")
show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/1"), token, nil)
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(show.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data["type_acme_page_inject"] != true || body.Data["type_acme_tag_robots"] != "noindex,follow" || body.Data["type_acme_page_robots"] != "" {
t.Fatalf("per-type defaults = %v", body.Data)
}
rec := env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{
"type_acme_page_title_template": "%title% - %nope%",
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "type_acme_page_title_template") || !strings.Contains(rec.Body.String(), "%nope%") {
t.Fatalf("unknown placeholder = %d %s, want 422 on the type field naming %%nope%%", rec.Code, rec.Body.String())
}
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Stored"}})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{
"type_acme_page_inject": false,
"type_acme_page_title_template": "%title% · Pages",
})
var row struct {
Inject bool
TitleTemplate string
}
if err := env.db.Raw(`SELECT inject, title_template FROM golem15_seo_type_settings WHERE type_key = ?`, pagesTypeKey).Scan(&row).Error; err != nil || row.Inject || row.TitleTemplate != "%title% · Pages" {
t.Fatalf("type settings row = %+v err=%v", row, err)
}
for _, f := range env.formSchema(t, token, "acme/pages/pages") {
if strings.HasPrefix(f.Name, "seo_") {
t.Fatalf("SEO tab field %s shown with inject off", f.Name)
}
}
got := env.resolve(t, "/pages/about")
if seoBody, _ := got["seo"].(map[string]any); seoBody["title"] != "Stored" {
t.Fatalf("resolve with the tab off = %v, want the stored title (id %s)", got, id)
}
})
t.Run("no content types", func(t *testing.T) {
env := bootPlugins(t, []string{"golem15.seo"})
token := env.admin(t, true)
if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Indexing | Addresses | Verification"; got != want {
t.Fatalf("settings tabs = %q, want %q", got, want)
}
})
}

View File

@@ -0,0 +1,31 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// createTypeSettings creates golem15_seo_type_settings: one row per content
// type key once an editor saves its settings tab (D-104). A type without a
// row uses the defaults: tab shown, general templates, D-21 robots.
var createTypeSettings = []*gormigrate.Migration{
{
ID: "202610270004_create_golem15_seo_type_settings",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE golem15_seo_type_settings (
type_key TEXT PRIMARY KEY,
inject BOOLEAN NOT NULL DEFAULT TRUE,
title_template TEXT NOT NULL DEFAULT '',
description_template TEXT NOT NULL DEFAULT '',
robots TEXT NOT NULL DEFAULT ''
)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_type_settings`).Error
},
},
}
func init() {
Register(createTypeSettings...)
}