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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user