feat(17-03): full sitemap generation, settings, hooks and import commands
- generator in Definition::addItemToSet order: 50000 URL cap, lastmod from the record or the updates manifest (regular file, 5 MiB cap, fail safe), BeforeAddItem (skip) and AddItem (extra XML) festival events, tier or item priority, per-alternate expansion of list type children - priority tiers from golem15.sitemap.priority_tiers, compiled at boot, defaults equal to the PHP getPriorityForUrl rules - frontend_url rewrite of loc, xhtml:link href and the stylesheet href; GET /sitemap.xsl serves the PHP stylesheet byte for byte - golem15_sitemap_settings singleton and Settings model - sitemap:import (PHP ImportCommand) and sitemap:import-winter with the read-only Winter export SQL - urlto and full goldens generated from PHP; TestSitemapImportSmoke
This commit is contained in:
56
classes/events.go
Normal file
56
classes/events.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
)
|
||||
|
||||
// ItemInfo is what the sitemap knows about one URL it is about to write, the
|
||||
// PHP $itemInfo array: URL (before the final Url::to in BeforeAddItem, the
|
||||
// written loc in AddItem), MTime, the record's modification time when the
|
||||
// item has one, Lastmod, the written lastmod ("" when omitted), and the
|
||||
// hreflang Alternates.
|
||||
type ItemInfo struct {
|
||||
URL string
|
||||
MTime *time.Time
|
||||
Lastmod string
|
||||
Alternates []Alternate
|
||||
}
|
||||
|
||||
// BeforeAddItem is the typed successor of the PHP event
|
||||
// golem15.sitemap.beforeAddItem. The generator fires it with
|
||||
// festival.Bus.UntilHandled for every URL before it is counted and written;
|
||||
// a listener that sets Skip drops the URL (PHP: a listener returned false).
|
||||
//
|
||||
// app.Events.Listen("acme.seo", func(ctx context.Context, e *classes.BeforeAddItem) error {
|
||||
// if strings.HasSuffix(e.Info.URL, "/draft") {
|
||||
// e.Skip = true
|
||||
// }
|
||||
// return nil
|
||||
// })
|
||||
type BeforeAddItem struct {
|
||||
Item models.DefinitionItem
|
||||
Info ItemInfo
|
||||
Skip bool
|
||||
}
|
||||
|
||||
// IsHandled reports Skip, so UntilHandled stops at the first listener that
|
||||
// drops the URL (festival.Handleable).
|
||||
func (e *BeforeAddItem) IsHandled() bool { return e != nil && e.Skip }
|
||||
|
||||
// AddItem is the typed successor of the PHP event golem15.sitemap.addItem.
|
||||
// The generator fires it with festival.Bus.Fire after a URL's elements are
|
||||
// built; listeners may append child XML to Extra, which is written as it is,
|
||||
// before the url closing tag, so it must be escaped already (EscapeText,
|
||||
// EscapeAttr).
|
||||
//
|
||||
// app.Events.Listen("acme.images", func(ctx context.Context, e *classes.AddItem) error {
|
||||
// e.Extra = append(e.Extra, "<image:image><image:loc>"+classes.EscapeText(img)+"</image:loc></image:image>")
|
||||
// return nil
|
||||
// })
|
||||
type AddItem struct {
|
||||
Item models.DefinitionItem
|
||||
Info ItemInfo
|
||||
Extra []string
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/festival"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// MaxURLs is the sitemap protocol's URL limit (PHP Definition::MAX_URLS):
|
||||
// the 50,000th URL is written, the 50,001st is not.
|
||||
// the 50000th URL is written, the 50001st is not.
|
||||
const MaxURLs = 50000
|
||||
|
||||
// lastmodLayout is PHP date('c') in UTC: "+00:00", never "Z".
|
||||
@@ -23,18 +24,27 @@ type Resolver interface {
|
||||
Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
|
||||
}
|
||||
|
||||
// Generator writes the sitemap of a definition. Root is the backend root URL
|
||||
// (scheme and host without a trailing slash) that Url::to builds absolute
|
||||
// URLs from. Resolver resolves registry items; nil skips them.
|
||||
// UnprefixedLocale is handed to every Resolve as the locale without a URL
|
||||
// prefix ("" leaves it to the type).
|
||||
// Generator writes the sitemap of a definition, following PHP
|
||||
// Definition::generateSitemap and addItemToSet.
|
||||
//
|
||||
// Root is the backend root URL (scheme and host without a trailing slash)
|
||||
// that Url::to builds absolute URLs from. Resolver resolves registry items;
|
||||
// nil skips them. Settings are the sitemap settings (tiers, priorities,
|
||||
// frontend default locale, manifest path), Tiers the compiled tier rules,
|
||||
// and Events the bus BeforeAddItem and AddItem are fired on (nil fires
|
||||
// nothing). Manifest, when set, is used instead of reading the settings'
|
||||
// updates_manifest_path.
|
||||
type Generator struct {
|
||||
Root string
|
||||
Resolver Resolver
|
||||
UnprefixedLocale string
|
||||
Root string
|
||||
Resolver Resolver
|
||||
Settings models.Settings
|
||||
Tiers Tiers
|
||||
Events *festival.Bus
|
||||
Manifest Manifest
|
||||
|
||||
entries []URLEntry
|
||||
count int
|
||||
manifestLoaded bool
|
||||
entries []URLEntry
|
||||
count int
|
||||
}
|
||||
|
||||
// itemInfo is PHP's $itemInfo: the URL (set marks it present), the record's
|
||||
@@ -47,19 +57,31 @@ type itemInfo struct {
|
||||
}
|
||||
|
||||
// Generate returns the sitemap XML of items, or "" when there are none (PHP
|
||||
// generateSitemap). A url item is added with its URL; a registry item is
|
||||
// resolved with its URL pattern and nesting, its URL added when it has one
|
||||
// and its sub-items added recursively. A type the registry does not know,
|
||||
// or a Resolve that finds nothing, adds nothing.
|
||||
// generateSitemap). A url item is added with its URL. A registry item is
|
||||
// resolved with its URL pattern, its nesting and the settings' frontend
|
||||
// default locale as the unprefixed locale; its URL is added when it has one,
|
||||
// then its sub-items: when the item has no URL of its own (a list type),
|
||||
// each sub-item is added once per alternate, with that alternate's URL as
|
||||
// loc and every alternate as hreflang, as the PHP list types returned them;
|
||||
// otherwise each sub-item is added once with its own URL. A type the
|
||||
// registry does not know, or a Resolve that finds nothing, adds nothing.
|
||||
//
|
||||
// Generate does not apply the frontend_url rewrite; the route does
|
||||
// (Rewrite).
|
||||
func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem) (string, error) {
|
||||
if len(items) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
g.entries, g.count = nil, 0
|
||||
for i := range items {
|
||||
item := &items[i]
|
||||
if item.Type == models.ItemTypeURL {
|
||||
g.addItem(item, itemInfo{})
|
||||
if err := g.addItem(ctx, item, itemInfo{}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
resolved, err := g.resolve(ctx, item)
|
||||
@@ -70,9 +92,17 @@ func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem)
|
||||
continue
|
||||
}
|
||||
if resolved.URL != "" {
|
||||
g.addItem(item, infoOf(*resolved))
|
||||
if err := g.addItem(ctx, item, infoOf(*resolved)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := g.addChildren(ctx, item, resolved.Items); err != nil {
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := g.addPerLocale(ctx, item, resolved.Items); err != nil {
|
||||
return "", err
|
||||
}
|
||||
g.addChildren(item, resolved.Items)
|
||||
}
|
||||
return Serialize(g.Stylesheet(), g.entries), nil
|
||||
}
|
||||
@@ -94,17 +124,54 @@ func (g *Generator) resolve(ctx context.Context, item *models.DefinitionItem) (*
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, g.UnprefixedLocale)
|
||||
return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, models.Text(g.Settings.FrontendDefaultLocale))
|
||||
}
|
||||
|
||||
// addChildren adds every child with a URL, then its own children.
|
||||
func (g *Generator) addChildren(item *models.DefinitionItem, children []pact.ResolvedMenuItem) {
|
||||
func (g *Generator) addChildren(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
|
||||
for _, child := range children {
|
||||
if child.URL != "" {
|
||||
g.addItem(item, infoOf(child))
|
||||
if err := g.addItem(ctx, item, infoOf(child)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := g.addChildren(ctx, item, child.Items); err != nil {
|
||||
return err
|
||||
}
|
||||
g.addChildren(item, child.Items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPerLocale adds the children of a list type: each once per alternate
|
||||
// (once with its URL when it has no alternates), then its own children.
|
||||
func (g *Generator) addPerLocale(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error {
|
||||
for _, child := range children {
|
||||
info := infoOf(child)
|
||||
if len(child.Alternates) == 0 {
|
||||
if child.URL != "" {
|
||||
if err := g.addItem(ctx, item, info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := g.addChildren(ctx, item, child.Items); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, alt := range child.Alternates {
|
||||
perLocale := info
|
||||
perLocale.url = alt.URL
|
||||
if perLocale.url != "" {
|
||||
if err := g.addItem(ctx, item, perLocale); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := g.addChildren(ctx, item, child.Items); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func infoOf(r pact.ResolvedMenuItem) itemInfo {
|
||||
@@ -115,33 +182,76 @@ func infoOf(r pact.ResolvedMenuItem) itemInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// addItem is PHP Definition::addItemToSet.
|
||||
func (g *Generator) addItem(item *models.DefinitionItem, info itemInfo) {
|
||||
func (g *Generator) manifest() Manifest {
|
||||
if !g.manifestLoaded {
|
||||
if g.Manifest == nil {
|
||||
g.Manifest = LoadManifest(models.Text(g.Settings.UpdatesManifestPath))
|
||||
}
|
||||
g.manifestLoaded = true
|
||||
}
|
||||
return g.Manifest
|
||||
}
|
||||
|
||||
// addItem is PHP Definition::addItemToSet, in its order: the URL cap, a
|
||||
// missing URL filled through Url::to, lastmod from the record or the
|
||||
// manifest, BeforeAddItem, an empty URL dropped and the URL passed through
|
||||
// Url::to again, the count, the tier or item priority, the elements and
|
||||
// AddItem.
|
||||
func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, info itemInfo) error {
|
||||
if g.count >= MaxURLs {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if !info.set {
|
||||
info.url = URLTo(g.Root, models.Text(item.URL))
|
||||
}
|
||||
lastmod := ""
|
||||
if info.mtime != nil && info.mtime.Unix() != 0 {
|
||||
lastmod = info.mtime.UTC().Format(lastmodLayout)
|
||||
var lastmod time.Time
|
||||
hasLastmod := false
|
||||
if info.mtime != nil {
|
||||
lastmod, hasLastmod = *info.mtime, true
|
||||
} else if t, ok := g.manifest().Lastmod(info.url); ok {
|
||||
lastmod, hasLastmod = t, true
|
||||
}
|
||||
lastmodText := ""
|
||||
if hasLastmod && lastmod.Unix() != 0 {
|
||||
lastmodText = lastmod.UTC().Format(lastmodLayout)
|
||||
}
|
||||
if g.Events != nil {
|
||||
before := &BeforeAddItem{Item: *item, Info: ItemInfo{URL: info.url, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), info.alternates...)}}
|
||||
skipped, err := g.Events.UntilHandled(ctx, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if skipped {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if info.url == "" || info.url == "0" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
loc := URLTo(g.Root, info.url)
|
||||
g.count++
|
||||
priority, ok := g.Tiers.Priority(loc, g.Settings)
|
||||
if !ok {
|
||||
priority = models.Text(item.Priority)
|
||||
}
|
||||
entry := URLEntry{
|
||||
Loc: loc,
|
||||
Lastmod: lastmod,
|
||||
Lastmod: lastmodText,
|
||||
Changefreq: models.Text(item.Changefreq),
|
||||
Priority: models.Text(item.Priority),
|
||||
Priority: priority,
|
||||
}
|
||||
if len(info.alternates) > 1 {
|
||||
for _, a := range info.alternates {
|
||||
entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: URLTo(g.Root, a.Href)})
|
||||
}
|
||||
}
|
||||
if g.Events != nil {
|
||||
added := &AddItem{Item: *item, Info: ItemInfo{URL: loc, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), entry.Alternates...)}}
|
||||
if err := g.Events.Fire(ctx, added); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Extra = added.Extra
|
||||
}
|
||||
g.entries = append(g.entries, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sitemap "git.golem15.com/golem15/sm-sitemap-plugin"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/festival"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
@@ -45,12 +51,19 @@ func (g goldenResolved) toPact(t *testing.T) pact.ResolvedMenuItem {
|
||||
return out
|
||||
}
|
||||
|
||||
// goldenCase is one sitemap case of a golden input.
|
||||
// goldenCase is one sitemap case of a golden input: the stored items, the
|
||||
// registry types' resolve results, the settings (PHP keys), the updates
|
||||
// manifest, the URLs a BeforeAddItem listener skips and the text of the
|
||||
// extra element an AddItem listener appends per URL.
|
||||
type goldenCase struct {
|
||||
Name string `json:"name"`
|
||||
Root string `json:"root"`
|
||||
Items models.DefinitionItems `json:"items"`
|
||||
Types map[string]map[string]goldenResolved `json:"types"`
|
||||
Name string `json:"name"`
|
||||
Root string `json:"root"`
|
||||
Items models.DefinitionItems `json:"items"`
|
||||
Types map[string]map[string]goldenResolved `json:"types"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
Manifest map[string]any `json:"manifest"`
|
||||
Skip []string `json:"skip"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
// fakeResolver answers Resolve from a golden case's types.
|
||||
@@ -73,14 +86,97 @@ func (f fakeResolver) Resolve(_ context.Context, key, reference, _ string, _ boo
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// pluginTiers compiles the tier rules the plugin's own config/config.yaml
|
||||
// ships.
|
||||
func pluginTiers(t *testing.T) classes.Tiers {
|
||||
t.Helper()
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.MergePlugin("golem15.sitemap", (&sitemap.Plugin{}).ConfigFS()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tiers, err := classes.LoadTiers(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
// caseSettings builds the settings of a case from its PHP keys.
|
||||
func caseSettings(t *testing.T, c goldenCase) models.Settings {
|
||||
t.Helper()
|
||||
s := models.DefaultSettings()
|
||||
texts := map[string]**string{
|
||||
"frontend_url": &s.FrontendURL,
|
||||
"frontend_default_locale": &s.FrontendDefaultLocale,
|
||||
"priority_homepage": &s.PriorityHomepage,
|
||||
"priority_services": &s.PriorityServices,
|
||||
"priority_blog": &s.PriorityBlog,
|
||||
"priority_categories": &s.PriorityCategories,
|
||||
"priority_legal": &s.PriorityLegal,
|
||||
}
|
||||
for key, raw := range c.Settings {
|
||||
if key == "enable_priority_tiers" {
|
||||
if err := json.Unmarshal(raw, &s.EnablePriorityTiers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
dest, ok := texts[key]
|
||||
if !ok {
|
||||
t.Fatalf("case %s: unknown setting %s", c.Name, key)
|
||||
}
|
||||
v, err := models.DecodeText(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*dest = v
|
||||
}
|
||||
if c.Manifest != nil {
|
||||
raw, err := json.Marshal(c.Manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "updates.json")
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.UpdatesManifestPath = &path
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// renderCase runs a case through the generator and the route's rewrite.
|
||||
func renderCase(t *testing.T, c goldenCase) string {
|
||||
t.Helper()
|
||||
gen := &classes.Generator{Root: c.Root, Resolver: fakeResolver{t: t, types: c.Types}}
|
||||
bus := festival.New()
|
||||
bus.Listen("test.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
|
||||
if slices.Contains(c.Skip, e.Info.URL) {
|
||||
e.Skip = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
bus.Listen("test.extra", func(_ context.Context, e *classes.AddItem) error {
|
||||
if text, ok := c.Extra[e.Info.URL]; ok {
|
||||
e.Extra = append(e.Extra, "<extra>"+classes.EscapeText(text)+"</extra>")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
settings := caseSettings(t, c)
|
||||
gen := &classes.Generator{
|
||||
Root: c.Root,
|
||||
Resolver: fakeResolver{t: t, types: c.Types},
|
||||
Settings: settings,
|
||||
Tiers: pluginTiers(t),
|
||||
Events: bus,
|
||||
}
|
||||
xml, err := gen.Generate(context.Background(), c.Items)
|
||||
if err != nil {
|
||||
t.Fatalf("generate %s: %v", c.Name, err)
|
||||
}
|
||||
return xml
|
||||
return classes.Rewrite(xml, classes.BackendURL(c.Root), models.Text(settings.FrontendURL))
|
||||
}
|
||||
|
||||
func readTestdata(t *testing.T, name string) []byte {
|
||||
@@ -94,6 +190,35 @@ func readTestdata(t *testing.T, name string) []byte {
|
||||
|
||||
// TestSitemapGolden compares the Go sitemap with the PHP goldens.
|
||||
func TestSitemapGolden(t *testing.T) {
|
||||
t.Run("urlto", func(t *testing.T) {
|
||||
var in struct {
|
||||
Root string `json:"root"`
|
||||
URLs []*string `json:"urls"`
|
||||
}
|
||||
if err := json.Unmarshal(readTestdata(t, "urlto.json"), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Split(strings.TrimSuffix(string(readTestdata(t, "urlto.golden")), "\n"), "\n")
|
||||
if len(want) != len(in.URLs) {
|
||||
t.Fatalf("urlto.golden has %d lines for %d inputs", len(want), len(in.URLs))
|
||||
}
|
||||
var got strings.Builder
|
||||
for i, u := range in.URLs {
|
||||
path := ""
|
||||
if u != nil {
|
||||
path = *u
|
||||
}
|
||||
out := classes.URLTo(in.Root, path)
|
||||
if out != want[i] {
|
||||
t.Errorf("URLTo(%q) = %q, PHP %q", path, out, want[i])
|
||||
}
|
||||
got.WriteString(out + "\n")
|
||||
}
|
||||
if got.String() != string(readTestdata(t, "urlto.golden")) {
|
||||
t.Error("urlto output differs from the golden file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tracer", func(t *testing.T) {
|
||||
var in struct {
|
||||
Case goldenCase `json:"case"`
|
||||
@@ -106,4 +231,113 @@ func TestSitemapGolden(t *testing.T) {
|
||||
t.Fatalf("tracer sitemap differs from the PHP golden\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full", func(t *testing.T) {
|
||||
var in struct {
|
||||
Cases []goldenCase `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(readTestdata(t, "full.json"), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
golden := string(readTestdata(t, "full.golden"))
|
||||
var all strings.Builder
|
||||
for i, c := range in.Cases {
|
||||
got := renderCase(t, c)
|
||||
all.WriteString("=== " + c.Name + "\n" + got)
|
||||
header := "=== " + c.Name + "\n"
|
||||
start := strings.Index(golden, header)
|
||||
if start < 0 {
|
||||
t.Errorf("case %s is missing from full.golden", c.Name)
|
||||
continue
|
||||
}
|
||||
want := golden[start+len(header):]
|
||||
if i+1 < len(in.Cases) {
|
||||
if end := strings.Index(want, "=== "+in.Cases[i+1].Name+"\n"); end >= 0 {
|
||||
want = want[:end]
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("case %s differs from the PHP golden\n got: %q\nwant: %q", c.Name, got, want)
|
||||
}
|
||||
}
|
||||
if all.String() != golden {
|
||||
t.Error("full output differs from full.golden")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cap", func(t *testing.T) {
|
||||
items := make([]models.DefinitionItem, classes.MaxURLs+1)
|
||||
for i := range items {
|
||||
u := "/u/" + strconv.Itoa(i+1)
|
||||
items[i] = models.DefinitionItem{Type: models.ItemTypeURL, URL: &u}
|
||||
}
|
||||
gen := &classes.Generator{Root: "https://api.example.test"}
|
||||
xml, err := gen.Generate(context.Background(), items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(xml, "<url>"); n != classes.MaxURLs {
|
||||
t.Fatalf("%d urls written, want %d", n, classes.MaxURLs)
|
||||
}
|
||||
if !strings.Contains(xml, "<loc>https://api.example.test/u/50000</loc>") || strings.Contains(xml, "/u/50001<") {
|
||||
t.Fatal("the 50000th URL must be written and the 50001st must not")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSitemapTiersConfig checks the boot-time tier rule checks, a host's
|
||||
// replacement of the default list and the manifest's fail-safe reads.
|
||||
func TestSitemapTiersConfig(t *testing.T) {
|
||||
for name, rules := range map[string][]classes.TierConfig{
|
||||
"unknown setting": {{Setting: "priority_other", Exact: []string{""}}},
|
||||
"neither": {{Setting: models.PriorityHomepage}},
|
||||
"both": {{Setting: models.PriorityHomepage, Exact: []string{""}, Pattern: "^/$"}},
|
||||
"bad pattern": {{Setting: models.PriorityBlog, Pattern: "^/(blog"}},
|
||||
} {
|
||||
if _, err := classes.CompileTiers(rules); err == nil {
|
||||
t.Errorf("%s: compiled, want an error", name)
|
||||
}
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
host := "sitemap:\n priority_tiers:\n - setting: priority_legal\n pattern: '^/legal(/.*)?$'\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "golem15.yaml"), []byte(host), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.MergePlugin("golem15.sitemap", (&sitemap.Plugin{}).ConfigFS()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tiers, err := classes.LoadTiers(cfg)
|
||||
if err != nil || len(tiers) != 1 {
|
||||
t.Fatalf("host tiers = %+v, %v", tiers, err)
|
||||
}
|
||||
s := models.DefaultSettings()
|
||||
if p, ok := tiers.Priority("https://h/legal/terms/", s); !ok || p != "0.2" {
|
||||
t.Fatalf("host rule priority = %q %v", p, ok)
|
||||
}
|
||||
if _, ok := tiers.Priority("https://h/", s); ok {
|
||||
t.Fatal("the host list must replace the default homepage rule")
|
||||
}
|
||||
|
||||
if m := classes.LoadManifest(dir); m != nil {
|
||||
t.Fatal("a directory is not a manifest")
|
||||
}
|
||||
list := filepath.Join(dir, "list.json")
|
||||
if err := os.WriteFile(list, []byte(`["/a"]`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := classes.LoadManifest(list); m != nil {
|
||||
t.Fatal("a JSON list is not a manifest")
|
||||
}
|
||||
big := filepath.Join(dir, "big.json")
|
||||
if err := os.WriteFile(big, append([]byte(`{"/a":"2026-01-01","x":"`), append(make([]byte, classes.ManifestMaxBytes), '"', '}')...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := classes.LoadManifest(big); m != nil {
|
||||
t.Fatal("a manifest over the size cap must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
113
classes/manifest.go
Normal file
113
classes/manifest.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ManifestMaxBytes is the largest updates manifest the sitemap reads.
|
||||
const ManifestMaxBytes = 5 << 20
|
||||
|
||||
// Manifest maps a normalized URL path to its last-modified date text, the
|
||||
// updates manifest a frontend build writes.
|
||||
type Manifest map[string]string
|
||||
|
||||
// LoadManifest reads the updates manifest at path: a regular file of at
|
||||
// most ManifestMaxBytes holding a JSON object of URL path to date. It fails
|
||||
// safe like PHP: an empty path, a missing, unreadable, oversized or invalid
|
||||
// file is no manifest (nil), logged without the file's content. Keys are
|
||||
// normalized like PHP normalizeManifestPath; values that are not strings are
|
||||
// ignored.
|
||||
func LoadManifest(path string) Manifest {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if info.Size() > ManifestMaxBytes {
|
||||
slog.Default().Warn("golem15.sitemap: updates manifest is too large; lastmod omitted", "path", path, "max_bytes", ManifestMaxBytes)
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, ManifestMaxBytes+1))
|
||||
if err != nil || len(raw) > ManifestMaxBytes {
|
||||
return nil
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
var decoded map[string]any
|
||||
if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &decoded) != nil {
|
||||
slog.Default().Warn("golem15.sitemap: updates manifest is not a JSON object; lastmod omitted", "path", path)
|
||||
return nil
|
||||
}
|
||||
m := Manifest{}
|
||||
for key, value := range decoded {
|
||||
if s, ok := value.(string); ok {
|
||||
m[normalizeManifestPath(key)] = s
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// normalizeManifestPath is PHP normalizeManifestPath: a leading slash, no
|
||||
// trailing slash except for the root.
|
||||
func normalizeManifestPath(path string) string {
|
||||
path = "/" + strings.TrimLeft(path, "/")
|
||||
if path == "/" {
|
||||
return path
|
||||
}
|
||||
return strings.TrimRight(path, "/")
|
||||
}
|
||||
|
||||
// Lastmod is PHP lookupManifestLastmod: the date listed for the URL's path,
|
||||
// parsed like the ISO 8601 forms strtotime reads (a date without a zone is
|
||||
// UTC). ok is false when the path is not listed or its date does not parse.
|
||||
func (m Manifest) Lastmod(url string) (time.Time, bool) {
|
||||
if len(m) == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
path := URLPath(url)
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
text, ok := m[normalizeManifestPath(path)]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, ok := parseManifestDate(text)
|
||||
if !ok || t.Unix() == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
var manifestLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04:05.999999999Z0700",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
func parseManifestDate(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, layout := range manifestLayouts {
|
||||
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
49
classes/rewrite.go
Normal file
49
classes/rewrite.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The PHP routes.php patterns of the frontend rewrite.
|
||||
var (
|
||||
locElement = regexp.MustCompile(`<loc>(.*?)</loc>`)
|
||||
xhtmlLinkRef = regexp.MustCompile(`(<xhtml:link[^>]*href=")([^"]*)(")`)
|
||||
)
|
||||
|
||||
// Rewrite is the frontend_url rewrite of PHP routes.php: with frontendURL
|
||||
// set, backendURL is replaced literally (str_replace) inside every loc
|
||||
// element, inside every xhtml:link href and in the exact stylesheet href.
|
||||
// Both URLs are right-trimmed of slashes first. An empty frontendURL (or
|
||||
// "0", which PHP reads as false) leaves xml unchanged.
|
||||
func Rewrite(xml, backendURL, frontendURL string) string {
|
||||
if frontendURL == "" || frontendURL == "0" {
|
||||
return xml
|
||||
}
|
||||
frontend := strings.TrimRight(frontendURL, "/")
|
||||
backend := strings.TrimRight(backendURL, "/")
|
||||
replace := func(s string) string {
|
||||
if backend == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, backend, frontend)
|
||||
}
|
||||
xml = locElement.ReplaceAllStringFunc(xml, func(m string) string {
|
||||
inner := m[len("<loc>") : len(m)-len("</loc>")]
|
||||
return "<loc>" + replace(inner) + "</loc>"
|
||||
})
|
||||
xml = xhtmlLinkRef.ReplaceAllStringFunc(xml, func(m string) string {
|
||||
sub := xhtmlLinkRef.FindStringSubmatch(m)
|
||||
return sub[1] + replace(sub[2]) + sub[3]
|
||||
})
|
||||
if backend != "" {
|
||||
xml = strings.ReplaceAll(xml, `href="`+backend+`/sitemap.xsl"`, `href="`+frontend+`/sitemap.xsl"`)
|
||||
}
|
||||
return xml
|
||||
}
|
||||
|
||||
// BackendURL is PHP rtrim(url('/'), '/') for a request whose root URL is
|
||||
// root: the URL the rewrite replaces.
|
||||
func BackendURL(root string) string {
|
||||
return strings.TrimRight(URLTo(root, "/"), "/")
|
||||
}
|
||||
58
classes/testdata/full.golden
vendored
Normal file
58
classes/testdata/full.golden
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
=== escaping
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="https://api.example.test/sitemap.xsl"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://a&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
Normal file
693
classes/testdata/full.json
vendored
Normal file
@@ -0,0 +1,693 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "escaping",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "https://a&b<c>.pl/x",
|
||||
"changefreq": "a&b<c>\"d'e\rf\tg\nh",
|
||||
"priority": "0.5 & <1> \"q\" 'a'"
|
||||
},
|
||||
{
|
||||
"type": "acme-post",
|
||||
"reference": "1",
|
||||
"urlPattern": "",
|
||||
"changefreq": "x>y",
|
||||
"priority": "<0.9>",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-post": {
|
||||
"1": {
|
||||
"url": "/zażółć & co",
|
||||
"mtime": "2026-01-02T03:04:05+00:00",
|
||||
"alternates": [
|
||||
[
|
||||
"x\"y&z<\t\n\r'>",
|
||||
"https://q&r<s>.pl/p"
|
||||
],
|
||||
[
|
||||
"en",
|
||||
"/en/zażółć"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty-urlset",
|
||||
"root": "https://api.example.test",
|
||||
"items": [
|
||||
{
|
||||
"type": "acme-post",
|
||||
"reference": "404",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
},
|
||||
{
|
||||
"type": "unknown-type",
|
||||
"reference": "1",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-post": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no-items",
|
||||
"root": "https://api.example.test",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"name": "hreflang-list",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "acme-list",
|
||||
"reference": "",
|
||||
"urlPattern": "",
|
||||
"changefreq": "monthly",
|
||||
"priority": "0.4",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-list": {
|
||||
"": {
|
||||
"url": "",
|
||||
"items": [
|
||||
{
|
||||
"url": "/pl/a",
|
||||
"mtime": "2026-02-01T00:00:00+00:00",
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/pl/a"
|
||||
],
|
||||
[
|
||||
"en",
|
||||
"/en/a"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "/pl/b",
|
||||
"mtime": null,
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/pl/b"
|
||||
],
|
||||
[
|
||||
"en",
|
||||
"/en/b"
|
||||
],
|
||||
[
|
||||
"de",
|
||||
"/de/b"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "/solo",
|
||||
"alternates": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nested",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "acme-cat",
|
||||
"reference": "5",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-cat": {
|
||||
"5": {
|
||||
"url": "/kategoria/a",
|
||||
"mtime": "2026-03-01T12:00:00+00:00",
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/kategoria/a"
|
||||
],
|
||||
[
|
||||
"en",
|
||||
"/en/category/a"
|
||||
]
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"url": "/kategoria/b",
|
||||
"mtime": "2026-03-02T12:00:00+00:00",
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/kategoria/b"
|
||||
],
|
||||
[
|
||||
"en",
|
||||
"/en/category/b"
|
||||
]
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"url": "/kategoria/c",
|
||||
"alternates": [
|
||||
[
|
||||
"x-default",
|
||||
"/kategoria/c"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tiers-default",
|
||||
"root": "https://api.example.test",
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/en",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/en/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/uslugi/x",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/uslugi/x/y",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/realizacje/r",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/en/work/w",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog/a/b",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/en/blog/a/b",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog/a",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/en/blog/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/uslugi",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/kontakt",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/kontakt/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/en/ai-lab/blob-playground",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/other",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "https://elsewhere.pl/uslugi/z",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/o-nas?x=1",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog/a/b/c",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tiers-settings",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": true,
|
||||
"priority_homepage": "0.95",
|
||||
"priority_blog": "",
|
||||
"priority_legal": "0.15"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog/a/b",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/regulamin",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/x",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tiers-off",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.1"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog/a/b",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rewrite",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"frontend_url": "https://www.example.test/",
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/a",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "https://api.example.test/b?x=https://api.example.test/c",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "https://other.example.test/api.example.test",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "acme-list",
|
||||
"reference": "",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-list": {
|
||||
"": {
|
||||
"url": "",
|
||||
"items": [
|
||||
{
|
||||
"url": "/p",
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/p"
|
||||
],
|
||||
[
|
||||
"en",
|
||||
"https://api.example.test/en/p"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rewrite-same",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"frontend_url": "https://api.example.test",
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/a",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "manifest",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"manifest": {
|
||||
"/": "2026-05-01T10:00:00+02:00",
|
||||
"about/": "2026-05-02",
|
||||
"/blog/x": "not a date",
|
||||
"/rel": "2026-05-03T01:02:03Z"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/about",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/about/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/blog/x",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/none",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "acme-post",
|
||||
"reference": "2",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
},
|
||||
{
|
||||
"type": "acme-post",
|
||||
"reference": "3",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-post": {
|
||||
"2": {
|
||||
"url": "/rel",
|
||||
"mtime": null,
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/rel"
|
||||
]
|
||||
]
|
||||
},
|
||||
"3": {
|
||||
"url": "/about",
|
||||
"mtime": "2026-06-01T00:00:00+00:00",
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/about"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skip-extra",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"skip": [
|
||||
"https://api.example.test/skip-me",
|
||||
"/rel-skip"
|
||||
],
|
||||
"extra": {
|
||||
"https://api.example.test/extra": "a&b <c>"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/skip-me",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/extra",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/keep",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "acme-post",
|
||||
"reference": "9",
|
||||
"urlPattern": "",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.7",
|
||||
"nesting": "1"
|
||||
}
|
||||
],
|
||||
"types": {
|
||||
"acme-post": {
|
||||
"9": {
|
||||
"url": "/rel-skip",
|
||||
"alternates": [
|
||||
[
|
||||
"pl",
|
||||
"/rel-skip"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "precision",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/p1",
|
||||
"changefreq": "daily",
|
||||
"priority": 0.5
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/p2",
|
||||
"changefreq": "daily",
|
||||
"priority": 1.0
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/p3",
|
||||
"changefreq": 7,
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/p4",
|
||||
"changefreq": "daily",
|
||||
"priority": "1.0"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/p5",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.50"
|
||||
},
|
||||
{
|
||||
"url": "/p6",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.6",
|
||||
"cmsPage": "x",
|
||||
"exists": true,
|
||||
"fillable": [
|
||||
"a"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "url-null",
|
||||
"root": "https://api.example.test",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": null,
|
||||
"changefreq": null,
|
||||
"priority": null
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "0",
|
||||
"changefreq": "daily",
|
||||
"priority": "0.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "port-root",
|
||||
"root": "http://localhost:8096",
|
||||
"settings": {
|
||||
"enable_priority_tiers": false
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"url": "/a b",
|
||||
"changefreq": "weekly",
|
||||
"priority": "0.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
58
classes/testdata/urlto.golden
vendored
Normal file
58
classes/testdata/urlto.golden
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
https://api.ex.pl/
|
||||
https://api.ex.pl/
|
||||
https://api.ex.pl/
|
||||
https://api.ex.pl/blog
|
||||
https://api.ex.pl/blog
|
||||
https://api.ex.pl/blog
|
||||
https://api.ex.pl/blog/za%C5%BC%C3%B3%C5%82%C4%87%20g%C4%99%C5%9Bl%C4%85
|
||||
https://api.ex.pl/en/blog/%3Aslug
|
||||
https://x.pl/q/
|
||||
http://x.pl/
|
||||
HTTP://X.pl:443/a
|
||||
https://h:8080/a
|
||||
https://h/a
|
||||
http://h/
|
||||
https://api.ex.pl/a?b=1&c=2
|
||||
https://api.ex.pl/a?x%5B%5D=1&x%5B%5D=2&0=y
|
||||
https://api.ex.pl/a?q=a&0=b%20c
|
||||
https://api.ex.pl/a
|
||||
https://api.ex.pl/a?b=&0=c
|
||||
https://api.ex.pl/a?x&y
|
||||
https://api.ex.pl/a?a=2
|
||||
https://api.ex.pl/a?a%5Bb%5D=1
|
||||
https://api.ex.pl/a#b%20c
|
||||
https://api.ex.pl/a
|
||||
#frag
|
||||
cdn.x/a
|
||||
mailto:a%40b.pl
|
||||
tel:%2B48%20123
|
||||
sms:%2B48123
|
||||
https://api.ex.pl/a/c
|
||||
https://api.ex.pl/a//b
|
||||
https://api.ex.pl/a/%20b/%25zz
|
||||
https://api.ex.pl/a%2Fb
|
||||
https://api.ex.pl/%C5%9Bcie%C5%BCka/%C4%85%C4%99?x=%C5%BC#%C5%82
|
||||
https://api.ex.pl/ftp%3A//x.pl/a%20b
|
||||
ftp://x.pl/a
|
||||
news:///comp.x
|
||||
https://api.ex.pl/a%3Ab
|
||||
https://api.ex.pl/a%3A1
|
||||
https://api.ex.pl/user%40host
|
||||
https://user:pass@h/x
|
||||
https://api.ex.pl/%20%20/lead
|
||||
https://api.ex.pl/trail
|
||||
https://api.ex.pl/tab_here
|
||||
https://api.ex.pl/new_line
|
||||
https://api.ex.pl/
|
||||
https://api.ex.pl/javascript%3Aalert%281%29
|
||||
https://a&b<c>.pl/x
|
||||
https://h/
|
||||
https://api.ex.pl/en
|
||||
https://api.ex.pl/en
|
||||
https://api.ex.pl/a?x=%2B
|
||||
https://api.ex.pl/a?k=v%20w
|
||||
https://api.ex.pl/a%20b?x=1#y%20z
|
||||
https://h/a?b=1#c
|
||||
https://api.ex.pl/~tilde/_under.dot-dash
|
||||
https://api.ex.pl/a%27b%22c
|
||||
https://api.ex.pl/%3Cx%3E%26y
|
||||
63
classes/testdata/urlto.json
vendored
Normal file
63
classes/testdata/urlto.json
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"root": "https://api.ex.pl",
|
||||
"urls": [
|
||||
"/",
|
||||
"",
|
||||
null,
|
||||
"/blog/",
|
||||
"/blog",
|
||||
"blog",
|
||||
"/blog/zażółć gęślą",
|
||||
"/en/blog/:slug",
|
||||
"https://x.pl/q/",
|
||||
"http://x.pl",
|
||||
"HTTP://X.pl:443/a",
|
||||
"https://h:8080/a",
|
||||
"https://h:443/a",
|
||||
"http://h:80/",
|
||||
"/a?b=1&c=2",
|
||||
"/a?x[]=1&x[]=2&y",
|
||||
"/a?q=a%26b+c",
|
||||
"/a?",
|
||||
"/a?b=&c",
|
||||
"/a?0=x&1=y",
|
||||
"/a?a=1&a=2",
|
||||
"/a?a[b]=1",
|
||||
"/a#b c",
|
||||
"/a#",
|
||||
"#frag",
|
||||
"//cdn.x/a",
|
||||
"mailto:a@b.pl",
|
||||
"tel:+48 123",
|
||||
"sms:+48123",
|
||||
"/../a/./b/../c",
|
||||
"/a//b",
|
||||
"/a/%20b/%zz",
|
||||
"/a%2Fb",
|
||||
"/ścieżka/ąę?x=ż#ł",
|
||||
"ftp://x.pl/a b",
|
||||
"ftp://x.pl/a",
|
||||
"news:comp.x",
|
||||
"/a:b",
|
||||
"a:1",
|
||||
"/user@host",
|
||||
"https://user:pass@h/x",
|
||||
" /lead",
|
||||
"/trail ",
|
||||
"/tab\there",
|
||||
"/new\nline",
|
||||
"/a/b/../../..",
|
||||
"javascript:alert(1)",
|
||||
"https://a&b<c>.pl/x",
|
||||
"https://h/./",
|
||||
"/en",
|
||||
"/en/",
|
||||
"/a?x=%2B",
|
||||
"/a?k=v w",
|
||||
"/a b?x=1#y z",
|
||||
"https://h/a?b=1#c",
|
||||
"/~tilde/_under.dot-dash",
|
||||
"/a'b\"c",
|
||||
"/<x>&y"
|
||||
]
|
||||
}
|
||||
98
classes/tiers.go
Normal file
98
classes/tiers.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// TiersConfigKey is the config key of the priority tier rules. The plugin's
|
||||
// config/config.yaml ships the rules of the PHP getPriorityForUrl; a host
|
||||
// replaces the whole list in its own config.
|
||||
const TiersConfigKey = "golem15.sitemap.priority_tiers"
|
||||
|
||||
// TierConfig is one configured tier rule: Setting names the priority setting
|
||||
// whose value applies (priority_homepage, priority_services, priority_blog,
|
||||
// priority_categories or priority_legal), and exactly one of Exact (paths
|
||||
// that match as they are) and Pattern (a regular expression, RE2 syntax)
|
||||
// selects the URLs.
|
||||
type TierConfig struct {
|
||||
Setting string `koanf:"setting"`
|
||||
Exact []string `koanf:"exact"`
|
||||
Pattern string `koanf:"pattern"`
|
||||
}
|
||||
|
||||
// TierRule is a compiled tier rule.
|
||||
type TierRule struct {
|
||||
Setting string
|
||||
Exact []string
|
||||
Pattern *regexp.Regexp
|
||||
}
|
||||
|
||||
// Tiers is the ordered tier rule list; the first matching rule wins.
|
||||
type Tiers []TierRule
|
||||
|
||||
// CompileTiers checks and compiles tier rules. Every error names the rule:
|
||||
// an unknown setting, neither or both of exact and pattern, or a pattern
|
||||
// that does not compile.
|
||||
func CompileTiers(rules []TierConfig) (Tiers, error) {
|
||||
out := make(Tiers, 0, len(rules))
|
||||
for i, r := range rules {
|
||||
if _, ok := models.PriorityDefaults[r.Setting]; !ok {
|
||||
return nil, fmt.Errorf("%s[%d]: setting %q is not one of priority_homepage, priority_services, priority_blog, priority_categories, priority_legal", TiersConfigKey, i, r.Setting)
|
||||
}
|
||||
hasPattern := strings.TrimSpace(r.Pattern) != ""
|
||||
if hasPattern == (len(r.Exact) > 0) {
|
||||
return nil, fmt.Errorf("%s[%d]: set exactly one of exact and pattern", TiersConfigKey, i)
|
||||
}
|
||||
rule := TierRule{Setting: r.Setting, Exact: append([]string(nil), r.Exact...)}
|
||||
if hasPattern {
|
||||
re, err := regexp.Compile(r.Pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s[%d]: pattern: %w", TiersConfigKey, i, err)
|
||||
}
|
||||
rule.Pattern = re
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadTiers reads and compiles the rules at TiersConfigKey; an absent key
|
||||
// is no rules.
|
||||
func LoadTiers(cfg *compass.Config) (Tiers, error) {
|
||||
if cfg == nil || !cfg.Has(TiersConfigKey) {
|
||||
return Tiers{}, nil
|
||||
}
|
||||
var rules []TierConfig
|
||||
if err := cfg.LoadSection(TiersConfigKey, &rules); err != nil {
|
||||
return nil, fmt.Errorf("%s must be a list of {setting, exact} or {setting, pattern} rules: %w", TiersConfigKey, err)
|
||||
}
|
||||
return CompileTiers(rules)
|
||||
}
|
||||
|
||||
// Priority is PHP getPriorityForUrl with configured rules: when the
|
||||
// settings enable tiers, the URL's path (parse_url's, "/" when it has none)
|
||||
// right-trimmed of slashes is matched against the rules in order and the
|
||||
// first match's setting value is returned. ok is false when tiers are off or
|
||||
// no rule matches, so the item's own priority applies.
|
||||
func (t Tiers) Priority(url string, s models.Settings) (string, bool) {
|
||||
if !s.EnablePriorityTiers {
|
||||
return "", false
|
||||
}
|
||||
path := URLPath(url)
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
path = strings.TrimRight(path, "/")
|
||||
for _, r := range t {
|
||||
if r.Pattern != nil && r.Pattern.MatchString(path) || r.Pattern == nil && slices.Contains(r.Exact, path) {
|
||||
return s.Priority(r.Setting), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
Reference in New Issue
Block a user