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
This commit is contained in:
2026-10-12 16:53:09 +02:00
commit fe39e26ce0
20 changed files with 2742 additions and 0 deletions

147
classes/generator.go Normal file
View File

@@ -0,0 +1,147 @@
// 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)
}

109
classes/golden_test.go Normal file
View File

@@ -0,0 +1,109 @@
package classes_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The goldens in testdata are written by tests/php/gen-goldens.php from the
// same JSON inputs with the PHP plugin's generation code and the vendored
// Winter URL generator; these tests feed the inputs through the Go port and
// compare bytes.
// goldenResolved is a registry item's resolve result in a golden input.
type goldenResolved struct {
URL string `json:"url"`
MTime *string `json:"mtime"`
Alternates [][2]string `json:"alternates"`
Items []goldenResolved `json:"items"`
}
func (g goldenResolved) toPact(t *testing.T) pact.ResolvedMenuItem {
t.Helper()
out := pact.ResolvedMenuItem{URL: g.URL}
if g.MTime != nil {
at, err := time.Parse(time.RFC3339, *g.MTime)
if err != nil {
t.Fatalf("mtime %q: %v", *g.MTime, err)
}
out.MTime = &at
}
for _, a := range g.Alternates {
out.Alternates = append(out.Alternates, pact.MenuItemAlternate{Locale: a[0], URL: a[1]})
}
for _, child := range g.Items {
out.Items = append(out.Items, child.toPact(t))
}
return out
}
// goldenCase is one sitemap case of a golden input.
type goldenCase struct {
Name string `json:"name"`
Root string `json:"root"`
Items models.DefinitionItems `json:"items"`
Types map[string]map[string]goldenResolved `json:"types"`
}
// fakeResolver answers Resolve from a golden case's types.
type fakeResolver struct {
t *testing.T
types map[string]map[string]goldenResolved
}
func (f fakeResolver) Type(key string) (pact.MenuItemType, bool) {
_, ok := f.types[key]
return pact.MenuItemType{Key: key}, ok
}
func (f fakeResolver) Resolve(_ context.Context, key, reference, _ string, _ bool, _ string) (*pact.ResolvedMenuItem, error) {
r, ok := f.types[key][reference]
if !ok {
return nil, nil
}
out := r.toPact(f.t)
return &out, nil
}
func renderCase(t *testing.T, c goldenCase) string {
t.Helper()
gen := &classes.Generator{Root: c.Root, Resolver: fakeResolver{t: t, types: c.Types}}
xml, err := gen.Generate(context.Background(), c.Items)
if err != nil {
t.Fatalf("generate %s: %v", c.Name, err)
}
return xml
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return raw
}
// TestSitemapGolden compares the Go sitemap with the PHP goldens.
func TestSitemapGolden(t *testing.T) {
t.Run("tracer", func(t *testing.T) {
var in struct {
Case goldenCase `json:"case"`
}
if err := json.Unmarshal(readTestdata(t, "tracer.json"), &in); err != nil {
t.Fatal(err)
}
got := renderCase(t, in.Case)
if want := string(readTestdata(t, "tracer.golden")); got != want {
t.Fatalf("tracer sitemap differs from the PHP golden\n got: %q\nwant: %q", got, want)
}
})
}

105
classes/serialize.go Normal file
View File

@@ -0,0 +1,105 @@
package classes
import "strings"
// The sitemap document is written by hand in the exact format PHP's
// DOMDocument::saveXML (libxml2) produced for the PHP plugin: the XML
// declaration and the stylesheet processing instruction each followed by a
// newline, everything else on one line, an empty urlset self-closed,
// xhtml:link elements self-closed without a space. The standard library's
// XML encoder escapes and closes elements differently, so the writer is a
// strings.Builder.
// The urlset opening tag's attributes, in the order PHP set them.
const urlsetAttrs = ` 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"`
// textEscaper is libxml's escaping of text nodes: &, <, > and carriage
// return; quotes, tabs, newlines and UTF-8 stay literal.
var textEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\r", "&#13;")
// attrEscaper is libxml's escaping of attribute values: also the double
// quote, tab and newline; the single quote stays literal.
var attrEscaper = strings.NewReplacer(
"&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;",
"\r", "&#13;", "\t", "&#9;", "\n", "&#10;",
)
// EscapeText escapes s as libxml writes a text node.
func EscapeText(s string) string { return textEscaper.Replace(s) }
// EscapeAttr escapes s as libxml writes an attribute value.
func EscapeAttr(s string) string { return attrEscaper.Replace(s) }
// Alternate is one hreflang link of a sitemap URL.
type Alternate struct {
Hreflang string
Href string
}
// URLEntry is one url element: Loc, an optional Lastmod, Changefreq and
// Priority as they are written, the alternates (written only when there are
// more than one) and Extra, pre-escaped child XML that AddItem listeners
// appended, written before the closing tag.
type URLEntry struct {
Loc string
Lastmod string
Changefreq string
Priority string
Alternates []Alternate
Extra []string
}
// writeURL appends one url element.
func writeURL(b *strings.Builder, e URLEntry) {
b.WriteString("<url><loc>")
b.WriteString(EscapeText(e.Loc))
b.WriteString("</loc>")
if e.Lastmod != "" {
b.WriteString("<lastmod>")
b.WriteString(EscapeText(e.Lastmod))
b.WriteString("</lastmod>")
}
b.WriteString("<changefreq>")
b.WriteString(EscapeText(e.Changefreq))
b.WriteString("</changefreq><priority>")
b.WriteString(EscapeText(e.Priority))
b.WriteString("</priority>")
if len(e.Alternates) > 1 {
for _, a := range e.Alternates {
b.WriteString(`<xhtml:link rel="alternate" hreflang="`)
b.WriteString(EscapeAttr(a.Hreflang))
b.WriteString(`" href="`)
b.WriteString(EscapeAttr(a.Href))
b.WriteString(`"/>`)
}
}
for _, x := range e.Extra {
b.WriteString(x)
}
b.WriteString("</url>")
}
// Serialize writes the sitemap document: the XML declaration, the
// stylesheet processing instruction pointing at stylesheet (written as it
// is, like libxml writes PI data) and the urlset with entries.
func Serialize(stylesheet string, entries []URLEntry) string {
var b strings.Builder
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
b.WriteString(`<?xml-stylesheet type="text/xsl" href="`)
b.WriteString(stylesheet)
b.WriteString("\"?>\n<urlset")
b.WriteString(urlsetAttrs)
if len(entries) == 0 {
b.WriteString("/>\n")
return b.String()
}
b.WriteString(">")
for _, e := range entries {
writeURL(&b, e)
}
b.WriteString("</urlset>\n")
return b.String()
}

3
classes/testdata/tracer.golden vendored Normal file
View File

@@ -0,0 +1,3 @@
<?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>always</changefreq><priority>1.0</priority></url><url><loc>https://api.example.test/news/hello</loc><lastmod>2026-03-04T10:00:00+00:00</lastmod><changefreq>weekly</changefreq><priority>0.5</priority><xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/news/hello"/><xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/news/hello"/></url></urlset>

18
classes/testdata/tracer.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"case": {
"root": "https://api.example.test",
"items": [
{"type": "url", "url": "/", "changefreq": "always", "priority": "1.0", "nesting": "0"},
{"type": "acme-post", "reference": "7", "urlPattern": "", "changefreq": "weekly", "priority": "0.5", "nesting": "1"}
],
"types": {
"acme-post": {
"7": {
"url": "/news/hello",
"mtime": "2026-03-04T10:00:00+00:00",
"alternates": [["pl", "/news/hello"], ["en", "/en/news/hello"]]
}
}
}
}
}

533
classes/urlto.go Normal file
View File

@@ -0,0 +1,533 @@
package classes
import (
"regexp"
"strconv"
"strings"
)
// This file ports the parts of WinterCMS's URL generator the PHP sitemap
// called, so the Go sitemap writes the same bytes: Laravel's Url::to plus
// Winter's UrlGenerator::buildUrl, PHP's parse_url, rawurlencode,
// rawurldecode and urldecode, and FILTER_VALIDATE_URL. It is deliberately
// hand written: net/url escapes differently. The golden table
// testdata/urlto.golden, generated from the vendored Winter router, pins
// it.
// validURLPrefix is Laravel UrlGenerator::isValidUrl's fast path.
var validURLPrefix = regexp.MustCompile(`^(#|//|https?://|(mailto|tel|sms):)`)
// URLTo is Winter's Url::to(path) for a request whose root URL is root
// (scheme and host, plus a base path if any, without a trailing slash). A
// valid URL is kept; anything else becomes root plus "/" plus the path
// trimmed of slashes, the query string appended as it is. The result then
// goes through BuildURL, which re-encodes every path segment, so "/" is the
// root with its slash, "/blog/" loses its trailing slash and ":" becomes
// "%3A".
func URLTo(root, path string) string {
if isValidURL(path) {
return BuildURL(path)
}
p, query := path, ""
if i := strings.IndexByte(path, '?'); i >= 0 {
p, query = path[:i], path[i:]
}
inner := strings.Trim(p+"/", "/")
return BuildURL(strings.Trim(root+"/"+inner, "/") + query)
}
// isValidURL is Laravel UrlGenerator::isValidUrl.
func isValidURL(path string) bool {
if validURLPrefix.MatchString(path) {
return true
}
return filterValidateURL(path)
}
// filterURLChars is the character set PHP's FILTER_VALIDATE_URL accepts;
// a value with any other byte fails.
const filterURLChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$-_.+!*'(),{}|\\^~[]`<>#%\";/?:@&="
// filterValidateURL approximates PHP filter_var(FILTER_VALIDATE_URL): only
// URL characters, a scheme, a host unless the scheme is mailto, news or
// file, and a valid host name for http and https.
func filterValidateURL(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if !strings.ContainsRune(filterURLChars, rune(s[i])) {
return false
}
}
u := parseURL(s)
if !u.has(partScheme) || u.scheme == "" {
return false
}
scheme := strings.ToLower(u.scheme)
if !u.has(partHost) || u.host == "" {
return scheme == "mailto" || scheme == "news" || scheme == "file"
}
if scheme == "http" || scheme == "https" {
return validHostName(u.host)
}
return true
}
// validHostName reports whether host is dot-separated labels of letters,
// digits and inner hyphens.
func validHostName(host string) bool {
host = strings.TrimSuffix(host, ".")
if host == "" || len(host) > 253 {
return false
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !(isAlnum(c) || c == '-') {
return false
}
}
}
return true
}
// The parts a parsed URL may hold, as PHP parse_url's array keys.
const (
partScheme = 1 << iota
partHost
partUser
partPass
partPort
partPath
partQuery
partFragment
)
// phpURL is the array PHP parse_url returns; set records which keys exist.
type phpURL struct {
set int
scheme, host, user, pass, port, path, query, frag string
}
func (u phpURL) has(part int) bool { return u.set&part != 0 }
// parseURL ports PHP parse_url for the URL shapes a sitemap meets: an
// optional scheme, an authority after "//", a path, a query and a fragment.
// Control characters in a part are replaced by "_", as PHP does.
func parseURL(s string) phpURL {
var u phpURL
rest := s
if i := strings.IndexByte(rest, ':'); i > 0 && validScheme(rest[:i]) {
after := rest[i+1:]
if !strings.HasPrefix(after, "//") && portOnly(after) {
// "host:port/path" without a scheme.
} else {
u.scheme, u.set = rest[:i], u.set|partScheme
rest = after
}
}
if strings.HasPrefix(rest, "//") {
rest = rest[2:]
end := strings.IndexAny(rest, "/?#")
authority := rest
if end >= 0 {
authority, rest = rest[:end], rest[end:]
} else {
rest = ""
}
u.parseAuthority(authority)
}
if i := strings.IndexByte(rest, '#'); i >= 0 {
u.frag, u.set = rest[i+1:], u.set|partFragment
rest = rest[:i]
}
if i := strings.IndexByte(rest, '?'); i >= 0 {
u.query, u.set = rest[i+1:], u.set|partQuery
rest = rest[:i]
}
if rest != "" {
u.path, u.set = rest, u.set|partPath
}
u.scheme = replaceControl(u.scheme)
u.host = replaceControl(u.host)
u.user = replaceControl(u.user)
u.pass = replaceControl(u.pass)
u.path = replaceControl(u.path)
u.query = replaceControl(u.query)
u.frag = replaceControl(u.frag)
return u
}
func (u *phpURL) parseAuthority(a string) {
if i := strings.LastIndexByte(a, '@'); i >= 0 {
userinfo := a[:i]
a = a[i+1:]
if j := strings.IndexByte(userinfo, ':'); j >= 0 {
u.user, u.pass = userinfo[:j], userinfo[j+1:]
u.set |= partUser | partPass
} else {
u.user = userinfo
u.set |= partUser
}
}
if i := strings.LastIndexByte(a, ':'); i >= 0 && !strings.HasSuffix(a, "]") {
port := a[i+1:]
if port != "" && allDigits(port) {
u.port, u.set = port, u.set|partPort
}
a = a[:i]
}
if a != "" {
u.host, u.set = a, u.set|partHost
}
}
func validScheme(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if !(isAlnum(c) || c == '+' || c == '-' || c == '.') {
return false
}
}
return s != ""
}
// portOnly reports whether s is a port number up to the first "/", the
// case PHP parse_url reads as host:port rather than scheme:path.
func portOnly(s string) bool {
end := strings.IndexByte(s, '/')
if end < 0 {
end = len(s)
}
return end > 0 && end <= 5 && allDigits(s[:end])
}
func allDigits(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return s != ""
}
func isAlnum(c byte) bool {
return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9'
}
func replaceControl(s string) string {
if strings.IndexFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) < 0 {
return s
}
b := []byte(s)
for i, c := range b {
if c < 0x20 || c == 0x7f {
b[i] = '_'
}
}
return string(b)
}
// phpTrim is PHP trim() with its default character list.
func phpTrim(s string) string {
return strings.Trim(s, " \t\n\r\x00\x0b")
}
// singleColonSchemes are written "scheme:" instead of "scheme://".
var singleColonSchemes = map[string]bool{"mailto": true, "tel": true, "sms": true}
// defaultPorts are Winter UrlGenerator::isDefaultPort's known ports.
var defaultPorts = map[string]int{
"ftp": 21, "ssh": 22, "sftp": 22, "telnet": 23, "smtp": 25, "dns": 53,
"gopher": 70, "http": 80, "pop3": 110, "imap": 143, "ldap": 389,
"https": 443, "smtps": 465, "ldaps": 636, "ftps": 990, "imaps": 993,
"pop3s": 995,
}
// BuildURL is Winter UrlGenerator::buildUrl($url) with no replacements: the
// query is URL-decoded, the URL parsed, every path segment re-encoded with
// rawurlencode(rawurldecode()) after "." and ".." are resolved, the query
// rebuilt with buildStr and the fragment re-encoded.
func BuildURL(raw string) string {
return buildParts(parseURL(decodeQueryParams(raw)))
}
// replacePath is buildUrl($url, ['path' => path]).
func replacePath(raw, path string) string {
u := parseURL(decodeQueryParams(raw))
u.path, u.set = path, u.set|partPath
return buildParts(u)
}
// decodeQueryParams URL-decodes everything after the first "?".
func decodeQueryParams(s string) string {
if i := strings.IndexByte(s, '?'); i >= 0 {
return s[:i] + "?" + urldecode(s[i+1:])
}
return s
}
func buildParts(u phpURL) string {
u.scheme, u.host, u.user, u.pass = phpTrim(u.scheme), phpTrim(u.host), phpTrim(u.user), phpTrim(u.pass)
u.path, u.query, u.frag = phpTrim(u.path), phpTrim(u.query), phpTrim(u.frag)
port := 0
if u.has(partPort) {
if n, err := strconv.Atoi(phpTrim(u.port)); err == nil && n >= 1 && n <= 65535 {
port = n
}
}
var b strings.Builder
hasScheme := u.has(partScheme) && u.scheme != ""
if hasScheme {
b.WriteString(u.scheme)
if singleColonSchemes[u.scheme] {
b.WriteString(":")
} else {
b.WriteString("://")
}
}
if u.has(partUser) && u.user != "" {
b.WriteString(u.user)
if u.has(partPass) && u.pass != "" {
b.WriteString(":" + u.pass)
}
b.WriteString("@")
}
if u.has(partHost) && u.host != "" {
b.WriteString(u.host)
}
if port != 0 {
if def, ok := defaultPorts[strings.ToLower(u.scheme)]; !ok || def != port {
b.WriteString(":" + strconv.Itoa(port))
}
}
path := ""
if u.has(partPath) && u.path != "" {
var kept []string
for _, seg := range strings.Split(u.path, "/") {
switch {
case seg == ".":
case seg == ".." && len(kept) > 0:
kept = kept[:len(kept)-1]
default:
kept = append(kept, seg)
}
}
for i, seg := range kept {
kept[i] = rawurlencode(rawurldecode(seg))
}
path = strings.Join(kept, "/")
}
if hasScheme && !singleColonSchemes[u.scheme] {
if !strings.HasPrefix(path, "/") {
b.WriteString("/")
}
b.WriteString(path)
} else {
b.WriteString(path)
}
if u.has(partQuery) && u.query != "" {
b.WriteString("?" + buildStr(parseQueryPairs(u.query), ""))
}
if u.has(partFragment) && u.frag != "" {
b.WriteString("#" + rawurlencode(rawurldecode(u.frag)))
}
return b.String()
}
// phpKey is a PHP array key: an int or a string.
type phpKey struct {
isInt bool
i int64
s string
}
func (k phpKey) String() string {
if k.isInt {
return strconv.FormatInt(k.i, 10)
}
return k.s
}
// keyOf is PHP's array key cast of a string: a canonical decimal integer
// becomes an int key.
func keyOf(s string) phpKey {
if n, err := strconv.ParseInt(s, 10, 64); err == nil && strconv.FormatInt(n, 10) == s {
return phpKey{isInt: true, i: n}
}
return phpKey{s: s}
}
// phpArray is an ordered PHP array of strings, nested arrays or nulls.
type phpArray struct {
keys []phpKey
vals []any
next int64
}
func (a *phpArray) index(k phpKey) int {
for i, have := range a.keys {
if have == k {
return i
}
}
return -1
}
func (a *phpArray) set(k phpKey, v any) {
if i := a.index(k); i >= 0 {
a.vals[i] = v
return
}
a.keys = append(a.keys, k)
a.vals = append(a.vals, v)
if k.isInt && k.i >= a.next {
a.next = k.i + 1
}
}
func (a *phpArray) push(v any) {
a.set(phpKey{isInt: true, i: a.next}, v)
}
// parseQueryPairs is buildUrl's split of a string query into
// $queryParams.
func parseQueryPairs(query string) *phpArray {
params := &phpArray{}
for _, pair := range strings.Split(query, "&") {
key, value := pair, pair
if i := strings.IndexByte(pair, '='); i >= 0 {
key, value = pair[:i], pair[i+1:]
}
if !strings.Contains(pair, "=") {
params.push(key)
continue
}
if strings.Contains(urldecode(pair), "[") {
k := keyOf(key)
var inner *phpArray
if i := params.index(k); i >= 0 {
inner, _ = params.vals[i].(*phpArray)
}
if inner == nil {
inner = &phpArray{}
}
inner.push(value)
params.set(k, inner)
continue
}
params.set(keyOf(key), value)
}
return params
}
// buildStr is Winter UrlGenerator::buildStr.
func buildStr(query *phpArray, prefix string) string {
var out []string
for i, k := range query.keys {
v := query.vals[i]
var key string
if k.isInt && k.i == int64(i) {
if prefix != "" {
if strings.HasSuffix(prefix, "]") {
key = prefix
} else {
key = prefix + "[]"
}
} else {
key, _ = v.(string)
v = nil
}
} else if prefix != "" {
key = prefix + "[" + k.String() + "]"
} else {
key = k.String()
}
if nested, ok := v.(*phpArray); ok {
out = append(out, buildStr(nested, key))
continue
}
part := rawurlencode(rawurldecode(key))
if s, ok := v.(string); ok {
part += "=" + rawurlencode(rawurldecode(s))
}
out = append(out, part)
}
return strings.Join(out, "&")
}
// rawurlencode is PHP rawurlencode: every byte except A-Z a-z 0-9 - _ . ~
// becomes %XX with upper-case hex.
func rawurlencode(s string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if isAlnum(c) || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
return b.String()
}
// rawurldecode is PHP rawurldecode: %XX becomes its byte, anything else,
// including a malformed escape, stays.
func rawurldecode(s string) string { return percentDecode(s, false) }
// urldecode is PHP urldecode: rawurldecode that also turns "+" into a
// space.
func urldecode(s string) string { return percentDecode(s, true) }
func percentDecode(s string, plus bool) string {
if !strings.ContainsAny(s, "%+") {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c == '+' && plus {
b.WriteByte(' ')
continue
}
if c == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
continue
}
b.WriteByte(c)
}
return b.String()
}
func isHex(c byte) bool {
return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F'
}
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
default:
return c - 'A' + 10
}
}
// URLPath is the path PHP parse_url($url, PHP_URL_PATH) returns, "" when
// the URL has none.
func URLPath(raw string) string {
u := parseURL(raw)
if !u.has(partPath) {
return ""
}
return u.path
}