feat: hreflang alternates and the sitemap filter contract
- every listed address of a record with more than one listed locale carries xhtml:link alternates for each locale plus x-default on the default locale (D-55 to D-57); no alternate for a missing translation - each page of records and the additional addresses go to the published contract.Filter in one batch: Exclude leaves the files with its reason, Group moves the address, LastMod seeds a new row (D-95, D-24) - classes.Listing implements contract.Listing and is published in Register
This commit is contained in:
154
classes/listing.go
Normal file
154
classes/listing.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Listing implements contract.Listing over the slot table and the
|
||||
// settings (D-95): other plugins read the site address, the index address,
|
||||
// the file groups and each group's listed addresses without importing the
|
||||
// sitemap plugin.
|
||||
type Listing struct {
|
||||
App *backpack.App
|
||||
}
|
||||
|
||||
var _ contract.Listing = Listing{}
|
||||
|
||||
// The page sizes of Entries.
|
||||
const (
|
||||
defaultEntriesLimit = 100
|
||||
maxEntriesLimit = 1000
|
||||
)
|
||||
|
||||
func (l Listing) db(ctx context.Context) (*gorm.DB, error) {
|
||||
if l.App == nil {
|
||||
return nil, ErrNoDatabase
|
||||
}
|
||||
db, ok := l.App.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, ErrNoDatabase
|
||||
}
|
||||
return db.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// Settings answers the site address, the index address, the default
|
||||
// locale and the groups with listed addresses (by key), with their titles:
|
||||
// a content type's saved HTML sitemap headings, else the titles of the
|
||||
// published Filter's group of that key.
|
||||
func (l Listing) Settings(ctx context.Context) (contract.ListingSettings, error) {
|
||||
var out contract.ListingSettings
|
||||
db, err := l.db(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.SiteURL = SiteURL(l.App, settings)
|
||||
if out.SiteURL != "" {
|
||||
out.IndexURL = out.SiteURL + "/sitemap.xml"
|
||||
}
|
||||
out.DefaultLocale = models.Text(settings.DefaultLocale)
|
||||
var counts []struct {
|
||||
Grp string `gorm:"column:grp"`
|
||||
N int `gorm:"column:n"`
|
||||
}
|
||||
if err := db.Raw(`SELECT grp, COUNT(*) AS n FROM golem15_sitemap_urls WHERE excluded_reason = '' GROUP BY grp ORDER BY grp`).Scan(&counts).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
titles := map[string]map[string]string{}
|
||||
if filter, ok := l.App.Lookup[contract.Filter](); ok && filter != nil {
|
||||
groups, err := filter.Groups(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, g := range groups {
|
||||
if len(g.Titles) > 0 {
|
||||
titles[g.Key] = g.Titles
|
||||
}
|
||||
}
|
||||
}
|
||||
if reg, ok := l.App.Lookup[*signpost.Registry](); ok && reg != nil {
|
||||
rows, err := LoadTypeSettings(db)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, typ := range reg.ContentTypes() {
|
||||
cfg := EffectiveType(typ, rows)
|
||||
if len(cfg.Titles) > 0 {
|
||||
titles[cfg.Slug] = cfg.Titles
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Groups = make([]contract.GroupInfo, 0, len(counts))
|
||||
for _, c := range counts {
|
||||
out.Groups = append(out.Groups, contract.GroupInfo{Key: c.Grp, Slug: c.Grp, Titles: titles[c.Grp], Count: c.N})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Entries pages a group's listed addresses in the order they were stored
|
||||
// (row id); Next is the last row id of a page that has a successor. A
|
||||
// Locale keeps the addresses of that locale and those without one.
|
||||
func (l Listing) Entries(ctx context.Context, q contract.EntriesQuery) (contract.EntriesPage, error) {
|
||||
page := contract.EntriesPage{Entries: []contract.Entry{}}
|
||||
db, err := l.db(ctx)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
site := SiteURL(l.App, settings)
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultEntriesLimit
|
||||
}
|
||||
limit = min(limit, maxEntriesLimit)
|
||||
scope := func() *gorm.DB {
|
||||
s := db.Model(&models.URL{}).Where("grp = ? AND excluded_reason = ''", q.Group)
|
||||
if loc := strings.TrimSpace(q.Locale); loc != "" {
|
||||
s = s.Where("locale = ? OR locale = ''", loc)
|
||||
}
|
||||
return s
|
||||
}
|
||||
var total int64
|
||||
if err := scope().Count(&total).Error; err != nil {
|
||||
return page, err
|
||||
}
|
||||
page.Total = int(total)
|
||||
q2 := scope()
|
||||
if after, err := strconv.ParseUint(strings.TrimSpace(q.After), 10, 64); err == nil && after > 0 {
|
||||
q2 = q2.Where("id > ?", after)
|
||||
}
|
||||
var rows []models.URL
|
||||
if err := q2.Order("id ASC").Limit(limit + 1).Find(&rows).Error; err != nil {
|
||||
return page, err
|
||||
}
|
||||
if len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
page.Next = strconv.FormatUint(rows[len(rows)-1].ID, 10)
|
||||
}
|
||||
for _, r := range rows {
|
||||
page.Entries = append(page.Entries, contract.Entry{
|
||||
Group: r.Grp,
|
||||
TypeKey: r.TypeKey,
|
||||
RecordID: r.RecordID,
|
||||
Locale: r.Locale,
|
||||
URL: Absolute(site, r.URL),
|
||||
Title: r.Title,
|
||||
LastMod: r.LastMod,
|
||||
})
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
@@ -130,7 +130,8 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
|
||||
return res, err
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
found, err := collect(ctx, reg, settings, types)
|
||||
filter, _ := app.Lookup[contract.Filter]()
|
||||
found, err := collect(ctx, reg, filter, settings, types)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
@@ -152,31 +153,43 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
|
||||
// collect walks the content types page by page and adds the additional
|
||||
// addresses. The records of a type switched off in the settings are kept
|
||||
// in the table with the reason contract.ReasonType, so the settings screen
|
||||
// counts them.
|
||||
func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings, types map[string]models.TypeSetting) (collected, error) {
|
||||
// counts them. Every other page of addresses (and the additional
|
||||
// addresses) is one batch for the published contract.Filter, when there is
|
||||
// one (D-95): an excluded address keeps its row with the Filter's reason, a
|
||||
// Decision.Group moves it to that file group, and Decision.LastMod is the
|
||||
// first lastmod of a new row. The hreflang alternates of a record are its
|
||||
// listed addresses (D-55 to D-57).
|
||||
func collect(ctx context.Context, reg *signpost.Registry, filter contract.Filter, settings models.Settings, types map[string]models.TypeSetting) (collected, error) {
|
||||
out := collected{unpublished: map[slotKey]bool{}}
|
||||
seen := map[slotKey]bool{}
|
||||
add := func(w wanted) {
|
||||
if seen[w.key] {
|
||||
return
|
||||
defaultLocale := models.Text(settings.DefaultLocale)
|
||||
// batch decides one batch and adds its rows; rows of one record are
|
||||
// adjacent, so the alternates are computed per run of a record.
|
||||
batch := func(rows []wanted) error {
|
||||
var fresh []wanted
|
||||
for _, w := range rows {
|
||||
if !seen[w.key] {
|
||||
seen[w.key] = true
|
||||
fresh = append(fresh, w)
|
||||
}
|
||||
}
|
||||
seen[w.key] = true
|
||||
out.rows = append(out.rows, w)
|
||||
if err := decide(ctx, filter, fresh); err != nil {
|
||||
return err
|
||||
}
|
||||
setAlternates(fresh, defaultLocale)
|
||||
out.rows = append(out.rows, fresh...)
|
||||
return nil
|
||||
}
|
||||
unprefixed := models.Text(settings.DefaultLocale)
|
||||
if reg != nil {
|
||||
for _, typ := range reg.ContentTypes() {
|
||||
cfg := EffectiveType(typ, types)
|
||||
reason := ""
|
||||
if !cfg.Include {
|
||||
reason = contract.ReasonType
|
||||
}
|
||||
after := ""
|
||||
for {
|
||||
page, err := reg.Records(ctx, typ.Key, after, recordsPage, unprefixed)
|
||||
page, err := reg.Records(ctx, typ.Key, after, recordsPage, defaultLocale)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("golem15.sitemap: records of %s: %w", typ.Key, err)
|
||||
}
|
||||
var rows []wanted
|
||||
for _, rec := range page.Records {
|
||||
if !rec.Published {
|
||||
out.unpublished[slotKey{typ.Key, rec.ID, ""}] = true
|
||||
@@ -186,16 +199,22 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
if strings.TrimSpace(l.URL) == "" {
|
||||
continue
|
||||
}
|
||||
add(wanted{
|
||||
w := wanted{
|
||||
key: slotKey{typ.Key, rec.ID, l.Locale},
|
||||
grp: cfg.Slug,
|
||||
url: l.URL,
|
||||
title: l.Title,
|
||||
firstMod: rec.LastMod,
|
||||
reason: reason,
|
||||
})
|
||||
}
|
||||
if !cfg.Include {
|
||||
w.reason = contract.ReasonType
|
||||
}
|
||||
rows = append(rows, w)
|
||||
}
|
||||
}
|
||||
if err := batch(rows); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if page.Next == "" || page.Next == after {
|
||||
break
|
||||
}
|
||||
@@ -203,13 +222,14 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
}
|
||||
}
|
||||
}
|
||||
var extras []wanted
|
||||
for _, e := range settings.Extra() {
|
||||
u := strings.TrimSpace(e.URL)
|
||||
if !models.AllowedAddress(u) {
|
||||
continue
|
||||
}
|
||||
mod := models.ParseDate(e.LastMod)
|
||||
add(wanted{
|
||||
extras = append(extras, wanted{
|
||||
key: slotKey{models.ExtraTypeKey, ExtraRecordID(u), ""},
|
||||
grp: models.ExtraGroup,
|
||||
url: u,
|
||||
@@ -218,9 +238,101 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
|
||||
firstMod: mod,
|
||||
})
|
||||
}
|
||||
if err := batch(extras); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decide asks filter about the rows not already left out (a type switched
|
||||
// off is decided by the settings) and applies its decisions. Without a
|
||||
// Filter every row stays listed.
|
||||
func decide(ctx context.Context, filter contract.Filter, rows []wanted) error {
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
var asked []int
|
||||
var candidates []contract.Candidate
|
||||
for i, w := range rows {
|
||||
if w.reason != "" {
|
||||
continue
|
||||
}
|
||||
asked = append(asked, i)
|
||||
candidates = append(candidates, contract.Candidate{TypeKey: w.key.typeKey, RecordID: w.key.recordID, Locale: w.key.locale, URL: w.url})
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
decisions, err := filter.Decide(ctx, candidates)
|
||||
if err != nil {
|
||||
return fmt.Errorf("golem15.sitemap: filter: %w", err)
|
||||
}
|
||||
if len(decisions) != len(candidates) {
|
||||
return fmt.Errorf("golem15.sitemap: filter answered %d decisions for %d candidates", len(decisions), len(candidates))
|
||||
}
|
||||
for j, d := range decisions {
|
||||
w := &rows[asked[j]]
|
||||
if d.Exclude {
|
||||
w.reason = strings.TrimSpace(d.Reason)
|
||||
if w.reason == "" {
|
||||
w.reason = contract.ReasonNoindex
|
||||
}
|
||||
continue
|
||||
}
|
||||
if g := strings.TrimSpace(d.Group); g != "" {
|
||||
if ValidSlug(g) && !ReservedSlugs[g] {
|
||||
w.grp = g
|
||||
} else {
|
||||
pluginLog.Warn("golem15.sitemap: filter group ignored", "group", g, "type", w.key.typeKey, "record", w.key.recordID)
|
||||
}
|
||||
}
|
||||
if d.LastMod != nil {
|
||||
w.firstMod = d.LastMod
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAlternates gives the listed addresses of every record with more than
|
||||
// one listed locale its hreflang alternates: each listed locale with its
|
||||
// address, itself included, then x-default on the default locale's address
|
||||
// (the record's first listed locale when the default has none). A locale
|
||||
// without an address or left out has no alternate; a record with one
|
||||
// listed locale gets none (D-55 to D-57, RESEARCH Pitfall 3).
|
||||
func setAlternates(rows []wanted, defaultLocale string) {
|
||||
type record struct{ typeKey, id string }
|
||||
listed := map[record][]int{}
|
||||
var order []record
|
||||
for i, w := range rows {
|
||||
if w.reason != "" || w.extra || w.key.locale == "" || w.key.locale == "x-default" {
|
||||
continue
|
||||
}
|
||||
r := record{w.key.typeKey, w.key.recordID}
|
||||
if _, ok := listed[r]; !ok {
|
||||
order = append(order, r)
|
||||
}
|
||||
listed[r] = append(listed[r], i)
|
||||
}
|
||||
for _, r := range order {
|
||||
idx := listed[r]
|
||||
if len(idx) < 2 {
|
||||
continue
|
||||
}
|
||||
alts := make([]models.Alternate, 0, len(idx)+1)
|
||||
xDefault := rows[idx[0]].url
|
||||
for _, i := range idx {
|
||||
alts = append(alts, models.Alternate{Hreflang: rows[i].key.locale, Href: rows[i].url})
|
||||
if rows[i].key.locale == defaultLocale {
|
||||
xDefault = rows[i].url
|
||||
}
|
||||
}
|
||||
alts = append(alts, models.Alternate{Hreflang: "x-default", Href: xDefault})
|
||||
for _, i := range idx {
|
||||
rows[i].alternates = alts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply writes the found addresses into the slot table and numbers the
|
||||
// files, inside the rebuild's transaction.
|
||||
func apply(tx *gorm.DB, found collected, settings models.Settings, site string) (RebuildResult, error) {
|
||||
|
||||
59
filter_fixture_test.go
Normal file
59
filter_fixture_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package sitemap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// filterPluginID is a fake plugin that publishes a contract.Filter, as the
|
||||
// SEO plugin does: it imports only the contract package.
|
||||
const filterPluginID = "acme.filter"
|
||||
|
||||
// fakeFilter excludes addresses containing "noindex" (reason noindex) and
|
||||
// puts addresses containing "promo" into the group offers. It records the
|
||||
// batches it was asked.
|
||||
type fakeFilter struct {
|
||||
mu sync.Mutex
|
||||
batches [][]contract.Candidate
|
||||
}
|
||||
|
||||
func (f *fakeFilter) Decide(_ context.Context, batch []contract.Candidate) ([]contract.Decision, error) {
|
||||
f.mu.Lock()
|
||||
f.batches = append(f.batches, append([]contract.Candidate(nil), batch...))
|
||||
f.mu.Unlock()
|
||||
out := make([]contract.Decision, len(batch))
|
||||
for i, c := range batch {
|
||||
switch {
|
||||
case strings.Contains(c.URL, "noindex"):
|
||||
out[i] = contract.Decision{Exclude: true, Reason: contract.ReasonNoindex}
|
||||
case strings.Contains(c.URL, "promo"):
|
||||
out[i] = contract.Decision{Group: "offers"}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeFilter) Groups(context.Context) ([]contract.Group, error) {
|
||||
return []contract.Group{{Key: "offers", Titles: map[string]string{"en": "Offers", "pl": "Oferty"}}}, nil
|
||||
}
|
||||
|
||||
var theFilter = &fakeFilter{}
|
||||
|
||||
// filterPlugin is acme.filter.
|
||||
type filterPlugin struct{}
|
||||
|
||||
func (filterPlugin) ID() string { return filterPluginID }
|
||||
func (filterPlugin) Requires() []string { return nil }
|
||||
func (filterPlugin) Register(app *backpack.App) error {
|
||||
return app.Publish[contract.Filter](theFilter)
|
||||
}
|
||||
func (filterPlugin) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func init() {
|
||||
party.Register(filterPlugin{})
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/console"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -46,9 +48,11 @@ func (p *Plugin) ID() string { return "golem15.sitemap" }
|
||||
// from a named plugin, and a Filter through the service registry.
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
|
||||
// Register publishes the sitemap's contract.Listing for other plugins
|
||||
// (D-95).
|
||||
func (p *Plugin) Register(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
return app.Publish[contract.Listing](classes.Listing{App: app})
|
||||
}
|
||||
|
||||
// Boot keeps the booted application for the routes, jobs and commands and
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/console"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
@@ -620,3 +621,192 @@ func TestRebuildCommand(t *testing.T) {
|
||||
t.Fatalf("sitemap:rebuild --queue: %d\n%s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHreflangAlternates pins D-55 to D-57: every address of a record with
|
||||
// more than one translated locale carries xhtml:link alternates for each
|
||||
// locale, itself included, plus x-default on the default locale's address;
|
||||
// a locale without an address gets none, a single-locale record none.
|
||||
func TestHreflangAlternates(t *testing.T) {
|
||||
e := bootIndex(t)
|
||||
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET default_locale = 'pl' WHERE id = 1`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
|
||||
contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{
|
||||
{Locale: "en", URL: "/en/pages/a"}, {Locale: "pl", URL: "/strony/a"}, {Locale: "de", URL: ""},
|
||||
}})
|
||||
contentStore.put(pageType, fakeRecord{ID: 2, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/strony/b"}}})
|
||||
e.rebuild(t)
|
||||
body := e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
|
||||
if !strings.Contains(body, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">`) {
|
||||
t.Fatalf("the xhtml namespace is not declared:\n%s", body)
|
||||
}
|
||||
alts := `<xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/pages/a"/>` +
|
||||
`<xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/strony/a"/>` +
|
||||
`<xhtml:link rel="alternate" hreflang="x-default" href="https://api.example.test/strony/a"/>`
|
||||
for _, loc := range []string{"https://api.example.test/en/pages/a", "https://api.example.test/strony/a"} {
|
||||
if !strings.Contains(body, "<url><loc>"+loc+"</loc><lastmod>2026-10-01T08:00:00Z</lastmod>"+alts+"</url>") {
|
||||
t.Errorf("%s lacks the alternates:\n%s", loc, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `hreflang="de"`) {
|
||||
t.Errorf("a locale without an address got an alternate:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<url><loc>https://api.example.test/strony/b</loc><lastmod>2026-10-01T08:00:00Z</lastmod></url>") {
|
||||
t.Errorf("a single-locale record got alternates:\n%s", body)
|
||||
}
|
||||
|
||||
// Without a default locale setting, x-default is the record's first
|
||||
// locale; a locale that leaves the record drops out of the alternates.
|
||||
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET default_locale = NULL WHERE id = 1`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{
|
||||
{Locale: "en", URL: "/en/pages/a"}, {Locale: "pl", URL: "/strony/a"}, {Locale: "de", URL: "/de/seiten/a"},
|
||||
}})
|
||||
e.rebuild(t)
|
||||
body = e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
|
||||
if !strings.Contains(body, `hreflang="x-default" href="https://api.example.test/en/pages/a"`) || strings.Count(body, `hreflang="de"`) != 3 {
|
||||
t.Fatalf("x-default without a default locale, or the new locale:\n%s", body)
|
||||
}
|
||||
contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/en/pages/a"}}})
|
||||
e.rebuild(t)
|
||||
if body = e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String(); strings.Contains(body, "xhtml:link") {
|
||||
t.Fatalf("a record down to one locale keeps alternates:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterExcludes pins D-95 and D-24 with a published Filter: an
|
||||
// excluded address leaves the files and is counted under its reason, an
|
||||
// address the Filter moves lands in sitemap-offers-1.xml, every batch is
|
||||
// one page of one type (or the additional addresses), and the Filter's
|
||||
// group titles reach Listing.Settings.
|
||||
func TestFilterExcludes(t *testing.T) {
|
||||
theFilter.mu.Lock()
|
||||
theFilter.batches = nil
|
||||
theFilter.mu.Unlock()
|
||||
e := bootIndex(t, filterPluginID)
|
||||
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
|
||||
contentStore.put(pageType, page1(1, "a", mod))
|
||||
contentStore.put(pageType, page1(2, "noindex-me", mod))
|
||||
contentStore.put(pageType, page1(3, "promo", mod))
|
||||
contentStore.put(offerType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/offers/x"}}})
|
||||
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/kontakt"},{"url":"/noindex-extra"}]' WHERE id = 1`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.rebuild(t)
|
||||
pages := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String())
|
||||
if strings.Join(pages, " ") != "https://api.example.test/pages/a https://api.example.test/kontakt" {
|
||||
t.Fatalf("pages file = %v", pages)
|
||||
}
|
||||
offers := locs(e.get(t, "/_sitemap/files/sitemap-offers-1.xml").Body.String())
|
||||
if strings.Join(offers, " ") != "https://api.example.test/pages/promo https://api.example.test/offers/x" {
|
||||
t.Fatalf("offers file = %v", offers)
|
||||
}
|
||||
var n int64
|
||||
e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_urls WHERE excluded_reason = 'noindex'`).Scan(&n)
|
||||
if n != 2 {
|
||||
t.Fatalf("%d rows left out as noindex, want 2", n)
|
||||
}
|
||||
theFilter.mu.Lock()
|
||||
batches := theFilter.batches
|
||||
theFilter.mu.Unlock()
|
||||
if len(batches) != 3 || len(batches[0]) != 3 || batches[0][0].TypeKey != pageType || batches[0][0].RecordID != "1" || batches[0][0].Locale != "en" ||
|
||||
batches[1][0].TypeKey != offerType || batches[2][0].TypeKey != "extra" || batches[2][0].URL != "/kontakt" {
|
||||
t.Fatalf("batches = %+v", batches)
|
||||
}
|
||||
|
||||
listing, ok := e.app.Lookup[contract.Listing]()
|
||||
if !ok {
|
||||
t.Fatal("the sitemap publishes no contract.Listing")
|
||||
}
|
||||
settings, err := listing.Settings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.SiteURL != "https://api.example.test" || settings.IndexURL != "https://api.example.test/sitemap.xml" || len(settings.Groups) != 2 {
|
||||
t.Fatalf("listing settings = %+v", settings)
|
||||
}
|
||||
offersGroup := settings.Groups[0]
|
||||
if offersGroup.Key != "offers" || offersGroup.Slug != "offers" || offersGroup.Count != 2 || offersGroup.Titles["pl"] != "Oferty" || settings.Groups[1].Key != "pages" || settings.Groups[1].Count != 2 {
|
||||
t.Fatalf("listing groups = %+v", settings.Groups)
|
||||
}
|
||||
s1 := e.call(t, http.MethodGet, "/golem15/sitemap/settings/partials/sitemap_files", e.admin(t, permSitemap), nil).Body.String()
|
||||
if !strings.Contains(s1, `{"text":"noindex"}`) || !strings.Contains(s1, `{"text":"2"}`) {
|
||||
t.Fatalf("S1 lacks the noindex count:\n%s", s1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoFilter pins D-95 without a Filter: everything published is listed.
|
||||
func TestNoFilter(t *testing.T) {
|
||||
e := bootIndex(t)
|
||||
if _, ok := e.app.Lookup[contract.Filter](); ok {
|
||||
t.Fatal("a Filter is published without the filter plugin")
|
||||
}
|
||||
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
|
||||
contentStore.put(pageType, page1(1, "noindex-me", mod))
|
||||
contentStore.put(pageType, page1(2, "promo", mod))
|
||||
e.rebuild(t)
|
||||
if got := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()); len(got) != 2 {
|
||||
t.Fatalf("without a Filter: %v", got)
|
||||
}
|
||||
if rec := e.get(t, "/_sitemap/files/sitemap-offers-1.xml"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("an offers file without a Filter: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListing pins contract.Listing.Entries: a group's listed addresses in
|
||||
// pages of Limit with Next and Total, absolute URLs with title and
|
||||
// lastmod, the locale filter keeping addresses without a locale, and an
|
||||
// unknown group empty.
|
||||
func TestListing(t *testing.T) {
|
||||
e := bootIndex(t)
|
||||
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
|
||||
for i := 1; i <= 5; i++ {
|
||||
contentStore.put(pageType, page1(i, string(rune('a'+i-1)), mod))
|
||||
}
|
||||
contentStore.put(pageType, fakeRecord{ID: 6, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/strony/f", Title: "F"}}})
|
||||
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/kontakt"}]' WHERE id = 1`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.rebuild(t)
|
||||
listing, ok := e.app.Lookup[contract.Listing]()
|
||||
if !ok {
|
||||
t.Fatal("no contract.Listing")
|
||||
}
|
||||
ctx := context.Background()
|
||||
var all []string
|
||||
after := ""
|
||||
for range 10 {
|
||||
page, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", After: after, Limit: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 7 {
|
||||
t.Fatalf("Total = %d", page.Total)
|
||||
}
|
||||
for _, en := range page.Entries {
|
||||
all = append(all, en.URL)
|
||||
}
|
||||
if page.Next == "" {
|
||||
break
|
||||
}
|
||||
after = page.Next
|
||||
}
|
||||
if len(all) != 7 || all[0] != "https://api.example.test/pages/a" || all[6] != "https://api.example.test/kontakt" {
|
||||
t.Fatalf("paged entries = %v", all)
|
||||
}
|
||||
first, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", Limit: 1})
|
||||
if err != nil || len(first.Entries) != 1 || first.Entries[0].Title != "Page a" || first.Entries[0].TypeKey != pageType || first.Entries[0].RecordID != "1" ||
|
||||
first.Entries[0].LastMod == nil || !first.Entries[0].LastMod.Equal(mod) || first.Next == "" {
|
||||
t.Fatalf("first entry = %+v %v", first, err)
|
||||
}
|
||||
pl, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", Locale: "pl"})
|
||||
if err != nil || pl.Total != 2 || pl.Entries[0].URL != "https://api.example.test/strony/f" || pl.Entries[1].URL != "https://api.example.test/kontakt" {
|
||||
t.Fatalf("pl entries = %+v %v", pl, err)
|
||||
}
|
||||
none, err := listing.Entries(ctx, contract.EntriesQuery{Group: "nope"})
|
||||
if err != nil || none.Total != 0 || len(none.Entries) != 0 || none.Next != "" {
|
||||
t.Fatalf("unknown group = %+v %v", none, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user