Files
sm-sitemap-plugin/classes/generator.go
Jakub Zych 4a82240429 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
2026-10-12 17:07:39 +02:00

258 lines
7.9 KiB
Go

// 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.
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)
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 {
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
}