diff --git a/admin.go b/admin.go
index c299864..cf4d096 100644
--- a/admin.go
+++ b/admin.go
@@ -5,29 +5,28 @@ import (
"io/fs"
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
- "git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
-// adminFS holds the admin YAML file by file: the controllers directory also
-// holds Go sources.
+// adminFS holds the admin YAML and partial templates by pattern (the
+// controllers directory also holds Go sources), so a new screen or partial
+// needs no edit here.
//
-//go:embed controllers/definitions/config_form.yaml models/definition/fields.yaml models/settings/fields.yaml
+//go:embed controllers/*/*.yaml controllers/*/_*.htm models/*/*.yaml
var adminFS embed.FS
//go:embed lang
var langFS embed.FS
-// AdminFS serves the admin YAML.
+// AdminFS serves the admin YAML and partials.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// LangFS is the embedded English and Polish phrasebooks.
func (p *Plugin) LangFS() fs.FS { return langFS }
// AdminControllers hands the controllers a lazy accessor of the booted app,
-// so they read the database and the menu item type registry at request
-// time.
+// so they read the database and the content type registry at request time.
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App {
if p == nil {
@@ -37,31 +36,21 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
})
}
-// Settings registers the two PHP settings entries in the CMS category: the
-// sitemap definition (a link to the singleton definitions controller) and
-// the sitemap settings form. Both require golem15.sitemap.access_definitions.
+// Settings registers the one sitemap settings tile (UI-SPEC C26): a link to
+// the settings singleton controller in the CMS category, behind
+// golem15.sitemap.access_definitions. It replaces the Phase 17 definitions
+// and settings tiles (D-32).
func (p *Plugin) Settings() []pact.SettingsItem {
- return []pact.SettingsItem{
- {
- Code: "sitemap",
- Label: "golem15.sitemap::lang.plugin.name",
- Description: "golem15.sitemap::lang.plugin.description",
- Category: "golem15.sitemap::lang.settings.category",
- Icon: "network",
- Permissions: []string{controllers.PermissionAccessDefinitions},
- Controller: controllers.DefinitionsControllerID,
- },
- {
- Code: "sitemap_settings",
- Label: "golem15.sitemap::lang.settings.label",
- Description: "golem15.sitemap::lang.settings.description",
- Category: "golem15.sitemap::lang.settings.category",
- Icon: "settings",
- Permissions: []string{controllers.PermissionAccessDefinitions},
- Form: "models/settings/fields.yaml",
- NewModel: func() any { return &models.Settings{} },
- },
- }
+ return []pact.SettingsItem{{
+ Code: "sitemap",
+ Label: "golem15.sitemap::lang.settings.label",
+ Description: "golem15.sitemap::lang.settings.description",
+ Category: "golem15.sitemap::lang.settings.category",
+ Icon: "network",
+ Keywords: []string{"sitemap", "sitemap.xml", "lastmod", "hreflang"},
+ Permissions: []string{controllers.PermissionAccessDefinitions},
+ Controller: controllers.SettingsControllerID,
+ }}
}
var (
diff --git a/classes/dispatch.go b/classes/dispatch.go
new file mode 100644
index 0000000..722d2df
--- /dev/null
+++ b/classes/dispatch.go
@@ -0,0 +1,66 @@
+package classes
+
+import (
+ "context"
+ "time"
+
+ "git.golem15.com/golem15/summercms/modules/backpack"
+ "git.golem15.com/golem15/summercms/modules/conga"
+ "git.golem15.com/golem15/summercms/modules/lagoon"
+ "git.golem15.com/golem15/summercms/modules/phrasebook"
+ "gorm.io/gorm"
+)
+
+// RebuildLabelKey is the phrase key of the rebuild job's label.
+const RebuildLabelKey = "golem15.sitemap::lang.jobs.rebuild"
+
+// buildingWindow bounds the "rebuilding now" state of the settings screen:
+// a request older than this without a finished rebuild (no worker runs)
+// is no longer shown as running.
+const buildingWindow = time.Hour
+
+// RequestRebuild records that a rebuild was asked for (the settings
+// screen's files.building state) and dispatches the rebuild job once db's
+// transaction commits, so the job reads what the transaction wrote. A
+// queue error is logged and never fails the caller.
+func RequestRebuild(ctx context.Context, app *backpack.App, db *gorm.DB) error {
+ if app == nil || db == nil {
+ return ErrNoDatabase
+ }
+ if err := db.Exec(`UPDATE golem15_sitemap_settings SET rebuild_requested_at = ? WHERE id = 1`, time.Now().UTC()).Error; err != nil {
+ return err
+ }
+ lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
+ if _, err := DispatchRebuild(ctx, app); err != nil {
+ pluginLog.Warn("golem15.sitemap: queue rebuild", "error", err)
+ }
+ })
+ return nil
+}
+
+// DispatchRebuild dispatches the rebuild job now, with a summer_jobs row
+// labelled in the request locale, and returns the row id.
+func DispatchRebuild(ctx context.Context, app *backpack.App) (uint, error) {
+ db, ok := app.Lookup[*gorm.DB]()
+ if !ok || db == nil {
+ return 0, ErrNoDatabase
+ }
+ m, err := conga.From(app)
+ if err != nil {
+ return 0, err
+ }
+ label := RebuildLabelKey
+ if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
+ label = tr.Get(ctx, RebuildLabelKey, nil)
+ }
+ return m.Dispatch(ctx, db.WithContext(ctx), RebuildArgs{}, conga.DispatchOpts{Label: label})
+}
+
+// Building reports whether a rebuild asked for from the admin has not
+// finished yet (within buildingWindow).
+func Building(requested, built *time.Time, now time.Time) bool {
+ if requested == nil || now.Sub(*requested) > buildingWindow {
+ return false
+ }
+ return built == nil || built.Before(*requested)
+}
diff --git a/classes/rebuild.go b/classes/rebuild.go
index c9adeda..c161d6d 100644
--- a/classes/rebuild.go
+++ b/classes/rebuild.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "git.golem15.com/golem15/sm-sitemap-plugin/contract"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
@@ -124,8 +125,12 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
if err != nil {
return res, err
}
+ types, err := LoadTypeSettings(db)
+ if err != nil {
+ return res, err
+ }
reg, _ := app.Lookup[*signpost.Registry]()
- found, err := collect(ctx, reg, settings)
+ found, err := collect(ctx, reg, settings, types)
if err != nil {
return res, err
}
@@ -145,8 +150,10 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
}
// collect walks the content types page by page and adds the additional
-// addresses.
-func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings) (collected, error) {
+// addresses. The records of a type switched off in the settings are kept
+// in the table with the reason contract.ReasonType, so the settings screen
+// counts them.
+func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings, types map[string]models.TypeSetting) (collected, error) {
out := collected{unpublished: map[slotKey]bool{}}
seen := map[slotKey]bool{}
add := func(w wanted) {
@@ -159,7 +166,11 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
unprefixed := models.Text(settings.DefaultLocale)
if reg != nil {
for _, typ := range reg.ContentTypes() {
- slug := DefaultSlug(typ.Key)
+ cfg := EffectiveType(typ, types)
+ reason := ""
+ if !cfg.Include {
+ reason = contract.ReasonType
+ }
after := ""
for {
page, err := reg.Records(ctx, typ.Key, after, recordsPage, unprefixed)
@@ -177,10 +188,11 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
}
add(wanted{
key: slotKey{typ.Key, rec.ID, l.Locale},
- grp: slug,
+ grp: cfg.Slug,
url: l.URL,
title: l.Title,
firstMod: rec.LastMod,
+ reason: reason,
})
}
}
@@ -196,7 +208,7 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
if !models.AllowedAddress(u) {
continue
}
- mod := ParseDate(e.LastMod)
+ mod := models.ParseDate(e.LastMod)
add(wanted{
key: slotKey{models.ExtraTypeKey, ExtraRecordID(u), ""},
grp: models.ExtraGroup,
@@ -209,23 +221,6 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
return out, nil
}
-// ParseDate reads a date as the admin's date picker or an import sends it
-// (RFC 3339, "2006-01-02 15:04:05" or "2006-01-02", UTC); nil when empty or
-// unreadable.
-func ParseDate(s string) *time.Time {
- s = strings.TrimSpace(s)
- if s == "" {
- return nil
- }
- for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"} {
- if t, err := time.Parse(layout, s); err == nil {
- t = t.UTC()
- return &t
- }
- }
- return nil
-}
-
// apply writes the found addresses into the slot table and numbers the
// files, inside the rebuild's transaction.
func apply(tx *gorm.DB, found collected, settings models.Settings, site string) (RebuildResult, error) {
diff --git a/classes/types.go b/classes/types.go
new file mode 100644
index 0000000..32b552e
--- /dev/null
+++ b/classes/types.go
@@ -0,0 +1,54 @@
+package classes
+
+import (
+ "git.golem15.com/golem15/sm-sitemap-plugin/models"
+ "git.golem15.com/golem15/summercms/modules/pact"
+ "gorm.io/gorm"
+)
+
+// ReservedSlugs are file name parts no content type may use: the image
+// and News sitemaps own them.
+var ReservedSlugs = map[string]bool{"images": true, "news": true}
+
+// TypeConfig is a content type's effective sitemap setting: its key and
+// label, whether its records are listed, its group slug and its HTML
+// sitemap headings (nil when none was saved).
+type TypeConfig struct {
+ Key string
+ Label string
+ Include bool
+ Slug string
+ Titles map[string]string
+}
+
+// LoadTypeSettings reads the saved content type settings 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 _, r := range rows {
+ out[r.TypeKey] = r
+ }
+ return out, nil
+}
+
+// EffectiveType is the setting of typ: the saved row, else listed under
+// DefaultSlug. A saved slug that is not a ValidSlug falls back to the
+// default.
+func EffectiveType(typ pact.MenuItemType, rows map[string]models.TypeSetting) TypeConfig {
+ cfg := TypeConfig{Key: typ.Key, Label: typ.Label, Include: true, Slug: DefaultSlug(typ.Key)}
+ row, ok := rows[typ.Key]
+ if !ok {
+ return cfg
+ }
+ cfg.Include = row.Include
+ if ValidSlug(row.Slug) {
+ cfg.Slug = row.Slug
+ }
+ if titles := row.Titles.Get(); len(titles) > 0 {
+ cfg.Titles = titles
+ }
+ return cfg
+}
diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go
index 5e8130d..5e72d50 100644
--- a/controllers/admin_registry.go
+++ b/controllers/admin_registry.go
@@ -11,5 +11,6 @@ import (
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
definitionsAdminController{app: app},
+ settingsAdminController{app: app},
}
}
diff --git a/controllers/settings.go b/controllers/settings.go
new file mode 100644
index 0000000..940a03b
--- /dev/null
+++ b/controllers/settings.go
@@ -0,0 +1,266 @@
+package controllers
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "git.golem15.com/golem15/sm-sitemap-plugin/classes"
+ "git.golem15.com/golem15/sm-sitemap-plugin/contract"
+ "git.golem15.com/golem15/sm-sitemap-plugin/models"
+ "git.golem15.com/golem15/summercms/modules/backpack"
+ "git.golem15.com/golem15/summercms/modules/cabana"
+ "git.golem15.com/golem15/summercms/modules/pact"
+ "git.golem15.com/golem15/summercms/modules/phrasebook"
+ "gorm.io/gorm"
+)
+
+// SettingsControllerID is the cabana controller id of the sitemap settings
+// singleton (UI-SPEC C26), linked from the sitemap settings tile.
+const SettingsControllerID = "golem15.sitemap.settings"
+
+// The reasons the files partial counts as "left out", in display order
+// (P33-11). A deleted or unpublished address is a hole, not left out.
+var leftOutReasons = []string{contract.ReasonNoindex, contract.ReasonCanonical, contract.ReasonRedirect, contract.ReasonType}
+
+// settingsAdminController edits the sitemap settings singleton: Files,
+// Content types (added by the plugin's own form extension) and Additional
+// addresses. A save dispatches a rebuild.
+type settingsAdminController struct {
+ app func() *backpack.App
+}
+
+func (settingsAdminController) ID() string { return SettingsControllerID }
+func (settingsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Settings` }
+func (settingsAdminController) ConfigDir() string { return "controllers/settings" }
+func (settingsAdminController) NewRecord() any { return &models.Settings{} }
+func (settingsAdminController) RequiredPermissions() []string {
+ return []string{PermissionAccessDefinitions}
+}
+
+// SingletonRecord returns settings row 1, creating it with the defaults
+// when the migration's row is gone (safe against a concurrent first
+// request through ON CONFLICT DO NOTHING).
+func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
+ if db == nil {
+ return nil, errors.New("golem15.sitemap: database is not configured")
+ }
+ var rows []models.Settings
+ if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ if len(rows) > 0 {
+ return &rows[0], nil
+ }
+ if err := db.Exec(`INSERT INTO golem15_sitemap_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
+ return nil, errors.New("golem15.sitemap: settings row is missing")
+ }
+ return &rows[0], nil
+}
+
+// FormBeforeUpdate refuses the settings Problems finds (UI-SPEC C26:
+// errors.frontend_url on site_url, errors.file_size, errors.url_scheme and
+// errors.lastmod on an additional address).
+func (settingsAdminController) FormBeforeUpdate(_ context.Context, model any) error {
+ s, ok := model.(*models.Settings)
+ if !ok || s == nil {
+ return nil
+ }
+ return s.Validate()
+}
+
+// FormBeforeCreate checks a created row like an update.
+func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
+ return c.FormBeforeUpdate(ctx, model)
+}
+
+// FormAfterUpdate dispatches a rebuild once the save commits (C26: saving
+// the form rebuilds the files now). A queue error is logged and never fails
+// the save.
+func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error {
+ app := appOf(c.app)
+ tx, err := database(ctx, c.app)
+ if err != nil || app == nil {
+ return nil
+ }
+ return classes.RequestRebuild(ctx, app, tx)
+}
+
+// FormAfterCreate dispatches a rebuild like an update.
+func (c settingsAdminController) FormAfterCreate(ctx context.Context, model any) error {
+ return c.FormAfterUpdate(ctx, model)
+}
+
+// filesView is the view model of the S1 partial sitemap_files.
+type filesView struct {
+ Building bool
+ Empty bool
+ Files []fileRow
+ LeftOut []reasonRow
+ Note string
+}
+
+// fileRow is one row of the S1 files table: the index first, then each
+// file. URL is the backend route that serves it.
+type fileRow struct {
+ Name string
+ URL string
+ Count int
+ LastMod string
+}
+
+// reasonRow is one row of the S1 left-out table.
+type reasonRow struct {
+ Label string
+ Count int
+}
+
+// PartialData serves the S1 files partial: the index and its files with
+// their address counts and newest lastmod, the left-out counts by reason
+// and the index note; files.empty before the first build and
+// files.building while a rebuild asked for from the admin runs.
+func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
+ if name != "sitemap_files" {
+ return nil, nil
+ }
+ app := appOf(c.app)
+ db, err := database(ctx, c.app)
+ if err != nil {
+ return nil, err
+ }
+ settings, err := classes.LoadSettings(db)
+ if err != nil {
+ return nil, err
+ }
+ site := classes.SiteURL(app, settings)
+ view := filesView{Building: classes.Building(settings.RebuildRequestedAt, settings.BuiltAt, time.Now())}
+ var files []struct {
+ Grp string `gorm:"column:grp"`
+ FileNo int `gorm:"column:file_no"`
+ N int `gorm:"column:n"`
+ LastMod *time.Time `gorm:"column:lastmod"`
+ }
+ if err := db.Raw(`SELECT grp, file_no, COUNT(*) AS n, MAX(lastmod) AS lastmod FROM golem15_sitemap_urls
+WHERE excluded_reason = '' AND file_no IS NOT NULL
+GROUP BY grp, file_no ORDER BY grp, file_no`).Scan(&files).Error; err != nil {
+ return nil, err
+ }
+ // The partial kit links only root-relative addresses, so a file links
+ // to the backend route that serves it; the note names the public index.
+ link := func(name string) string {
+ if name == "sitemap.xml" {
+ return "/sitemap.xml"
+ }
+ return "/_sitemap/files/" + name
+ }
+ if len(files) == 0 {
+ view.Empty = true
+ } else {
+ index := fileRow{Name: "sitemap.xml", URL: link("sitemap.xml")}
+ var newest *time.Time
+ for _, f := range files {
+ index.Count += f.N
+ if f.LastMod != nil && (newest == nil || f.LastMod.After(*newest)) {
+ newest = f.LastMod
+ }
+ name := classes.FileName(f.Grp, f.FileNo)
+ view.Files = append(view.Files, fileRow{Name: name, URL: link(name), Count: f.N, LastMod: formatTime(f.LastMod)})
+ }
+ index.LastMod = formatTime(newest)
+ view.Files = append([]fileRow{index}, view.Files...)
+ }
+ var counts []struct {
+ Reason string `gorm:"column:excluded_reason"`
+ N int `gorm:"column:n"`
+ }
+ if err := db.Raw(`SELECT excluded_reason, COUNT(*) AS n FROM golem15_sitemap_urls
+WHERE excluded_reason NOT IN ('', ?, ?) GROUP BY excluded_reason`, models.ReasonDeleted, models.ReasonUnpublished).Scan(&counts).Error; err != nil {
+ return nil, err
+ }
+ byReason := map[string]int{}
+ for _, row := range counts {
+ byReason[row.Reason] = row.N
+ }
+ tr := translator(c.app)
+ for _, reason := range leftOutReasons {
+ if n := byReason[reason]; n > 0 {
+ view.LeftOut = append(view.LeftOut, reasonRow{Label: Phrase(ctx, tr, "golem15.sitemap::lang.files.reason_"+reason, nil), Count: n})
+ delete(byReason, reason)
+ }
+ }
+ for reason, n := range byReason {
+ view.LeftOut = append(view.LeftOut, reasonRow{Label: reason, Count: n})
+ }
+ indexURL := "/sitemap.xml"
+ if site != "" {
+ indexURL = site + "/sitemap.xml"
+ }
+ view.Note = Phrase(ctx, tr, "golem15.sitemap::lang.files.index_note", map[string]string{"url": indexURL})
+ return view, nil
+}
+
+// formatTime renders a lastmod for the files table, "" when unknown.
+func formatTime(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return t.UTC().Format("2006-01-02 15:04 UTC")
+}
+
+// appOf calls the lazy app accessor.
+func appOf(app func() *backpack.App) *backpack.App {
+ if app == nil {
+ return nil
+ }
+ return app()
+}
+
+// translator returns the application's translator, nil when none.
+func translator(app func() *backpack.App) *phrasebook.Translator {
+ a := appOf(app)
+ if a == nil {
+ return nil
+ }
+ tr, _ := a.Lookup[*phrasebook.Translator]()
+ return tr
+}
+
+// database returns the write transaction of ctx, else the app database.
+func database(ctx context.Context, app func() *backpack.App) (*gorm.DB, error) {
+ if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
+ return tx, nil
+ }
+ a := appOf(app)
+ if a == nil {
+ return nil, errors.New("golem15.sitemap: app is not booted")
+ }
+ db, ok := a.Lookup[*gorm.DB]()
+ if !ok || db == nil {
+ return nil, errors.New("golem15.sitemap: database is not configured")
+ }
+ return db.WithContext(ctx), nil
+}
+
+// Phrase translates key with params in the request locale; without a
+// translator it returns the key.
+func Phrase(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string {
+ if tr == nil {
+ return key
+ }
+ return tr.Get(ctx, key, params)
+}
+
+var (
+ _ pact.AdminController = settingsAdminController{}
+ _ pact.AdminPermissioned = settingsAdminController{}
+ _ pact.AdminRecordSource = settingsAdminController{}
+ _ pact.AdminSingleton = settingsAdminController{}
+ _ pact.AdminPartialData = settingsAdminController{}
+ _ pact.FormBeforeCreate = settingsAdminController{}
+ _ pact.FormBeforeUpdate = settingsAdminController{}
+ _ pact.FormAfterCreate = settingsAdminController{}
+ _ pact.FormAfterUpdate = settingsAdminController{}
+)
diff --git a/controllers/settings/_sitemap_files.htm b/controllers/settings/_sitemap_files.htm
new file mode 100644
index 0000000..1a2d07f
--- /dev/null
+++ b/controllers/settings/_sitemap_files.htm
@@ -0,0 +1,42 @@
+{{- if .Data.Building -}}
+
+
{{ trans "golem15.sitemap::lang.files.building" }}
+
+{{- end -}}
+{{- if .Data.Empty -}}
+
+
{{ trans "golem15.sitemap::lang.files.empty" }}
+
+{{- else -}}
+
+{{ trans "golem15.sitemap::lang.files.label" }}
+
+
+| {{ trans "golem15.sitemap::lang.files.file" }} | {{ trans "golem15.sitemap::lang.files.file_urls" }} | {{ trans "golem15.sitemap::lang.files.file_lastmod" }} |
+
+{{- range .Data.Files -}}
+{{ if .URL }}{{ .Name }}{{ else }}{{ .Name }}{{ end }} | {{ .Count }} | {{ .LastMod }} |
+{{- end -}}
+
+
+
+
+{{- end -}}
+{{- if .Data.LeftOut -}}
+
+{{ trans "golem15.sitemap::lang.files.left_out" }}
+
+
+| {{ trans "golem15.sitemap::lang.files.left_out_reason" }} | {{ trans "golem15.sitemap::lang.files.left_out_count" }} |
+
+{{- range .Data.LeftOut -}}
+| {{ .Label }} | {{ .Count }} |
+{{- end -}}
+
+
+
+
+{{- end -}}
+
diff --git a/controllers/settings/config_form.yaml b/controllers/settings/config_form.yaml
new file mode 100644
index 0000000..b211d7c
--- /dev/null
+++ b/controllers/settings/config_form.yaml
@@ -0,0 +1,20 @@
+# ===================================
+# Form Behavior Config
+# ===================================
+#
+# The sitemap settings singleton (UI-SPEC C26), linked from the sitemap
+# settings tile. The plugin's form extension adds the Content types tab
+# after Files.
+
+name: golem15.sitemap::lang.settings.label
+form: ~/plugins/golem15/sitemap/models/settings/fields.yaml
+modelClass: Golem15\Sitemap\Models\Settings
+defaultRedirect: system/settings
+
+update:
+ redirect: golem15/sitemap/settings
+ redirectClose: system/settings
+
+messages:
+ update: golem15.sitemap::lang.settings.label
+ saved: backend::lang.messages.form.saved
diff --git a/extensions_settings.go b/extensions_settings.go
new file mode 100644
index 0000000..f3e1d93
--- /dev/null
+++ b/extensions_settings.go
@@ -0,0 +1,287 @@
+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.
+const (
+ TabFiles = "golem15.sitemap::lang.settings.tab_files"
+ TabTypes = "golem15.sitemap::lang.settings.tab_types"
+)
+
+// 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 adds the Content types tab to the plugin's own
+// settings singleton: one block per signpost content type, in registration
+// order. Nothing is added when no content type is registered.
+func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
+ reg := p.registry()
+ if reg == nil {
+ return nil
+ }
+ types := reg.ContentTypes()
+ if len(types) == 0 {
+ return nil
+ }
+ 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 Content types tab
+// (UI-SPEC C26): per type, the include switch (labelled with the type's
+// label), the file name and the HTML sitemap heading.
+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},
+ }},
+ )
+ }
+ return yaml.Marshal(yaml.MapSlice{
+ {Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}},
+ {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.
+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
+ }
+ return out, nil
+ },
+ Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
+ return pact.AdminFormSaveResult{}, saveTypeSettings(ctx, in, types)
+ },
+ }
+}
+
+// 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
+}
+
+// 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)
diff --git a/go.mod b/go.mod
index a4b085c..86b35d7 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.27.0
require (
git.golem15.com/golem15/summercms v0.6.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
@@ -75,7 +76,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
diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml
index 1f26c43..f4f10ce 100644
--- a/lang/en/lang.yaml
+++ b/lang/en/lang.yaml
@@ -1,6 +1,6 @@
plugin:
name: Sitemap
- description: Generate a sitemap.xml file for your website.
+ description: XML sitemaps per content type, with stable files, real lastmod and hreflang.
permissions:
access_settings: Access sitemap configuration settings
access_definitions: Access sitemap definitions page
@@ -41,29 +41,80 @@ errors:
url_scheme: Use a path such as /about, or a link that starts with http:// or https://.
url_pattern: Use a path that starts with a single /, for example /blog/:slug.
frontend_url: 'Use a full address that starts with http:// or https://, without spaces, quotes or the characters < > &. Leave it empty to keep the backend URL.'
+ file_size: Enter a whole number from 1 to 50 000.
+ slug: Use lowercase letters, digits and hyphens, e.g. posts.
+ slug_taken: Another content type already uses this file name.
+ html_path: Start the address with / and use a path only, e.g. /mapa-strony.
+ page_size: Enter a whole number from 50 to 500.
+ lastmod: 'Enter a date as YYYY-MM-DD, e.g. 2026-10-28, or leave it empty.'
definition:
not_found: No sitemap definition was found. Try creating one first.
title: Sitemap definition
view: View sitemap.xml
settings:
- label: Sitemap settings
- description: Frontend URL, locale and priority options.
+ label: Sitemaps
+ description: XML sitemaps per content type, images, News, IndexNow and the HTML sitemap.
category: CMS
- frontend_url: Frontend URL
- frontend_url_comment: The public URL of the frontend app (e.g. https://www.example.com). When set, sitemap URLs use this domain instead of the backend URL.
- frontend_default_locale: Frontend Default Locale
- frontend_default_locale_comment: The unprefixed locale on the frontend (e.g. pl for a prefix_except_default strategy). Other locales get a /{code}/ prefix in sitemap URLs.
- updates_manifest_path: Updates Manifest Path
- updates_manifest_path_comment: Absolute path to a JSON file mapping URL path => ISO 8601 last-modified date, produced by the frontend build. Used as the for URL items that have no date of their own (e.g. static pages). When empty or unreadable, those items omit . Dynamic sets keep their own dates.
- enable_priority_tiers: Enable Priority Tiers
- enable_priority_tiers_comment: When enabled, priorities are auto-assigned based on the URL path rules in the config, overriding per-item priorities. When disabled, per-item priorities are used.
- priority_homepage: Homepage Priority
- priority_homepage_comment: Used for URLs that match the homepage tier rules in the config. Default 1.0. Applies only when priority tiers are enabled.
- priority_services: Services & Work Priority
- priority_services_comment: Used for URLs that match the services tier rules in the config. Default 0.8. Applies only when priority tiers are enabled.
- priority_blog: Blog Posts Priority
- priority_blog_comment: Used for URLs that match the blog tier rules in the config. Default 0.6. Applies only when priority tiers are enabled.
- priority_categories: Categories & Hub Pages Priority
- priority_categories_comment: Used for URLs that match the categories tier rules in the config. Default 0.4. Applies only when priority tiers are enabled.
- priority_legal: Legal Pages Priority
- priority_legal_comment: Used for URLs that match the legal tier rules in the config. Default 0.2. Applies only when priority tiers are enabled.
+ tab_files: Files
+ tab_types: Content types
+ tab_extra: Additional addresses
+ tab_media: Images and News
+ tab_indexnow: IndexNow
+ tab_html: HTML sitemap
+ site_url: Site address
+ site_url_comment: The public address of the site, e.g. https://www.example.com. Every address in the sitemaps starts with it.
+ file_size: Addresses per file
+ file_size_comment: 1000 by default, at most 50 000. Smaller files are easier to monitor one by one in Google Search Console. A change renumbers every file.
+ type_include: Include this content type in the sitemaps.
+ type_slug: File name
+ type_slug_comment: Files are named sitemap--1.xml, sitemap--2.xml and so on.
+ type_title: Heading in the HTML sitemap
+ extra_urls: Additional addresses
+ extra_urls_add: Add address
+ extra_urls_comment: Addresses that no content type lists, e.g. the site's static pages. They go into the sitemap-pages files.
+ extra_url: Address
+ extra_url_lastmod: Last modified
+ extra_url_lastmod_comment: 'Optional, as YYYY-MM-DD, e.g. 2026-10-28.'
+ images_enabled: Image sitemap
+ images_enabled_comment: Lists the featured image and the images in the content of every listed page, in the sitemap-images files.
+ news_enabled: Google News sitemap
+ news_enabled_comment: Only for sites accepted in Google News. Lists articles published in the last two days.
+ news_publication: Publication name
+ news_publication_comment: 'As registered in Google News. Empty: the site name.'
+ news_sources: Types and categories
+ news_sources_comment: 'Articles of these types or categories go into the News sitemap. Empty: every article type.'
+ indexnow_enabled: IndexNow
+ indexnow_enabled_comment: Tells Bing, Yandex and other IndexNow engines about new, changed and deleted addresses right away. Google does not use IndexNow.
+ indexnow_new_key: Generate a new key on save
+ indexnow_new_key_comment: The old key file stops working at once. Use it only when the key leaked.
+ html_enabled: HTML sitemap
+ html_enabled_comment: A page for visitors that lists every indexable address, linked from the site footer.
+ html_path: Address
+ html_path_comment: The site's sitemap page uses this address. Change it in the site as well.
+ html_page_size: Links per page
+ html_page_size_comment: Larger groups are split into numbered pages. Default 150.
+files:
+ label: Files
+ file: File
+ file_urls: Addresses
+ file_lastmod: Last modified
+ left_out: Left out of the sitemaps
+ left_out_reason: Reason
+ left_out_count: Addresses
+ reason_noindex: noindex
+ reason_canonical: Canonical URL points elsewhere
+ reason_redirect: Address redirects
+ reason_type: Content type turned off
+ index_note: Search engines read the index at :url, and robots.txt points to it. The files are rebuilt after content changes and every night; saving this form rebuilds them now.
+ empty: No files yet. They are built within a few minutes after the first save.
+ building: Rebuilding now. Reload the page in a minute.
+indexnow:
+ key: 'Key: :key, served at :url'
+ no_key: A key is generated when you turn IndexNow on and save.
+ last: 'Last notification: :time, :count addresses, answer :status.'
+ never: No notification sent yet.
+ err_key: The engines could not read the key file at :url. Check that the site serves it.
+ err_host: The engines refused the addresses because they do not match the site address.
+ err_rate: 'Too many notifications; the next attempt is at :time.'
+jobs:
+ rebuild: Rebuild the sitemaps
diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml
index 0cc1cb3..2ee3f53 100644
--- a/lang/pl/lang.yaml
+++ b/lang/pl/lang.yaml
@@ -1,6 +1,6 @@
plugin:
- name: Mapa strony
- description: Generuje plik sitemap.xml dla Twojej strony.
+ name: Mapy witryny
+ description: Mapy XML według typów treści, ze stałymi plikami, prawdziwym lastmod i hreflang.
permissions:
access_settings: Zarządzaj ustawieniami wtyczki
access_definitions: Zarządzaj definicją mapy strony
@@ -41,29 +41,80 @@ errors:
url_scheme: Podaj ścieżkę, np. /o-nas, albo link zaczynający się od http:// lub https://.
url_pattern: Podaj ścieżkę zaczynającą się od jednego /, np. /blog/:slug.
frontend_url: 'Podaj pełny adres zaczynający się od http:// lub https://, bez spacji, cudzysłowów i znaków < > &. Zostaw puste, aby zachować adres backendu.'
+ file_size: Wpisz liczbę całkowitą od 1 do 50 000.
+ slug: Użyj małych liter, cyfr i łączników, np. posts.
+ slug_taken: Inny typ treści już używa tej nazwy pliku.
+ html_path: Zacznij adres od / i użyj samej ścieżki, np. /mapa-strony.
+ page_size: Wpisz liczbę całkowitą od 50 do 500.
+ lastmod: 'Wpisz datę w formacie RRRR-MM-DD, np. 2026-10-28, albo zostaw puste.'
definition:
not_found: Nie znaleziono definicji mapy strony. Musisz ją utworzyć.
title: Definicja mapy strony
view: Zobacz sitemap.xml
settings:
- label: Ustawienia mapy strony
- description: Adres frontendu, język i priorytety.
+ label: Mapy witryny
+ description: Mapy XML według typów treści, obrazy, News, IndexNow i mapa HTML.
category: CMS
- frontend_url: Adres URL frontendu
- frontend_url_comment: Publiczny adres aplikacji frontendowej (np. https://www.example.com). Gdy jest ustawiony, adresy w mapie strony używają tej domeny zamiast adresu backendu.
- frontend_default_locale: Domyślny język frontendu
- frontend_default_locale_comment: Język bez prefiksu na frontendzie (np. pl przy strategii prefix_except_default). Pozostałe języki dostają w mapie strony prefiks /{kod}/.
- updates_manifest_path: Ścieżka manifestu aktualizacji
- updates_manifest_path_comment: Bezwzględna ścieżka do pliku JSON, który mapuje ścieżkę URL na datę ostatniej zmiany ISO 8601, tworzonego przez build frontendu. Służy jako dla pozycji URL bez własnej daty (np. stron statycznych). Gdy jest pusta lub plik jest nieczytelny, te pozycje nie mają . Zestawy dynamiczne zachowują własne daty.
- enable_priority_tiers: Włącz poziomy priorytetów
- enable_priority_tiers_comment: Po włączeniu priorytety są nadawane według reguł ścieżek URL z konfiguracji i zastępują priorytety pozycji. Po wyłączeniu używane są priorytety pozycji.
- priority_homepage: Priorytet strony głównej
- priority_homepage_comment: Dla adresów pasujących do reguł poziomu strony głównej z konfiguracji. Domyślnie 1.0. Działa tylko przy włączonych poziomach priorytetów.
- priority_services: Priorytet usług i realizacji
- priority_services_comment: Dla adresów pasujących do reguł poziomu usług z konfiguracji. Domyślnie 0.8. Działa tylko przy włączonych poziomach priorytetów.
- priority_blog: Priorytet wpisów bloga
- priority_blog_comment: Dla adresów pasujących do reguł poziomu bloga z konfiguracji. Domyślnie 0.6. Działa tylko przy włączonych poziomach priorytetów.
- priority_categories: Priorytet kategorii i stron zbiorczych
- priority_categories_comment: Dla adresów pasujących do reguł poziomu kategorii z konfiguracji. Domyślnie 0.4. Działa tylko przy włączonych poziomach priorytetów.
- priority_legal: Priorytet stron prawnych
- priority_legal_comment: Dla adresów pasujących do reguł poziomu stron prawnych z konfiguracji. Domyślnie 0.2. Działa tylko przy włączonych poziomach priorytetów.
+ tab_files: Pliki
+ tab_types: Typy treści
+ tab_extra: Dodatkowe adresy
+ tab_media: Obrazy i News
+ tab_indexnow: IndexNow
+ tab_html: Mapa HTML
+ site_url: Adres witryny
+ site_url_comment: Publiczny adres witryny, np. https://www.example.com. Od niego zaczyna się każdy adres w mapach.
+ file_size: Adresów w pliku
+ file_size_comment: Domyślnie 1000, najwyżej 50 000. Mniejsze pliki łatwiej monitorować pojedynczo w Google Search Console. Zmiana przenumerowuje wszystkie pliki.
+ type_include: Uwzględniaj ten typ treści w mapach witryny.
+ type_slug: Nazwa pliku
+ type_slug_comment: Pliki nazywają się sitemap--1.xml, sitemap--2.xml i tak dalej.
+ type_title: Nagłówek grupy w mapie HTML
+ extra_urls: Dodatkowe adresy
+ extra_urls_add: Dodaj adres
+ extra_urls_comment: Adresy, których nie podaje żaden typ treści, np. statyczne strony witryny. Trafiają do plików sitemap-pages.
+ extra_url: Adres
+ extra_url_lastmod: Ostatnia zmiana
+ extra_url_lastmod_comment: 'Opcjonalnie, w formacie RRRR-MM-DD, np. 2026-10-28.'
+ images_enabled: Mapa obrazów
+ images_enabled_comment: Wypisuje obraz wyróżniający i obrazy w treści każdej podanej strony, w plikach sitemap-images.
+ news_enabled: Mapa Google News
+ news_enabled_comment: Tylko dla witryn przyjętych do Google News. Wypisuje artykuły opublikowane w ciągu ostatnich dwóch dni.
+ news_publication: Nazwa publikacji
+ news_publication_comment: 'Tak jak w Google News. Puste: nazwa witryny.'
+ news_sources: Typy i kategorie
+ news_sources_comment: 'Artykuły tych typów lub kategorii trafiają do mapy News. Puste: każdy typ artykułów.'
+ indexnow_enabled: IndexNow
+ indexnow_enabled_comment: Od razu informuje Bing, Yandex i inne wyszukiwarki IndexNow o nowych, zmienionych i usuniętych adresach. Google nie korzysta z IndexNow.
+ indexnow_new_key: Wygeneruj nowy klucz przy zapisie
+ indexnow_new_key_comment: Stary plik klucza od razu przestaje działać. Używaj tylko, gdy klucz wyciekł.
+ html_enabled: Mapa witryny HTML
+ html_enabled_comment: Strona dla odwiedzających z listą wszystkich indeksowalnych adresów, linkowana ze stopki witryny.
+ html_path: Adres
+ html_path_comment: Strona mapy witryny używa tego adresu. Zmień go także w witrynie.
+ html_page_size: Linków na stronie
+ html_page_size_comment: Większe grupy są dzielone na numerowane strony. Domyślnie 150.
+files:
+ label: Pliki
+ file: Plik
+ file_urls: Adresy
+ file_lastmod: Ostatnia zmiana
+ left_out: Pominięte w mapach witryny
+ left_out_reason: Powód
+ left_out_count: Adresy
+ reason_noindex: noindex
+ reason_canonical: Adres kanoniczny wskazuje inny adres
+ reason_redirect: Adres przekierowuje
+ reason_type: Wyłączony typ treści
+ index_note: Wyszukiwarki czytają indeks pod :url, a robots.txt go wskazuje. Pliki są odbudowywane po zmianach treści i co noc; zapisanie tego formularza odbudowuje je od razu.
+ empty: Nie ma jeszcze plików. Powstaną w ciągu kilku minut po pierwszym zapisie.
+ building: Trwa odbudowa. Odśwież stronę za minutę.
+indexnow:
+ key: 'Klucz: :key, udostępniany pod :url'
+ no_key: Klucz zostanie wygenerowany po włączeniu IndexNow i zapisie.
+ last: 'Ostatnie powiadomienie: :time, adresy: :count, odpowiedź :status.'
+ never: Nie wysłano jeszcze powiadomienia.
+ err_key: Wyszukiwarki nie mogły odczytać pliku klucza pod :url. Sprawdź, czy witryna go udostępnia.
+ err_host: Wyszukiwarki odrzuciły adresy, bo nie pasują do adresu witryny.
+ err_rate: 'Zbyt wiele powiadomień; następna próba: :time.'
+jobs:
+ rebuild: Odbudowa map witryny
diff --git a/models/definition_test.go b/models/definition_test.go
index 1c9eb77..dd5f2b7 100644
--- a/models/definition_test.go
+++ b/models/definition_test.go
@@ -45,7 +45,7 @@ func TestFillable(t *testing.T) {
if rules := (models.Definition{}).Rules(); len(rules) != 0 {
t.Fatalf("Rules = %v", rules)
}
- if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 9 {
+ if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 3 {
t.Fatalf("Settings.Fillable = %v", got)
}
}
diff --git a/models/settings.go b/models/settings.go
index 83666a2..8615fe1 100644
--- a/models/settings.go
+++ b/models/settings.go
@@ -2,6 +2,7 @@ package models
import (
"net/url"
+ "strconv"
"strings"
"time"
"unicode"
@@ -9,7 +10,6 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
- "gorm.io/gorm"
)
// errFrontendURL is the phrase key of the frontend_url refusal.
@@ -95,28 +95,62 @@ func (s Settings) Extra() []ExtraURL { return s.ExtraURLs.Get() }
func (Settings) TableName() string { return "golem15_sitemap_settings" }
-// Fillable lists only the settings columns.
+// Fillable lists the columns the settings form edits.
func (Settings) Fillable() []string {
- return []string{
- "frontend_url", "frontend_default_locale", "updates_manifest_path",
- "enable_priority_tiers", PriorityHomepage, PriorityServices,
- PriorityBlog, PriorityCategories, PriorityLegal,
- }
+ return []string{"site_url", "file_size", "extra_urls"}
}
// Rules is empty: the PHP settings form validated nothing. The one check of
// the port, on frontend_url, is in BeforeValidate.
func (Settings) Rules() map[string]string { return map[string]string{} }
-// BeforeValidate is the lagoon.HasBeforeValidate hook cabana calls on a
-// settings save, after the fill and before the row is written: a
-// frontend_url that AllowedFrontendURL refuses is a validation error on
-// frontend_url, so the save answers 422 and the row is left as it was.
-func (s Settings) BeforeValidate(*gorm.DB) error {
- if AllowedFrontendURL(Text(s.FrontendURL)) {
+// The phrase keys of the other settings refusals.
+const (
+ errFileSize = "golem15.sitemap::lang.errors.file_size"
+ errURLScheme = "golem15.sitemap::lang.errors.url_scheme"
+ errLastmod = "golem15.sitemap::lang.errors.lastmod"
+)
+
+// Validate answers every problem Problems finds as a validation error on
+// its field; the settings controller calls it before a save, so the save
+// answers 422 and the row is left as it was.
+func (s Settings) Validate() error {
+ problems := s.Problems()
+ if len(problems) == 0 {
return nil
}
- return &cabana.ValidationError{Details: map[string]any{"frontend_url": []string{errFrontendURL}}}
+ details := make(map[string]any, len(problems))
+ for field, keys := range problems {
+ details[field] = keys
+ }
+ return &cabana.ValidationError{Details: details}
+}
+
+// Problems lists the settings' refusals by field (UI-SPEC C26): a site
+// address that AllowedFrontendURL refuses (site_url and the Phase 17
+// frontend_url), a file size outside 1..MaxFileSize and an additional
+// address that is not a path or an http(s) URL (extra_urls..url) or
+// whose lastmod is not a date (extra_urls..lastmod).
+func (s Settings) Problems() map[string][]string {
+ out := map[string][]string{}
+ if !AllowedFrontendURL(Text(s.SiteURL)) {
+ out["site_url"] = []string{errFrontendURL}
+ }
+ if !AllowedFrontendURL(Text(s.FrontendURL)) {
+ out["frontend_url"] = []string{errFrontendURL}
+ }
+ if s.FileSize < 1 || s.FileSize > MaxFileSize {
+ out["file_size"] = []string{errFileSize}
+ }
+ for i, e := range s.Extra() {
+ if !AllowedAddress(strings.TrimSpace(e.URL)) {
+ out["extra_urls."+strconv.Itoa(i)+".url"] = []string{errURLScheme}
+ }
+ if mod := strings.TrimSpace(e.LastMod); mod != "" && !ValidDate(mod) {
+ out["extra_urls."+strconv.Itoa(i)+".lastmod"] = []string{errLastmod}
+ }
+ }
+ return out
}
// AllowedFrontendURL reports whether u may be the frontend_url setting: the
@@ -180,3 +214,26 @@ func (s Settings) Priority(key string) string {
func init() {
Register(Settings{})
}
+
+// dateLayouts are the lastmod forms an additional address may carry.
+var dateLayouts = []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"}
+
+// ParseDate reads a lastmod as the settings form or an import gives it
+// (YYYY-MM-DD, "YYYY-MM-DD hh:mm:ss" or RFC 3339; UTC unless it names a
+// zone); nil when empty or unreadable.
+func ParseDate(s string) *time.Time {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil
+ }
+ for _, layout := range dateLayouts {
+ if t, err := time.Parse(layout, s); err == nil {
+ t = t.UTC()
+ return &t
+ }
+ }
+ return nil
+}
+
+// ValidDate reports whether s is a lastmod ParseDate reads.
+func ValidDate(s string) bool { return ParseDate(s) != nil }
diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml
index 2670aeb..4d8c761 100644
--- a/models/settings/fields.yaml
+++ b/models/settings/fields.yaml
@@ -2,67 +2,49 @@
# Form Field Definitions
# ===================================
#
-# The WinterCMS form's nginx hint and Priority Tiers section are field
-# comments and README text here, and the tier fields always show (the
-# trigger on enable_priority_tiers is not ported).
+# Sitemap settings (UI-SPEC C26): Files, Content types (one block per
+# signpost content type, added by the plugin's form extension after Files)
+# and Additional addresses. A repeater row takes no date picker, so an
+# additional address's lastmod is a text field read as YYYY-MM-DD.
fields:
- frontend_url:
- label: golem15.sitemap::lang.settings.frontend_url
- comment: golem15.sitemap::lang.settings.frontend_url_comment
+ site_url:
+ label: golem15.sitemap::lang.settings.site_url
+ comment: golem15.sitemap::lang.settings.site_url_comment
type: text
span: left
+ tab: golem15.sitemap::lang.settings.tab_files
- frontend_default_locale:
- label: golem15.sitemap::lang.settings.frontend_default_locale
- comment: golem15.sitemap::lang.settings.frontend_default_locale_comment
- type: text
+ file_size:
+ label: golem15.sitemap::lang.settings.file_size
+ comment: golem15.sitemap::lang.settings.file_size_comment
+ type: number
+ default: 1000
span: right
+ tab: golem15.sitemap::lang.settings.tab_files
- updates_manifest_path:
- label: golem15.sitemap::lang.settings.updates_manifest_path
- comment: golem15.sitemap::lang.settings.updates_manifest_path_comment
- type: text
+ _files:
+ type: partial
+ path: sitemap_files
span: full
+ tab: golem15.sitemap::lang.settings.tab_files
- enable_priority_tiers:
- label: golem15.sitemap::lang.settings.enable_priority_tiers
- comment: golem15.sitemap::lang.settings.enable_priority_tiers_comment
- type: checkbox
- default: true
+ extra_urls:
+ label: golem15.sitemap::lang.settings.extra_urls
+ comment: golem15.sitemap::lang.settings.extra_urls_comment
+ type: repeater
+ prompt: golem15.sitemap::lang.settings.extra_urls_add
+ titleFrom: url
span: full
-
- priority_homepage:
- label: golem15.sitemap::lang.settings.priority_homepage
- comment: golem15.sitemap::lang.settings.priority_homepage_comment
- type: text
- default: '1.0'
- span: auto
-
- priority_services:
- label: golem15.sitemap::lang.settings.priority_services
- comment: golem15.sitemap::lang.settings.priority_services_comment
- type: text
- default: '0.8'
- span: auto
-
- priority_blog:
- label: golem15.sitemap::lang.settings.priority_blog
- comment: golem15.sitemap::lang.settings.priority_blog_comment
- type: text
- default: '0.6'
- span: auto
-
- priority_categories:
- label: golem15.sitemap::lang.settings.priority_categories
- comment: golem15.sitemap::lang.settings.priority_categories_comment
- type: text
- default: '0.4'
- span: auto
-
- priority_legal:
- label: golem15.sitemap::lang.settings.priority_legal
- comment: golem15.sitemap::lang.settings.priority_legal_comment
- type: text
- default: '0.2'
- span: auto
+ tab: golem15.sitemap::lang.settings.tab_extra
+ form:
+ fields:
+ url:
+ label: golem15.sitemap::lang.settings.extra_url
+ type: text
+ span: left
+ lastmod:
+ label: golem15.sitemap::lang.settings.extra_url_lastmod
+ comment: golem15.sitemap::lang.settings.extra_url_lastmod_comment
+ type: text
+ span: right
diff --git a/models/settings_test.go b/models/settings_test.go
index 43db04e..85903f2 100644
--- a/models/settings_test.go
+++ b/models/settings_test.go
@@ -10,9 +10,6 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
)
-// cabana calls the hook on a settings save through this interface.
-var _ lagoon.HasBeforeValidate = (*models.Settings)(nil)
-
// TestAllowedFrontendURL pins the one definition of a frontend_url that may
// be saved, imported and applied (T-17-15): empty, or an absolute http(s)
// URL with a host and without whitespace, control characters, a double
@@ -67,31 +64,59 @@ func TestAllowedFrontendURL(t *testing.T) {
}
}
-// TestSettingsBeforeValidate pins the save check of T-17-15: the hook cabana
-// calls on a settings save passes a null, empty or accepted frontend_url and
-// answers a validation error keyed frontend_url for any other value.
-func TestSettingsBeforeValidate(t *testing.T) {
+// TestSettingsValidate pins the save checks of the settings singleton
+// (UI-SPEC C26, T-33-12): the check the controller runs before a save passes
+// a null, empty or accepted site_url, a file size from 1 to 50 000 and
+// additional addresses that are paths or http(s) URLs, and answers a
+// validation error keyed by the field for any other value.
+func TestSettingsValidate(t *testing.T) {
text := func(s string) *string { return &s }
- for _, url := range []*string{nil, text(""), text("https://www.ex.pl/")} {
- if err := (&models.Settings{FrontendURL: url}).BeforeValidate(nil); err != nil {
- t.Errorf("BeforeValidate(%q) = %v, want nil", models.Text(url), err)
+ ok := func(s models.Settings) models.Settings {
+ if s.FileSize == 0 {
+ s.FileSize = 1000
+ }
+ return s
+ }
+ extra := func(urls ...string) lagoon.Jsonable[[]models.ExtraURL] {
+ rows := []models.ExtraURL{}
+ for _, u := range urls {
+ rows = append(rows, models.ExtraURL{URL: u})
+ }
+ return lagoon.Jsonable[[]models.ExtraURL]{Data: rows, Valid: true}
+ }
+ for name, s := range map[string]models.Settings{
+ "null site_url": ok(models.Settings{}),
+ "empty site_url": ok(models.Settings{SiteURL: text("")}),
+ "site_url": ok(models.Settings{SiteURL: text("https://www.ex.pl/")}),
+ "file size 1": {FileSize: 1},
+ "file size 50 000": {FileSize: 50000},
+ "extra addresses": ok(models.Settings{ExtraURLs: extra("/", "/o-nas?x=1&y=2", "https://www.ex.pl/kontakt")}),
+ } {
+ if err := s.Validate(); err != nil {
+ t.Errorf("%s: Validate = %v, want nil", name, err)
}
}
- want := map[string]any{"frontend_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}
- for _, url := range []string{
- "javascript:alert(document.domain)//",
- `https://x"/>-.xml; "" means the default) and its heading per locale
+// in the HTML sitemap. A type without a row is listed under its default
+// slug.
+type TypeSetting struct {
+ TypeKey string `gorm:"column:type_key;primaryKey"`
+ Include bool `gorm:"column:include"`
+ Slug string `gorm:"column:slug"`
+ Titles lagoon.Jsonable[map[string]string] `gorm:"column:titles"`
+ UpdatedAt time.Time `gorm:"column:updated_at"`
+}
+
+func (TypeSetting) TableName() string { return "golem15_sitemap_types" }
+
+func init() {
+ Register(TypeSetting{})
+}
diff --git a/sitemap_index_test.go b/sitemap_index_test.go
index 1b17476..b63c3e6 100644
--- a/sitemap_index_test.go
+++ b/sitemap_index_test.go
@@ -131,3 +131,176 @@ func TestSitemapIndex(t *testing.T) {
}
}
}
+
+// permSitemap is the permission of the sitemap settings.
+const permSitemap = "golem15.sitemap.access_definitions"
+
+// riverJobs counts the queued jobs of kind.
+func (e *sitemapEnv) riverJobs(t *testing.T, kind string) int64 {
+ t.Helper()
+ var n int64
+ if err := e.gdb.Raw(`SELECT COUNT(*) FROM river_job WHERE kind = ?`, kind).Scan(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+// TestSettingsSingleton drives the settings singleton (UI-SPEC C26): one
+// tile opens it, the form has the Files, Content types and Additional
+// addresses tabs, a save stores the values and dispatches a rebuild, the
+// errors.* refusals answer 422 and leave the row as it was, a type switched
+// off leaves the files with reason type and an additional address is listed
+// in the pages group with its lastmod; S1 shows files.empty before the
+// first build, files.building while the asked-for rebuild has not run, and
+// the files with the left-out counts after it.
+func TestSettingsSingleton(t *testing.T) {
+ e := bootIndex(t)
+ token := e.admin(t, permSitemap)
+ const base = "/golem15/sitemap/settings"
+ base0 := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
+ contentStore.put(pageType, page1(1, "a", base0))
+ contentStore.put(offerType, fakeRecord{ID: 1, Published: true, LastMod: base0, Locales: []pact.ContentLocale{{Locale: "en", URL: "/offers/x", Title: "Offer"}}})
+
+ // One tile, linked to the singleton; the Phase 17 tiles are gone.
+ rec := e.call(t, http.MethodGet, "/settings", token, nil)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"controller":"golem15.sitemap.settings"`) ||
+ strings.Contains(rec.Body.String(), "golem15.sitemap.definitions") || strings.Contains(rec.Body.String(), "sitemap_settings") {
+ t.Fatalf("settings index: %d %s", rec.Code, rec.Body.String())
+ }
+ rec = e.call(t, http.MethodGet, base+"/singleton", token, nil)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"id":1`) {
+ t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
+ }
+ rec = e.call(t, http.MethodGet, base+"/schema/form", token, nil)
+ schema := rec.Body.String()
+ if rec.Code != http.StatusOK || !strings.Contains(schema, `"singleton":true`) {
+ t.Fatalf("form schema: %d %s", rec.Code, schema)
+ }
+ for _, want := range []string{`"site_url"`, `"file_size"`, `"extra_urls"`, `"type_acme_page_include"`, `"type_acme_page_slug"`, `"type_acme_page_title"`, `"type_acme_offer_include"`, "Files", "Content types", "Additional addresses"} {
+ if !strings.Contains(schema, want) {
+ t.Errorf("form schema lacks %s", want)
+ }
+ }
+ files := func() string {
+ t.Helper()
+ rec := e.call(t, http.MethodGet, base+"/partials/sitemap_files", token, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("S1: %d %s", rec.Code, rec.Body.String())
+ }
+ return rec.Body.String()
+ }
+ if s1 := files(); !strings.Contains(s1, "No files yet.") || strings.Contains(s1, "Rebuilding now.") {
+ t.Fatalf("S1 before the first build:\n%s", s1)
+ }
+
+ // Refusals leave the row as it was.
+ for _, c := range []struct {
+ name string
+ body map[string]any
+ field string
+ }{
+ {"file size 0", map[string]any{"file_size": 0}, "file_size"},
+ {"file size 50 001", map[string]any{"file_size": 50001}, "file_size"},
+ {"javascript site", map[string]any{"site_url": "javascript:alert(1)//"}, "site_url"},
+ {"relative site", map[string]any{"site_url": "/x"}, "site_url"},
+ {"bad address", map[string]any{"extra_urls": []map[string]any{{"url": "javascript:alert(1)", "lastmod": ""}}}, "extra_urls.0.url"},
+ {"bad lastmod", map[string]any{"extra_urls": []map[string]any{{"url": "/x", "lastmod": "yesterday"}}}, "extra_urls.0.lastmod"},
+ {"bad slug", map[string]any{"type_acme_page_slug": "Bad Slug"}, "type_acme_page_slug"},
+ {"duplicate slug", map[string]any{"type_acme_page_slug": "things", "type_acme_offer_slug": "things"}, "type_acme_offer_slug"},
+ {"reserved slug", map[string]any{"type_acme_page_slug": "images"}, "type_acme_page_slug"},
+ } {
+ rec := e.call(t, http.MethodPut, base+"/1", token, c.body)
+ if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) {
+ t.Errorf("%s: %d %s", c.name, rec.Code, rec.Body.String())
+ }
+ }
+ var types int64
+ e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_types`).Scan(&types)
+ if types != 0 || e.riverJobs(t, classes.RebuildKind) != 0 {
+ t.Fatalf("a refused save wrote %d type rows and queued %d rebuilds", types, e.riverJobs(t, classes.RebuildKind))
+ }
+
+ // A save stores everything and dispatches one rebuild.
+ rec = e.call(t, http.MethodPut, base+"/1", token, map[string]any{
+ "site_url": "https://www.example.test/",
+ "file_size": 500,
+ "extra_urls": []map[string]any{{"url": "/kontakt", "lastmod": "2026-09-30"}, {"url": "/o-nas", "lastmod": ""}},
+ "type_acme_page_include": true,
+ "type_acme_page_slug": "",
+ "type_acme_page_title": "Our pages",
+ "type_acme_offer_include": false,
+ "type_acme_offer_slug": "offers",
+ "type_acme_offer_title": "",
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
+ }
+ if n := e.riverJobs(t, classes.RebuildKind); n != 1 {
+ t.Fatalf("the save queued %d rebuild jobs, want 1", n)
+ }
+ var row struct {
+ SiteURL string `gorm:"column:site_url"`
+ FileSize int `gorm:"column:file_size"`
+ }
+ e.gdb.Raw(`SELECT site_url, file_size FROM golem15_sitemap_settings WHERE id = 1`).Scan(&row)
+ if row.SiteURL != "https://www.example.test/" || row.FileSize != 500 {
+ t.Fatalf("stored settings = %+v", row)
+ }
+ var stored []struct {
+ TypeKey string `gorm:"column:type_key"`
+ Include bool `gorm:"column:include"`
+ Slug string `gorm:"column:slug"`
+ Titles string `gorm:"column:titles"`
+ }
+ e.gdb.Raw(`SELECT type_key, include, slug, titles::text AS titles FROM golem15_sitemap_types ORDER BY type_key`).Scan(&stored)
+ if len(stored) != 2 || stored[0].TypeKey != offerType || stored[0].Include || stored[0].Slug != "" ||
+ stored[1].TypeKey != pageType || !stored[1].Include || !strings.Contains(stored[1].Titles, "Our pages") {
+ t.Fatalf("type rows = %+v", stored)
+ }
+ if s1 := files(); !strings.Contains(s1, "Rebuilding now.") {
+ t.Fatalf("S1 while the rebuild is queued:\n%s", s1)
+ }
+ rec = e.call(t, http.MethodGet, base+"/1", token, nil)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"type_acme_page_title"`) || !strings.Contains(rec.Body.String(), "Our pages") {
+ t.Fatalf("GET settings: %d %s", rec.Code, rec.Body.String())
+ }
+
+ e.rebuild(t)
+ index := e.get(t, "/sitemap.xml").Body.String()
+ if got := locs(index); strings.Join(got, " ") != "https://www.example.test/sitemap-pages-1.xml" {
+ t.Fatalf("index after the save = %v\n%s", got, index)
+ }
+ pages := e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
+ if got := locs(pages); strings.Join(got, " ") != "https://www.example.test/pages/a https://www.example.test/kontakt https://www.example.test/o-nas" {
+ t.Fatalf("pages file = %v\n%s", got, pages)
+ }
+ if !strings.Contains(pages, "https://www.example.test/kontakt2026-09-30T00:00:00Z") || !strings.Contains(pages, "https://www.example.test/o-nas") {
+ t.Fatalf("additional addresses lack their lastmod:\n%s", pages)
+ }
+ var reason string
+ e.gdb.Raw(`SELECT excluded_reason FROM golem15_sitemap_urls WHERE type_key = ?`, offerType).Scan(&reason)
+ if reason != "type" {
+ t.Fatalf("the switched-off type's row has reason %q", reason)
+ }
+ s1 := files()
+ for _, want := range []string{"sitemap.xml", "sitemap-pages-1.xml", `"href":"/_sitemap/files/sitemap-pages-1.xml"`, `"href":"/sitemap.xml"`, "Left out of the sitemaps", "Content type turned off", "https://www.example.test/sitemap.xml"} {
+ if !strings.Contains(s1, want) {
+ t.Errorf("S1 lacks %q:\n%s", want, s1)
+ }
+ }
+ if strings.Contains(s1, "Rebuilding now.") || strings.Contains(s1, "No files yet.") {
+ t.Errorf("S1 after the rebuild:\n%s", s1)
+ }
+
+ // Without the permission every route answers 403.
+ other := e.admin(t)
+ for _, c := range []struct{ method, path string }{
+ {http.MethodGet, base + "/singleton"},
+ {http.MethodPut, base + "/1"},
+ {http.MethodGet, base + "/partials/sitemap_files"},
+ } {
+ if rec := e.call(t, c.method, c.path, other, map[string]any{}); rec.Code != http.StatusForbidden {
+ t.Errorf("%s %s without permission: %d %s", c.method, c.path, rec.Code, rec.Body.String())
+ }
+ }
+}
diff --git a/sitemap_smoke_test.go b/sitemap_smoke_test.go
index 3824357..bf85e6d 100644
--- a/sitemap_smoke_test.go
+++ b/sitemap_smoke_test.go
@@ -264,153 +264,3 @@ func (e *sitemapEnv) admin(t *testing.T, permissions ...string) string {
}
return body.Data.AccessToken
}
-
-// TestSitemapAdminSmoke drives the definition singleton, the item repeater
-// with its dependent reference, the settings form and the permission.
-func TestSitemapAdminSmoke(t *testing.T) {
- e := bootSitemap(t)
- const perm = "golem15.sitemap.access_definitions"
- token := e.admin(t, perm)
- const base = "/golem15/sitemap/definitions"
-
- // The singleton route creates the definition once.
- var ids []float64
- for range 2 {
- rec := e.call(t, http.MethodGet, base+"/singleton", token, nil)
- var body struct {
- Data struct {
- ID float64 `json:"id"`
- } `json:"data"`
- }
- if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.ID == 0 {
- t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
- }
- ids = append(ids, body.Data.ID)
- }
- var n int64
- e.gdb.Model(&models.Definition{}).Count(&n)
- if ids[0] != ids[1] || n != 1 {
- t.Fatalf("singleton ids %v, %d rows", ids, n)
- }
- id := fmt.Sprintf("%d", int(ids[0]))
-
- // The form schema compiles: a singleton with the type options and the
- // dependent reference.
- rec := e.call(t, http.MethodGet, base+"/schema/form", token, nil)
- if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"singleton":true`) || !strings.Contains(rec.Body.String(), `"acme-post"`) || !strings.Contains(rec.Body.String(), `"dependsOn":"type"`) {
- t.Fatalf("form schema: %d %s", rec.Code, rec.Body.String())
- }
- rec = e.call(t, http.MethodGet, base+"/fields/data.reference/dependent-options?values%5Btype%5D=acme-post", token, nil)
- if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"7"`) {
- t.Fatalf("dependent options: %d %s", rec.Code, rec.Body.String())
- }
-
- // Saving rows stores PHP-keyed item JSON.
- rows := []map[string]any{
- {"type": "url", "url": "/", "reference": "", "urlPattern": "", "priority": "1.0", "changefreq": "always", "nesting": false},
- {"type": "acme-post", "url": "", "reference": "7", "urlPattern": "/n/:slug", "priority": "0.5", "changefreq": "weekly", "nesting": true},
- }
- rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
- if rec.Code != http.StatusOK {
- t.Fatalf("PUT rows: %d %s", rec.Code, rec.Body.String())
- }
- var raw string
- if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil {
- t.Fatal(err)
- }
- const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":""},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":"","reference":"7","cmsPage":null,"urlPattern":"/n/:slug"}]`
- if raw != wantData {
- t.Fatalf("stored data = %s", raw)
- }
-
- // A reference not offered for its row type is a 422 on the row.
- rows[1]["reference"] = "99"
- rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
- if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "data.1.reference") {
- t.Fatalf("PUT unknown reference: %d %s", rec.Code, rec.Body.String())
- }
-
- // A missing reference for a type that takes one (WR-03), a javascript:
- // URL and a protocol-relative pattern (CR-01) are 422s on their rows.
- for _, c := range []struct {
- row int
- key, value string
- field string
- message string
- }{
- {1, "reference", "", "data.1.reference", "golem15.sitemap::lang.item.reference_required"},
- {1, "reference", " ", "data.1.reference", ""},
- {0, "url", "javascript://%0aalert(1)%0a//x", "data.0.url", "golem15.sitemap::lang.errors.url_scheme"},
- {1, "urlPattern", "//evil.test/:slug", "data.1.urlPattern", "golem15.sitemap::lang.errors.url_pattern"},
- } {
- bad := []map[string]any{{}, {}}
- for i := range rows {
- for k, v := range rows[i] {
- bad[i][k] = v
- }
- }
- bad[1]["reference"] = "7"
- bad[c.row][c.key] = c.value
- rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": bad})
- if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) {
- t.Fatalf("PUT %s=%q: %d %s", c.key, c.value, rec.Code, rec.Body.String())
- }
- if c.message != "" && !strings.Contains(rec.Body.String(), c.message) {
- t.Fatalf("PUT %s=%q: want %s: %s", c.key, c.value, c.message, rec.Body.String())
- }
- }
- if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil || raw != wantData {
- t.Fatalf("a refused save changed the data: %s %v", raw, err)
- }
-
- // The settings form saves the frontend URL.
- rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": "https://www.example.test", "enable_priority_tiers": true})
- if rec.Code != http.StatusOK {
- t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
- }
- var st models.Settings
- if err := e.gdb.First(&st, 1).Error; err != nil || models.Text(st.FrontendURL) != "https://www.example.test" {
- t.Fatalf("settings = %+v %v", st, err)
- }
-
- // T-17-15: a frontend URL that is not a plain http(s) URL is a 422 on
- // frontend_url and the stored row keeps its value.
- for _, value := range []string{"javascript:alert(document.domain)//", `https://x"/>