- GET /_sitemap/api/v1/html?locale= answers the overview: the listed
addresses of the XML sitemaps by group, in the order pages, offers,
services, categories (as a tree), posts (newest first), tags, authors,
other content types, each with its title in the locale, total, the
first html_page_size links and more
- GET /_sitemap/api/v1/html/{group}?locale=&page= answers one page of a
group; past the last page, an unknown group or the HTML sitemap off
answer 404; every answer is noindex; per-IP bucket sitemap-html
- the HTML sitemap tab: html_enabled, the address per locale (html_paths)
and html_page_size 50 to 500, with errors.html_path and errors.page_size
- contract.Listing.Settings answers HTMLEnabled, HTMLPaths, HTMLPageSize
and KeyFile
335 lines
10 KiB
Go
335 lines
10 KiB
Go
package sitemap
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
"github.com/goccy/go-yaml"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// The settings form's tab keys: the Content types tab follows Files; the
|
|
// HTML sitemap tab's address field is added after html_enabled.
|
|
const (
|
|
TabFiles = "golem15.sitemap::lang.settings.tab_files"
|
|
TabTypes = "golem15.sitemap::lang.settings.tab_types"
|
|
TabHTML = "golem15.sitemap::lang.settings.tab_html"
|
|
)
|
|
|
|
// htmlPathField is the HTML sitemap address field: translatable, stored
|
|
// as a locale map in html_paths by the plugin's form extension (the
|
|
// settings row has no translation writer of its own).
|
|
const htmlPathField = "html_path"
|
|
|
|
// The phrase keys of the Content types refusals (UI-SPEC C26).
|
|
const (
|
|
errSlug = "golem15.sitemap::lang.errors.slug"
|
|
errSlugTaken = "golem15.sitemap::lang.errors.slug_taken"
|
|
)
|
|
|
|
var typeFieldUnsafe = regexp.MustCompile(`[^a-z0-9_]`)
|
|
|
|
// typeFieldKey turns a content type key into the part of a field name:
|
|
// every character outside [a-z0-9_] becomes "_".
|
|
func typeFieldKey(key string) string {
|
|
return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_")
|
|
}
|
|
|
|
// AdminActionExtensions extends the plugin's own settings singleton: the
|
|
// Content types tab (one block per signpost content type, in registration
|
|
// order; none without content types) and the HTML sitemap address.
|
|
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
|
var types []pact.MenuItemType
|
|
if reg := p.registry(); reg != nil {
|
|
types = reg.ContentTypes()
|
|
}
|
|
form := p.typeSettingsTab(types)
|
|
if form == nil {
|
|
return nil
|
|
}
|
|
return []pact.AdminActionExtension{{Controller: controllers.SettingsControllerID, Form: form}}
|
|
}
|
|
|
|
func (p *Plugin) registry() *signpost.Registry {
|
|
if p == nil || p.app == nil {
|
|
return nil
|
|
}
|
|
reg, _ := p.app.Lookup[*signpost.Registry]()
|
|
return reg
|
|
}
|
|
|
|
// typeSettingsDocument is the fields document of the extension (UI-SPEC
|
|
// C26): per type, the include switch (labelled with the type's label), the
|
|
// file name and the HTML sitemap heading on the Content types tab, then
|
|
// the HTML sitemap address after html_enabled.
|
|
func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
|
|
fields := yaml.MapSlice{}
|
|
for _, typ := range types {
|
|
base := "type_" + typeFieldKey(typ.Key) + "_"
|
|
label := typ.Label
|
|
if strings.TrimSpace(label) == "" {
|
|
label = typ.Key
|
|
}
|
|
fields = append(fields,
|
|
yaml.MapItem{Key: base + "include", Value: yaml.MapSlice{
|
|
{Key: "label", Value: label},
|
|
{Key: "comment", Value: "golem15.sitemap::lang.settings.type_include"},
|
|
{Key: "type", Value: "switch"},
|
|
{Key: "span", Value: "left"},
|
|
{Key: "tab", Value: TabTypes},
|
|
}},
|
|
yaml.MapItem{Key: base + "slug", Value: yaml.MapSlice{
|
|
{Key: "label", Value: "golem15.sitemap::lang.settings.type_slug"},
|
|
{Key: "comment", Value: "golem15.sitemap::lang.settings.type_slug_comment"},
|
|
{Key: "type", Value: "text"},
|
|
{Key: "span", Value: "right"},
|
|
{Key: "tab", Value: TabTypes},
|
|
}},
|
|
yaml.MapItem{Key: base + "title", Value: yaml.MapSlice{
|
|
{Key: "label", Value: "golem15.sitemap::lang.settings.type_title"},
|
|
{Key: "type", Value: "mltext"},
|
|
{Key: "span", Value: "full"},
|
|
{Key: "tab", Value: TabTypes},
|
|
}},
|
|
)
|
|
}
|
|
fields = append(fields, yaml.MapItem{Key: htmlPathField, Value: yaml.MapSlice{
|
|
{Key: "label", Value: "golem15.sitemap::lang.settings.html_path"},
|
|
{Key: "comment", Value: "golem15.sitemap::lang.settings.html_path_comment"},
|
|
{Key: "type", Value: "mltext"},
|
|
{Key: "span", Value: "left"},
|
|
{Key: "tab", Value: TabHTML},
|
|
{Key: "after", Value: "html_enabled"},
|
|
}})
|
|
doc := yaml.MapSlice{}
|
|
if len(types) > 0 {
|
|
doc = append(doc, yaml.MapItem{Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}})
|
|
}
|
|
return yaml.Marshal(append(doc, yaml.MapItem{Key: "fields", Value: fields}))
|
|
}
|
|
|
|
// typeSettingsTab is the plugin's form extension on its own settings
|
|
// controller: the Content types tab, loaded from and saved to
|
|
// golem15_sitemap_types, and the HTML sitemap address (html_paths).
|
|
func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExtension {
|
|
doc, err := typeSettingsDocument(types)
|
|
if err != nil {
|
|
pluginLog.Error("golem15.sitemap: content types tab", "error", err)
|
|
return nil
|
|
}
|
|
return &pact.AdminFormExtension{
|
|
FieldsYAML: doc,
|
|
Permissions: []string{controllers.PermissionAccessDefinitions},
|
|
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
|
|
}
|
|
def := p.defaultLocale(ctx, in.DB)
|
|
tr := p.translator()
|
|
out := map[string]any{}
|
|
for _, typ := range types {
|
|
cfg := classes.EffectiveType(typ, rows)
|
|
base := "type_" + typeFieldKey(typ.Key) + "_"
|
|
out[base+"include"] = cfg.Include
|
|
out[base+"slug"] = cfg.Slug
|
|
titles := cfg.Titles
|
|
if len(titles) == 0 {
|
|
titles = map[string]string{def: controllers.Phrase(ctx, tr, typ.Label, nil)}
|
|
}
|
|
out[base+"title"] = titles
|
|
}
|
|
settings, err := classes.LoadSettings(in.DB.WithContext(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[htmlPathField] = settings.EffectiveHTMLPaths()
|
|
return out, nil
|
|
},
|
|
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
if err := saveTypeSettings(ctx, in, types); err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
return pact.AdminFormSaveResult{}, saveHTMLPaths(in)
|
|
},
|
|
}
|
|
}
|
|
|
|
// saveTypeSettings stores the submitted Content types values. A slug
|
|
// outside [a-z0-9-] is refused with errors.slug, one that another type (or
|
|
// the image and News sitemaps) uses with errors.slug_taken; an empty slug
|
|
// is the type's default.
|
|
func saveTypeSettings(ctx context.Context, in pact.AdminFormSave, types []pact.MenuItemType) error {
|
|
rows, err := classes.LoadTypeSettings(in.DB)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
refusals := pact.AdminFormErrors{}
|
|
next := make([]models.TypeSetting, 0, len(types))
|
|
slugOwner := map[string]string{}
|
|
for _, typ := range types {
|
|
base := "type_" + typeFieldKey(typ.Key) + "_"
|
|
cfg := classes.EffectiveType(typ, rows)
|
|
row, ok := rows[typ.Key]
|
|
if !ok {
|
|
row = models.TypeSetting{TypeKey: typ.Key, Include: true}
|
|
}
|
|
if raw, sent := in.Values[base+"include"]; sent {
|
|
row.Include = truthy(raw)
|
|
}
|
|
slug := cfg.Slug
|
|
if raw, sent := in.Values[base+"slug"]; sent {
|
|
slug = strings.TrimSpace(stringOf(raw))
|
|
if slug == "" {
|
|
slug = classes.DefaultSlug(typ.Key)
|
|
} else if !classes.ValidSlug(slug) {
|
|
refusals[base+"slug"] = append(refusals[base+"slug"], errSlug)
|
|
}
|
|
}
|
|
if classes.ReservedSlugs[slug] {
|
|
refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken)
|
|
} else if owner, taken := slugOwner[slug]; taken && owner != typ.Key {
|
|
refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken)
|
|
}
|
|
slugOwner[slug] = typ.Key
|
|
if slug == classes.DefaultSlug(typ.Key) {
|
|
row.Slug = ""
|
|
} else {
|
|
row.Slug = slug
|
|
}
|
|
if raw, sent := in.Values[base+"title"]; sent {
|
|
row.Titles = lagoon.Jsonable[map[string]string]{Data: titlesOf(raw), Valid: true}
|
|
}
|
|
next = append(next, row)
|
|
}
|
|
if len(refusals) > 0 {
|
|
return refusals
|
|
}
|
|
now := time.Now().UTC()
|
|
for _, row := range next {
|
|
row.UpdatedAt = now
|
|
if !row.Titles.Valid {
|
|
row.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
|
}
|
|
if err := in.DB.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "type_key"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"include", "slug", "titles", "updated_at"}),
|
|
}).Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saveHTMLPaths stores a submitted HTML sitemap address: the locales sent
|
|
// replace theirs in html_paths (a blank one removes it), the others keep
|
|
// theirs. An address that models.ValidHTMLPath refuses is refused with
|
|
// errors.html_path.
|
|
func saveHTMLPaths(in pact.AdminFormSave) error {
|
|
raw, sent := in.Values[htmlPathField]
|
|
if !sent {
|
|
return nil
|
|
}
|
|
paths := map[string]string{}
|
|
switch v := raw.(type) {
|
|
case map[string]string:
|
|
paths = v
|
|
case map[string]any:
|
|
for k, s := range v {
|
|
text, _ := s.(string)
|
|
paths[k] = text
|
|
}
|
|
}
|
|
merged, problem := classes.MergeHTMLPaths(in.DB, paths)
|
|
if problem {
|
|
return pact.AdminFormErrors{htmlPathField: {models.HTMLPathProblem}}
|
|
}
|
|
return classes.StoreHTMLPaths(in.DB, merged)
|
|
}
|
|
|
|
// titlesOf reads a submitted translatable value (a map of locale to text);
|
|
// blank texts are dropped.
|
|
func titlesOf(v any) map[string]string {
|
|
out := map[string]string{}
|
|
switch m := v.(type) {
|
|
case map[string]string:
|
|
for k, s := range m {
|
|
if strings.TrimSpace(s) != "" {
|
|
out[k] = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
case map[string]any:
|
|
for k, s := range m {
|
|
if text, ok := s.(string); ok && strings.TrimSpace(text) != "" {
|
|
out[k] = strings.TrimSpace(text)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// translator returns the application's translator, nil when none.
|
|
func (p *Plugin) translator() *phrasebook.Translator {
|
|
if p == nil || p.app == nil {
|
|
return nil
|
|
}
|
|
tr, _ := p.app.Lookup[*phrasebook.Translator]()
|
|
return tr
|
|
}
|
|
|
|
// defaultLocale is the site's default content locale: the translation
|
|
// writer's default, else app.locale, else en.
|
|
func (p *Plugin) defaultLocale(ctx context.Context, db *gorm.DB) string {
|
|
if p != nil && p.app != nil {
|
|
if w, ok := p.app.Lookup[cabana.TranslationWriter](); ok && w != nil {
|
|
if code, err := w.DefaultLocale(ctx, db); err == nil && code != "" {
|
|
return code
|
|
}
|
|
}
|
|
if p.app.Config != nil {
|
|
if code := strings.TrimSpace(p.app.Config.String("app.locale")); code != "" {
|
|
return code
|
|
}
|
|
}
|
|
}
|
|
return "en"
|
|
}
|
|
|
|
var _ pact.HasAdminActionExtensions = (*Plugin)(nil)
|