242 lines
7.1 KiB
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)
|
|
}
|