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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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&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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "/"), "/")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">", "\r", " ")
|
||||
|
||||
// attrEscaper is libxml's escaping of attribute values: also the double
|
||||
// quote, tab and newline; the single quote stays literal.
|
||||
var attrEscaper = strings.NewReplacer(
|
||||
"&", "&", "<", "<", ">", ">", `"`, """,
|
||||
"\r", " ", "\t", "	", "\n", " ",
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -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&b<c>d\"e'f g\th\ni" {
|
||||
t.Fatalf("EscapeText = %q", got)
|
||||
}
|
||||
if got := classes.EscapeAttr(raw); got != "a&b<c>d"e'f g	h i" {
|
||||
t.Fatalf("EscapeAttr = %q", got)
|
||||
}
|
||||
if got := classes.EscapeText("zażółć — &"); got != "zażółć — &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&y=<2></loc><lastmod>2026-01-02T03:04:05+00:00</lastmod><changefreq>weekly&<></changefreq><priority>0.5 </priority>" +
|
||||
`<xhtml:link rel="alternate" hreflang="pl"x" href="https://h/pl?a=1&b="2"	 "/>` +
|
||||
`<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&<weekly></changefreq>",
|
||||
"<priority>0.9\"'</priority>",
|
||||
`hreflang="pl&<"`,
|
||||
`hreflang="en""`,
|
||||
"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 }
|
||||
58
classes/testdata/full.golden
vendored
58
classes/testdata/full.golden
vendored
@@ -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&b<c>.pl/x</loc><changefreq>a&b<c>"d'e f g
|
||||
h</changefreq><priority>0.5 & <1> "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>y</changefreq><priority><0.9></priority><xhtml:link rel="alternate" hreflang="x"y&z<	 '>" href="https://q&r<s>.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&b <c></extra></url><url><loc>https://api.example.test/keep</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
|
||||
=== precision
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/p1</loc><changefreq>daily</changefreq><priority>0.5</priority></url><url><loc>https://api.example.test/p2</loc><changefreq>daily</changefreq><priority>1</priority></url><url><loc>https://api.example.test/p3</loc><changefreq>7</changefreq><priority>1</priority></url><url><loc>https://api.example.test/p4</loc><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/p5</loc><changefreq>daily</changefreq><priority>0.50</priority></url><url><loc>https://api.example.test/p6</loc><changefreq>daily</changefreq><priority>0.6</priority></url></urlset>
|
||||
=== url-null
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://api.example.test/</loc><changefreq></changefreq><priority></priority></url><url><loc>https://api.example.test/0</loc><changefreq>daily</changefreq><priority>0.5</priority></url></urlset>
|
||||
=== port-root
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="http://localhost:8096/sitemap.xsl"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>http://localhost:8096/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>http://localhost:8096/a%20b</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
|
||||
693
classes/testdata/full.json
vendored
693
classes/testdata/full.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
3
classes/testdata/tracer.golden
vendored
3
classes/testdata/tracer.golden
vendored
@@ -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>
|
||||
18
classes/testdata/tracer.json
vendored
18
classes/testdata/tracer.json
vendored
@@ -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"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
classes/testdata/urlto.golden
vendored
58
classes/testdata/urlto.golden
vendored
@@ -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
|
||||
63
classes/testdata/urlto.json
vendored
63
classes/testdata/urlto.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
566
classes/urlto.go
566
classes/urlto.go
@@ -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://")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user