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:
147
classes/generator.go
Normal file
147
classes/generator.go
Normal 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
109
classes/golden_test.go
Normal 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
105
classes/serialize.go
Normal 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("&", "&", "<", "<", ">", ">", "\r", " ")
|
||||
|
||||
// attrEscaper is libxml's escaping of attribute values: also the double
|
||||
// quote, tab and newline; the single quote stays literal.
|
||||
var attrEscaper = strings.NewReplacer(
|
||||
"&", "&", "<", "<", ">", ">", `"`, """,
|
||||
"\r", " ", "\t", "	", "\n", " ",
|
||||
)
|
||||
|
||||
// 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
3
classes/testdata/tracer.golden
vendored
Normal 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
18
classes/testdata/tracer.json
vendored
Normal 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
533
classes/urlto.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user