feat: per-type settings tabs and the SEO tab permission
This commit is contained in:
43
classes/type_settings.go
Normal file
43
classes/type_settings.go
Normal 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
|
||||
}
|
||||
@@ -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
241
extensions_settings.go
Normal 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)
|
||||
}
|
||||
@@ -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
2
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
39
models/type_setting.go
Normal 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{})
|
||||
}
|
||||
@@ -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
86
settings_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
31
updates/202610270004_create_golem15_seo_type_settings.go
Normal file
31
updates/202610270004_create_golem15_seo_type_settings.go
Normal 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...)
|
||||
}
|
||||
Reference in New Issue
Block a user