feat: HTML sitemap groups API

- GET /_sitemap/api/v1/html?locale= answers the overview: the listed
  addresses of the XML sitemaps by group, in the order pages, offers,
  services, categories (as a tree), posts (newest first), tags, authors,
  other content types, each with its title in the locale, total, the
  first html_page_size links and more
- GET /_sitemap/api/v1/html/{group}?locale=&page= answers one page of a
  group; past the last page, an unknown group or the HTML sitemap off
  answer 404; every answer is noindex; per-IP bucket sitemap-html
- the HTML sitemap tab: html_enabled, the address per locale (html_paths)
  and html_page_size 50 to 500, with errors.html_path and errors.page_size
- contract.Listing.Settings answers HTMLEnabled, HTMLPaths, HTMLPageSize
  and KeyFile
This commit is contained in:
Jakub Zych
2026-11-04 12:32:47 +01:00
parent e02d0b173f
commit 8f4ff2a97d
12 changed files with 981 additions and 67 deletions

View File

@@ -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.<i>.url) or
// whose lastmod is not a date (extra_urls.<i>.lastmod).
// whose lastmod is not a date (extra_urls.<i>.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:<type key> and
// category:<type key>:<id>.
const (