diff --git a/classes/type_settings.go b/classes/type_settings.go new file mode 100644 index 0000000..c80eae9 --- /dev/null +++ b/classes/type_settings.go @@ -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 +} diff --git a/extensions.go b/extensions.go index 39bc33b..379060a 100644 --- a/extensions.go +++ b/extensions.go @@ -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 +} diff --git a/extensions_settings.go b/extensions_settings.go new file mode 100644 index 0000000..fb1cc8c --- /dev/null +++ b/extensions_settings.go @@ -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) +} diff --git a/fixture_test.go b/fixture_test.go index d0ab29c..33f429e 100644 --- a/fixture_test.go +++ b/fixture_test.go @@ -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) } diff --git a/go.mod b/go.mod index c0ef1f5..7cf8cb9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 8cf9973..1cd01e7 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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 diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index d8b1096..e7b8b04 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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 diff --git a/models/type_setting.go b/models/type_setting.go new file mode 100644 index 0000000..6dba0ae --- /dev/null +++ b/models/type_setting.go @@ -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{}) +} diff --git a/seo_tab_test.go b/seo_tab_test.go index 8547261..dc052d4 100644 --- a/seo_tab_test.go +++ b/seo_tab_test.go @@ -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()) + } +} diff --git a/settings_test.go b/settings_test.go new file mode 100644 index 0000000..aaf6763 --- /dev/null +++ b/settings_test.go @@ -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) + } + }) +} diff --git a/updates/202610270004_create_golem15_seo_type_settings.go b/updates/202610270004_create_golem15_seo_type_settings.go new file mode 100644 index 0000000..ef6bfd9 --- /dev/null +++ b/updates/202610270004_create_golem15_seo_type_settings.go @@ -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...) +}