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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
go.work.sum
|
||||
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
|
||||
}
|
||||
123
go.mod
Normal file
123
go.mod
Normal file
@@ -0,0 +1,123 @@
|
||||
module git.golem15.com/golem15/sm-sitemap-plugin
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.1.23
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect
|
||||
github.com/knadh/koanf/providers/confmap v1.0.1 // indirect
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect
|
||||
github.com/knadh/koanf/providers/file v1.2.1 // indirect
|
||||
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.55.0 // indirect
|
||||
github.com/moby/moby/client v0.5.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/riverqueue/river v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
gocloud.dev v0.46.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/image v0.46.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gorm.io/driver/postgres v1.6.3 // indirect
|
||||
)
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
368
go.sum
Normal file
368
go.sum
Normal file
@@ -0,0 +1,368 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
|
||||
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
|
||||
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
|
||||
github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A=
|
||||
github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0=
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc=
|
||||
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
|
||||
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
|
||||
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
|
||||
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
|
||||
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
|
||||
github.com/riverqueue/river/riverdriver v0.47.0/go.mod h1:NOXl0fUiF1AT/TaQOjdx2A/c0Davn+SKbW8nAXWjfC4=
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 h1:4wckhMlLN08F76/GWxGF6vlrbs5HCMuV01dpUnvOGI4=
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0/go.mod h1:EME1EGr7RU/vMUf61mpf9YXhsZJ+GlFk4AHrIHT+YdE=
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 h1:5N9nvemhQwbUElMxASw4oEaYJ/v6hiS5Y9VcOQfdC5g=
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0/go.mod h1:ZboiXXZKC4+fTkxBxGRVmAsCuUu0NPYliqbWYuQAZyw=
|
||||
github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOtPKQZAmThkjFpLs=
|
||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
|
||||
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
|
||||
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
7
go.work
Normal file
7
go.work
Normal file
@@ -0,0 +1,7 @@
|
||||
go 1.27.0
|
||||
|
||||
use .
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
|
||||
replace git.golem15.com/golem15/sm-translate-plugin => ../sm-translate-plugin
|
||||
44
models/definition.go
Normal file
44
models/definition.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// DefinitionTheme is the fixed theme key of the one sitemap definition of an
|
||||
// application (D-11). The theme column stays so the table matches PHP; the
|
||||
// import commands write this key whatever the PHP theme was.
|
||||
const DefinitionTheme = "default"
|
||||
|
||||
// Definition is a sitemap definition stored on golem15_sitemap_definitions,
|
||||
// the PHP Golem15\Sitemap\Models\Definition. Data holds the items as JSON
|
||||
// text (PHP $jsonable data).
|
||||
type Definition struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Theme *string `gorm:"column:theme"`
|
||||
Data lagoon.Jsonable[DefinitionItems] `gorm:"column:data"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (Definition) TableName() string { return "golem15_sitemap_definitions" }
|
||||
|
||||
// MorphName is the PHP class string of the model.
|
||||
func (Definition) MorphName() string { return `Golem15\Sitemap\Models\Definition` }
|
||||
|
||||
// Fillable is exactly theme and data, the PHP $fillable with $guarded = ['*']
|
||||
// (MassAssignmentTest).
|
||||
func (Definition) Fillable() []string { return []string{"theme", "data"} }
|
||||
|
||||
// Items returns the stored items.
|
||||
func (d Definition) Items() []DefinitionItem { return d.Data.Get() }
|
||||
|
||||
// SetItems stores items as the definition's data.
|
||||
func (d *Definition) SetItems(items []DefinitionItem) {
|
||||
d.Data = lagoon.Jsonable[DefinitionItems]{Data: DefinitionItems(items), Valid: true}
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Definition{})
|
||||
}
|
||||
255
models/definition_item.go
Normal file
255
models/definition_item.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ItemTypeURL is the built-in item type of an explicit URL. Every other
|
||||
// item type is a menu item type of the framework's registry (signpost).
|
||||
const ItemTypeURL = "url"
|
||||
|
||||
// DefinitionItem is one sitemap definition item, the PHP
|
||||
// Golem15\Sitemap\Classes\DefinitionItem. Type is "url" or a registry menu
|
||||
// item type key; URL is the address of a url item; Reference is the record a
|
||||
// registry item points at; URLPattern overrides the type's URL pattern;
|
||||
// Nesting asks a registry item for its sub-items; Priority and Changefreq
|
||||
// are written to the sitemap as they are stored.
|
||||
//
|
||||
// Text fields are pointers so a stored JSON null stays null on the next
|
||||
// save. Decoding is tolerant of what PHP stored: numbers and booleans are
|
||||
// accepted for text fields, strings and numbers for Nesting, and unknown keys
|
||||
// (cmsPage, code, exists, fillable and any other) are ignored. Encoding
|
||||
// writes the PHP toArray key set: changefreq, priority, nesting, type, url,
|
||||
// reference, cmsPage (always null, CMS pages are not supported) and
|
||||
// urlPattern.
|
||||
type DefinitionItem struct {
|
||||
Type string
|
||||
URL *string
|
||||
Reference *string
|
||||
URLPattern *string
|
||||
Nesting bool
|
||||
Priority *string
|
||||
Changefreq *string
|
||||
}
|
||||
|
||||
// Text returns the value of a text field, "" for null.
|
||||
func Text(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes one stored item tolerantly. A missing type is the
|
||||
// PHP default "url"; a null type is "".
|
||||
func (d *DefinitionItem) UnmarshalJSON(raw []byte) error {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
// PHP initFromArray kept a default item for a null entry.
|
||||
*d = DefinitionItem{Type: ItemTypeURL}
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&fields); err != nil {
|
||||
return fmt.Errorf("sitemap definition item: %w", err)
|
||||
}
|
||||
item := DefinitionItem{Type: ItemTypeURL}
|
||||
if v, ok := fields["type"]; ok {
|
||||
s, err := decodeText(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sitemap definition item type: %w", err)
|
||||
}
|
||||
item.Type = Text(s)
|
||||
}
|
||||
for key, dest := range map[string]**string{
|
||||
"url": &item.URL,
|
||||
"reference": &item.Reference,
|
||||
"urlPattern": &item.URLPattern,
|
||||
"priority": &item.Priority,
|
||||
"changefreq": &item.Changefreq,
|
||||
} {
|
||||
v, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, err := decodeText(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sitemap definition item %s: %w", key, err)
|
||||
}
|
||||
*dest = s
|
||||
}
|
||||
if v, ok := fields["nesting"]; ok {
|
||||
b, err := decodeTruthy(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sitemap definition item nesting: %w", err)
|
||||
}
|
||||
item.Nesting = b
|
||||
}
|
||||
*d = item
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes the PHP toArray key set in its order.
|
||||
func (d DefinitionItem) MarshalJSON() ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
write := func(key string, v any, last bool) error {
|
||||
kb, _ := json.Marshal(key)
|
||||
b.Write(kb)
|
||||
b.WriteByte(':')
|
||||
vb, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(vb)
|
||||
if !last {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, f := range []struct {
|
||||
key string
|
||||
v any
|
||||
}{
|
||||
{"changefreq", d.Changefreq},
|
||||
{"priority", d.Priority},
|
||||
{"nesting", d.Nesting},
|
||||
{"type", d.Type},
|
||||
{"url", d.URL},
|
||||
{"reference", d.Reference},
|
||||
{"cmsPage", nil},
|
||||
} {
|
||||
if err := write(f.key, f.v, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := write("urlPattern", d.URLPattern, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// DefinitionItems is the stored item list of a definition. It decodes a
|
||||
// JSON array, and a JSON object (a PHP array whose keys were not sequential)
|
||||
// in key order.
|
||||
type DefinitionItems []DefinitionItem
|
||||
|
||||
// UnmarshalJSON accepts a JSON array or object of items; null is no items.
|
||||
func (l *DefinitionItems) UnmarshalJSON(raw []byte) error {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
*l = nil
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var items []DefinitionItem
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
*l = items
|
||||
return nil
|
||||
}
|
||||
if raw[0] != '{' {
|
||||
return fmt.Errorf("sitemap definition data must be a list of items")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
if _, err := dec.Token(); err != nil {
|
||||
return err
|
||||
}
|
||||
var items []DefinitionItem
|
||||
for dec.More() {
|
||||
if _, err := dec.Token(); err != nil {
|
||||
return err
|
||||
}
|
||||
var item DefinitionItem
|
||||
if err := dec.Decode(&item); err != nil {
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
*l = items
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes a JSON array; no items is [].
|
||||
func (l DefinitionItems) MarshalJSON() ([]byte, error) {
|
||||
if l == nil {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
return json.Marshal([]DefinitionItem(l))
|
||||
}
|
||||
|
||||
// decodeText decodes a stored text value: a string as it is, a number in
|
||||
// its PHP string form, a boolean as PHP casts it ("1" or ""), null as nil.
|
||||
func decodeText(raw json.RawMessage) (*string, error) {
|
||||
var v any
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s string
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
s = t
|
||||
case json.Number:
|
||||
s = phpNumberString(t)
|
||||
case bool:
|
||||
if t {
|
||||
s = "1"
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("must be text, got %T", v)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// decodeTruthy decodes a stored flag with PHP truthiness: false, 0, "",
|
||||
// "0" and null are false.
|
||||
func decodeTruthy(raw json.RawMessage) (bool, error) {
|
||||
var v any
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return false, nil
|
||||
case bool:
|
||||
return t, nil
|
||||
case string:
|
||||
return t != "" && t != "0", nil
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return f != 0, nil
|
||||
}
|
||||
return false, fmt.Errorf("must be a flag, got %T", v)
|
||||
}
|
||||
|
||||
// phpNumberString is PHP's string cast of a json_decode'd number: an
|
||||
// integer as written, a float in its shortest round-trip form (1.0 is "1").
|
||||
func phpNumberString(n json.Number) string {
|
||||
text := n.String()
|
||||
if !strings.ContainsAny(text, ".eE") {
|
||||
if i, err := strconv.ParseInt(text, 10, 64); err == nil {
|
||||
return strconv.FormatInt(i, 10)
|
||||
}
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
16
models/registry.go
Normal file
16
models/registry.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Package models holds the sitemap plugin's GORM models: the sitemap
|
||||
// definition and its items.
|
||||
package models
|
||||
|
||||
var all []any
|
||||
|
||||
// Register appends GORM models so later files add models without editing
|
||||
// plugin.go.
|
||||
func Register(models ...any) {
|
||||
all = append(all, models...)
|
||||
}
|
||||
|
||||
// All returns every model registered by this package.
|
||||
func All() []any {
|
||||
return all
|
||||
}
|
||||
50
plugin.go
Normal file
50
plugin.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Package sitemap is the golem15.sitemap plugin: it serves /sitemap.xml
|
||||
// from the application's one sitemap definition, byte for byte the way the
|
||||
// WinterCMS Golem15.Sitemap plugin did, with items that are explicit URLs
|
||||
// or menu item types of the framework's registry.
|
||||
package sitemap
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
// Plugin is the golem15.sitemap plugin.
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.sitemap" }
|
||||
|
||||
// Requires is empty: the sitemap reads content types through the framework's
|
||||
// menu item type registry, not from a named plugin.
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
|
||||
func (p *Plugin) Register(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
|
||||
func init() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
137
routes.go
Normal file
137
routes.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package sitemap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The response headers of the PHP routes (live responses).
|
||||
const (
|
||||
contentTypeXML = "application/xml"
|
||||
cacheControl = "no-cache, private"
|
||||
)
|
||||
|
||||
// Routes mounts GET /sitemap.xml at the application root, outside any
|
||||
// throttle group and without the backend middleware: search engines fetch it
|
||||
// anonymously.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Get("/sitemap.xml", p.sitemapXML)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sitemapXML serves the definition with the fixed theme key (D-11): 404
|
||||
// when there is none, an empty application/xml body when it has no items,
|
||||
// else the generated XML.
|
||||
func (p *Plugin) sitemapXML(w http.ResponseWriter, r *http.Request) {
|
||||
app := p.app
|
||||
db, err := database(app)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var defs []models.Definition
|
||||
if err := db.WithContext(r.Context()).Where("theme = ?", models.DefinitionTheme).Order("id ASC").Limit(1).Find(&defs).Error; err != nil {
|
||||
slog.Default().Error("golem15.sitemap: load definition", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
slog.Default().Info("golem15.sitemap: No sitemap definition was found. Try creating one first.")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
gen := &classes.Generator{Root: backendURL(app, r)}
|
||||
if reg, ok := app.Lookup[*signpost.Registry](); ok && reg != nil {
|
||||
gen.Resolver = reg
|
||||
}
|
||||
xml, err := gen.Generate(r.Context(), defs[0].Items())
|
||||
if err != nil {
|
||||
slog.Default().Error("golem15.sitemap: generate", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentTypeXML)
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(xml))
|
||||
}
|
||||
|
||||
// database returns the app's published *gorm.DB.
|
||||
func database(app *backpack.App) (*gorm.DB, error) {
|
||||
if app == nil {
|
||||
return nil, errors.New("golem15.sitemap: app is not booted")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("golem15.sitemap: database is not configured")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// backendURL is the root URL the sitemap's absolute URLs start with, PHP
|
||||
// url('/') right-trimmed of slashes: app.url when it is set, else the
|
||||
// request's scheme and Host. The scheme is https for a TLS connection, or
|
||||
// when the immediate peer is in http.trusted_proxies and its last
|
||||
// X-Forwarded-Proto value says https; a client that sends the header
|
||||
// directly is ignored.
|
||||
func backendURL(app *backpack.App, r *http.Request) string {
|
||||
if app != nil && app.Config != nil {
|
||||
if u := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
scheme := "http"
|
||||
if isHTTPS(app, r) {
|
||||
scheme = "https"
|
||||
}
|
||||
return strings.TrimRight(scheme+"://"+r.Host, "/")
|
||||
}
|
||||
|
||||
func isHTTPS(app *backpack.App, r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if app == nil || app.Config == nil {
|
||||
return false
|
||||
}
|
||||
trusted := surf.TrustedProxies(app.Config)
|
||||
if len(trusted) == 0 || !peerTrusted(r.RemoteAddr, trusted) {
|
||||
return false
|
||||
}
|
||||
values := r.Header.Values("X-Forwarded-Proto")
|
||||
if len(values) == 0 {
|
||||
return false
|
||||
}
|
||||
hops := strings.Split(values[len(values)-1], ",")
|
||||
return strings.EqualFold(strings.TrimSpace(hops[len(hops)-1]), "https")
|
||||
}
|
||||
|
||||
func peerTrusted(remoteAddr string, trusted []netip.Prefix) bool {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
host = remoteAddr
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
for _, p := range trusted {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
189
sitemap_smoke_test.go
Normal file
189
sitemap_smoke_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package sitemap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "git.golem15.com/golem15/sm-sitemap-plugin"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testTypesPluginID is the fake content plugin that registers menu item
|
||||
// types for the sitemap smoke tests. The real Journal and sitemap
|
||||
// combination is proven by the host application's tests.
|
||||
const testTypesPluginID = "acme.sitemaptypes"
|
||||
|
||||
// acmeTypes is a content plugin with one menu item type, acme-post, whose
|
||||
// reference "7" resolves to a fixed post with two alternates.
|
||||
type acmeTypes struct{}
|
||||
|
||||
func (acmeTypes) ID() string { return testTypesPluginID }
|
||||
func (acmeTypes) Requires() []string { return nil }
|
||||
func (acmeTypes) Register(*backpack.App) error { return nil }
|
||||
func (acmeTypes) Boot(*backpack.App) error { return nil }
|
||||
func (acmeTypes) MenuItemTypes() []pact.MenuItemType {
|
||||
mtime := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||
return []pact.MenuItemType{{
|
||||
Key: "acme-post",
|
||||
Label: "Acme post",
|
||||
DefaultPattern: "/news/:slug",
|
||||
References: func(context.Context) ([]pact.MenuItemReference, error) {
|
||||
return []pact.MenuItemReference{{Value: "7", Label: "Hello"}}, nil
|
||||
},
|
||||
Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
if in.Reference != "7" {
|
||||
return nil, nil
|
||||
}
|
||||
return &pact.ResolvedMenuItem{
|
||||
Title: "Hello",
|
||||
URL: "/news/hello",
|
||||
MTime: &mtime,
|
||||
Alternates: []pact.MenuItemAlternate{
|
||||
{Locale: "pl", URL: "/news/hello"},
|
||||
{Locale: "en", URL: "/en/news/hello"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
var _ pact.HasMenuItemTypes = acmeTypes{}
|
||||
|
||||
func init() {
|
||||
party.Register(acmeTypes{})
|
||||
}
|
||||
|
||||
// sitemapEnv is a booted application with the sitemap and the fake types.
|
||||
type sitemapEnv struct {
|
||||
h http.Handler
|
||||
app *backpack.App
|
||||
gdb *gorm.DB
|
||||
}
|
||||
|
||||
func bootSitemap(t *testing.T) *sitemapEnv {
|
||||
t.Helper()
|
||||
sqlDB := freshDB(t)
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: sitemap-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=sitemap-smoke-test-only-hs256-secret-key"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]any{
|
||||
"http.body_limits.default_bytes": 1048576,
|
||||
"http.body_limits.upload_bytes": 8388608,
|
||||
"mail.driver": "log",
|
||||
"mail.from": "noreply@example.test",
|
||||
} {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugins, err := party.Activate(app, []string{testTypesPluginID, "golem15.sitemap"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &sitemapEnv{h: h, app: app, gdb: gdb}
|
||||
}
|
||||
|
||||
func (e *sitemapEnv) get(t *testing.T, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "api.example.test"
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (e *sitemapEnv) storeDefinition(t *testing.T, items []models.DefinitionItem) models.Definition {
|
||||
t.Helper()
|
||||
theme := models.DefinitionTheme
|
||||
def := models.Definition{Theme: &theme}
|
||||
def.SetItems(items)
|
||||
if err := e.gdb.Create(&def).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func text(s string) *string { return &s }
|
||||
|
||||
// TestSitemapTracer stores a definition with a url item and a registry
|
||||
// item, GETs /sitemap.xml and compares the body with the PHP golden.
|
||||
func TestSitemapTracer(t *testing.T) {
|
||||
e := bootSitemap(t)
|
||||
|
||||
if rec := e.get(t, "/sitemap.xml"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("no definition: %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
def := e.storeDefinition(t, nil)
|
||||
rec := e.get(t, "/sitemap.xml")
|
||||
if rec.Code != http.StatusOK || rec.Body.Len() != 0 || rec.Header().Get("Content-Type") != "application/xml" {
|
||||
t.Fatalf("empty definition: %d %q %q", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||
}
|
||||
|
||||
def.SetItems([]models.DefinitionItem{
|
||||
{Type: models.ItemTypeURL, URL: text("/"), Changefreq: text("always"), Priority: text("1.0")},
|
||||
{Type: "acme-post", Reference: text("7"), URLPattern: text(""), Nesting: true, Changefreq: text("weekly"), Priority: text("0.5")},
|
||||
})
|
||||
if err := e.gdb.Save(&def).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = e.get(t, "/sitemap.xml")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /sitemap.xml: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/xml" {
|
||||
t.Fatalf("Content-Type = %q", ct)
|
||||
}
|
||||
want, err := os.ReadFile(filepath.Join("classes", "testdata", "tracer.golden"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rec.Body.String(); got != string(want) {
|
||||
t.Fatalf("/sitemap.xml differs from the PHP golden\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
|
||||
// The stored data keeps the PHP item keys.
|
||||
var raw string
|
||||
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, def.ID).Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":null},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":null,"reference":"7","cmsPage":null,"urlPattern":""}]`
|
||||
if raw != wantData {
|
||||
t.Fatalf("stored data = %s", raw)
|
||||
}
|
||||
}
|
||||
115
testmain_test.go
Normal file
115
testmain_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package sitemap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
)
|
||||
|
||||
var (
|
||||
pgContainer *postgres.PostgresContainer
|
||||
pgAdmin *sql.DB
|
||||
pgDSN string
|
||||
pgErr error
|
||||
)
|
||||
|
||||
// TestMain starts one Postgres container for the smoke tests unless the run
|
||||
// is -short. go test passes -test.short=true, so the flag is parsed and read
|
||||
// through testing.Short, never by scanning os.Args.
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if !testing.Short() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
pgErr = startPostgres(ctx)
|
||||
cancel()
|
||||
if pgErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "golem15.sitemap: testcontainers postgres: %v\n", pgErr)
|
||||
}
|
||||
}
|
||||
code := m.Run()
|
||||
stopPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func startPostgres(ctx context.Context) error {
|
||||
ctr, err := postgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres.WithDatabase("golem15_sitemap"),
|
||||
postgres.WithUsername("golem15_sitemap"),
|
||||
postgres.WithPassword("golem15_sitemap"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pgContainer = ctr
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return err
|
||||
}
|
||||
pgAdmin, pgDSN = db, dsn
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopPostgres() {
|
||||
if pgAdmin != nil {
|
||||
_ = pgAdmin.Close()
|
||||
}
|
||||
if pgContainer != nil {
|
||||
_ = testcontainers.TerminateContainer(pgContainer)
|
||||
}
|
||||
}
|
||||
|
||||
// freshDB creates a database of its own for one test and drops it after.
|
||||
func freshDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
if pgErr != nil {
|
||||
t.Fatalf("postgres unavailable: %v", pgErr)
|
||||
}
|
||||
if pgAdmin == nil {
|
||||
t.Fatal("postgres unavailable: container was not started")
|
||||
}
|
||||
name := fmt.Sprintf("sitemap_%d", time.Now().UnixNano())
|
||||
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
u, err := url.Parse(pgDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
db, err := sql.Open("pgx", u.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
_, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
|
||||
})
|
||||
if err := db.PingContext(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
465
tests/php/gen-goldens.php
Normal file
465
tests/php/gen-goldens.php
Normal file
@@ -0,0 +1,465 @@
|
||||
<?php
|
||||
/**
|
||||
* Golden generator for the Go sitemap port.
|
||||
*
|
||||
* Runs the PHP Golem15.Sitemap generation code (Definition::generateSitemap,
|
||||
* getXmlObject, getUrlSet, addItemToSet, lookupManifestLastmod,
|
||||
* getPriorityForUrl and the routes.php frontend_url rewrite, copied below
|
||||
* with the Winter facades replaced by the case's inputs) and the vendored
|
||||
* Winter URL generator (Url::to, Url::buildUrl) over a JSON input, and
|
||||
* writes the bytes PHP produces. The Go tests compare their output with the
|
||||
* committed goldens, so CI needs no PHP.
|
||||
*
|
||||
* Usage (from the plugin root, with a WinterCMS vendor directory):
|
||||
*
|
||||
* WINTER_VENDOR=/path/to/winter-app/vendor \
|
||||
* php tests/php/gen-goldens.php classes/testdata/tracer.json classes/testdata/tracer.golden
|
||||
*
|
||||
* Input shapes:
|
||||
*
|
||||
* {"case": {...}} one sitemap case; the golden is its XML
|
||||
* {"cases": [{"name": ..., ...}]} several cases; each written as
|
||||
* "=== name\n" followed by its XML
|
||||
* {"root": ..., "urls": [...]} Url::to of each input, one per line
|
||||
*
|
||||
* A sitemap case: root (the backend root URL), items (stored definition
|
||||
* items, PHP DefinitionItem keys), types (registry item type => reference =>
|
||||
* resolved item {url, mtime, alternates: [[locale, url], ...], items}),
|
||||
* settings (Sitemap settings values; absent keys take the PHP defaults),
|
||||
* manifest (the updates manifest object), skip (URLs a beforeAddItem
|
||||
* listener drops) and extra (URL => text of an <extra> element an addItem
|
||||
* listener appends).
|
||||
*
|
||||
* A registry type's resolved item is given in the Go shape (one item per
|
||||
* record with ordered alternates); this script turns it into what the PHP
|
||||
* headless resolvers returned: a list type (no url) yields one entry per
|
||||
* alternate for each child, any other type its children with their default
|
||||
* URL.
|
||||
*/
|
||||
|
||||
$vendor = getenv('WINTER_VENDOR');
|
||||
if (!$vendor || !is_file($vendor . '/autoload.php')) {
|
||||
fwrite(STDERR, "set WINTER_VENDOR to the vendor directory of a WinterCMS application\n");
|
||||
exit(2);
|
||||
}
|
||||
require $vendor . '/autoload.php';
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\RouteCollection;
|
||||
use Winter\Storm\Router\UrlGenerator;
|
||||
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
if ($argc !== 3) {
|
||||
fwrite(STDERR, "usage: php gen-goldens.php INPUT.json OUTPUT.golden\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents($argv[1]), true);
|
||||
if (!is_array($input)) {
|
||||
fwrite(STDERR, "invalid JSON input\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function urlGenerator(string $root): UrlGenerator
|
||||
{
|
||||
return new UrlGenerator(new RouteCollection, Request::create(rtrim($root, '/') . '/sitemap.xml'));
|
||||
}
|
||||
|
||||
if (isset($input['urls'])) {
|
||||
$url = urlGenerator($input['root']);
|
||||
$out = '';
|
||||
foreach ($input['urls'] as $path) {
|
||||
$out .= $url->to($path) . "\n";
|
||||
}
|
||||
file_put_contents($argv[2], $out);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (isset($input['case'])) {
|
||||
file_put_contents($argv[2], (new GoldenSitemap($input['case']))->render());
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$out = '';
|
||||
foreach ($input['cases'] as $case) {
|
||||
$out .= '=== ' . $case['name'] . "\n" . (new GoldenSitemap($case))->render();
|
||||
}
|
||||
file_put_contents($argv[2], $out);
|
||||
|
||||
/**
|
||||
* Golem15\Sitemap\Classes\DefinitionItem, unchanged properties.
|
||||
*/
|
||||
class GoldenDefinitionItem
|
||||
{
|
||||
public $changefreq;
|
||||
public $priority;
|
||||
public $nesting = false;
|
||||
public $type = 'url';
|
||||
public $url = null;
|
||||
public $code = null;
|
||||
public $reference = null;
|
||||
public $cmsPage = null;
|
||||
public $urlPattern = null;
|
||||
public $exists = false;
|
||||
|
||||
public static function initFromArray(array $items): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($items as $itemData) {
|
||||
$obj = new self;
|
||||
foreach ($itemData as $name => $value) {
|
||||
if (property_exists($obj, $name)) {
|
||||
$obj->$name = $value;
|
||||
}
|
||||
}
|
||||
$result[] = $obj;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The generation code of Golem15\Sitemap\Models\Definition and routes.php.
|
||||
*/
|
||||
class GoldenSitemap
|
||||
{
|
||||
const MAX_URLS = 50000;
|
||||
|
||||
protected $case;
|
||||
protected $url;
|
||||
protected $items;
|
||||
protected $updatesManifest = null;
|
||||
protected $urlCount = 0;
|
||||
protected $urlSet;
|
||||
protected $xmlObject;
|
||||
|
||||
public function __construct(array $case)
|
||||
{
|
||||
$this->case = $case;
|
||||
$this->url = urlGenerator($case['root']);
|
||||
$this->items = GoldenDefinitionItem::initFromArray($case['items'] ?? []);
|
||||
}
|
||||
|
||||
protected function setting(string $key, $default)
|
||||
{
|
||||
$settings = $this->case['settings'] ?? [];
|
||||
return array_key_exists($key, $settings) ? $settings[$key] : $default;
|
||||
}
|
||||
|
||||
/** routes.php */
|
||||
public function render(): string
|
||||
{
|
||||
$xml = $this->generateSitemap();
|
||||
|
||||
$frontendUrl = $this->setting('frontend_url', null);
|
||||
if ($frontendUrl) {
|
||||
$frontendUrl = rtrim($frontendUrl, '/');
|
||||
$backendUrl = rtrim($this->url->to('/'), '/');
|
||||
$xml = preg_replace_callback('/<loc>(.*?)<\/loc>/', function ($m) use ($backendUrl, $frontendUrl) {
|
||||
return '<loc>' . str_replace($backendUrl, $frontendUrl, $m[1]) . '</loc>';
|
||||
}, $xml);
|
||||
$xml = preg_replace_callback('/(<xhtml:link[^>]*href=")([^"]*)(")/', function ($m) use ($backendUrl, $frontendUrl) {
|
||||
return $m[1] . str_replace($backendUrl, $frontendUrl, $m[2]) . $m[3];
|
||||
}, $xml);
|
||||
$xml = str_replace(
|
||||
'href="' . $backendUrl . '/sitemap.xsl"',
|
||||
'href="' . $frontendUrl . '/sitemap.xsl"',
|
||||
$xml
|
||||
);
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the PHP headless resolvers returned for a resolved item in the
|
||||
* Go shape.
|
||||
*/
|
||||
protected function phpResolved(array $r, bool $listChildren): array
|
||||
{
|
||||
$info = [];
|
||||
if (!empty($r['url'])) {
|
||||
$info['url'] = $r['url'];
|
||||
}
|
||||
if (array_key_exists('mtime', $r)) {
|
||||
$info['mtime'] = $r['mtime'] === null ? null : new DateTime($r['mtime']);
|
||||
}
|
||||
$links = [];
|
||||
foreach ($r['alternates'] ?? [] as [$locale, $href]) {
|
||||
$links[$locale] = $href;
|
||||
}
|
||||
$info['alternateLinks'] = $links;
|
||||
if (isset($r['items'])) {
|
||||
$info['items'] = [];
|
||||
foreach ($r['items'] as $child) {
|
||||
$childInfo = $this->phpResolved($child, false);
|
||||
if ($listChildren) {
|
||||
foreach ($childInfo['alternateLinks'] as $locale => $href) {
|
||||
$perLocale = $childInfo;
|
||||
$perLocale['url'] = $href;
|
||||
$info['items'][] = $perLocale;
|
||||
}
|
||||
} else {
|
||||
$info['items'][] = $childInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $info;
|
||||
}
|
||||
|
||||
protected function resolveItem(GoldenDefinitionItem $item)
|
||||
{
|
||||
$types = $this->case['types'] ?? [];
|
||||
$ref = (string) $item->reference;
|
||||
if (!isset($types[$item->type][$ref])) {
|
||||
return null;
|
||||
}
|
||||
$r = $types[$item->type][$ref];
|
||||
return $this->phpResolved($r, empty($r['url']));
|
||||
}
|
||||
|
||||
public function generateSitemap(): string
|
||||
{
|
||||
if (!$this->items) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
if ($item->type === 'url') {
|
||||
$this->addItemToSet($item);
|
||||
} else {
|
||||
$menuItemInfo = $this->resolveItem($item);
|
||||
if (!is_array($menuItemInfo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($menuItemInfo['url'])) {
|
||||
$this->addItemToSet($item, $menuItemInfo);
|
||||
}
|
||||
|
||||
if (isset($menuItemInfo['items'])) {
|
||||
$menuItemIterator = function ($menuItems) use (&$menuItemIterator, $item) {
|
||||
foreach ($menuItems as $menuItem) {
|
||||
if (isset($menuItem['url'])) {
|
||||
$this->addItemToSet($item, $menuItem);
|
||||
}
|
||||
|
||||
if (isset($menuItem['items'])) {
|
||||
$menuItemIterator($menuItem['items']);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$menuItemIterator($menuItemInfo['items']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$urlSet = $this->getUrlSet();
|
||||
$xml = $this->getXmlObject();
|
||||
$xml->appendChild($urlSet);
|
||||
|
||||
return $xml->saveXML();
|
||||
}
|
||||
|
||||
protected function getXmlObject(): DOMDocument
|
||||
{
|
||||
if ($this->xmlObject !== null) {
|
||||
return $this->xmlObject;
|
||||
}
|
||||
|
||||
$xml = new DOMDocument;
|
||||
$xml->encoding = 'UTF-8';
|
||||
$xss = $xml->createProcessingInstruction('xml-stylesheet',
|
||||
'type="text/xsl" href="' . UrlGenerator::buildUrl($this->url->current(), ['path' => 'sitemap.xsl']) . '"'
|
||||
);
|
||||
$xml->appendChild($xss);
|
||||
|
||||
return $this->xmlObject = $xml;
|
||||
}
|
||||
|
||||
protected function getUrlSet(): DOMElement
|
||||
{
|
||||
if ($this->urlSet !== null) {
|
||||
return $this->urlSet;
|
||||
}
|
||||
|
||||
$xml = $this->getXmlObject();
|
||||
$urlSet = $xml->createElement('urlset');
|
||||
$urlSet->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
|
||||
$urlSet->setAttribute('xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
|
||||
$urlSet->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
|
||||
$urlSet->setAttribute('xsi:schemaLocation', 'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd');
|
||||
|
||||
return $this->urlSet = $urlSet;
|
||||
}
|
||||
|
||||
protected function addItemToSet(GoldenDefinitionItem $item, ?array $itemInfo = null): void
|
||||
{
|
||||
$xml = $this->getXmlObject();
|
||||
$urlSet = $this->getUrlSet();
|
||||
|
||||
if ($this->urlCount >= self::MAX_URLS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($itemInfo['url'])) {
|
||||
$itemInfo['url'] = $this->url->to($item->url);
|
||||
}
|
||||
|
||||
$lastModified = $itemInfo['mtime'] ?? null;
|
||||
|
||||
if ($lastModified === null) {
|
||||
$lastModified = $this->lookupManifestLastmod($itemInfo['url']);
|
||||
}
|
||||
|
||||
if ($lastModified instanceof \DateTime) {
|
||||
$lastModified = $lastModified->getTimestamp();
|
||||
}
|
||||
$itemInfo['lastModified'] = $lastModified ? date('c', $lastModified) : null;
|
||||
|
||||
// golem15.sitemap.beforeAddItem: a listener that drops the case's
|
||||
// skip URLs.
|
||||
if (in_array($itemInfo['url'], $this->case['skip'] ?? [], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($itemInfo['url'])) {
|
||||
return;
|
||||
} else {
|
||||
$itemInfo['url'] = $this->url->to($itemInfo['url']);
|
||||
}
|
||||
|
||||
$this->urlCount++;
|
||||
|
||||
$tierPriority = $this->getPriorityForUrl($itemInfo['url']);
|
||||
$effectivePriority = $tierPriority ?? $item->priority;
|
||||
|
||||
$urlElement = $xml->createElement('url');
|
||||
|
||||
$loc = $xml->createElement('loc');
|
||||
$loc->appendChild($xml->createTextNode($itemInfo['url']));
|
||||
$urlElement->appendChild($loc);
|
||||
|
||||
if (!empty($itemInfo['lastModified'])) {
|
||||
$lastmod = $xml->createElement('lastmod');
|
||||
$lastmod->appendChild($xml->createTextNode((string) $itemInfo['lastModified']));
|
||||
$urlElement->appendChild($lastmod);
|
||||
}
|
||||
|
||||
$changefreq = $xml->createElement('changefreq');
|
||||
$changefreq->appendChild($xml->createTextNode((string) $item->changefreq));
|
||||
$urlElement->appendChild($changefreq);
|
||||
|
||||
$priority = $xml->createElement('priority');
|
||||
$priority->appendChild($xml->createTextNode((string) $effectivePriority));
|
||||
$urlElement->appendChild($priority);
|
||||
|
||||
if (!empty($itemInfo['alternateLinks']) && count($itemInfo['alternateLinks']) > 1) {
|
||||
foreach ($itemInfo['alternateLinks'] as $hreflang => $href) {
|
||||
$link = $xml->createElement('xhtml:link');
|
||||
$link->setAttribute('rel', 'alternate');
|
||||
$link->setAttribute('hreflang', $hreflang);
|
||||
$link->setAttribute('href', $this->url->to($href));
|
||||
$urlElement->appendChild($link);
|
||||
}
|
||||
}
|
||||
|
||||
$urlSet->appendChild($urlElement);
|
||||
|
||||
// golem15.sitemap.addItem: a listener that appends the case's extra
|
||||
// element to the URL.
|
||||
$extra = $this->case['extra'] ?? [];
|
||||
if (isset($extra[$itemInfo['url']])) {
|
||||
$element = $xml->createElement('extra');
|
||||
$element->appendChild($xml->createTextNode($extra[$itemInfo['url']]));
|
||||
$urlElement->appendChild($element);
|
||||
}
|
||||
}
|
||||
|
||||
protected function lookupManifestLastmod(string $url): ?int
|
||||
{
|
||||
$manifest = $this->getUpdatesManifest();
|
||||
if (!$manifest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: '/';
|
||||
$path = $this->normalizeManifestPath($path);
|
||||
|
||||
if (!isset($manifest[$path])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = strtotime((string) $manifest[$path]);
|
||||
|
||||
return $timestamp ?: null;
|
||||
}
|
||||
|
||||
protected function getUpdatesManifest(): array
|
||||
{
|
||||
if ($this->updatesManifest !== null) {
|
||||
return $this->updatesManifest;
|
||||
}
|
||||
|
||||
$decoded = $this->case['manifest'] ?? null;
|
||||
if (!is_array($decoded)) {
|
||||
return $this->updatesManifest = [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($decoded as $key => $value) {
|
||||
$normalized[$this->normalizeManifestPath((string) $key)] = $value;
|
||||
}
|
||||
|
||||
return $this->updatesManifest = $normalized;
|
||||
}
|
||||
|
||||
protected function normalizeManifestPath(string $path): string
|
||||
{
|
||||
$path = '/' . ltrim($path, '/');
|
||||
|
||||
return $path === '/' ? '/' : rtrim($path, '/');
|
||||
}
|
||||
|
||||
protected function getPriorityForUrl(string $url): ?string
|
||||
{
|
||||
if (!$this->setting('enable_priority_tiers', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: '/';
|
||||
$path = rtrim($path, '/');
|
||||
|
||||
if ($path === '' || $path === '/en') {
|
||||
return $this->setting('priority_homepage', '1.0');
|
||||
}
|
||||
|
||||
if (preg_match('#^/(uslugi|en/services|realizacje|en/work)/[^/]+$#', $path)) {
|
||||
return $this->setting('priority_services', '0.8');
|
||||
}
|
||||
|
||||
if (preg_match('#^/(blog|en/blog)/[^/]+/[^/]+$#', $path)) {
|
||||
return $this->setting('priority_blog', '0.6');
|
||||
}
|
||||
|
||||
if (preg_match('#^/(uslugi|en/services|realizacje|en/work|blog|en/blog)(/[^/]+)?$#', $path)) {
|
||||
return $this->setting('priority_categories', '0.4');
|
||||
}
|
||||
|
||||
$legalPaths = [
|
||||
'/polityka-prywatnosci', '/en/privacy-policy',
|
||||
'/regulamin', '/en/terms',
|
||||
'/o-nas', '/en/about',
|
||||
'/kontakt', '/en/contact',
|
||||
'/laboratorium-ai', '/en/ai-lab',
|
||||
'/laboratorium-ai/zabawa-animacja', '/en/ai-lab/blob-playground',
|
||||
];
|
||||
if (in_array($path, $legalPaths)) {
|
||||
return $this->setting('priority_legal', '0.2');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
updates/202610110001_create_golem15_sitemap_definitions.go
Normal file
39
updates/202610110001_create_golem15_sitemap_definitions.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// createDefinitions creates golem15_sitemap_definitions with the PHP columns
|
||||
// (v1.0.2 create_definitions_table, renamed in v2.0.0 and v2.0.1): a
|
||||
// nullable indexed theme and the data text of the items.
|
||||
var createDefinitions = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610110001_create_golem15_sitemap_definitions",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE golem15_sitemap_definitions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
theme VARCHAR(255) NULL,
|
||||
data TEXT NULL,
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
)`,
|
||||
`CREATE INDEX golem15_sitemap_definitions_theme_index ON golem15_sitemap_definitions (theme)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS golem15_sitemap_definitions`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(createDefinitions...)
|
||||
}
|
||||
17
updates/registry.go
Normal file
17
updates/registry.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package updates holds the sitemap plugin's ordered gormigrate steps.
|
||||
package updates
|
||||
|
||||
import "github.com/go-gormigrate/gormigrate/v2"
|
||||
|
||||
var all []*gormigrate.Migration
|
||||
|
||||
// Register appends gormigrate steps so later files add migrations without
|
||||
// editing plugin.go.
|
||||
func Register(ms ...*gormigrate.Migration) {
|
||||
all = append(all, ms...)
|
||||
}
|
||||
|
||||
// All returns every migration registered by this package.
|
||||
func All() []*gormigrate.Migration {
|
||||
return all
|
||||
}
|
||||
Reference in New Issue
Block a user