Files
sm-sitemap-plugin/classes/generator.go
Jakub Zych fe39e26ce0 feat(17-03): sitemap plugin tracer, /sitemap.xml byte-identical to PHP
- golem15.sitemap plugin: golem15_sitemap_definitions migration, Definition
  model (Fillable theme and data) with tolerant PHP-shaped item decoding and
  PHP toArray item encoding
- Url::to and buildUrl port, libxml-format serializer, generator for url and
  registry items
- GET /sitemap.xml serves the definition with theme default (404 without one,
  empty body without items)
- tests/php/gen-goldens.php and the tracer golden; TestSitemapGolden and the
  Postgres TestSitemapTracer
2026-10-12 16:53:09 +02:00

148 lines
4.4 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/pact"
)
// MaxURLs is the sitemap protocol's URL limit (PHP Definition::MAX_URLS):
// the 50,000th URL is written, the 50,001st 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. 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).
type Generator struct {
Root string
Resolver Resolver
UnprefixedLocale string
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 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.
func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem) (string, error) {
if len(items) == 0 {
return "", nil
}
g.entries, g.count = nil, 0
for i := range items {
item := &items[i]
if item.Type == models.ItemTypeURL {
g.addItem(item, itemInfo{})
continue
}
resolved, err := g.resolve(ctx, item)
if err != nil {
return "", err
}
if resolved == nil {
continue
}
if resolved.URL != "" {
g.addItem(item, infoOf(*resolved))
}
g.addChildren(item, resolved.Items)
}
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, g.UnprefixedLocale)
}
// addChildren adds every child with a URL, then its own children.
func (g *Generator) addChildren(item *models.DefinitionItem, children []pact.ResolvedMenuItem) {
for _, child := range children {
if child.URL != "" {
g.addItem(item, infoOf(child))
}
g.addChildren(item, child.Items)
}
}
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
}
// addItem is PHP Definition::addItemToSet.
func (g *Generator) addItem(item *models.DefinitionItem, info itemInfo) {
if g.count >= MaxURLs {
return
}
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)
}
if info.url == "" || info.url == "0" {
return
}
loc := URLTo(g.Root, info.url)
g.count++
entry := URLEntry{
Loc: loc,
Lastmod: lastmod,
Changefreq: models.Text(item.Changefreq),
Priority: models.Text(item.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)})
}
}
g.entries = append(g.entries, entry)
}