feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL

BREAKING CHANGE: /sitemap.xsl is gone (404), the definitions screen and
the Phase 17 settings tile are replaced by the settings singleton, and
priority, change frequency, priority tiers and the update manifest are
no longer written or read. The config keys golem15.sitemap.cache_seconds
and golem15.sitemap.priority_tiers are removed; site_url, lastmod_ignore
and rebuild_delay_seconds are new.

- sitemap:import and sitemap:import-winter write the additional addresses
  (and site_url, default_locale) instead of the definition
- the golem15_sitemap_definitions table stays in the database, unread
This commit is contained in:
Jakub Zych
2026-11-04 11:12:51 +01:00
parent 3ba70bb69f
commit 84cb27a810
42 changed files with 582 additions and 5371 deletions

View File

@@ -1,56 +0,0 @@
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

@@ -1,268 +0,0 @@
// Package classes generates the sitemap: it walks the definition items,
// resolves registry items through the framework's menu item type registry
// and writes the PHP-identical XML.
package classes
import (
"context"
"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 50000th URL is written, the 50001st is not.
const MaxURLs = 50000
// lastmodLayout is PHP date('c') in UTC: "+00:00", never "Z".
const lastmodLayout = "2006-01-02T15:04:05-07:00"
// Resolver resolves a registry item; *signpost.Registry implements it.
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, 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
Settings models.Settings
Tiers Tiers
Events *festival.Bus
Manifest Manifest
manifestLoaded bool
entries []URLEntry
count int
}
// itemInfo is PHP's $itemInfo: the URL (set marks it present), the record's
// modification time and the hreflang alternates.
type itemInfo struct {
url string
set bool
mtime *time.Time
alternates []Alternate
}
// 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, 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 {
if err := g.addItem(ctx, item, itemInfo{}); err != nil {
return "", err
}
continue
}
resolved, err := g.resolve(ctx, item)
if err != nil {
return "", err
}
if resolved == nil {
continue
}
if resolved.URL != "" {
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
}
}
return Serialize(g.Stylesheet(), g.entries), nil
}
// Stylesheet is the stylesheet href PHP built:
// Url::buildUrl(Url::current(), ['path' => 'sitemap.xsl']).
func (g *Generator) Stylesheet() string {
return replacePath(URLTo(g.Root, "/sitemap.xml"), "sitemap.xsl")
}
func (g *Generator) resolve(ctx context.Context, item *models.DefinitionItem) (*pact.ResolvedMenuItem, error) {
if g.Resolver == nil || item.Type == "" {
return nil, nil
}
if reg, ok := g.Resolver.(interface {
Type(string) (pact.MenuItemType, bool)
}); ok {
if _, known := reg.Type(item.Type); !known {
return nil, nil
}
}
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(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
for _, child := range children {
if child.URL != "" {
if err := g.addItem(ctx, item, infoOf(child)); err != nil {
return err
}
}
if err := g.addChildren(ctx, item, child.Items); err != nil {
return err
}
}
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 {
info := itemInfo{url: r.URL, set: true, mtime: r.MTime}
for _, a := range r.Alternates {
info.alternates = append(info.alternates, Alternate{Hreflang: a.Locale, Href: a.URL})
}
return info
}
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. Unlike PHP, a URL or alternate that does not come out as an
// http or https URL is dropped.
func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, info itemInfo) error {
if g.count >= MaxURLs {
return nil
}
if !info.set {
info.url = URLTo(g.Root, models.Text(item.URL))
}
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 nil
}
loc := URLTo(g.Root, info.url)
if !httpURL(loc) {
// Only http and https URLs are written: a javascript: or other
// scheme that an import, a registry type or a pattern let through
// would be a clickable link in sitemap.xsl.
return nil
}
g.count++
priority, ok := g.Tiers.Priority(loc, g.Settings)
if !ok {
priority = models.Text(item.Priority)
}
entry := URLEntry{
Loc: loc,
Lastmod: lastmodText,
Changefreq: models.Text(item.Changefreq),
Priority: priority,
}
if len(info.alternates) > 1 {
for _, a := range info.alternates {
href := URLTo(g.Root, a.Href)
if !httpURL(href) {
continue
}
entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: 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

@@ -1,183 +0,0 @@
package classes_test
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/festival"
)
func urlItems(paths ...string) []models.DefinitionItem {
out := make([]models.DefinitionItem, 0, len(paths))
for _, p := range paths {
out = append(out, models.DefinitionItem{Type: models.ItemTypeURL, URL: text(p), Changefreq: text("daily"), Priority: text("0.5")})
}
return out
}
// TestHooks pins the typed successors of the PHP events (D-14): a
// BeforeAddItem listener that sets Skip drops the URL before it is counted,
// the first skipping listener stops the rest, AddItem listeners see the
// written loc and their Extra is written before the url closing tag in
// order, and a listener error stops the generation.
func TestHooks(t *testing.T) {
bus := festival.New()
var seenBefore, seenAdd, afterSkip []string
bus.Listen("acme.first", func(_ context.Context, e *classes.BeforeAddItem) error {
seenBefore = append(seenBefore, e.Info.URL)
if strings.HasSuffix(e.Info.URL, "/skip") {
e.Skip = true
}
return nil
})
bus.Listen("acme.second", func(_ context.Context, e *classes.BeforeAddItem) error {
afterSkip = append(afterSkip, e.Info.URL)
return nil
})
bus.Listen("acme.extra", func(_ context.Context, e *classes.AddItem) error {
seenAdd = append(seenAdd, e.Info.URL)
e.Extra = append(e.Extra, "<image:image><image:loc>"+classes.EscapeText(e.Info.URL+"?a&b")+"</image:loc></image:image>")
return nil
})
bus.Listen("acme.more", func(_ context.Context, e *classes.AddItem) error {
e.Extra = append(e.Extra, "<x/>")
return nil
})
gen := &classes.Generator{Root: "https://h.test", Events: bus}
xml, err := gen.Generate(context.Background(), urlItems("/a", "/skip", "/b"))
if err != nil {
t.Fatal(err)
}
if strings.Join(seenBefore, ",") != "https://h.test/a,https://h.test/skip,https://h.test/b" {
// The url item's URL is filled through Url::to before the event.
t.Fatalf("BeforeAddItem saw %v", seenBefore)
}
if strings.Join(afterSkip, ",") != "https://h.test/a,https://h.test/b" {
t.Fatalf("a listener after the skipping one ran for the skipped URL: %v", afterSkip)
}
if strings.Contains(xml, "/skip") {
t.Fatalf("the skipped URL is written:\n%s", xml)
}
if strings.Join(seenAdd, ",") != "https://h.test/a,https://h.test/b" {
t.Fatalf("AddItem saw %v", seenAdd)
}
want := "<url><loc>https://h.test/a</loc><changefreq>daily</changefreq><priority>0.5</priority><image:image><image:loc>https://h.test/a?a&amp;b</image:loc></image:image><x/></url>"
if !strings.Contains(xml, want) {
t.Fatalf("extra not written before the closing tag:\n%s", xml)
}
// A skip on the exact URL the event carries.
bus2 := festival.New()
var second []string
bus2.Listen("acme.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
e.Skip = strings.HasSuffix(e.Info.URL, "/skip")
return nil
})
bus2.Listen("acme.later", func(_ context.Context, e *classes.BeforeAddItem) error {
second = append(second, e.Info.URL)
return nil
})
gen = &classes.Generator{Root: "https://h.test", Events: bus2}
xml, err = gen.Generate(context.Background(), urlItems("/a", "/skip"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(xml, "/skip<") || strings.Join(second, ",") != "https://h.test/a" {
t.Fatalf("UntilHandled did not stop at the skipping listener: %v\n%s", second, xml)
}
// Without listeners or a bus nothing changes.
gen = &classes.Generator{Root: "https://h.test"}
if xml, err := gen.Generate(context.Background(), urlItems("/a")); err != nil || !strings.Contains(xml, "<loc>https://h.test/a</loc>") {
t.Fatalf("no bus: %v\n%s", err, xml)
}
// A listener error stops the generation.
failing := festival.New()
failing.Listen("acme.fail", func(_ context.Context, e *classes.AddItem) error { return errors.New("listener failed") })
gen = &classes.Generator{Root: "https://h.test", Events: failing}
if _, err := gen.Generate(context.Background(), urlItems("/a")); err == nil || !strings.Contains(err.Error(), "listener failed") {
t.Fatalf("listener error = %v", err)
}
}
// TestURLCap pins the protocol limit: of 50,001 URLs the 50,000th is
// written and the 50,001st is not; skipped and empty URLs are not counted;
// no items is an empty document.
func TestURLCap(t *testing.T) {
paths := make([]string, classes.MaxURLs+1)
for i := range paths {
paths[i] = "/u/" + strconv.Itoa(i+1)
}
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), urlItems(paths...))
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != classes.MaxURLs {
t.Fatalf("%d url elements, want %d", n, classes.MaxURLs)
}
if !strings.Contains(xml, "<loc>https://api.example.test/u/50000</loc>") || strings.Contains(xml, "<loc>https://api.example.test/u/50001</loc>") {
t.Fatal("the 50000th URL must be written and the 50001st must not")
}
// Skipped URLs do not count toward the cap.
bus := festival.New()
bus.Listen("acme.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
e.Skip = strings.HasSuffix(e.Info.URL, "/u/1")
return nil
})
gen = &classes.Generator{Root: "https://api.example.test", Events: bus}
xml, err = gen.Generate(context.Background(), urlItems(paths...))
if err != nil {
t.Fatal(err)
}
if strings.Count(xml, "<url>") != classes.MaxURLs || !strings.Contains(xml, "/u/50001</loc>") {
t.Fatal("a skipped URL took a place under the cap")
}
// A generator is reusable: a second run starts its count again.
if again, err := gen.Generate(context.Background(), urlItems("/x")); err != nil || strings.Count(again, "<url>") != 1 {
t.Fatalf("second run = %v, %d urls", err, strings.Count(again, "<url>"))
}
if empty, err := (&classes.Generator{Root: "https://h"}).Generate(context.Background(), nil); err != nil || empty != "" {
t.Fatalf("no items = %q %v", empty, err)
}
}
// TestOnlyHTTPLocs pins the CR-01 output filter: a URL that Url::to keeps
// but that is not http or https (a javascript: URL FILTER_VALIDATE_URL
// accepts, mailto:, a bare fragment) is not written and not counted. A
// protocol-relative URL comes out of Url::to on the root host.
func TestOnlyHTTPLocs(t *testing.T) {
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), urlItems(
"javascript://%0aalert(1)%0a//x",
"JavaScript://example.test/%0aalert(1)",
"//evil.test/x",
"mailto:a@example.test",
"#top",
"/ok",
"https://other.example.test/page",
))
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != 3 {
t.Fatalf("%d url elements, want 3:\n%s", n, xml)
}
if !strings.Contains(xml, "<loc>https://api.example.test/evil.test/x</loc>") {
t.Fatalf("the protocol-relative URL did not stay on the root host:\n%s", xml)
}
if strings.Contains(strings.ToLower(xml), "javascript") || strings.Contains(xml, "mailto:") || strings.Contains(xml, "#top") {
t.Fatalf("a non-http URL was written:\n%s", xml)
}
if !strings.Contains(xml, "<loc>https://api.example.test/ok</loc>") || !strings.Contains(xml, "<loc>https://other.example.test/page</loc>") {
t.Fatalf("an http(s) URL is missing:\n%s", xml)
}
}

View File

@@ -1,343 +0,0 @@
package classes_test
import (
"context"
"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"
)
// The goldens in testdata are written by tests/php/gen-goldens.php from the
// same JSON inputs with the PHP plugin's generation code and the vendored
// Winter URL generator; these tests feed the inputs through the Go port and
// compare bytes.
// goldenResolved is a registry item's resolve result in a golden input.
type goldenResolved struct {
URL string `json:"url"`
MTime *string `json:"mtime"`
Alternates [][2]string `json:"alternates"`
Items []goldenResolved `json:"items"`
}
func (g goldenResolved) toPact(t *testing.T) pact.ResolvedMenuItem {
t.Helper()
out := pact.ResolvedMenuItem{URL: g.URL}
if g.MTime != nil {
at, err := time.Parse(time.RFC3339, *g.MTime)
if err != nil {
t.Fatalf("mtime %q: %v", *g.MTime, err)
}
out.MTime = &at
}
for _, a := range g.Alternates {
out.Alternates = append(out.Alternates, pact.MenuItemAlternate{Locale: a[0], URL: a[1]})
}
for _, child := range g.Items {
out.Items = append(out.Items, child.toPact(t))
}
return out
}
// 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"`
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.
type fakeResolver struct {
t *testing.T
types map[string]map[string]goldenResolved
}
func (f fakeResolver) Type(key string) (pact.MenuItemType, bool) {
_, ok := f.types[key]
return pact.MenuItemType{Key: key}, ok
}
func (f fakeResolver) Resolve(_ context.Context, key, reference, _ string, _ bool, _ string) (*pact.ResolvedMenuItem, error) {
r, ok := f.types[key][reference]
if !ok {
return nil, nil
}
out := r.toPact(f.t)
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()
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 classes.Rewrite(xml, classes.BackendURL(c.Root), models.Text(settings.FrontendURL))
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return raw
}
// 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"`
}
if err := json.Unmarshal(readTestdata(t, "tracer.json"), &in); err != nil {
t.Fatal(err)
}
got := renderCase(t, in.Case)
if want := string(readTestdata(t, "tracer.golden")); got != want {
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")
}
}

View File

@@ -1,112 +0,0 @@
package classes
import (
"bytes"
"encoding/json"
"io"
"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 {
pluginLog.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 {
pluginLog.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
}

View File

@@ -1,81 +0,0 @@
package classes_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// TestManifest pins the updates manifest reader: it fails safe (a missing,
// empty-path, directory, oversized, non-object or invalid file is no
// manifest, so lastmod is omitted), normalizes keys, ignores non-string
// values, and parses the ISO dates strtotime reads, a date without a zone
// as UTC.
func TestManifest(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
for name, path := range map[string]string{
"empty path": "",
"spaces": " ",
"missing": filepath.Join(dir, "missing.json"),
"directory": dir,
"list": write("list.json", `["/a"]`),
"string": write("string.json", `"x"`),
"invalid": write("invalid.json", `{"/a": `),
"empty file": write("empty.json", ""),
"oversized": write("big.json", `{"/a":"2026-01-01","pad":"`+strings.Repeat("x", classes.ManifestMaxBytes)+`"}`),
"null": write("null.json", "null"),
"leading space": write("space.json", " \n"),
} {
if m := classes.LoadManifest(path); m != nil {
t.Errorf("%s: manifest = %v, want none", name, m)
}
}
if fifo := filepath.Join(dir, "link.json"); os.Symlink(dir, fifo) == nil {
if m := classes.LoadManifest(fifo); m != nil {
t.Error("a symlink to a directory is not a manifest")
}
}
path := write("updates.json", ` {"blog/a/":"2026-01-02T03:04:05+02:00","/b":"2026-02-03","c":"2026-03-04 05:06:07","/":"2026-04-05T06:07","/num":12,"/bad":"yesterday","/epoch":"1970-01-01T00:00:00Z","/nested":{"x":1}}`)
m := classes.LoadManifest(path)
if m == nil {
t.Fatal("valid manifest not read")
}
if _, ok := m["/num"]; ok {
t.Fatal("a non-string value was kept")
}
for url, want := range map[string]string{
"https://h/blog/a": "2026-01-02T01:04:05Z",
"https://h/blog/a/": "2026-01-02T01:04:05Z",
"https://h/b?x=1": "2026-02-03T00:00:00Z",
"/c": "2026-03-04T05:06:07Z",
"https://h": "2026-04-05T06:07:00Z",
"https://h/": "2026-04-05T06:07:00Z",
} {
got, ok := m.Lastmod(url)
if !ok || got.UTC().Format(time.RFC3339) != want {
t.Errorf("Lastmod(%s) = %v %v, want %s", url, got, ok, want)
}
}
for _, url := range []string{"https://h/bad", "https://h/epoch", "https://h/unlisted", "https://h/num"} {
if got, ok := m.Lastmod(url); ok {
t.Errorf("Lastmod(%s) = %v, want none", url, got)
}
}
var none classes.Manifest
if _, ok := none.Lastmod("https://h/b"); ok {
t.Fatal("a nil manifest answered")
}
}

View File

@@ -1,69 +0,0 @@
package classes
import (
"regexp"
"strings"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// 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.
//
// PHP inserted the stored text as it was. Three rules keep that text out of
// the document unless it is a plain http(s) URL (T-17-15):
//
// - a frontendURL that models.AllowedFrontendURL refuses leaves xml
// unchanged, whatever is stored in the settings row;
// - the frontend URL is inserted through EscapeText in loc and through
// EscapeAttr in the hrefs. Both are the identity for every accepted
// value, which keeps the PHP bytes;
// - a loc or xhtml:link href whose rewritten value does not start with
// http:// or https:// keeps its value. The generator applies the same
// filter before the rewrite.
func Rewrite(xml, backendURL, frontendURL string) string {
if frontendURL == "" || frontendURL == "0" {
return xml
}
if !models.AllowedFrontendURL(frontendURL) {
return xml
}
frontend := strings.TrimRight(frontendURL, "/")
backend := strings.TrimRight(backendURL, "/")
if backend == "" {
return xml
}
frontendText, frontendAttr := EscapeText(frontend), EscapeAttr(frontend)
xml = locElement.ReplaceAllStringFunc(xml, func(m string) string {
inner := m[len("<loc>") : len(m)-len("</loc>")]
rewritten := strings.ReplaceAll(inner, backend, frontendText)
if !httpURL(rewritten) {
return m
}
return "<loc>" + rewritten + "</loc>"
})
xml = xhtmlLinkRef.ReplaceAllStringFunc(xml, func(m string) string {
sub := xhtmlLinkRef.FindStringSubmatch(m)
rewritten := strings.ReplaceAll(sub[2], backend, frontendAttr)
if !httpURL(rewritten) {
return m
}
return sub[1] + rewritten + sub[3]
})
return strings.ReplaceAll(xml, `href="`+backend+`/sitemap.xsl"`, `href="`+frontendAttr+`/sitemap.xsl"`)
}
// 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, "/"), "/")
}

View File

@@ -1,131 +0,0 @@
package classes_test
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// rewriteBackend and rewriteDoc are the backend URL and the document of the
// rewrite tests: a stylesheet instruction, a loc, hreflang links and the
// backend URL in places the rewrite leaves alone.
const rewriteBackend = "https://api.ex.pl"
var rewriteDoc = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://api.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://api.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://api.ex.pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.ex.pl/en/a"/>` +
`<extra>https://api.ex.pl/x</extra></url><url><loc>https://other.pl/https://api.ex.pl</loc><changefreq>c</changefreq><priority>p</priority></url></urlset>` + "\n"
// TestRewrite pins the PHP routes.php frontend_url rewrite: the backend URL
// is replaced inside loc, inside xhtml:link href and in the exact stylesheet
// href, nowhere else; slashes are trimmed on both; an empty or "0" frontend
// URL and equal URLs leave the document unchanged.
func TestRewrite(t *testing.T) {
const backend = rewriteBackend
doc := rewriteDoc
want := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://www.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://www.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://www.ex.pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://www.ex.pl/en/a"/>` +
`<extra>https://api.ex.pl/x</extra></url><url><loc>https://other.pl/https://www.ex.pl</loc><changefreq>c</changefreq><priority>p</priority></url></urlset>` + "\n"
for _, frontend := range []string{"https://www.ex.pl", "https://www.ex.pl/", "https://www.ex.pl///"} {
for _, back := range []string{backend, backend + "/"} {
if got := classes.Rewrite(doc, back, frontend); got != want {
t.Fatalf("Rewrite(%q, %q):\n%s\nwant:\n%s", back, frontend, got, want)
}
}
}
for _, frontend := range []string{"", "0"} {
if got := classes.Rewrite(doc, backend, frontend); got != doc {
t.Fatalf("frontend %q changed the document", frontend)
}
}
if got := classes.Rewrite(doc, backend, backend); got != doc {
t.Fatal("equal URLs changed the document")
}
if got := classes.Rewrite(doc, backend+"/", backend); got != doc {
t.Fatal("equal URLs up to a slash changed the document")
}
// An empty backend URL replaces nothing.
if got := classes.Rewrite(doc, "", "https://www.ex.pl"); got != doc {
t.Fatal("an empty backend URL changed the document")
}
// A stylesheet on another path is not the PHP pattern.
other := `<?xml-stylesheet type="text/xsl" href="https://api.ex.pl/other.xsl"?>`
if got := classes.Rewrite(other, backend, "https://www.ex.pl"); got != other {
t.Fatalf("other stylesheet = %q", got)
}
}
// The two frontend_url values of the T-17-15 reproduction: a script URL the
// stylesheet would render as a link, and a value that closes the attribute
// and the element it is written into.
var frontendAttacks = []string{
"javascript:alert(document.domain)//",
`https://x"/><y a="`,
}
// TestRewriteUnsafeFrontendURL pins the T-17-15 output rule: a frontend URL
// that models.AllowedFrontendURL refuses leaves the document byte for byte
// as it was, so no part of it reaches loc, an hreflang href or the
// stylesheet instruction; an accepted one still rewrites; and a loc or href
// that the rewrite would turn into something other than an http(s) URL
// keeps its value.
func TestRewriteUnsafeFrontendURL(t *testing.T) {
refused := append([]string{
"https://www.ex.pl/?a=1&b=2",
"https://www.ex.pl <x>",
" https://www.ex.pl",
"https://www.ex.pl\n",
"//www.ex.pl",
"www.ex.pl",
"https:///x",
"data:text/html,x",
}, frontendAttacks...)
for _, frontend := range refused {
if got := classes.Rewrite(rewriteDoc, rewriteBackend, frontend); got != rewriteDoc {
t.Errorf("frontend %q changed the document:\n%s", frontend, got)
}
}
// The reproduction through the generator.
const root = "https://api.example.test"
xml, err := (&classes.Generator{Root: root}).Generate(context.Background(), urlItems("/blog/x"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(xml, `href="https://api.example.test/sitemap.xsl"`) {
t.Fatalf("the generated document lacks the stylesheet instruction:\n%s", xml)
}
for _, frontend := range frontendAttacks {
got := classes.Rewrite(xml, classes.BackendURL(root), frontend)
if got != xml {
t.Errorf("frontend %q changed the generated sitemap:\n%s", frontend, got)
}
if !strings.Contains(got, "<loc>https://api.example.test/blog/x</loc>") {
t.Errorf("frontend %q: the loc is not on the backend URL:\n%s", frontend, got)
}
if lower := strings.ToLower(got); strings.Contains(lower, "javascript") || strings.Contains(lower, "<y a=") {
t.Errorf("frontend %q reached the sitemap:\n%s", frontend, got)
}
}
// A value the predicate accepts still rewrites all three places.
got := classes.Rewrite(xml, classes.BackendURL(root), "https://www.example.test")
if !strings.Contains(got, "<loc>https://www.example.test/blog/x</loc>") || !strings.Contains(got, `href="https://www.example.test/sitemap.xsl"`) || strings.Contains(got, "api.example.test") {
t.Errorf("a valid frontend URL did not rewrite the sitemap:\n%s", got)
}
if got := classes.Rewrite(xml, classes.BackendURL(root), "HTTPS://WWW.EXAMPLE.TEST"); !strings.Contains(got, "<loc>HTTPS://WWW.EXAMPLE.TEST/blog/x</loc>") {
t.Errorf("an upper-case frontend URL did not rewrite the loc:\n%s", got)
}
// The http(s) output filter after the rewrite: replacing "s://api.ex.pl"
// would write httphttps://www.ex.pl/a, so loc and href keep their values.
entry := `<url><loc>https://api.ex.pl/a</loc><xhtml:link rel="alternate" hreflang="pl" href="https://api.ex.pl/a"/></url>`
if got := classes.Rewrite(entry, "s://api.ex.pl", "https://www.ex.pl"); got != entry {
t.Errorf("a rewrite to a non-http(s) URL was written:\n%s", got)
}
}

View File

@@ -1,105 +0,0 @@
package classes
import "strings"
// The sitemap document is written by hand in the exact format PHP's
// DOMDocument::saveXML (libxml2) produced for the PHP plugin: the XML
// declaration and the stylesheet processing instruction each followed by a
// newline, everything else on one line, an empty urlset self-closed,
// xhtml:link elements self-closed without a space. The standard library's
// XML encoder escapes and closes elements differently, so the writer is a
// strings.Builder.
// The urlset opening tag's attributes, in the order PHP set them.
const urlsetAttrs = ` 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"`
// textEscaper is libxml's escaping of text nodes: &, <, > and carriage
// return; quotes, tabs, newlines and UTF-8 stay literal.
var textEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\r", "&#13;")
// attrEscaper is libxml's escaping of attribute values: also the double
// quote, tab and newline; the single quote stays literal.
var attrEscaper = strings.NewReplacer(
"&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;",
"\r", "&#13;", "\t", "&#9;", "\n", "&#10;",
)
// EscapeText escapes s as libxml writes a text node.
func EscapeText(s string) string { return textEscaper.Replace(s) }
// EscapeAttr escapes s as libxml writes an attribute value.
func EscapeAttr(s string) string { return attrEscaper.Replace(s) }
// Alternate is one hreflang link of a sitemap URL.
type Alternate struct {
Hreflang string
Href string
}
// URLEntry is one url element: Loc, an optional Lastmod, Changefreq and
// Priority as they are written, the alternates (written only when there are
// more than one) and Extra, pre-escaped child XML that AddItem listeners
// appended, written before the closing tag.
type URLEntry struct {
Loc string
Lastmod string
Changefreq string
Priority string
Alternates []Alternate
Extra []string
}
// writeURL appends one url element.
func writeURL(b *strings.Builder, e URLEntry) {
b.WriteString("<url><loc>")
b.WriteString(EscapeText(e.Loc))
b.WriteString("</loc>")
if e.Lastmod != "" {
b.WriteString("<lastmod>")
b.WriteString(EscapeText(e.Lastmod))
b.WriteString("</lastmod>")
}
b.WriteString("<changefreq>")
b.WriteString(EscapeText(e.Changefreq))
b.WriteString("</changefreq><priority>")
b.WriteString(EscapeText(e.Priority))
b.WriteString("</priority>")
if len(e.Alternates) > 1 {
for _, a := range e.Alternates {
b.WriteString(`<xhtml:link rel="alternate" hreflang="`)
b.WriteString(EscapeAttr(a.Hreflang))
b.WriteString(`" href="`)
b.WriteString(EscapeAttr(a.Href))
b.WriteString(`"/>`)
}
}
for _, x := range e.Extra {
b.WriteString(x)
}
b.WriteString("</url>")
}
// Serialize writes the sitemap document: the XML declaration, the
// stylesheet processing instruction pointing at stylesheet (written as it
// is, like libxml writes PI data) and the urlset with entries.
func Serialize(stylesheet string, entries []URLEntry) string {
var b strings.Builder
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
b.WriteString(`<?xml-stylesheet type="text/xsl" href="`)
b.WriteString(stylesheet)
b.WriteString("\"?>\n<urlset")
b.WriteString(urlsetAttrs)
if len(entries) == 0 {
b.WriteString("/>\n")
return b.String()
}
b.WriteString(">")
for _, e := range entries {
writeURL(&b, e)
}
b.WriteString("</urlset>\n")
return b.String()
}

View File

@@ -1,95 +0,0 @@
package classes_test
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// TestXMLEscaping ports the PHP XmlEscapingTest (SITE-002) as behaviour:
// every value the sitemap writes is escaped exactly as libxml escapes it,
// text nodes (loc, lastmod, changefreq, priority) and attribute values
// (hreflang, href), and no raw value reaches the output.
func TestXMLEscaping(t *testing.T) {
const raw = "a&b<c>d\"e'f\rg\th\ni"
if got := classes.EscapeText(raw); got != "a&amp;b&lt;c&gt;d\"e'f&#13;g\th\ni" {
t.Fatalf("EscapeText = %q", got)
}
if got := classes.EscapeAttr(raw); got != "a&amp;b&lt;c&gt;d&quot;e'f&#13;g&#9;h&#10;i" {
t.Fatalf("EscapeAttr = %q", got)
}
if got := classes.EscapeText("zażółć — &amp;"); got != "zażółć — &amp;amp;" {
t.Fatalf("EscapeText keeps UTF-8 and escapes an existing entity again: %q", got)
}
xml := classes.Serialize("https://h/sitemap.xsl", []classes.URLEntry{{
Loc: "https://h/a?x=1&y=<2>",
Lastmod: "2026-01-02T03:04:05+00:00",
Changefreq: "weekly&<>",
Priority: "0.5\r",
Alternates: []classes.Alternate{
{Hreflang: "pl\"x", Href: "https://h/pl?a=1&b=\"2\"\t\n"},
{Hreflang: "en", Href: "https://h/en'x"},
},
}})
want := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<?xml-stylesheet type=\"text/xsl\" href=\"https://h/sitemap.xsl\"?>\n" +
`<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://h/a?x=1&amp;y=&lt;2&gt;</loc><lastmod>2026-01-02T03:04:05+00:00</lastmod><changefreq>weekly&amp;&lt;&gt;</changefreq><priority>0.5&#13;</priority>" +
`<xhtml:link rel="alternate" hreflang="pl&quot;x" href="https://h/pl?a=1&amp;b=&quot;2&quot;&#9;&#10;"/>` +
`<xhtml:link rel="alternate" hreflang="en" href="https://h/en'x"/>` +
"</url></urlset>\n"
if xml != want {
t.Fatalf("serialized:\n%q\nwant:\n%q", xml, want)
}
// One alternate is not written; an empty urlset is self-closed.
single := classes.Serialize("s", []classes.URLEntry{{Loc: "l", Changefreq: "c", Priority: "p", Alternates: []classes.Alternate{{Hreflang: "pl", Href: "x"}}}})
if strings.Contains(single, "xhtml:link") || strings.Contains(single, "<lastmod>") {
t.Fatalf("single alternate or empty lastmod written: %q", single)
}
if empty := classes.Serialize("s", nil); !strings.HasSuffix(empty, `sitemap.xsd"/>`+"\n") {
t.Fatalf("empty urlset = %q", empty)
}
// Through the generator: stored changefreq and priority, a resolved
// alternate's locale and a url item with every special character.
url := "/q?a=1&b=<x>&c=\"y\""
changefreq := "daily&<weekly>"
priority := "0.9\"'"
gen := &classes.Generator{Root: "https://api.example.test", Settings: models.Settings{}, Resolver: fakeResolver{t: t, types: map[string]map[string]goldenResolved{
"acme-post": {"1": {URL: "/p?x=1&y=2", Alternates: [][2]string{{"pl&<", "/p?x=1&y=2"}, {"en\"", "/en/p?z=<1>"}}}},
}}}
out, err := gen.Generate(context.Background(), []models.DefinitionItem{
{Type: models.ItemTypeURL, URL: &url, Changefreq: &changefreq, Priority: &priority},
{Type: "acme-post", Reference: text("1"), Changefreq: &changefreq, Priority: &priority},
})
if err != nil {
t.Fatal(err)
}
body := out[strings.Index(out, "<url>"):]
for _, bad := range []string{"<x>", "&b", "&y", `"y"`, "<weekly>", "daily&<", `hreflang="pl&<"`, `en""`, "<1>"} {
if strings.Contains(body, bad) {
t.Fatalf("raw value %q reached the output:\n%s", bad, body)
}
}
for _, good := range []string{
"<changefreq>daily&amp;&lt;weekly&gt;</changefreq>",
"<priority>0.9\"'</priority>",
`hreflang="pl&amp;&lt;"`,
`hreflang="en&quot;"`,
"y=2",
} {
if !strings.Contains(body, good) {
t.Fatalf("output lacks %q:\n%s", good, body)
}
}
if strings.Count(body, "<url>") != 2 {
t.Fatalf("urls = %d:\n%s", strings.Count(body, "<url>"), body)
}
}
func text(s string) *string { return &s }

View File

@@ -1,58 +0,0 @@
=== 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>

View File

@@ -1,693 +0,0 @@
{
"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"
}
]
}
]
}

View File

@@ -1,3 +0,0 @@
<?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>always</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/news/hello</loc><lastmod>2026-03-04T10:00:00+00:00</lastmod><changefreq>weekly</changefreq><priority>0.5</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/news/hello"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/news/hello"/></url></urlset>

View File

@@ -1,18 +0,0 @@
{
"case": {
"root": "https://api.example.test",
"items": [
{"type": "url", "url": "/", "changefreq": "always", "priority": "1.0", "nesting": "0"},
{"type": "acme-post", "reference": "7", "urlPattern": "", "changefreq": "weekly", "priority": "0.5", "nesting": "1"}
],
"types": {
"acme-post": {
"7": {
"url": "/news/hello",
"mtime": "2026-03-04T10:00:00+00:00",
"alternates": [["pl", "/news/hello"], ["en", "/en/news/hello"]]
}
}
}
}
}

View File

@@ -1,58 +0,0 @@
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

View File

@@ -1,63 +0,0 @@
{
"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"
]
}

View File

@@ -1,98 +0,0 @@
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
}

View File

@@ -1,94 +0,0 @@
package classes_test
import (
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// TestTiers pins the priority tiers (D-13): each default rule of the PHP
// getPriorityForUrl with paths from its list, trailing slashes, the first
// match winning, the settings' values, tiers turned off, a host list that
// replaces the defaults, and a bad rule failing the plugin's Boot.
func TestTiers(t *testing.T) {
tiers := pluginTiers(t)
if len(tiers) != 5 {
t.Fatalf("default rules = %d", len(tiers))
}
s := models.DefaultSettings()
for url, want := range map[string]string{
"https://h": "1.0",
"https://h/": "1.0",
"https://h/en": "1.0",
"https://h/en/": "1.0",
"https://h/uslugi/strony-www": "0.8",
"https://h/en/services/web": "0.8",
"https://h/realizacje/projekt/": "0.8",
"https://h/en/work/x": "0.8",
"https://h/blog/kategoria/wpis": "0.6",
"https://h/en/blog/category/post/": "0.6",
"https://h/blog": "0.4",
"https://h/blog/kategoria": "0.4",
"https://h/uslugi": "0.4",
"https://h/en/work": "0.4",
"https://h/polityka-prywatnosci": "0.2",
"https://h/en/terms/": "0.2",
"https://h/laboratorium-ai/zabawa-animacja": "0.2",
"https://h/en/ai-lab/blob-playground": "0.2",
"https://h/kontakt?x=1": "0.2",
} {
if got, ok := tiers.Priority(url, s); !ok || got != want {
t.Errorf("Priority(%s) = %q %v, want %s", url, got, ok, want)
}
}
for _, url := range []string{"https://h/other", "https://h/blog/a/b/c", "https://h/uslugi/a/b", "https://h/en/blogs/x", "https://h/kontakt/more"} {
if got, ok := tiers.Priority(url, s); ok {
t.Errorf("Priority(%s) = %q, want no rule", url, got)
}
}
custom := s
custom.PriorityBlog = text("0.65")
if got, _ := tiers.Priority("https://h/blog/a/b", custom); got != "0.65" {
t.Fatalf("custom blog priority = %q", got)
}
off := s
off.EnablePriorityTiers = false
if _, ok := tiers.Priority("https://h/", off); ok {
t.Fatal("tiers apply while turned off")
}
// First match wins: an exact rule listed before a pattern that also matches.
ordered, err := classes.CompileTiers([]classes.TierConfig{
{Setting: models.PriorityLegal, Exact: []string{"/blog"}},
{Setting: models.PriorityCategories, Pattern: "^/blog$"},
})
if err != nil {
t.Fatal(err)
}
if got, _ := ordered.Priority("https://h/blog/", s); got != "0.2" {
t.Fatalf("first match = %q", got)
}
if empty, err := classes.CompileTiers(nil); err != nil || len(empty) != 0 {
t.Fatalf("no rules = %v %v", empty, err)
}
if none, err := classes.LoadTiers(nil); err != nil || len(none) != 0 {
t.Fatalf("LoadTiers(nil) = %v %v", none, err)
}
// Every refused rule names its index; a bad RE2 pattern too.
for name, rule := range map[string]classes.TierConfig{
"unknown setting": {Setting: "priority_other", Exact: []string{""}},
"neither": {Setting: models.PriorityHomepage},
"both": {Setting: models.PriorityHomepage, Exact: []string{""}, Pattern: "^/$"},
"lookahead": {Setting: models.PriorityBlog, Pattern: "^/(?=blog)"},
"unbalanced": {Setting: models.PriorityBlog, Pattern: "^/(blog"},
} {
_, err := classes.CompileTiers([]classes.TierConfig{{Setting: models.PriorityHomepage, Exact: []string{""}}, rule})
if err == nil || !strings.Contains(err.Error(), classes.TiersConfigKey+"[1]") {
t.Errorf("%s: err = %v", name, err)
}
}
}

View File

@@ -1,566 +0,0 @@
package classes
import (
"regexp"
"strconv"
"strings"
)
// This file ports the parts of WinterCMS's URL generator the PHP sitemap
// called, so the Go sitemap writes the same bytes: Laravel's Url::to plus
// Winter's UrlGenerator::buildUrl, PHP's parse_url, rawurlencode,
// rawurldecode and urldecode, and FILTER_VALIDATE_URL. It is deliberately
// hand written: net/url escapes differently. The golden table
// testdata/urlto.golden, generated from the vendored Winter router, pins
// it.
// validURLPrefix is Laravel UrlGenerator::isValidUrl's fast path.
var validURLPrefix = regexp.MustCompile(`^(#|//|https?://|(mailto|tel|sms):)`)
// URLTo is Winter's Url::to(path) for a request whose root URL is root
// (scheme and host, plus a base path if any, without a trailing slash). A
// valid URL is kept; anything else becomes root plus "/" plus the path
// trimmed of slashes, the query string appended as it is. The result then
// goes through BuildURL, which re-encodes every path segment, so "/" is the
// root with its slash, "/blog/" loses its trailing slash and ":" becomes
// "%3A".
func URLTo(root, path string) string {
if isValidURL(path) {
return BuildURL(path)
}
p, query := path, ""
if i := strings.IndexByte(path, '?'); i >= 0 {
p, query = path[:i], path[i:]
}
inner := strings.Trim(p+"/", "/")
return BuildURL(strings.Trim(root+"/"+inner, "/") + query)
}
// isValidURL is Laravel UrlGenerator::isValidUrl.
func isValidURL(path string) bool {
if validURLPrefix.MatchString(path) {
return true
}
return filterValidateURL(path)
}
// filterURLChars is the character set PHP's FILTER_VALIDATE_URL accepts;
// a value with any other byte fails.
const filterURLChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$-_.+!*'(),{}|\\^~[]`<>#%\";/?:@&="
// filterValidateURL approximates PHP filter_var(FILTER_VALIDATE_URL): only
// URL characters, a scheme, a host unless the scheme is mailto, news or
// file, and a valid host name for http and https.
func filterValidateURL(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if !strings.ContainsRune(filterURLChars, rune(s[i])) {
return false
}
}
u := parseURL(s)
if !u.has(partScheme) || u.scheme == "" {
return false
}
scheme := strings.ToLower(u.scheme)
if !u.has(partHost) || u.host == "" {
return scheme == "mailto" || scheme == "news" || scheme == "file"
}
if scheme == "http" || scheme == "https" {
return validHostName(u.host)
}
return true
}
// validHostName reports whether host is dot-separated labels of letters,
// digits and inner hyphens.
func validHostName(host string) bool {
host = strings.TrimSuffix(host, ".")
if host == "" || len(host) > 253 {
return false
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !(isAlnum(c) || c == '-') {
return false
}
}
}
return true
}
// The parts a parsed URL may hold, as PHP parse_url's array keys.
const (
partScheme = 1 << iota
partHost
partUser
partPass
partPort
partPath
partQuery
partFragment
)
// phpURL is the array PHP parse_url returns; set records which keys exist.
type phpURL struct {
set int
scheme, host, user, pass, port, path, query, frag string
}
func (u phpURL) has(part int) bool { return u.set&part != 0 }
// parseURL ports PHP parse_url for the URL shapes a sitemap meets: an
// optional scheme, an authority after "//", a path, a query and a fragment.
// Control characters in a part are replaced by "_", as PHP does.
func parseURL(s string) phpURL {
var u phpURL
rest := s
if i := strings.IndexByte(rest, ':'); i > 0 && validScheme(rest[:i]) {
after := rest[i+1:]
if !strings.HasPrefix(after, "//") && portOnly(after) {
// "host:port/path" without a scheme.
} else {
u.scheme, u.set = rest[:i], u.set|partScheme
rest = after
}
}
if strings.HasPrefix(rest, "//") {
rest = rest[2:]
end := strings.IndexAny(rest, "/?#")
authority := rest
if end >= 0 {
authority, rest = rest[:end], rest[end:]
} else {
rest = ""
}
u.parseAuthority(authority)
}
if i := strings.IndexByte(rest, '#'); i >= 0 {
u.frag, u.set = rest[i+1:], u.set|partFragment
rest = rest[:i]
}
if i := strings.IndexByte(rest, '?'); i >= 0 {
u.query, u.set = rest[i+1:], u.set|partQuery
rest = rest[:i]
}
if rest != "" {
u.path, u.set = rest, u.set|partPath
}
u.scheme = replaceControl(u.scheme)
u.host = replaceControl(u.host)
u.user = replaceControl(u.user)
u.pass = replaceControl(u.pass)
u.path = replaceControl(u.path)
u.query = replaceControl(u.query)
u.frag = replaceControl(u.frag)
return u
}
func (u *phpURL) parseAuthority(a string) {
if i := strings.LastIndexByte(a, '@'); i >= 0 {
userinfo := a[:i]
a = a[i+1:]
if j := strings.IndexByte(userinfo, ':'); j >= 0 {
u.user, u.pass = userinfo[:j], userinfo[j+1:]
u.set |= partUser | partPass
} else {
u.user = userinfo
u.set |= partUser
}
}
if i := strings.LastIndexByte(a, ':'); i >= 0 && !strings.HasSuffix(a, "]") {
port := a[i+1:]
if port != "" && allDigits(port) {
u.port, u.set = port, u.set|partPort
}
a = a[:i]
}
if a != "" {
u.host, u.set = a, u.set|partHost
}
}
func validScheme(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if !(isAlnum(c) || c == '+' || c == '-' || c == '.') {
return false
}
}
return s != ""
}
// portOnly reports whether s is a port number up to the first "/", the
// case PHP parse_url reads as host:port rather than scheme:path.
func portOnly(s string) bool {
end := strings.IndexByte(s, '/')
if end < 0 {
end = len(s)
}
return end > 0 && end <= 5 && allDigits(s[:end])
}
func allDigits(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return s != ""
}
func isAlnum(c byte) bool {
return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9'
}
func replaceControl(s string) string {
if strings.IndexFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) < 0 {
return s
}
b := []byte(s)
for i, c := range b {
if c < 0x20 || c == 0x7f {
b[i] = '_'
}
}
return string(b)
}
// phpTrim is PHP trim() with its default character list.
func phpTrim(s string) string {
return strings.Trim(s, " \t\n\r\x00\x0b")
}
// singleColonSchemes are written "scheme:" instead of "scheme://".
var singleColonSchemes = map[string]bool{"mailto": true, "tel": true, "sms": true}
// defaultPorts are Winter UrlGenerator::isDefaultPort's known ports.
var defaultPorts = map[string]int{
"ftp": 21, "ssh": 22, "sftp": 22, "telnet": 23, "smtp": 25, "dns": 53,
"gopher": 70, "http": 80, "pop3": 110, "imap": 143, "ldap": 389,
"https": 443, "smtps": 465, "ldaps": 636, "ftps": 990, "imaps": 993,
"pop3s": 995,
}
// BuildURL is Winter UrlGenerator::buildUrl($url) with no replacements: the
// query is URL-decoded, the URL parsed, every path segment re-encoded with
// rawurlencode(rawurldecode()) after "." and ".." are resolved, the query
// rebuilt with buildStr and the fragment re-encoded.
func BuildURL(raw string) string {
return buildParts(parseURL(decodeQueryParams(raw)))
}
// replacePath is buildUrl($url, ['path' => path]).
func replacePath(raw, path string) string {
u := parseURL(decodeQueryParams(raw))
u.path, u.set = path, u.set|partPath
return buildParts(u)
}
// decodeQueryParams URL-decodes everything after the first "?".
func decodeQueryParams(s string) string {
if i := strings.IndexByte(s, '?'); i >= 0 {
return s[:i] + "?" + urldecode(s[i+1:])
}
return s
}
func buildParts(u phpURL) string {
u.scheme, u.host, u.user, u.pass = phpTrim(u.scheme), phpTrim(u.host), phpTrim(u.user), phpTrim(u.pass)
u.path, u.query, u.frag = phpTrim(u.path), phpTrim(u.query), phpTrim(u.frag)
port := 0
if u.has(partPort) {
if n, err := strconv.Atoi(phpTrim(u.port)); err == nil && n >= 1 && n <= 65535 {
port = n
}
}
var b strings.Builder
hasScheme := u.has(partScheme) && u.scheme != ""
if hasScheme {
b.WriteString(u.scheme)
if singleColonSchemes[u.scheme] {
b.WriteString(":")
} else {
b.WriteString("://")
}
}
if u.has(partUser) && u.user != "" {
b.WriteString(u.user)
if u.has(partPass) && u.pass != "" {
b.WriteString(":" + u.pass)
}
b.WriteString("@")
}
if u.has(partHost) && u.host != "" {
b.WriteString(u.host)
}
if port != 0 {
if def, ok := defaultPorts[strings.ToLower(u.scheme)]; !ok || def != port {
b.WriteString(":" + strconv.Itoa(port))
}
}
path := ""
if u.has(partPath) && u.path != "" {
var kept []string
for _, seg := range strings.Split(u.path, "/") {
switch {
case seg == ".":
case seg == ".." && len(kept) > 0:
kept = kept[:len(kept)-1]
default:
kept = append(kept, seg)
}
}
for i, seg := range kept {
kept[i] = rawurlencode(rawurldecode(seg))
}
path = strings.Join(kept, "/")
}
if hasScheme && !singleColonSchemes[u.scheme] {
if !strings.HasPrefix(path, "/") {
b.WriteString("/")
}
b.WriteString(path)
} else {
b.WriteString(path)
}
if u.has(partQuery) && u.query != "" {
b.WriteString("?" + buildStr(parseQueryPairs(u.query), ""))
}
if u.has(partFragment) && u.frag != "" {
b.WriteString("#" + rawurlencode(rawurldecode(u.frag)))
}
return b.String()
}
// phpKey is a PHP array key: an int or a string.
type phpKey struct {
isInt bool
i int64
s string
}
func (k phpKey) String() string {
if k.isInt {
return strconv.FormatInt(k.i, 10)
}
return k.s
}
// keyOf is PHP's array key cast of a string: a canonical decimal integer
// becomes an int key.
func keyOf(s string) phpKey {
if n, err := strconv.ParseInt(s, 10, 64); err == nil && strconv.FormatInt(n, 10) == s {
return phpKey{isInt: true, i: n}
}
return phpKey{s: s}
}
// phpArray is an ordered PHP array of strings, nested arrays or nulls.
type phpArray struct {
keys []phpKey
vals []any
next int64
}
func (a *phpArray) index(k phpKey) int {
for i, have := range a.keys {
if have == k {
return i
}
}
return -1
}
func (a *phpArray) set(k phpKey, v any) {
if i := a.index(k); i >= 0 {
a.vals[i] = v
return
}
a.keys = append(a.keys, k)
a.vals = append(a.vals, v)
if k.isInt && k.i >= a.next {
a.next = k.i + 1
}
}
func (a *phpArray) push(v any) {
a.set(phpKey{isInt: true, i: a.next}, v)
}
// parseQueryPairs is buildUrl's split of a string query into
// $queryParams.
func parseQueryPairs(query string) *phpArray {
params := &phpArray{}
for _, pair := range strings.Split(query, "&") {
key, value := pair, pair
if i := strings.IndexByte(pair, '='); i >= 0 {
key, value = pair[:i], pair[i+1:]
}
if !strings.Contains(pair, "=") {
params.push(key)
continue
}
if strings.Contains(urldecode(pair), "[") {
k := keyOf(key)
var inner *phpArray
if i := params.index(k); i >= 0 {
inner, _ = params.vals[i].(*phpArray)
}
if inner == nil {
inner = &phpArray{}
}
inner.push(value)
params.set(k, inner)
continue
}
params.set(keyOf(key), value)
}
return params
}
// buildStr is Winter UrlGenerator::buildStr.
func buildStr(query *phpArray, prefix string) string {
var out []string
for i, k := range query.keys {
v := query.vals[i]
var key string
if k.isInt && k.i == int64(i) {
if prefix != "" {
if strings.HasSuffix(prefix, "]") {
key = prefix
} else {
key = prefix + "[]"
}
} else {
key, _ = v.(string)
v = nil
}
} else if prefix != "" {
key = prefix + "[" + k.String() + "]"
} else {
key = k.String()
}
if nested, ok := v.(*phpArray); ok {
out = append(out, buildStr(nested, key))
continue
}
part := rawurlencode(rawurldecode(key))
if s, ok := v.(string); ok {
part += "=" + rawurlencode(rawurldecode(s))
}
out = append(out, part)
}
return strings.Join(out, "&")
}
// rawurlencode is PHP rawurlencode: every byte except A-Z a-z 0-9 - _ . ~
// becomes %XX with upper-case hex.
func rawurlencode(s string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if isAlnum(c) || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
return b.String()
}
// rawurldecode is PHP rawurldecode: %XX becomes its byte, anything else,
// including a malformed escape, stays.
func rawurldecode(s string) string { return percentDecode(s, false) }
// urldecode is PHP urldecode: rawurldecode that also turns "+" into a
// space.
func urldecode(s string) string { return percentDecode(s, true) }
func percentDecode(s string, plus bool) string {
if !strings.ContainsAny(s, "%+") {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c == '+' && plus {
b.WriteByte(' ')
continue
}
if c == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
continue
}
b.WriteByte(c)
}
return b.String()
}
func isHex(c byte) bool {
return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F'
}
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
default:
return c - 'A' + 10
}
}
// URLPath is the path PHP parse_url($url, PHP_URL_PATH) returns, "" when
// the URL has none.
func URLPath(raw string) string {
u := parseURL(raw)
if !u.has(partPath) {
return ""
}
return u.path
}
// schemePrefix matches a URL that starts with a scheme and its colon.
var schemePrefix = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.\-]*:`)
// AllowedItemURL reports whether a url item's address may be stored: empty,
// a path (relative or starting with a single "/"), or an absolute http or
// https URL. Any other scheme (javascript:, data:, mailto: and the like), a
// protocol-relative "//host" or "/\host", a bare fragment, surrounding
// whitespace and control characters are refused. Url::to keeps any URL
// FILTER_VALIDATE_URL accepts, so without this a javascript: URL reached
// <loc>, which sitemap.xsl renders as a link.
func AllowedItemURL(u string) bool {
if u == "" {
return true
}
if strings.TrimSpace(u) != u || strings.ContainsFunc(u, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
return false
}
if httpURL(u) {
return true
}
if schemePrefix.MatchString(u) || u[0] == '#' || u[0] == '\\' {
return false
}
return !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, `/\`)
}
// httpURL reports whether u starts with http:// or https://
// (case-insensitive).
func httpURL(u string) bool {
lower := strings.ToLower(u)
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")
}

View File

@@ -1,84 +0,0 @@
package classes_test
import (
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// TestURLTo runs every case of testdata/urlto.json through URLTo and
// compares it with the line PHP's Url::to wrote to urlto.golden (SC-3), then
// pins the helpers built on it: BackendURL and URLPath.
func TestURLTo(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(in.URLs) < 50 || len(want) != len(in.URLs) {
t.Fatalf("%d inputs, %d golden lines", len(in.URLs), len(want))
}
for i, u := range in.URLs {
path := "<null>"
input := ""
if u != nil {
path, input = *u, *u
}
if got := classes.URLTo(in.Root, input); got != want[i] {
t.Errorf("case %d URLTo(%q) = %q, PHP %q", i, path, got, want[i])
}
}
for _, tc := range []struct{ root, want string }{
{"https://api.ex.pl", "https://api.ex.pl"},
{"https://api.ex.pl/", "https://api.ex.pl"},
{"http://h:8080/base", "http://h:8080/base"},
} {
if got := classes.BackendURL(tc.root); got != tc.want {
t.Errorf("BackendURL(%q) = %q, want %q", tc.root, got, tc.want)
}
}
for raw, want := range map[string]string{
"https://h/a/b?x=1#f": "/a/b",
"https://h": "",
"https://h/": "/",
"/rel/path": "/rel/path",
"": "",
} {
if got := classes.URLPath(raw); got != want {
t.Errorf("URLPath(%q) = %q, want %q", raw, got, want)
}
}
}
// TestAllowedItemURL pins the save rule of a url item's address (CR-01).
func TestAllowedItemURL(t *testing.T) {
for u, want := range map[string]bool{
"": true,
"/": true,
"/about": true,
"about/team?x=1": true,
"https://example.test/a": true,
"HTTP://example.test/a": true,
"javascript://%0aalert(1)%0a//x": false,
"javascript:alert(1)": false,
"data:text/html,x": false,
"mailto:a@example.test": false,
"//evil.test/x": false,
`/\evil.test/x`: false,
`\\evil.test`: false,
"#top": false,
" /about": false,
"/about\n": false,
"java\tscript:alert(1)": false,
} {
if got := classes.AllowedItemURL(u); got != want {
t.Errorf("AllowedItemURL(%q) = %v, want %v", u, got, want)
}
}
}