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