Files
sm-seo-plugin/extensions_settings.go

242 lines
7.1 KiB
Go

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