feat(17-03): full sitemap generation, settings, hooks and import commands

- generator in Definition::addItemToSet order: 50000 URL cap, lastmod from
  the record or the updates manifest (regular file, 5 MiB cap, fail safe),
  BeforeAddItem (skip) and AddItem (extra XML) festival events, tier or item
  priority, per-alternate expansion of list type children
- priority tiers from golem15.sitemap.priority_tiers, compiled at boot,
  defaults equal to the PHP getPriorityForUrl rules
- frontend_url rewrite of loc, xhtml:link href and the stylesheet href;
  GET /sitemap.xsl serves the PHP stylesheet byte for byte
- golem15_sitemap_settings singleton and Settings model
- sitemap:import (PHP ImportCommand) and sitemap:import-winter with the
  read-only Winter export SQL
- urlto and full goldens generated from PHP; TestSitemapImportSmoke
This commit is contained in:
2026-10-12 17:07:39 +02:00
parent fe39e26ce0
commit 4a82240429
24 changed files with 2970 additions and 54 deletions

56
classes/events.go Normal file
View File

@@ -0,0 +1,56 @@
package classes
import (
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// ItemInfo is what the sitemap knows about one URL it is about to write, the
// PHP $itemInfo array: URL (before the final Url::to in BeforeAddItem, the
// written loc in AddItem), MTime, the record's modification time when the
// item has one, Lastmod, the written lastmod ("" when omitted), and the
// hreflang Alternates.
type ItemInfo struct {
URL string
MTime *time.Time
Lastmod string
Alternates []Alternate
}
// BeforeAddItem is the typed successor of the PHP event
// golem15.sitemap.beforeAddItem. The generator fires it with
// festival.Bus.UntilHandled for every URL before it is counted and written;
// a listener that sets Skip drops the URL (PHP: a listener returned false).
//
// app.Events.Listen("acme.seo", func(ctx context.Context, e *classes.BeforeAddItem) error {
// if strings.HasSuffix(e.Info.URL, "/draft") {
// e.Skip = true
// }
// return nil
// })
type BeforeAddItem struct {
Item models.DefinitionItem
Info ItemInfo
Skip bool
}
// IsHandled reports Skip, so UntilHandled stops at the first listener that
// drops the URL (festival.Handleable).
func (e *BeforeAddItem) IsHandled() bool { return e != nil && e.Skip }
// AddItem is the typed successor of the PHP event golem15.sitemap.addItem.
// The generator fires it with festival.Bus.Fire after a URL's elements are
// built; listeners may append child XML to Extra, which is written as it is,
// before the url closing tag, so it must be escaped already (EscapeText,
// EscapeAttr).
//
// app.Events.Listen("acme.images", func(ctx context.Context, e *classes.AddItem) error {
// e.Extra = append(e.Extra, "<image:image><image:loc>"+classes.EscapeText(img)+"</image:loc></image:image>")
// return nil
// })
type AddItem struct {
Item models.DefinitionItem
Info ItemInfo
Extra []string
}

View File

@@ -8,11 +8,12 @@ import (
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/festival"
"git.golem15.com/golem15/summercms/modules/pact"
)
// MaxURLs is the sitemap protocol's URL limit (PHP Definition::MAX_URLS):
// the 50,000th URL is written, the 50,001st is not.
// the 50000th URL is written, the 50001st is not.
const MaxURLs = 50000
// lastmodLayout is PHP date('c') in UTC: "+00:00", never "Z".
@@ -23,18 +24,27 @@ type Resolver interface {
Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
}
// Generator writes the sitemap of a definition. Root is the backend root URL
// (scheme and host without a trailing slash) that Url::to builds absolute
// URLs from. Resolver resolves registry items; nil skips them.
// UnprefixedLocale is handed to every Resolve as the locale without a URL
// prefix ("" leaves it to the type).
// Generator writes the sitemap of a definition, following PHP
// Definition::generateSitemap and addItemToSet.
//
// Root is the backend root URL (scheme and host without a trailing slash)
// that Url::to builds absolute URLs from. Resolver resolves registry items;
// nil skips them. Settings are the sitemap settings (tiers, priorities,
// frontend default locale, manifest path), Tiers the compiled tier rules,
// and Events the bus BeforeAddItem and AddItem are fired on (nil fires
// nothing). Manifest, when set, is used instead of reading the settings'
// updates_manifest_path.
type Generator struct {
Root string
Resolver Resolver
UnprefixedLocale string
Root string
Resolver Resolver
Settings models.Settings
Tiers Tiers
Events *festival.Bus
Manifest Manifest
entries []URLEntry
count int
manifestLoaded bool
entries []URLEntry
count int
}
// itemInfo is PHP's $itemInfo: the URL (set marks it present), the record's
@@ -47,19 +57,31 @@ type itemInfo struct {
}
// Generate returns the sitemap XML of items, or "" when there are none (PHP
// generateSitemap). A url item is added with its URL; a registry item is
// resolved with its URL pattern and nesting, its URL added when it has one
// and its sub-items added recursively. A type the registry does not know,
// or a Resolve that finds nothing, adds nothing.
// generateSitemap). A url item is added with its URL. A registry item is
// resolved with its URL pattern, its nesting and the settings' frontend
// default locale as the unprefixed locale; its URL is added when it has one,
// then its sub-items: when the item has no URL of its own (a list type),
// each sub-item is added once per alternate, with that alternate's URL as
// loc and every alternate as hreflang, as the PHP list types returned them;
// otherwise each sub-item is added once with its own URL. A type the
// registry does not know, or a Resolve that finds nothing, adds nothing.
//
// Generate does not apply the frontend_url rewrite; the route does
// (Rewrite).
func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem) (string, error) {
if len(items) == 0 {
return "", nil
}
if ctx == nil {
ctx = context.Background()
}
g.entries, g.count = nil, 0
for i := range items {
item := &items[i]
if item.Type == models.ItemTypeURL {
g.addItem(item, itemInfo{})
if err := g.addItem(ctx, item, itemInfo{}); err != nil {
return "", err
}
continue
}
resolved, err := g.resolve(ctx, item)
@@ -70,9 +92,17 @@ func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem)
continue
}
if resolved.URL != "" {
g.addItem(item, infoOf(*resolved))
if err := g.addItem(ctx, item, infoOf(*resolved)); err != nil {
return "", err
}
if err := g.addChildren(ctx, item, resolved.Items); err != nil {
return "", err
}
continue
}
if err := g.addPerLocale(ctx, item, resolved.Items); err != nil {
return "", err
}
g.addChildren(item, resolved.Items)
}
return Serialize(g.Stylesheet(), g.entries), nil
}
@@ -94,17 +124,54 @@ func (g *Generator) resolve(ctx context.Context, item *models.DefinitionItem) (*
return nil, nil
}
}
return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, g.UnprefixedLocale)
return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, models.Text(g.Settings.FrontendDefaultLocale))
}
// addChildren adds every child with a URL, then its own children.
func (g *Generator) addChildren(item *models.DefinitionItem, children []pact.ResolvedMenuItem) {
func (g *Generator) addChildren(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
for _, child := range children {
if child.URL != "" {
g.addItem(item, infoOf(child))
if err := g.addItem(ctx, item, infoOf(child)); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
g.addChildren(item, child.Items)
}
return nil
}
// addPerLocale adds the children of a list type: each once per alternate
// (once with its URL when it has no alternates), then its own children.
func (g *Generator) addPerLocale(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
for _, child := range children {
info := infoOf(child)
if len(child.Alternates) == 0 {
if child.URL != "" {
if err := g.addItem(ctx, item, info); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
continue
}
for _, alt := range child.Alternates {
perLocale := info
perLocale.url = alt.URL
if perLocale.url != "" {
if err := g.addItem(ctx, item, perLocale); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
}
}
return nil
}
func infoOf(r pact.ResolvedMenuItem) itemInfo {
@@ -115,33 +182,76 @@ func infoOf(r pact.ResolvedMenuItem) itemInfo {
return info
}
// addItem is PHP Definition::addItemToSet.
func (g *Generator) addItem(item *models.DefinitionItem, info itemInfo) {
func (g *Generator) manifest() Manifest {
if !g.manifestLoaded {
if g.Manifest == nil {
g.Manifest = LoadManifest(models.Text(g.Settings.UpdatesManifestPath))
}
g.manifestLoaded = true
}
return g.Manifest
}
// addItem is PHP Definition::addItemToSet, in its order: the URL cap, a
// missing URL filled through Url::to, lastmod from the record or the
// manifest, BeforeAddItem, an empty URL dropped and the URL passed through
// Url::to again, the count, the tier or item priority, the elements and
// AddItem.
func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, info itemInfo) error {
if g.count >= MaxURLs {
return
return nil
}
if !info.set {
info.url = URLTo(g.Root, models.Text(item.URL))
}
lastmod := ""
if info.mtime != nil && info.mtime.Unix() != 0 {
lastmod = info.mtime.UTC().Format(lastmodLayout)
var lastmod time.Time
hasLastmod := false
if info.mtime != nil {
lastmod, hasLastmod = *info.mtime, true
} else if t, ok := g.manifest().Lastmod(info.url); ok {
lastmod, hasLastmod = t, true
}
lastmodText := ""
if hasLastmod && lastmod.Unix() != 0 {
lastmodText = lastmod.UTC().Format(lastmodLayout)
}
if g.Events != nil {
before := &BeforeAddItem{Item: *item, Info: ItemInfo{URL: info.url, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), info.alternates...)}}
skipped, err := g.Events.UntilHandled(ctx, before)
if err != nil {
return err
}
if skipped {
return nil
}
}
if info.url == "" || info.url == "0" {
return
return nil
}
loc := URLTo(g.Root, info.url)
g.count++
priority, ok := g.Tiers.Priority(loc, g.Settings)
if !ok {
priority = models.Text(item.Priority)
}
entry := URLEntry{
Loc: loc,
Lastmod: lastmod,
Lastmod: lastmodText,
Changefreq: models.Text(item.Changefreq),
Priority: models.Text(item.Priority),
Priority: priority,
}
if len(info.alternates) > 1 {
for _, a := range info.alternates {
entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: URLTo(g.Root, a.Href)})
}
}
if g.Events != nil {
added := &AddItem{Item: *item, Info: ItemInfo{URL: loc, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), entry.Alternates...)}}
if err := g.Events.Fire(ctx, added); err != nil {
return err
}
entry.Extra = added.Extra
}
g.entries = append(g.entries, entry)
return nil
}

View File

@@ -5,11 +5,17 @@ import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
sitemap "git.golem15.com/golem15/sm-sitemap-plugin"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/festival"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -45,12 +51,19 @@ func (g goldenResolved) toPact(t *testing.T) pact.ResolvedMenuItem {
return out
}
// goldenCase is one sitemap case of a golden input.
// goldenCase is one sitemap case of a golden input: the stored items, the
// registry types' resolve results, the settings (PHP keys), the updates
// manifest, the URLs a BeforeAddItem listener skips and the text of the
// extra element an AddItem listener appends per URL.
type goldenCase struct {
Name string `json:"name"`
Root string `json:"root"`
Items models.DefinitionItems `json:"items"`
Types map[string]map[string]goldenResolved `json:"types"`
Name string `json:"name"`
Root string `json:"root"`
Items models.DefinitionItems `json:"items"`
Types map[string]map[string]goldenResolved `json:"types"`
Settings map[string]json.RawMessage `json:"settings"`
Manifest map[string]any `json:"manifest"`
Skip []string `json:"skip"`
Extra map[string]string `json:"extra"`
}
// fakeResolver answers Resolve from a golden case's types.
@@ -73,14 +86,97 @@ func (f fakeResolver) Resolve(_ context.Context, key, reference, _ string, _ boo
return &out, nil
}
// pluginTiers compiles the tier rules the plugin's own config/config.yaml
// ships.
func pluginTiers(t *testing.T) classes.Tiers {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if err := cfg.MergePlugin("golem15.sitemap", (&sitemap.Plugin{}).ConfigFS()); err != nil {
t.Fatal(err)
}
tiers, err := classes.LoadTiers(cfg)
if err != nil {
t.Fatal(err)
}
return tiers
}
// caseSettings builds the settings of a case from its PHP keys.
func caseSettings(t *testing.T, c goldenCase) models.Settings {
t.Helper()
s := models.DefaultSettings()
texts := map[string]**string{
"frontend_url": &s.FrontendURL,
"frontend_default_locale": &s.FrontendDefaultLocale,
"priority_homepage": &s.PriorityHomepage,
"priority_services": &s.PriorityServices,
"priority_blog": &s.PriorityBlog,
"priority_categories": &s.PriorityCategories,
"priority_legal": &s.PriorityLegal,
}
for key, raw := range c.Settings {
if key == "enable_priority_tiers" {
if err := json.Unmarshal(raw, &s.EnablePriorityTiers); err != nil {
t.Fatal(err)
}
continue
}
dest, ok := texts[key]
if !ok {
t.Fatalf("case %s: unknown setting %s", c.Name, key)
}
v, err := models.DecodeText(raw)
if err != nil {
t.Fatal(err)
}
*dest = v
}
if c.Manifest != nil {
raw, err := json.Marshal(c.Manifest)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "updates.json")
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
s.UpdatesManifestPath = &path
}
return s
}
// renderCase runs a case through the generator and the route's rewrite.
func renderCase(t *testing.T, c goldenCase) string {
t.Helper()
gen := &classes.Generator{Root: c.Root, Resolver: fakeResolver{t: t, types: c.Types}}
bus := festival.New()
bus.Listen("test.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
if slices.Contains(c.Skip, e.Info.URL) {
e.Skip = true
}
return nil
})
bus.Listen("test.extra", func(_ context.Context, e *classes.AddItem) error {
if text, ok := c.Extra[e.Info.URL]; ok {
e.Extra = append(e.Extra, "<extra>"+classes.EscapeText(text)+"</extra>")
}
return nil
})
settings := caseSettings(t, c)
gen := &classes.Generator{
Root: c.Root,
Resolver: fakeResolver{t: t, types: c.Types},
Settings: settings,
Tiers: pluginTiers(t),
Events: bus,
}
xml, err := gen.Generate(context.Background(), c.Items)
if err != nil {
t.Fatalf("generate %s: %v", c.Name, err)
}
return xml
return classes.Rewrite(xml, classes.BackendURL(c.Root), models.Text(settings.FrontendURL))
}
func readTestdata(t *testing.T, name string) []byte {
@@ -94,6 +190,35 @@ func readTestdata(t *testing.T, name string) []byte {
// TestSitemapGolden compares the Go sitemap with the PHP goldens.
func TestSitemapGolden(t *testing.T) {
t.Run("urlto", func(t *testing.T) {
var in struct {
Root string `json:"root"`
URLs []*string `json:"urls"`
}
if err := json.Unmarshal(readTestdata(t, "urlto.json"), &in); err != nil {
t.Fatal(err)
}
want := strings.Split(strings.TrimSuffix(string(readTestdata(t, "urlto.golden")), "\n"), "\n")
if len(want) != len(in.URLs) {
t.Fatalf("urlto.golden has %d lines for %d inputs", len(want), len(in.URLs))
}
var got strings.Builder
for i, u := range in.URLs {
path := ""
if u != nil {
path = *u
}
out := classes.URLTo(in.Root, path)
if out != want[i] {
t.Errorf("URLTo(%q) = %q, PHP %q", path, out, want[i])
}
got.WriteString(out + "\n")
}
if got.String() != string(readTestdata(t, "urlto.golden")) {
t.Error("urlto output differs from the golden file")
}
})
t.Run("tracer", func(t *testing.T) {
var in struct {
Case goldenCase `json:"case"`
@@ -106,4 +231,113 @@ func TestSitemapGolden(t *testing.T) {
t.Fatalf("tracer sitemap differs from the PHP golden\n got: %q\nwant: %q", got, want)
}
})
t.Run("full", func(t *testing.T) {
var in struct {
Cases []goldenCase `json:"cases"`
}
if err := json.Unmarshal(readTestdata(t, "full.json"), &in); err != nil {
t.Fatal(err)
}
golden := string(readTestdata(t, "full.golden"))
var all strings.Builder
for i, c := range in.Cases {
got := renderCase(t, c)
all.WriteString("=== " + c.Name + "\n" + got)
header := "=== " + c.Name + "\n"
start := strings.Index(golden, header)
if start < 0 {
t.Errorf("case %s is missing from full.golden", c.Name)
continue
}
want := golden[start+len(header):]
if i+1 < len(in.Cases) {
if end := strings.Index(want, "=== "+in.Cases[i+1].Name+"\n"); end >= 0 {
want = want[:end]
}
}
if got != want {
t.Errorf("case %s differs from the PHP golden\n got: %q\nwant: %q", c.Name, got, want)
}
}
if all.String() != golden {
t.Error("full output differs from full.golden")
}
})
t.Run("cap", func(t *testing.T) {
items := make([]models.DefinitionItem, classes.MaxURLs+1)
for i := range items {
u := "/u/" + strconv.Itoa(i+1)
items[i] = models.DefinitionItem{Type: models.ItemTypeURL, URL: &u}
}
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), items)
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != classes.MaxURLs {
t.Fatalf("%d urls written, want %d", n, classes.MaxURLs)
}
if !strings.Contains(xml, "<loc>https://api.example.test/u/50000</loc>") || strings.Contains(xml, "/u/50001<") {
t.Fatal("the 50000th URL must be written and the 50001st must not")
}
})
}
// TestSitemapTiersConfig checks the boot-time tier rule checks, a host's
// replacement of the default list and the manifest's fail-safe reads.
func TestSitemapTiersConfig(t *testing.T) {
for name, rules := range map[string][]classes.TierConfig{
"unknown setting": {{Setting: "priority_other", Exact: []string{""}}},
"neither": {{Setting: models.PriorityHomepage}},
"both": {{Setting: models.PriorityHomepage, Exact: []string{""}, Pattern: "^/$"}},
"bad pattern": {{Setting: models.PriorityBlog, Pattern: "^/(blog"}},
} {
if _, err := classes.CompileTiers(rules); err == nil {
t.Errorf("%s: compiled, want an error", name)
}
}
dir := t.TempDir()
host := "sitemap:\n priority_tiers:\n - setting: priority_legal\n pattern: '^/legal(/.*)?$'\n"
if err := os.WriteFile(filepath.Join(dir, "golem15.yaml"), []byte(host), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if err := cfg.MergePlugin("golem15.sitemap", (&sitemap.Plugin{}).ConfigFS()); err != nil {
t.Fatal(err)
}
tiers, err := classes.LoadTiers(cfg)
if err != nil || len(tiers) != 1 {
t.Fatalf("host tiers = %+v, %v", tiers, err)
}
s := models.DefaultSettings()
if p, ok := tiers.Priority("https://h/legal/terms/", s); !ok || p != "0.2" {
t.Fatalf("host rule priority = %q %v", p, ok)
}
if _, ok := tiers.Priority("https://h/", s); ok {
t.Fatal("the host list must replace the default homepage rule")
}
if m := classes.LoadManifest(dir); m != nil {
t.Fatal("a directory is not a manifest")
}
list := filepath.Join(dir, "list.json")
if err := os.WriteFile(list, []byte(`["/a"]`), 0o644); err != nil {
t.Fatal(err)
}
if m := classes.LoadManifest(list); m != nil {
t.Fatal("a JSON list is not a manifest")
}
big := filepath.Join(dir, "big.json")
if err := os.WriteFile(big, append([]byte(`{"/a":"2026-01-01","x":"`), append(make([]byte, classes.ManifestMaxBytes), '"', '}')...), 0o644); err != nil {
t.Fatal(err)
}
if m := classes.LoadManifest(big); m != nil {
t.Fatal("a manifest over the size cap must be ignored")
}
}

113
classes/manifest.go Normal file
View File

@@ -0,0 +1,113 @@
package classes
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"os"
"strings"
"time"
)
// ManifestMaxBytes is the largest updates manifest the sitemap reads.
const ManifestMaxBytes = 5 << 20
// Manifest maps a normalized URL path to its last-modified date text, the
// updates manifest a frontend build writes.
type Manifest map[string]string
// LoadManifest reads the updates manifest at path: a regular file of at
// most ManifestMaxBytes holding a JSON object of URL path to date. It fails
// safe like PHP: an empty path, a missing, unreadable, oversized or invalid
// file is no manifest (nil), logged without the file's content. Keys are
// normalized like PHP normalizeManifestPath; values that are not strings are
// ignored.
func LoadManifest(path string) Manifest {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return nil
}
if info.Size() > ManifestMaxBytes {
slog.Default().Warn("golem15.sitemap: updates manifest is too large; lastmod omitted", "path", path, "max_bytes", ManifestMaxBytes)
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, ManifestMaxBytes+1))
if err != nil || len(raw) > ManifestMaxBytes {
return nil
}
raw = bytes.TrimSpace(raw)
var decoded map[string]any
if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &decoded) != nil {
slog.Default().Warn("golem15.sitemap: updates manifest is not a JSON object; lastmod omitted", "path", path)
return nil
}
m := Manifest{}
for key, value := range decoded {
if s, ok := value.(string); ok {
m[normalizeManifestPath(key)] = s
}
}
return m
}
// normalizeManifestPath is PHP normalizeManifestPath: a leading slash, no
// trailing slash except for the root.
func normalizeManifestPath(path string) string {
path = "/" + strings.TrimLeft(path, "/")
if path == "/" {
return path
}
return strings.TrimRight(path, "/")
}
// Lastmod is PHP lookupManifestLastmod: the date listed for the URL's path,
// parsed like the ISO 8601 forms strtotime reads (a date without a zone is
// UTC). ok is false when the path is not listed or its date does not parse.
func (m Manifest) Lastmod(url string) (time.Time, bool) {
if len(m) == 0 {
return time.Time{}, false
}
path := URLPath(url)
if path == "" {
path = "/"
}
text, ok := m[normalizeManifestPath(path)]
if !ok {
return time.Time{}, false
}
t, ok := parseManifestDate(text)
if !ok || t.Unix() == 0 {
return time.Time{}, false
}
return t, true
}
var manifestLayouts = []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.999999999Z0700",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04:05Z07:00",
"2006-01-02 15:04:05",
"2006-01-02",
}
func parseManifestDate(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
for _, layout := range manifestLayouts {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t, true
}
}
return time.Time{}, false
}

49
classes/rewrite.go Normal file
View File

@@ -0,0 +1,49 @@
package classes
import (
"regexp"
"strings"
)
// The PHP routes.php patterns of the frontend rewrite.
var (
locElement = regexp.MustCompile(`<loc>(.*?)</loc>`)
xhtmlLinkRef = regexp.MustCompile(`(<xhtml:link[^>]*href=")([^"]*)(")`)
)
// Rewrite is the frontend_url rewrite of PHP routes.php: with frontendURL
// set, backendURL is replaced literally (str_replace) inside every loc
// element, inside every xhtml:link href and in the exact stylesheet href.
// Both URLs are right-trimmed of slashes first. An empty frontendURL (or
// "0", which PHP reads as false) leaves xml unchanged.
func Rewrite(xml, backendURL, frontendURL string) string {
if frontendURL == "" || frontendURL == "0" {
return xml
}
frontend := strings.TrimRight(frontendURL, "/")
backend := strings.TrimRight(backendURL, "/")
replace := func(s string) string {
if backend == "" {
return s
}
return strings.ReplaceAll(s, backend, frontend)
}
xml = locElement.ReplaceAllStringFunc(xml, func(m string) string {
inner := m[len("<loc>") : len(m)-len("</loc>")]
return "<loc>" + replace(inner) + "</loc>"
})
xml = xhtmlLinkRef.ReplaceAllStringFunc(xml, func(m string) string {
sub := xhtmlLinkRef.FindStringSubmatch(m)
return sub[1] + replace(sub[2]) + sub[3]
})
if backend != "" {
xml = strings.ReplaceAll(xml, `href="`+backend+`/sitemap.xsl"`, `href="`+frontend+`/sitemap.xsl"`)
}
return xml
}
// BackendURL is PHP rtrim(url('/'), '/') for a request whose root URL is
// root: the URL the rewrite replaces.
func BackendURL(root string) string {
return strings.TrimRight(URLTo(root, "/"), "/")
}

58
classes/testdata/full.golden vendored Normal file
View File

@@ -0,0 +1,58 @@
=== escaping
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://a&amp;b&lt;c&gt;.pl/x</loc><changefreq>a&amp;b&lt;c&gt;"d'e&#13;f g
h</changefreq><priority>0.5 &amp; &lt;1&gt; "q" 'a'</priority></url><url><loc>https://api.example.test/za%C5%BC%C3%B3%C5%82%C4%87%20%26%20co</loc><lastmod>2026-01-02T03:04:05+00:00</lastmod><changefreq>x&gt;y</changefreq><priority>&lt;0.9&gt;</priority><xhtml:link rel="alternate" hreflang="x&quot;y&amp;z&lt;&#9;&#10;&#13;'&gt;" href="https://q&amp;r&lt;s&gt;.pl/p"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/za%C5%BC%C3%B3%C5%82%C4%87"/></url></urlset>
=== empty-urlset
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"/>
=== no-items
=== hreflang-list
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/pl/a</loc><lastmod>2026-02-01T00:00:00+00:00</lastmod><changefreq>monthly</changefreq><priority>0.4</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/a"/></url><url><loc>https://api.example.test/en/a</loc><lastmod>2026-02-01T00:00:00+00:00</lastmod><changefreq>monthly</changefreq><priority>0.4</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/a"/></url><url><loc>https://api.example.test/pl/b</loc><changefreq>monthly</changefreq><priority>0.4</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/pl/b"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/b"/><xhtml:link rel="alternate" hreflang="de" href="https://api.example.test/de/b"/></url><url><loc>https://api.example.test/en/b</loc><changefreq>monthly</changefreq><priority>0.4</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/pl/b"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/b"/><xhtml:link rel="alternate" hreflang="de" href="https://api.example.test/de/b"/></url><url><loc>https://api.example.test/de/b</loc><changefreq>monthly</changefreq><priority>0.4</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/pl/b"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/b"/><xhtml:link rel="alternate" hreflang="de" href="https://api.example.test/de/b"/></url><url><loc>https://api.example.test/solo</loc><changefreq>monthly</changefreq><priority>0.4</priority></url></urlset>
=== nested
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/kategoria/a</loc><lastmod>2026-03-01T12:00:00+00:00</lastmod><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/kategoria/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/category/a"/></url><url><loc>https://api.example.test/kategoria/b</loc><lastmod>2026-03-02T12:00:00+00:00</lastmod><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/kategoria/b"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/category/b"/></url><url><loc>https://api.example.test/kategoria/c</loc><changefreq>daily</changefreq><priority>0.7</priority></url></urlset>
=== tiers-default
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/en</loc><changefreq>weekly</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/en</loc><changefreq>weekly</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/uslugi/x</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://api.example.test/uslugi/x/y</loc><changefreq>weekly</changefreq><priority>0.3</priority></url><url><loc>https://api.example.test/realizacje/r</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://api.example.test/en/work/w</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://api.example.test/blog/a/b</loc><changefreq>weekly</changefreq><priority>0.6</priority></url><url><loc>https://api.example.test/en/blog/a/b</loc><changefreq>weekly</changefreq><priority>0.6</priority></url><url><loc>https://api.example.test/blog/a</loc><changefreq>weekly</changefreq><priority>0.4</priority></url><url><loc>https://api.example.test/blog</loc><changefreq>weekly</changefreq><priority>0.4</priority></url><url><loc>https://api.example.test/en/blog</loc><changefreq>weekly</changefreq><priority>0.4</priority></url><url><loc>https://api.example.test/uslugi</loc><changefreq>weekly</changefreq><priority>0.4</priority></url><url><loc>https://api.example.test/kontakt</loc><changefreq>weekly</changefreq><priority>0.2</priority></url><url><loc>https://api.example.test/kontakt</loc><changefreq>weekly</changefreq><priority>0.2</priority></url><url><loc>https://api.example.test/en/ai-lab/blob-playground</loc><changefreq>weekly</changefreq><priority>0.2</priority></url><url><loc>https://api.example.test/other</loc><changefreq>weekly</changefreq><priority>0.3</priority></url><url><loc>https://elsewhere.pl/uslugi/z</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://api.example.test/o-nas?x=1</loc><changefreq>weekly</changefreq><priority>0.2</priority></url><url><loc>https://api.example.test/blog/a/b/c</loc><changefreq>weekly</changefreq><priority>0.3</priority></url></urlset>
=== tiers-settings
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/</loc><changefreq>weekly</changefreq><priority>0.95</priority></url><url><loc>https://api.example.test/blog/a/b</loc><changefreq>weekly</changefreq><priority></priority></url><url><loc>https://api.example.test/regulamin</loc><changefreq>weekly</changefreq><priority>0.15</priority></url><url><loc>https://api.example.test/x</loc><changefreq>weekly</changefreq><priority>0.3</priority></url></urlset>
=== tiers-off
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/</loc><changefreq>weekly</changefreq><priority>0.1</priority></url><url><loc>https://api.example.test/blog/a/b</loc><changefreq>weekly</changefreq><priority>0.2</priority></url></urlset>
=== rewrite
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://www.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://www.example.test/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://www.example.test/a</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://www.example.test/b?x=https%3A%2F%2Fapi.example.test%2Fc</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://other.example.test/api.example.test</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://www.example.test/p</loc><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="pl" href="https://www.example.test/p"/><xhtml:link rel="alternate" hreflang="en" href="https://www.example.test/en/p"/></url><url><loc>https://www.example.test/en/p</loc><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="pl" href="https://www.example.test/p"/><xhtml:link rel="alternate" hreflang="en" href="https://www.example.test/en/p"/></url></urlset>
=== rewrite-same
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/a</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
=== manifest
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/</loc><lastmod>2026-05-01T08:00:00+00:00</lastmod><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/about</loc><lastmod>2026-05-02T00:00:00+00:00</lastmod><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/about</loc><lastmod>2026-05-02T00:00:00+00:00</lastmod><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/blog/x</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/none</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/rel</loc><lastmod>2026-05-03T01:02:03+00:00</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url><url><loc>https://api.example.test/about</loc><lastmod>2026-06-01T00:00:00+00:00</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url></urlset>
=== skip-extra
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/extra</loc><changefreq>weekly</changefreq><priority>0.5</priority><extra>a&amp;b &lt;c&gt;</extra></url><url><loc>https://api.example.test/keep</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
=== precision
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/p1</loc><changefreq>daily</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/p2</loc><changefreq>daily</changefreq><priority>1</priority></url><url><loc>https://api.example.test/p3</loc><changefreq>7</changefreq><priority>1</priority></url><url><loc>https://api.example.test/p4</loc><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/p5</loc><changefreq>daily</changefreq><priority>0.50</priority></url><url><loc>https://api.example.test/p6</loc><changefreq>daily</changefreq><priority>0.6</priority></url></urlset>
=== url-null
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/</loc><changefreq></changefreq><priority></priority></url><url><loc>https://api.example.test/0</loc><changefreq>daily</changefreq><priority>0.5</priority></url></urlset>
=== port-root
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="http://localhost:8096/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>http://localhost:8096/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>http://localhost:8096/a%20b</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>

693
classes/testdata/full.json vendored Normal file
View File

@@ -0,0 +1,693 @@
{
"cases": [
{
"name": "escaping",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": "https://a&b<c>.pl/x",
"changefreq": "a&b<c>\"d'e\rf\tg\nh",
"priority": "0.5 & <1> \"q\" 'a'"
},
{
"type": "acme-post",
"reference": "1",
"urlPattern": "",
"changefreq": "x>y",
"priority": "<0.9>",
"nesting": "1"
}
],
"types": {
"acme-post": {
"1": {
"url": "/zażółć & co",
"mtime": "2026-01-02T03:04:05+00:00",
"alternates": [
[
"x\"y&z<\t\n\r'>",
"https://q&r<s>.pl/p"
],
[
"en",
"/en/zażółć"
]
]
}
}
}
},
{
"name": "empty-urlset",
"root": "https://api.example.test",
"items": [
{
"type": "acme-post",
"reference": "404",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
},
{
"type": "unknown-type",
"reference": "1",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
}
],
"types": {
"acme-post": {}
}
},
{
"name": "no-items",
"root": "https://api.example.test",
"items": []
},
{
"name": "hreflang-list",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "acme-list",
"reference": "",
"urlPattern": "",
"changefreq": "monthly",
"priority": "0.4",
"nesting": "1"
}
],
"types": {
"acme-list": {
"": {
"url": "",
"items": [
{
"url": "/pl/a",
"mtime": "2026-02-01T00:00:00+00:00",
"alternates": [
[
"pl",
"/pl/a"
],
[
"en",
"/en/a"
]
]
},
{
"url": "/pl/b",
"mtime": null,
"alternates": [
[
"pl",
"/pl/b"
],
[
"en",
"/en/b"
],
[
"de",
"/de/b"
]
]
},
{
"url": "/solo",
"alternates": []
}
]
}
}
}
},
{
"name": "nested",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "acme-cat",
"reference": "5",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
}
],
"types": {
"acme-cat": {
"5": {
"url": "/kategoria/a",
"mtime": "2026-03-01T12:00:00+00:00",
"alternates": [
[
"pl",
"/kategoria/a"
],
[
"en",
"/en/category/a"
]
],
"items": [
{
"url": "/kategoria/b",
"mtime": "2026-03-02T12:00:00+00:00",
"alternates": [
[
"pl",
"/kategoria/b"
],
[
"en",
"/en/category/b"
]
],
"items": [
{
"url": "/kategoria/c",
"alternates": [
[
"x-default",
"/kategoria/c"
]
]
}
]
}
]
}
}
}
},
{
"name": "tiers-default",
"root": "https://api.example.test",
"items": [
{
"type": "url",
"url": "/",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/en",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/en/",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/uslugi/x",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/uslugi/x/y",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/realizacje/r",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/en/work/w",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/blog/a/b",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/en/blog/a/b",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/blog/a",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/blog",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/en/blog/",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/uslugi",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/kontakt",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/kontakt/",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/en/ai-lab/blob-playground",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/other",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "https://elsewhere.pl/uslugi/z",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/o-nas?x=1",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/blog/a/b/c",
"changefreq": "weekly",
"priority": "0.3"
}
]
},
{
"name": "tiers-settings",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": true,
"priority_homepage": "0.95",
"priority_blog": "",
"priority_legal": "0.15"
},
"items": [
{
"type": "url",
"url": "/",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/blog/a/b",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/regulamin",
"changefreq": "weekly",
"priority": "0.3"
},
{
"type": "url",
"url": "/x",
"changefreq": "weekly",
"priority": "0.3"
}
]
},
{
"name": "tiers-off",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": "/",
"changefreq": "weekly",
"priority": "0.1"
},
{
"type": "url",
"url": "/blog/a/b",
"changefreq": "weekly",
"priority": "0.2"
}
]
},
{
"name": "rewrite",
"root": "https://api.example.test",
"settings": {
"frontend_url": "https://www.example.test/",
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": "/",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/a",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "https://api.example.test/b?x=https://api.example.test/c",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "https://other.example.test/api.example.test",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "acme-list",
"reference": "",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
}
],
"types": {
"acme-list": {
"": {
"url": "",
"items": [
{
"url": "/p",
"alternates": [
[
"pl",
"/p"
],
[
"en",
"https://api.example.test/en/p"
]
]
}
]
}
}
}
},
{
"name": "rewrite-same",
"root": "https://api.example.test",
"settings": {
"frontend_url": "https://api.example.test",
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": "/a",
"changefreq": "weekly",
"priority": "0.5"
}
]
},
{
"name": "manifest",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"manifest": {
"/": "2026-05-01T10:00:00+02:00",
"about/": "2026-05-02",
"/blog/x": "not a date",
"/rel": "2026-05-03T01:02:03Z"
},
"items": [
{
"type": "url",
"url": "/",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/about",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/about/",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/blog/x",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/none",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "acme-post",
"reference": "2",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
},
{
"type": "acme-post",
"reference": "3",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
}
],
"types": {
"acme-post": {
"2": {
"url": "/rel",
"mtime": null,
"alternates": [
[
"pl",
"/rel"
]
]
},
"3": {
"url": "/about",
"mtime": "2026-06-01T00:00:00+00:00",
"alternates": [
[
"pl",
"/about"
]
]
}
}
}
},
{
"name": "skip-extra",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"skip": [
"https://api.example.test/skip-me",
"/rel-skip"
],
"extra": {
"https://api.example.test/extra": "a&b <c>"
},
"items": [
{
"type": "url",
"url": "/skip-me",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/extra",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/keep",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "acme-post",
"reference": "9",
"urlPattern": "",
"changefreq": "daily",
"priority": "0.7",
"nesting": "1"
}
],
"types": {
"acme-post": {
"9": {
"url": "/rel-skip",
"alternates": [
[
"pl",
"/rel-skip"
]
]
}
}
}
},
{
"name": "precision",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": "/p1",
"changefreq": "daily",
"priority": 0.5
},
{
"type": "url",
"url": "/p2",
"changefreq": "daily",
"priority": 1.0
},
{
"type": "url",
"url": "/p3",
"changefreq": 7,
"priority": 1
},
{
"type": "url",
"url": "/p4",
"changefreq": "daily",
"priority": "1.0"
},
{
"type": "url",
"url": "/p5",
"changefreq": "daily",
"priority": "0.50"
},
{
"url": "/p6",
"changefreq": "daily",
"priority": "0.6",
"cmsPage": "x",
"exists": true,
"fillable": [
"a"
]
}
]
},
{
"name": "url-null",
"root": "https://api.example.test",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": null,
"changefreq": null,
"priority": null
},
{
"type": "url",
"url": "0",
"changefreq": "daily",
"priority": "0.5"
}
]
},
{
"name": "port-root",
"root": "http://localhost:8096",
"settings": {
"enable_priority_tiers": false
},
"items": [
{
"type": "url",
"url": "/",
"changefreq": "weekly",
"priority": "0.5"
},
{
"type": "url",
"url": "/a b",
"changefreq": "weekly",
"priority": "0.5"
}
]
}
]
}

58
classes/testdata/urlto.golden vendored Normal file
View File

@@ -0,0 +1,58 @@
https://api.ex.pl/
https://api.ex.pl/
https://api.ex.pl/
https://api.ex.pl/blog
https://api.ex.pl/blog
https://api.ex.pl/blog
https://api.ex.pl/blog/za%C5%BC%C3%B3%C5%82%C4%87%20g%C4%99%C5%9Bl%C4%85
https://api.ex.pl/en/blog/%3Aslug
https://x.pl/q/
http://x.pl/
HTTP://X.pl:443/a
https://h:8080/a
https://h/a
http://h/
https://api.ex.pl/a?b=1&c=2
https://api.ex.pl/a?x%5B%5D=1&x%5B%5D=2&0=y
https://api.ex.pl/a?q=a&0=b%20c
https://api.ex.pl/a
https://api.ex.pl/a?b=&0=c
https://api.ex.pl/a?x&y
https://api.ex.pl/a?a=2
https://api.ex.pl/a?a%5Bb%5D=1
https://api.ex.pl/a#b%20c
https://api.ex.pl/a
#frag
cdn.x/a
mailto:a%40b.pl
tel:%2B48%20123
sms:%2B48123
https://api.ex.pl/a/c
https://api.ex.pl/a//b
https://api.ex.pl/a/%20b/%25zz
https://api.ex.pl/a%2Fb
https://api.ex.pl/%C5%9Bcie%C5%BCka/%C4%85%C4%99?x=%C5%BC#%C5%82
https://api.ex.pl/ftp%3A//x.pl/a%20b
ftp://x.pl/a
news:///comp.x
https://api.ex.pl/a%3Ab
https://api.ex.pl/a%3A1
https://api.ex.pl/user%40host
https://user:pass@h/x
https://api.ex.pl/%20%20/lead
https://api.ex.pl/trail
https://api.ex.pl/tab_here
https://api.ex.pl/new_line
https://api.ex.pl/
https://api.ex.pl/javascript%3Aalert%281%29
https://a&b<c>.pl/x
https://h/
https://api.ex.pl/en
https://api.ex.pl/en
https://api.ex.pl/a?x=%2B
https://api.ex.pl/a?k=v%20w
https://api.ex.pl/a%20b?x=1#y%20z
https://h/a?b=1#c
https://api.ex.pl/~tilde/_under.dot-dash
https://api.ex.pl/a%27b%22c
https://api.ex.pl/%3Cx%3E%26y

63
classes/testdata/urlto.json vendored Normal file
View File

@@ -0,0 +1,63 @@
{
"root": "https://api.ex.pl",
"urls": [
"/",
"",
null,
"/blog/",
"/blog",
"blog",
"/blog/zażółć gęślą",
"/en/blog/:slug",
"https://x.pl/q/",
"http://x.pl",
"HTTP://X.pl:443/a",
"https://h:8080/a",
"https://h:443/a",
"http://h:80/",
"/a?b=1&c=2",
"/a?x[]=1&x[]=2&y",
"/a?q=a%26b+c",
"/a?",
"/a?b=&c",
"/a?0=x&1=y",
"/a?a=1&a=2",
"/a?a[b]=1",
"/a#b c",
"/a#",
"#frag",
"//cdn.x/a",
"mailto:a@b.pl",
"tel:+48 123",
"sms:+48123",
"/../a/./b/../c",
"/a//b",
"/a/%20b/%zz",
"/a%2Fb",
"/ścieżka/ąę?x=ż#ł",
"ftp://x.pl/a b",
"ftp://x.pl/a",
"news:comp.x",
"/a:b",
"a:1",
"/user@host",
"https://user:pass@h/x",
" /lead",
"/trail ",
"/tab\there",
"/new\nline",
"/a/b/../../..",
"javascript:alert(1)",
"https://a&b<c>.pl/x",
"https://h/./",
"/en",
"/en/",
"/a?x=%2B",
"/a?k=v w",
"/a b?x=1#y z",
"https://h/a?b=1#c",
"/~tilde/_under.dot-dash",
"/a'b\"c",
"/<x>&y"
]
}

98
classes/tiers.go Normal file
View File

@@ -0,0 +1,98 @@
package classes
import (
"fmt"
"regexp"
"slices"
"strings"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TiersConfigKey is the config key of the priority tier rules. The plugin's
// config/config.yaml ships the rules of the PHP getPriorityForUrl; a host
// replaces the whole list in its own config.
const TiersConfigKey = "golem15.sitemap.priority_tiers"
// TierConfig is one configured tier rule: Setting names the priority setting
// whose value applies (priority_homepage, priority_services, priority_blog,
// priority_categories or priority_legal), and exactly one of Exact (paths
// that match as they are) and Pattern (a regular expression, RE2 syntax)
// selects the URLs.
type TierConfig struct {
Setting string `koanf:"setting"`
Exact []string `koanf:"exact"`
Pattern string `koanf:"pattern"`
}
// TierRule is a compiled tier rule.
type TierRule struct {
Setting string
Exact []string
Pattern *regexp.Regexp
}
// Tiers is the ordered tier rule list; the first matching rule wins.
type Tiers []TierRule
// CompileTiers checks and compiles tier rules. Every error names the rule:
// an unknown setting, neither or both of exact and pattern, or a pattern
// that does not compile.
func CompileTiers(rules []TierConfig) (Tiers, error) {
out := make(Tiers, 0, len(rules))
for i, r := range rules {
if _, ok := models.PriorityDefaults[r.Setting]; !ok {
return nil, fmt.Errorf("%s[%d]: setting %q is not one of priority_homepage, priority_services, priority_blog, priority_categories, priority_legal", TiersConfigKey, i, r.Setting)
}
hasPattern := strings.TrimSpace(r.Pattern) != ""
if hasPattern == (len(r.Exact) > 0) {
return nil, fmt.Errorf("%s[%d]: set exactly one of exact and pattern", TiersConfigKey, i)
}
rule := TierRule{Setting: r.Setting, Exact: append([]string(nil), r.Exact...)}
if hasPattern {
re, err := regexp.Compile(r.Pattern)
if err != nil {
return nil, fmt.Errorf("%s[%d]: pattern: %w", TiersConfigKey, i, err)
}
rule.Pattern = re
}
out = append(out, rule)
}
return out, nil
}
// LoadTiers reads and compiles the rules at TiersConfigKey; an absent key
// is no rules.
func LoadTiers(cfg *compass.Config) (Tiers, error) {
if cfg == nil || !cfg.Has(TiersConfigKey) {
return Tiers{}, nil
}
var rules []TierConfig
if err := cfg.LoadSection(TiersConfigKey, &rules); err != nil {
return nil, fmt.Errorf("%s must be a list of {setting, exact} or {setting, pattern} rules: %w", TiersConfigKey, err)
}
return CompileTiers(rules)
}
// Priority is PHP getPriorityForUrl with configured rules: when the
// settings enable tiers, the URL's path (parse_url's, "/" when it has none)
// right-trimmed of slashes is matched against the rules in order and the
// first match's setting value is returned. ok is false when tiers are off or
// no rule matches, so the item's own priority applies.
func (t Tiers) Priority(url string, s models.Settings) (string, bool) {
if !s.EnablePriorityTiers {
return "", false
}
path := URLPath(url)
if path == "" {
path = "/"
}
path = strings.TrimRight(path, "/")
for _, r := range t {
if r.Pattern != nil && r.Pattern.MatchString(path) || r.Pattern == nil && slices.Contains(r.Exact, path) {
return s.Priority(r.Setting), true
}
}
return "", false
}