Files
sm-sitemap-plugin/classes/manifest.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

114 lines
3.0 KiB
Go

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
}