diff --git a/classes/htmlgroups.go b/classes/htmlgroups.go new file mode 100644 index 0000000..d38c194 --- /dev/null +++ b/classes/htmlgroups.go @@ -0,0 +1,456 @@ +package classes + +import ( + "context" + "encoding/json" + "slices" + "strings" + "time" + + "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/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/signpost" + "git.golem15.com/golem15/summercms/modules/towel" + "gorm.io/gorm" +) + +// HTMLEntry is one link of the HTML sitemap: the address's title (its +// path when it has none) and absolute URL; Children nest a category tree. +type HTMLEntry struct { + Title string `json:"title"` + URL string `json:"url"` + Children []HTMLEntry `json:"children,omitempty"` +} + +// HTMLGroup is one group of the HTML sitemap overview: Key and Slug name +// the file group (the group page is addressed by Slug), Title is its +// heading in the requested locale, Total its addresses, Entries the first +// page of them and More is true when the group has more than one page. +type HTMLGroup struct { + Key string `json:"key"` + Slug string `json:"slug"` + Title string `json:"title"` + Total int `json:"total"` + More bool `json:"more"` + Entries []HTMLEntry `json:"entries"` +} + +// HTMLOverview is the answer of GET /_sitemap/api/v1/html: the locale and +// the non-empty groups in the HTML sitemap order. +type HTMLOverview struct { + Locale string `json:"locale"` + PageSize int `json:"page_size"` + Groups []HTMLGroup `json:"groups"` +} + +// HTMLGroupPage is the answer of GET /_sitemap/api/v1/html/{group}: one +// page (from 1) of a group's links out of Pages. +type HTMLGroupPage struct { + Group string `json:"group"` + Key string `json:"key"` + Title string `json:"title"` + Locale string `json:"locale"` + Page int `json:"page"` + Pages int `json:"pages"` + Total int `json:"total"` + Entries []HTMLEntry `json:"entries"` +} + +// htmlGroupOrder is the order of the groups the HTML sitemap knows by +// name (UI-SPEC L3): pages, offers and services, categories, posts, tags, +// authors. The groups of other content types follow in registration order, +// then any other group (a Filter's) alphabetically. +var htmlGroupOrder = []string{"pages", "offers", "services", "categories", "posts", "tags", "authors"} + +// maxTreeRows bounds the rows a category tree is built from; a larger +// group is listed alphabetically. +const maxTreeRows = 10000 + +// The orders of a group's links. +const ( + orderTitle = "title" // alphabetical by title + orderNewest = "newest" // newest first (articles) + orderTree = "tree" // tree order (collections with parents) +) + +// htmlRow is what the HTML sitemap reads of a slot table row. +type htmlRow struct { + ID uint64 `gorm:"column:id"` + Grp string `gorm:"column:grp"` + TypeKey string `gorm:"column:type_key"` + RecordID string `gorm:"column:record_id"` + URL string `gorm:"column:url"` + Title string `gorm:"column:title"` + PublishedAt *time.Time `gorm:"column:published_at"` + ParentRefs string `gorm:"column:parent_refs"` +} + +// htmlGroupInfo is one group of the HTML sitemap before its links are +// read: its slug, title, total and order. +type htmlGroupInfo struct { + slug string + title string + total int + order string +} + +// HTMLSitemap reads the HTML sitemap (D-34): the listed addresses of the +// XML sitemaps (never an excluded one, D-24) of locale and those without +// one, by group. locale "" means the default_locale setting (no locale +// filter when that is empty too). +type HTMLSitemap struct { + App *backpack.App + DB *gorm.DB +} + +// scope is the listed rows of locale. +func (h HTMLSitemap) scope(locale string) *gorm.DB { + q := h.DB.Table("golem15_sitemap_urls").Where("excluded_reason = ''") + if locale != "" { + q = q.Where("locale = ? OR locale = ''", locale) + } + return q +} + +// locale is the locale the HTML sitemap filters by. +func (h HTMLSitemap) locale(s models.Settings, locale string) string { + if locale = strings.TrimSpace(locale); locale != "" { + return locale + } + return models.Text(s.DefaultLocale) +} + +// groups lists the groups with listed rows of locale in the HTML sitemap +// order, with their titles in locale and their orders. +func (h HTMLSitemap) groups(ctx context.Context, s models.Settings, locale string) ([]htmlGroupInfo, error) { + var counts []struct { + Grp string `gorm:"column:grp"` + N int `gorm:"column:n"` + Articles int `gorm:"column:articles"` + Nested int `gorm:"column:nested"` + } + if err := h.scope(locale).Select(`grp, COUNT(*) AS n, +COUNT(*) FILTER (WHERE kind = ?) AS articles, +COUNT(*) FILTER (WHERE kind = ? AND jsonb_array_length(parent_refs) > 0) AS nested`, pact.ContentKindArticle, pact.ContentKindCollection). + Group("grp").Scan(&counts).Error; err != nil { + return nil, err + } + titles, labels, typeOrder, err := groupTitles(ctx, h.App, h.DB) + if err != nil { + return nil, err + } + def := models.Text(s.DefaultLocale) + out := make([]htmlGroupInfo, 0, len(counts)) + for _, c := range counts { + if c.N == 0 { + continue + } + g := htmlGroupInfo{slug: c.Grp, total: c.N, order: orderTitle} + switch { + case c.Articles == c.N: + g.order = orderNewest + case c.Nested > 0 && c.N <= maxTreeRows: + g.order = orderTree + } + g.title = pickTitle(titles[c.Grp], locale, def) + if g.title == "" { + if label := labels[c.Grp]; label != "" { + g.title = translateIn(ctx, h.App, locale, label) + } + } + if g.title == "" { + g.title = c.Grp + } + out = append(out, g) + } + rank := func(slug string) (int, int) { + if i := slices.Index(htmlGroupOrder, slug); i >= 0 { + return 0, i + } + if i, ok := typeOrder[slug]; ok { + return 1, i + } + return 2, 0 + } + slices.SortStableFunc(out, func(a, b htmlGroupInfo) int { + ta, ia := rank(a.slug) + tb, ib := rank(b.slug) + if ta != tb { + return ta - tb + } + if ia != ib { + return ia - ib + } + return strings.Compare(a.slug, b.slug) + }) + return out, nil +} + +// pickTitle is the title of locale, else of the default locale, else "". +func pickTitle(titles map[string]string, locale, def string) string { + if t := strings.TrimSpace(titles[locale]); t != "" { + return t + } + if t := strings.TrimSpace(titles[def]); t != "" { + return t + } + return "" +} + +// translateIn translates a phrase key (a content type label) in locale; +// without a translator, or for a plain label, it is returned as is. +func translateIn(ctx context.Context, app *backpack.App, locale, key string) string { + if app == nil { + return key + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok || tr == nil { + return key + } + if locale != "" { + ctx = towel.WithLocale(ctx, locale) + } + return tr.Get(ctx, key, nil) +} + +// groupTitles collects the group headings (by group slug, per locale): +// the published Filter's group titles, overridden by the content types' +// saved HTML sitemap headings; the content types' labels by slug; and the +// content types' registration order by slug. +func groupTitles(ctx context.Context, app *backpack.App, db *gorm.DB) (titles map[string]map[string]string, labels map[string]string, order map[string]int, err error) { + titles, labels, order = map[string]map[string]string{}, map[string]string{}, map[string]int{} + if app == nil { + return titles, labels, order, nil + } + if filter, ok := app.Lookup[contract.Filter](); ok && filter != nil { + groups, err := filter.Groups(ctx) + if err != nil { + return nil, nil, nil, err + } + for _, g := range groups { + if len(g.Titles) > 0 { + titles[g.Key] = g.Titles + } + } + } + if reg, ok := app.Lookup[*signpost.Registry](); ok && reg != nil { + rows, err := LoadTypeSettings(db) + if err != nil { + return nil, nil, nil, err + } + for i, typ := range reg.ContentTypes() { + cfg := EffectiveType(typ, rows) + if len(cfg.Titles) > 0 { + titles[cfg.Slug] = cfg.Titles + } + if _, seen := labels[cfg.Slug]; !seen { + labels[cfg.Slug] = typ.Label + order[cfg.Slug] = i + } + } + } + return titles, labels, order, nil +} + +// entry is one link with what ordering and the tree need. +type entry struct { + HTMLEntry + node models.Ref + parent *models.Ref +} + +// rows reads the rows of one group in its order: newest first for +// articles, alphabetical by title otherwise (a tree is ordered later). +// offset and limit page the read; limit < 0 reads every row. +func (h HTMLSitemap) rows(g htmlGroupInfo, locale string, offset, limit int) ([]htmlRow, error) { + q := h.scope(locale).Select("id, grp, type_key, record_id, url, title, published_at, parent_refs::text AS parent_refs").Where("grp = ?", g.slug) + switch g.order { + case orderNewest: + q = q.Order("published_at DESC NULLS LAST, first_seen DESC, id DESC") + default: + q = q.Order("lower(COALESCE(NULLIF(title, ''), url)) ASC, id ASC") + } + if limit >= 0 { + q = q.Offset(offset).Limit(limit) + } + var out []htmlRow + return out, q.Scan(&out).Error +} + +// toEntries turns rows into links on site; a row whose address cannot be +// made absolute is skipped. +func toEntries(rows []htmlRow, site string) []entry { + out := make([]entry, 0, len(rows)) + for _, r := range rows { + u := Absolute(site, r.URL) + if u == "" { + continue + } + title := strings.TrimSpace(r.Title) + if title == "" { + title = r.URL + } + e := entry{HTMLEntry: HTMLEntry{Title: title, URL: u}, node: models.Ref{Type: r.TypeKey, ID: r.RecordID}} + var parents []models.Ref + if r.ParentRefs != "" { + _ = json.Unmarshal([]byte(r.ParentRefs), &parents) + } + if len(parents) > 0 { + e.parent = &parents[0] + } + out = append(out, e) + } + return out +} + +// treeOrder orders entries as a tree (alphabetical siblings, depth first) +// and returns them flat with each entry's parent index in the result (-1 +// for a root). An entry whose first parent is not in the group, or that +// would close a cycle, is a root. +func treeOrder(entries []entry) ([]entry, []int) { + index := make(map[models.Ref]int, len(entries)) + for i, e := range entries { + index[e.node] = i + } + children := map[int][]int{} + var roots []int + for i, e := range entries { + if e.parent != nil { + if p, ok := index[*e.parent]; ok && p != i { + children[p] = append(children[p], i) + continue + } + } + roots = append(roots, i) + } + flat := make([]entry, 0, len(entries)) + parents := make([]int, 0, len(entries)) + visited := make([]bool, len(entries)) + var walk func(i, parent int) + walk = func(i, parent int) { + if visited[i] { + return + } + visited[i] = true + at := len(flat) + flat = append(flat, entries[i]) + parents = append(parents, parent) + for _, c := range children[i] { + walk(c, at) + } + } + for _, r := range roots { + walk(r, -1) + } + // Entries only reachable through a cycle are roots. + for i := range entries { + walk(i, -1) + } + return flat, parents +} + +// nest builds the links of flat[start:end] (tree order, parents indices +// into flat): an entry whose parent is on the same page is its child, +// every other is a top-level link. +func nest(flat []entry, parents []int, start, end int) []HTMLEntry { + nodes := make([]*HTMLEntry, end-start) + for i := start; i < end; i++ { + e := flat[i].HTMLEntry + e.Children = nil + nodes[i-start] = &e + } + // Children are attached deepest first so a parent copies its finished + // children. + for i := end - 1; i >= start; i-- { + p := parents[i] + if p >= start && p < end { + parent := nodes[p-start] + parent.Children = append([]HTMLEntry{*nodes[i-start]}, parent.Children...) + nodes[i-start] = nil + } + } + out := []HTMLEntry{} + for _, n := range nodes { + if n != nil { + out = append(out, *n) + } + } + return out +} + +// page reads page n (from 1) of group g: its links and the number of +// pages. +func (h HTMLSitemap) page(g htmlGroupInfo, locale, site string, size, n int) ([]HTMLEntry, int, error) { + pages := (g.total + size - 1) / size + if g.order == orderTree { + rows, err := h.rows(g, locale, 0, -1) + if err != nil { + return nil, 0, err + } + flat, parents := treeOrder(toEntries(rows, site)) + pages = (len(flat) + size - 1) / size + start := (n - 1) * size + if start >= len(flat) { + return []HTMLEntry{}, pages, nil + } + return nest(flat, parents, start, min(start+size, len(flat))), pages, nil + } + rows, err := h.rows(g, locale, (n-1)*size, size) + if err != nil { + return nil, 0, err + } + entries := toEntries(rows, site) + out := make([]HTMLEntry, 0, len(entries)) + for _, e := range entries { + out = append(out, e.HTMLEntry) + } + return out, pages, nil +} + +// Overview answers the HTML sitemap overview of locale: every non-empty +// group in order with its first html_page_size links and more set when it +// has more. +func (h HTMLSitemap) Overview(ctx context.Context, s models.Settings, locale string) (HTMLOverview, error) { + locale = h.locale(s, locale) + size := s.EffectiveHTMLPageSize() + out := HTMLOverview{Locale: locale, PageSize: size, Groups: []HTMLGroup{}} + groups, err := h.groups(ctx, s, locale) + if err != nil { + return out, err + } + site := SiteURL(h.App, s) + for _, g := range groups { + entries, pages, err := h.page(g, locale, site, size, 1) + if err != nil { + return out, err + } + if len(entries) == 0 { + continue + } + out.Groups = append(out.Groups, HTMLGroup{Key: g.slug, Slug: g.slug, Title: g.title, Total: g.total, More: pages > 1, Entries: entries}) + } + return out, nil +} + +// Group answers page n of the group addressed by slug in locale; ok is +// false for an unknown or empty group and a page past the last. +func (h HTMLSitemap) Group(ctx context.Context, s models.Settings, locale, slug string, n int) (HTMLGroupPage, bool, error) { + locale = h.locale(s, locale) + groups, err := h.groups(ctx, s, locale) + if err != nil || n < 1 { + return HTMLGroupPage{}, false, err + } + i := slices.IndexFunc(groups, func(g htmlGroupInfo) bool { return g.slug == slug }) + if i < 0 { + return HTMLGroupPage{}, false, nil + } + g := groups[i] + entries, pages, err := h.page(g, locale, SiteURL(h.App, s), s.EffectiveHTMLPageSize(), n) + if err != nil || n > pages || len(entries) == 0 { + return HTMLGroupPage{}, false, err + } + return HTMLGroupPage{Group: g.slug, Key: g.slug, Title: g.title, Locale: locale, Page: n, Pages: pages, Total: g.total, Entries: entries}, true, nil +} diff --git a/classes/listing.go b/classes/listing.go index faddacf..2493792 100644 --- a/classes/listing.go +++ b/classes/listing.go @@ -8,7 +8,6 @@ import ( "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" ) @@ -40,9 +39,10 @@ func (l Listing) db(ctx context.Context) (*gorm.DB, error) { } // 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. +// 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), the HTML sitemap settings and the +// IndexNow key file name (while IndexNow is on with a key). func (l Listing) Settings(ctx context.Context) (contract.ListingSettings, error) { var out contract.ListingSettings db, err := l.db(ctx) @@ -65,34 +65,18 @@ func (l Listing) Settings(ctx context.Context) (contract.ListingSettings, error) 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 - } - } + titles, _, _, err := groupTitles(ctx, l.App, db) + if err != nil { + return out, err } 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}) } + out.HTMLEnabled = settings.HTMLEnabled + out.HTMLPaths = settings.EffectiveHTMLPaths() + out.HTMLPageSize = settings.EffectiveHTMLPageSize() + out.KeyFile = KeyFileName(ActiveKey(settings)) return out, nil } diff --git a/classes/settings.go b/classes/settings.go index 3b23aa6..97017a2 100644 --- a/classes/settings.go +++ b/classes/settings.go @@ -1,6 +1,7 @@ package classes import ( + "encoding/json" "strings" "git.golem15.com/golem15/sm-sitemap-plugin/models" @@ -59,3 +60,39 @@ func Absolute(site, u string) string { } return "" } + +// MergeHTMLPaths merges submitted HTML sitemap addresses (locale to path) +// into the stored ones: a sent locale replaces its address, a blank one +// removes it, the others keep theirs. problem is true when a sent address +// is not a models.ValidHTMLPath; nothing is merged then. +func MergeHTMLPaths(db *gorm.DB, sent map[string]string) (map[string]string, bool) { + settings, err := LoadSettings(db) + if err != nil { + return nil, true + } + merged := settings.EffectiveHTMLPaths() + for locale, path := range sent { + locale, path = strings.TrimSpace(locale), strings.TrimSpace(path) + if locale == "" { + continue + } + if path == "" { + delete(merged, locale) + continue + } + if !models.ValidHTMLPath(path) { + return nil, true + } + merged[locale] = path + } + return merged, false +} + +// StoreHTMLPaths writes the HTML sitemap addresses to settings row 1. +func StoreHTMLPaths(db *gorm.DB, paths map[string]string) error { + raw, err := json.Marshal(paths) + if err != nil { + return err + } + return db.Exec(`UPDATE golem15_sitemap_settings SET html_paths = ?::jsonb WHERE id = 1`, string(raw)).Error +} diff --git a/contract/contract.go b/contract/contract.go index 198690f..fe7b398 100644 --- a/contract/contract.go +++ b/contract/contract.go @@ -92,8 +92,10 @@ type Listing interface { // none is configured), IndexURL the absolute address of the sitemap index, // DefaultLocale the site's default locale ("" when not set) and Groups the // file groups with listed addresses. HTMLEnabled, HTMLPaths (locale to -// path), HTMLPageSize and KeyFile (the IndexNow key file name) describe the -// HTML sitemap and IndexNow; they stay zero until those settings exist. +// path) and HTMLPageSize are the HTML sitemap's settings (the sitemap +// serves its data at /_sitemap/api/v1/html); KeyFile is the IndexNow key +// file name (.txt, served at /_sitemap/indexnow/.txt for the +// site root), "" while IndexNow is off. type ListingSettings struct { SiteURL string IndexURL string diff --git a/controllers/api/html.go b/controllers/api/html.go new file mode 100644 index 0000000..0e87bdf --- /dev/null +++ b/controllers/api/html.go @@ -0,0 +1,116 @@ +package api + +import ( + "net/http" + "regexp" + "strconv" + + "git.golem15.com/golem15/sm-sitemap-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// GroupPattern is the {group} constraint of the HTML sitemap group route: +// a file group slug. +const GroupPattern = `[a-z0-9][a-z0-9-]{0,63}` + +// localePattern is the form a locale query value must have. +var localePattern = regexp.MustCompile(`^[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*$`) + +// pagePattern is the form of the page query value: 1 to 6 digits. +var pagePattern = regexp.MustCompile(`^[0-9]{1,6}$`) + +// htmlRequest checks the shared parts of an HTML sitemap request and +// returns the settings and database, or answers 404 (the HTML sitemap is +// off, a malformed locale) or 500 itself. +func htmlRequest(app func() *backpack.App, w http.ResponseWriter, r *http.Request) (classes.HTMLSitemap, bool) { + w.Header().Set("X-Robots-Tag", "noindex") + w.Header().Set("Cache-Control", "no-cache, private") + a := app() + if a == nil { + http.NotFound(w, r) + return classes.HTMLSitemap{}, false + } + if loc := r.URL.Query().Get("locale"); loc != "" && !localePattern.MatchString(loc) { + http.NotFound(w, r) + return classes.HTMLSitemap{}, false + } + db, ok := a.Lookup[*gorm.DB]() + if !ok || db == nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return classes.HTMLSitemap{}, false + } + return classes.HTMLSitemap{App: a, DB: db.WithContext(r.Context())}, true +} + +// HTMLOverview serves GET /_sitemap/api/v1/html?locale=: the HTML sitemap +// overview (D-34; classes.HTMLSitemap.Overview). It lists exactly the +// addresses of the XML sitemaps and answers 404 while the HTML sitemap is +// off. Every answer carries X-Robots-Tag: noindex. +func HTMLOverview(app func() *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h, ok := htmlRequest(app, w, r) + if !ok { + return + } + settings, err := classes.LoadSettings(h.DB) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !settings.HTMLEnabled { + http.NotFound(w, r) + return + } + out, err := h.Overview(r.Context(), settings, r.URL.Query().Get("locale")) + if err != nil { + log.Error("golem15.sitemap: html overview", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + wire.WriteJSON(w, http.StatusOK, out) + } +} + +// HTMLGroup serves GET /_sitemap/api/v1/html/{group}?locale=&page=: one +// page (from 1, the default) of a group (classes.HTMLSitemap.Group). An +// unknown group, a page past the last, a malformed page or the HTML +// sitemap off answer 404. +func HTMLGroup(app func() *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h, ok := htmlRequest(app, w, r) + if !ok { + return + } + page := 1 + if raw := r.URL.Query().Get("page"); raw != "" { + n, err := strconv.Atoi(raw) + if !pagePattern.MatchString(raw) || err != nil || n < 1 { + http.NotFound(w, r) + return + } + page = n + } + settings, err := classes.LoadSettings(h.DB) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !settings.HTMLEnabled { + http.NotFound(w, r) + return + } + out, found, err := h.Group(r.Context(), settings, r.URL.Query().Get("locale"), r.PathValue("group"), page) + if err != nil { + log.Error("golem15.sitemap: html group", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + wire.WriteJSON(w, http.StatusOK, out) + } +} diff --git a/controllers/api/logger.go b/controllers/api/logger.go new file mode 100644 index 0000000..e18266a --- /dev/null +++ b/controllers/api/logger.go @@ -0,0 +1,7 @@ +package api + +import "git.golem15.com/golem15/summercms/modules/diary" + +// log is the handlers' logger: its records carry component=sitemap, like +// the rest of the plugin (log.components.sitemap). +var log = diary.For("sitemap") diff --git a/extensions_settings.go b/extensions_settings.go index f3e1d93..c9315c4 100644 --- a/extensions_settings.go +++ b/extensions_settings.go @@ -20,12 +20,19 @@ import ( "gorm.io/gorm/clause" ) -// The settings form's tab keys: the Content types tab follows Files. +// The settings form's tab keys: the Content types tab follows Files; the +// HTML sitemap tab's address field is added after html_enabled. const ( TabFiles = "golem15.sitemap::lang.settings.tab_files" TabTypes = "golem15.sitemap::lang.settings.tab_types" + TabHTML = "golem15.sitemap::lang.settings.tab_html" ) +// htmlPathField is the HTML sitemap address field: translatable, stored +// as a locale map in html_paths by the plugin's form extension (the +// settings row has no translation writer of its own). +const htmlPathField = "html_path" + // The phrase keys of the Content types refusals (UI-SPEC C26). const ( errSlug = "golem15.sitemap::lang.errors.slug" @@ -40,17 +47,13 @@ func typeFieldKey(key string) string { return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_") } -// AdminActionExtensions adds the Content types tab to the plugin's own -// settings singleton: one block per signpost content type, in registration -// order. Nothing is added when no content type is registered. +// AdminActionExtensions extends the plugin's own settings singleton: the +// Content types tab (one block per signpost content type, in registration +// order; none without content types) and the HTML sitemap address. func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { - reg := p.registry() - if reg == nil { - return nil - } - types := reg.ContentTypes() - if len(types) == 0 { - return nil + var types []pact.MenuItemType + if reg := p.registry(); reg != nil { + types = reg.ContentTypes() } form := p.typeSettingsTab(types) if form == nil { @@ -67,9 +70,10 @@ func (p *Plugin) registry() *signpost.Registry { return reg } -// typeSettingsDocument is the fields document of the Content types tab -// (UI-SPEC C26): per type, the include switch (labelled with the type's -// label), the file name and the HTML sitemap heading. +// typeSettingsDocument is the fields document of the extension (UI-SPEC +// C26): per type, the include switch (labelled with the type's label), the +// file name and the HTML sitemap heading on the Content types tab, then +// the HTML sitemap address after html_enabled. func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) { fields := yaml.MapSlice{} for _, typ := range types { @@ -101,15 +105,24 @@ func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) { }}, ) } - return yaml.Marshal(yaml.MapSlice{ - {Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}}, - {Key: "fields", Value: fields}, - }) + fields = append(fields, yaml.MapItem{Key: htmlPathField, Value: yaml.MapSlice{ + {Key: "label", Value: "golem15.sitemap::lang.settings.html_path"}, + {Key: "comment", Value: "golem15.sitemap::lang.settings.html_path_comment"}, + {Key: "type", Value: "mltext"}, + {Key: "span", Value: "left"}, + {Key: "tab", Value: TabHTML}, + {Key: "after", Value: "html_enabled"}, + }}) + doc := yaml.MapSlice{} + if len(types) > 0 { + doc = append(doc, yaml.MapItem{Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}}) + } + return yaml.Marshal(append(doc, yaml.MapItem{Key: "fields", Value: fields})) } // typeSettingsTab is the plugin's form extension on its own settings // controller: the Content types tab, loaded from and saved to -// golem15_sitemap_types. +// golem15_sitemap_types, and the HTML sitemap address (html_paths). func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExtension { doc, err := typeSettingsDocument(types) if err != nil { @@ -138,10 +151,18 @@ func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExten } out[base+"title"] = titles } + settings, err := classes.LoadSettings(in.DB.WithContext(ctx)) + if err != nil { + return nil, err + } + out[htmlPathField] = settings.EffectiveHTMLPaths() return out, nil }, Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { - return pact.AdminFormSaveResult{}, saveTypeSettings(ctx, in, types) + if err := saveTypeSettings(ctx, in, types); err != nil { + return pact.AdminFormSaveResult{}, err + } + return pact.AdminFormSaveResult{}, saveHTMLPaths(in) }, } } @@ -212,6 +233,32 @@ func saveTypeSettings(ctx context.Context, in pact.AdminFormSave, types []pact.M return nil } +// saveHTMLPaths stores a submitted HTML sitemap address: the locales sent +// replace theirs in html_paths (a blank one removes it), the others keep +// theirs. An address that models.ValidHTMLPath refuses is refused with +// errors.html_path. +func saveHTMLPaths(in pact.AdminFormSave) error { + raw, sent := in.Values[htmlPathField] + if !sent { + return nil + } + paths := map[string]string{} + switch v := raw.(type) { + case map[string]string: + paths = v + case map[string]any: + for k, s := range v { + text, _ := s.(string) + paths[k] = text + } + } + merged, problem := classes.MergeHTMLPaths(in.DB, paths) + if problem { + return pact.AdminFormErrors{htmlPathField: {models.HTMLPathProblem}} + } + return classes.StoreHTMLPaths(in.DB, merged) +} + // titlesOf reads a submitted translatable value (a map of locale to text); // blank texts are dropped. func titlesOf(v any) map[string]string { diff --git a/models/settings.go b/models/settings.go index 5a6cfae..3657559 100644 --- a/models/settings.go +++ b/models/settings.go @@ -92,7 +92,7 @@ func (Settings) TableName() string { return "golem15_sitemap_settings" } // Fillable lists the columns the settings form edits. func (Settings) Fillable() []string { - return []string{"site_url", "file_size", "extra_urls", "images_enabled", "news_enabled", "news_publication", "news_sources", "indexnow_enabled"} + return []string{"site_url", "file_size", "extra_urls", "images_enabled", "news_enabled", "news_publication", "news_sources", "indexnow_enabled", "html_enabled", "html_page_size"} } // Rules is empty: the settings controller runs Validate before a save. @@ -105,6 +105,8 @@ const ( errLastmod = "golem15.sitemap::lang.errors.lastmod" errNewsName = "golem15.sitemap::lang.errors.news_publication" errNewsSrc = "golem15.sitemap::lang.errors.news_source" + errHTMLPath = "golem15.sitemap::lang.errors.html_path" + errPageSize = "golem15.sitemap::lang.errors.page_size" ) // MaxNewsPublication is the longest publication name. @@ -128,7 +130,9 @@ func (s Settings) Validate() error { // Problems lists the settings' refusals by field (UI-SPEC C26): a site // address that AllowedFrontendURL refuses, a file size outside 1..MaxFileSize and an additional // address that is not a path or an http(s) URL (extra_urls..url) or -// whose lastmod is not a date (extra_urls..lastmod). +// whose lastmod is not a date (extra_urls..lastmod), a publication name +// over 255 characters, a malformed News source, an HTML sitemap address +// that ValidHTMLPath refuses (html_path) and a page size outside 50..500. func (s Settings) Problems() map[string][]string { out := map[string][]string{} if !AllowedFrontendURL(Text(s.SiteURL)) { @@ -154,9 +158,42 @@ func (s Settings) Problems() map[string][]string { break } } + for _, p := range s.HTMLPaths.Get() { + if p = strings.TrimSpace(p); p != "" && !ValidHTMLPath(p) { + out["html_path"] = []string{errHTMLPath} + break + } + } + if s.HTMLPageSize < MinHTMLPageSize || s.HTMLPageSize > MaxHTMLPageSize { + out["html_page_size"] = []string{errPageSize} + } return out } +// HTMLPathProblem is the refusal of an HTML sitemap address (errors.html_path). +const HTMLPathProblem = errHTMLPath + +// ValidHTMLPath reports whether p may be the HTML sitemap's address: a +// path that starts with a single /, at most 255 characters of letters, +// digits and - _ . ~ / (no query, fragment, "//" or ".." segment), and not +// the site root. +func ValidHTMLPath(p string) bool { + if len(p) < 2 || len(p) > 255 || p[0] != '/' || strings.Contains(p, "//") { + return false + } + for _, seg := range strings.Split(p[1:], "/") { + if seg == "." || seg == ".." { + return false + } + } + for _, r := range p { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("-_.~/", r)) { + return false + } + } + return true +} + // The prefixes of the news_sources values (P33-15): type: and // category::. const ( diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 6592787..50d9869 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -127,3 +127,21 @@ fields: action: hide field: indexnow_enabled condition: unchecked + + # The address per locale (html_path, an mltext field) is added after + # this switch by the plugin's form extension, stored in html_paths. + html_enabled: + label: golem15.sitemap::lang.settings.html_enabled + comment: golem15.sitemap::lang.settings.html_enabled_comment + type: switch + default: true + span: full + tab: golem15.sitemap::lang.settings.tab_html + + html_page_size: + label: golem15.sitemap::lang.settings.html_page_size + comment: golem15.sitemap::lang.settings.html_page_size_comment + type: number + default: 150 + span: right + tab: golem15.sitemap::lang.settings.tab_html diff --git a/models/settings_test.go b/models/settings_test.go index 85903f2..06ab332 100644 --- a/models/settings_test.go +++ b/models/settings_test.go @@ -75,6 +75,9 @@ func TestSettingsValidate(t *testing.T) { if s.FileSize == 0 { s.FileSize = 1000 } + if s.HTMLPageSize == 0 { + s.HTMLPageSize = models.DefaultHTMLPageSize + } return s } extra := func(urls ...string) lagoon.Jsonable[[]models.ExtraURL] { @@ -88,8 +91,8 @@ func TestSettingsValidate(t *testing.T) { "null site_url": ok(models.Settings{}), "empty site_url": ok(models.Settings{SiteURL: text("")}), "site_url": ok(models.Settings{SiteURL: text("https://www.ex.pl/")}), - "file size 1": {FileSize: 1}, - "file size 50 000": {FileSize: 50000}, + "file size 1": ok(models.Settings{FileSize: 1}), + "file size 50 000": ok(models.Settings{FileSize: 50000}), "extra addresses": ok(models.Settings{ExtraURLs: extra("/", "/o-nas?x=1&y=2", "https://www.ex.pl/kontakt")}), } { if err := s.Validate(); err != nil { @@ -103,8 +106,8 @@ func TestSettingsValidate(t *testing.T) { "javascript site_url": {ok(models.Settings{SiteURL: text("javascript:alert(document.domain)//")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}}, "attribute site_url": {ok(models.Settings{SiteURL: text(`https://x"/>-.xml); the site's SEO layer or proxy @@ -33,36 +37,49 @@ const FilesPath = "/_sitemap/files/" // (one file) and GET /_sitemap/indexnow/{file} (the IndexNow key file) // without the backend middleware: search engines fetch them anonymously. // The sitemaps are served from the slot table, never generated per -// request, and every route is throttled per client IP (BucketSitemap). +// request, and every route is throttled per client IP (BucketSitemap). GET +// /_sitemap/api/v1/html and /_sitemap/api/v1/html/{group} answer the HTML +// sitemap's data (BucketHTML). func (p *Plugin) Routes(r pact.Router) error { r.Get("/sitemap.xml", p.sitemapIndex, "throttle:"+BucketSitemap) r.Get("/_sitemap/files/{file}", p.sitemapFile, "throttle:"+BucketSitemap) r.Where("file", `sitemap-(news|[a-z0-9-]+-[0-9]+)\.xml`) r.Get("/_sitemap/indexnow/{file}", apicontrollers.KeyFile(p.booted), "throttle:"+BucketSitemap) r.Where("file", apicontrollers.KeyFilePattern) + // The HTML sitemap's data (D-34), read by the site's server when it + // renders the HTML sitemap page; public, noindex, per-IP bucket + // sitemap-html. + r.Get("/_sitemap/api/v1/html", apicontrollers.HTMLOverview(p.booted), "throttle:"+BucketHTML) + r.Get("/_sitemap/api/v1/html/{group}", apicontrollers.HTMLGroup(p.booted), "throttle:"+BucketHTML) + r.Where("group", apicontrollers.GroupPattern) return nil } // booted returns the booted application. func (p *Plugin) booted() *backpack.App { return p.app } -// Buckets registers sitemap-xml: 60 requests a minute per client IP (behind -// the configured trusted proxies). 429 bodies are surf's -// {"message":"Too Many Attempts."}. +// Buckets registers sitemap-xml (60 requests a minute) and sitemap-html +// (300 a minute: the site's server renders every HTML sitemap page from +// it) per client IP, behind the configured trusted proxies. 429 bodies are +// surf's {"message":"Too Many Attempts."}. func (p *Plugin) Buckets() map[string]surf.Bucket { trusted := surf.TrustedProxies(nil) if p.app != nil { trusted = surf.TrustedProxies(p.app.Config) } - return map[string]surf.Bucket{ - BucketSitemap: { - Name: BucketSitemap, - Max: 60, + bucket := func(name string, max int) surf.Bucket { + return surf.Bucket{ + Name: name, + Max: max, Decay: time.Minute, Key: func(r *http.Request) string { - return "sitemap-xml|" + surf.ClientIP(r, trusted) + return name + "|" + surf.ClientIP(r, trusted) }, - }, + } + } + return map[string]surf.Bucket{ + BucketSitemap: bucket(BucketSitemap, 60), + BucketHTML: bucket(BucketHTML, 300), } } diff --git a/sitemap_extras_test.go b/sitemap_extras_test.go index 00324d6..93f0d98 100644 --- a/sitemap_extras_test.go +++ b/sitemap_extras_test.go @@ -13,6 +13,7 @@ import ( "time" "git.golem15.com/golem15/sm-sitemap-plugin/classes" + "git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/fetchguard" @@ -536,3 +537,192 @@ func TestIndexNowStaging(t *testing.T) { t.Fatalf("key file outside production: %d", rec.Code) } } + +// htmlJSON fetches an HTML sitemap API path and decodes its answer. +func (e *sitemapEnv) htmlJSON(t *testing.T, path string, status int) map[string]any { + t.Helper() + rec := e.get(t, path) + if rec.Code != status { + t.Fatalf("GET %s: %d, want %d: %s", path, rec.Code, status, rec.Body.String()) + } + if rec.Header().Get("X-Robots-Tag") != "noindex" { + t.Errorf("GET %s lacks X-Robots-Tag noindex: %v", path, rec.Header()) + } + out := map[string]any{} + if status == http.StatusOK { + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + } + return out +} + +// titlesOfEntries lists the titles of decoded entries, children in +// brackets. +func titlesOfEntries(v any) string { + list, _ := v.([]any) + var parts []string + for _, item := range list { + e, _ := item.(map[string]any) + s, _ := e["title"].(string) + if kids, ok := e["children"]; ok { + s += "[" + titlesOfEntries(kids) + "]" + } + parts = append(parts, s) + } + return strings.Join(parts, ", ") +} + +// TestHTMLGroups pins D-34's HTML sitemap API: the overview of a locale +// lists the groups in the L3 order (pages, offers, categories, posts) with +// titles in the locale, categories nested by parent, posts newest first, +// others alphabetical, the first html_page_size links and more when there +// are more; a group page pages the rest; past the last page, an unknown +// group and the HTML sitemap off are 404; excluded addresses never appear; +// every answer is noindex. The settings tab validates its values and +// contract.Listing answers the HTML sitemap and key file settings. +func TestHTMLGroups(t *testing.T) { + contentStore.reset(t) + e := bootPlugins(t, []string{contentPluginID, journalPluginID, filterPluginID, "golem15.sitemap"}) + at := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + pl := func(id int, url, title string) fakeRecord { + return fakeRecord{ID: id, Published: true, LastMod: at, Locales: []pact.ContentLocale{{Locale: "pl", URL: url, Title: title}}} + } + contentStore.put(pageType, pl(1, "/pages/o-nas", "O nas")) + contentStore.put(pageType, pl(2, "/pages/kontakt", "Kontakt")) + contentStore.put(pageType, pl(3, "/pages/noindex-me", "Ukryta")) + contentStore.put(pageType, fakeRecord{ID: 4, Published: true, LastMod: at, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/about", Title: "About"}}}) + contentStore.put(offerType, pl(1, "/offers/zestaw", "Zestaw")) + cat := func(id int, slug, title string, parent int) fakeRecord { + r := pl(id, "/kategoria/"+slug, title) + if parent > 0 { + r.Parents = []pact.ContentRef{{Type: categoryType, ID: fmt.Sprint(parent)}} + } + return r + } + contentStore.put(categoryType, cat(1, "grzyby", "Grzyby", 0)) + contentStore.put(categoryType, cat(2, "lecznicze", "Lecznicze", 1)) + contentStore.put(categoryType, cat(3, "jadalne", "Jadalne", 1)) + contentStore.put(categoryType, cat(4, "inne", "Inne", 0)) + for i := 1; i <= 160; i++ { + contentStore.put(postType, post(i, fmt.Sprintf("p%03d", i), "pl", at.Add(time.Duration(i)*time.Hour))) + } + e.exec(t, `INSERT INTO golem15_sitemap_types (type_key, include, slug, titles, updated_at) VALUES (?, TRUE, '', '{"pl":"Wpisy","en":"Posts"}', NOW())`, postType) + e.rebuild(t) + + ov := e.htmlJSON(t, "/_sitemap/api/v1/html?locale=pl", http.StatusOK) + groups, _ := ov["groups"].([]any) + var order []string + byKey := map[string]map[string]any{} + for _, g := range groups { + m := g.(map[string]any) + order = append(order, m["slug"].(string)) + byKey[m["slug"].(string)] = m + } + if strings.Join(order, " ") != "pages offers categories posts" { + t.Fatalf("group order = %v\n%v", order, ov) + } + if got := titlesOfEntries(byKey["pages"]["entries"]); got != "Kontakt, O nas" { + t.Errorf("pages = %s", got) + } + if got := titlesOfEntries(byKey["categories"]["entries"]); got != "Grzyby[Jadalne, Lecznicze], Inne" { + t.Errorf("categories tree = %s", got) + } + posts := byKey["posts"] + entries, _ := posts["entries"].([]any) + if posts["title"] != "Wpisy" || posts["total"] != float64(160) || posts["more"] != true || len(entries) != 150 { + t.Fatalf("posts group = title %v total %v more %v entries %d", posts["title"], posts["total"], posts["more"], len(entries)) + } + if first := entries[0].(map[string]any); first["title"] != "Post p160" || first["url"] != "https://api.example.test/blog/p160" { + t.Errorf("newest post first: %v", first) + } + if byKey["categories"]["title"] != "Categories" || byKey["pages"]["more"] != false { + t.Errorf("categories title %v, pages more %v", byKey["categories"]["title"], byKey["pages"]["more"]) + } + all := fmt.Sprint(ov) + for _, banned := range []string{"Ukryta", "noindex-me", "About"} { + if strings.Contains(all, banned) { + t.Errorf("the pl overview lists %q", banned) + } + } + if en := fmt.Sprint(e.htmlJSON(t, "/_sitemap/api/v1/html?locale=en", http.StatusOK)); !strings.Contains(en, "About") || strings.Contains(en, "Kontakt") { + t.Errorf("the en overview: %s", en) + } + + page2 := e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl&page=2", http.StatusOK) + if p2, _ := page2["entries"].([]any); page2["page"] != float64(2) || page2["pages"] != float64(2) || page2["group"] != "posts" || page2["title"] != "Wpisy" || len(p2) != 10 { + t.Fatalf("posts page 2 = %v", page2) + } + if got := titlesOfEntries(page2["entries"]); !strings.HasPrefix(got, "Post p010, Post p009") || !strings.HasSuffix(got, "Post p001") { + t.Errorf("posts page 2 = %s", got) + } + if p1 := e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl", http.StatusOK); p1["page"] != float64(1) { + t.Errorf("posts without page = %v", p1["page"]) + } + if got := titlesOfEntries(e.htmlJSON(t, "/_sitemap/api/v1/html/categories?locale=pl", http.StatusOK)["entries"]); got != "Grzyby[Jadalne, Lecznicze], Inne" { + t.Errorf("categories page = %s", got) + } + for _, path := range []string{ + "/_sitemap/api/v1/html/posts?locale=pl&page=3", + "/_sitemap/api/v1/html/posts?locale=pl&page=0", + "/_sitemap/api/v1/html/posts?locale=pl&page=x", + "/_sitemap/api/v1/html/unknown?locale=pl", + "/_sitemap/api/v1/html/offers?locale=en", + "/_sitemap/api/v1/html?locale=%3Cx%3E", + } { + e.htmlJSON(t, path, http.StatusNotFound) + } + + // The settings tab. + token := e.admin(t, permSitemap) + const base = "/golem15/sitemap/settings" + schema := e.call(t, http.MethodGet, base+"/schema/form", token, nil).Body.String() + for _, want := range []string{`"html_enabled"`, `"html_path"`, `"html_page_size"`, "HTML sitemap"} { + if !strings.Contains(schema, want) { + t.Errorf("form schema lacks %s", want) + } + } + for _, c := range []struct { + body map[string]any + field string + }{ + {map[string]any{"html_page_size": 49}, "html_page_size"}, + {map[string]any{"html_page_size": 501}, "html_page_size"}, + {map[string]any{"html_path": "mapa"}, "html_path"}, + {map[string]any{"html_path": "/mapa?x=1"}, "html_path"}, + {map[string]any{"html_path": "//evil.test"}, "html_path"}, + } { + if rec := e.call(t, http.MethodPut, base+"/1", token, c.body); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) { + t.Errorf("%v: %d %s", c.body, rec.Code, rec.Body.String()) + } + } + listing, ok := e.app.Lookup[contract.Listing]() + if !ok { + t.Fatal("no contract.Listing") + } + ls, err := listing.Settings(t.Context()) + if err != nil { + t.Fatal(err) + } + if !ls.HTMLEnabled || ls.HTMLPageSize != 150 || ls.HTMLPaths["pl"] != "/mapa-strony" || ls.HTMLPaths["en"] != "/sitemap" || ls.KeyFile != "" { + t.Fatalf("listing settings = %+v", ls) + } + if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"html_path": "/site-map", "html_page_size": 100}); rec.Code != http.StatusOK { + t.Fatalf("save the HTML tab: %d %s", rec.Code, rec.Body.String()) + } + e.exec(t, `UPDATE golem15_sitemap_settings SET indexnow_enabled = TRUE, indexnow_key = '0123456789abcdef0123456789abcdef' WHERE id = 1`) + ls, _ = listing.Settings(t.Context()) + if ls.HTMLPageSize != 100 || ls.HTMLPaths["en"] != "/site-map" || ls.HTMLPaths["pl"] != "/mapa-strony" || ls.KeyFile != "0123456789abcdef0123456789abcdef.txt" { + t.Fatalf("listing settings after the save = %+v", ls) + } + if p := e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl&page=2", http.StatusOK); p["pages"] != float64(2) || len(p["entries"].([]any)) != 60 { + t.Errorf("posts page 2 at size 100 = pages %v", p["pages"]) + } + + e.call(t, http.MethodPut, base+"/1", token, map[string]any{"html_enabled": false}) + e.htmlJSON(t, "/_sitemap/api/v1/html?locale=pl", http.StatusNotFound) + e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl", http.StatusNotFound) + if ls, _ := listing.Settings(t.Context()); ls.HTMLEnabled { + t.Error("listing says the HTML sitemap is on") + } +}