Files
sm-sitemap-plugin/classes/writer.go
Jakub Zych 5bf56fde57 feat: image sitemap files (tracer)
- migration 202610280004: image_file_no, kind, published_at and parent_refs
  on the slot table; the image, News, IndexNow and HTML sitemap settings
- the rebuild stores each page's featured and body images (absolute on the
  site, deduplicated, at most 1000) and numbers the rows with images into
  sitemap-images-<n>.xml with the per-type files' stable rules
- the index lists the image files while images_enabled is on; the files
  route serves them with image:image and image:loc only
2026-11-04 12:19:05 +01:00

169 lines
4.8 KiB
Go

package classes
import (
"bufio"
"encoding/xml"
"io"
"regexp"
"strconv"
"strings"
"time"
)
// The XML namespaces of the sitemap files.
const (
NSSitemap = "http://www.sitemaps.org/schemas/sitemap/0.9"
NSXHTML = "http://www.w3.org/1999/xhtml"
NSImage = "http://www.google.com/schemas/sitemap-image/1.1"
)
// IndexFile is one <sitemap> of the index: the file's absolute URL and the
// newest lastmod of its addresses (nil when none has one).
type IndexFile struct {
Loc string
LastMod *time.Time
}
// FileAlternate is one xhtml:link alternate of a <url>, with an absolute
// href.
type FileAlternate struct {
Hreflang string
Href string
}
// FileEntry is one <url> of a file: the absolute address, its lastmod and
// its hreflang alternates.
type FileEntry struct {
Loc string
LastMod *time.Time
Alternates []FileAlternate
}
// fileNamePattern is the name of a per-group file.
var fileNamePattern = regexp.MustCompile(`^sitemap-([a-z0-9][a-z0-9-]*)-([1-9][0-9]{0,8})\.xml$`)
// FileName is the name of file n of group grp: sitemap-<grp>-<n>.xml.
func FileName(grp string, n int) string {
return "sitemap-" + grp + "-" + strconv.Itoa(n) + ".xml"
}
// ParseFileName splits a file name made by FileName; ok is false for any
// other name.
func ParseFileName(name string) (grp string, n int, ok bool) {
m := fileNamePattern.FindStringSubmatch(name)
if m == nil {
return "", 0, false
}
n, err := strconv.Atoi(m[2])
if err != nil || n < 1 {
return "", 0, false
}
return m[1], n, true
}
// lastmod formats a lastmod in RFC 3339, UTC.
func lastmod(t time.Time) string { return t.UTC().Format(time.RFC3339) }
// escape writes s with encoding/xml's escaping, which covers &, <, >, both
// quotes and control whitespace, so it is safe in text and in attribute
// values.
func escape(w *bufio.Writer, s string) {
_ = xml.EscapeText(w, []byte(s))
}
// WriteIndex writes the sitemap index: one <sitemap> per file with its
// <loc> and, when known, its <lastmod>.
func WriteIndex(w io.Writer, files []IndexFile) error {
bw := bufio.NewWriter(w)
bw.WriteString(xml.Header)
bw.WriteString(`<sitemapindex xmlns="` + NSSitemap + `">`)
for _, f := range files {
bw.WriteString("\n<sitemap><loc>")
escape(bw, f.Loc)
bw.WriteString("</loc>")
if f.LastMod != nil {
bw.WriteString("<lastmod>" + lastmod(*f.LastMod) + "</lastmod>")
}
bw.WriteString("</sitemap>")
}
bw.WriteString("\n</sitemapindex>\n")
return bw.Flush()
}
// WriteURLSet writes one sitemap file: a <url> per entry with its <loc>, its
// <lastmod> when known and its xhtml:link alternates. The xhtml namespace
// is declared only when an entry has alternates. No <priority> and no
// <changefreq> are written (D-30).
func WriteURLSet(w io.Writer, entries []FileEntry) error {
bw := bufio.NewWriter(w)
bw.WriteString(xml.Header)
bw.WriteString(`<urlset xmlns="` + NSSitemap + `"`)
for _, e := range entries {
if len(e.Alternates) > 0 {
bw.WriteString(` xmlns:xhtml="` + NSXHTML + `"`)
break
}
}
bw.WriteString(">")
for _, e := range entries {
bw.WriteString("\n<url><loc>")
escape(bw, e.Loc)
bw.WriteString("</loc>")
if e.LastMod != nil {
bw.WriteString("<lastmod>" + lastmod(*e.LastMod) + "</lastmod>")
}
for _, a := range e.Alternates {
bw.WriteString(`<xhtml:link rel="alternate" hreflang="`)
escape(bw, a.Hreflang)
bw.WriteString(`" href="`)
escape(bw, a.Href)
bw.WriteString(`"/>`)
}
bw.WriteString("</url>")
}
bw.WriteString("\n</urlset>\n")
return bw.Flush()
}
// ImageEntry is one <url> of an image sitemap: the page's absolute address
// and its images' absolute addresses (at most MaxImagesPerURL).
type ImageEntry struct {
Loc string
Images []string
}
// WriteImageURLSet writes one image sitemap file: a <url> per page with its
// <loc> and one <image:image><image:loc> per image. Only image:image and
// image:loc are written, the tags Google still reads (D-31).
func WriteImageURLSet(w io.Writer, entries []ImageEntry) error {
bw := bufio.NewWriter(w)
bw.WriteString(xml.Header)
bw.WriteString(`<urlset xmlns="` + NSSitemap + `" xmlns:image="` + NSImage + `">`)
for _, e := range entries {
bw.WriteString("\n<url><loc>")
escape(bw, e.Loc)
bw.WriteString("</loc>")
for i, img := range e.Images {
if i >= MaxImagesPerURL {
break
}
bw.WriteString("<image:image><image:loc>")
escape(bw, img)
bw.WriteString("</image:loc></image:image>")
}
bw.WriteString("</url>")
}
bw.WriteString("\n</urlset>\n")
return bw.Flush()
}
// validSlug reports whether s may name a file group.
func validSlug(s string) bool {
return s != "" && len(s) <= 64 && fileNamePattern.MatchString(FileName(s, 1)) && !strings.HasSuffix(s, "-")
}
// ValidSlug reports whether s may name a file group: lowercase letters,
// digits and hyphens, starting with a letter or digit, at most 64
// characters.
func ValidSlug(s string) bool { return validSlug(s) }