feat: Google News sitemap

- sitemap-news.xml lists the articles (content kind article) first
  published in the last two days, newest first, at most 1000, with the
  publication name (news_publication, else app.name), the language of the
  locale, the publication date and the title
- news_sources narrows it to type:<key> and category:<key>:<id> values;
  the Images and News tab lists the values to use in a partial
- the index lists the file while news_enabled is on; otherwise it is 404
This commit is contained in:
Jakub Zych
2026-11-04 12:22:40 +01:00
parent 5bf56fde57
commit a10ce2d9de
9 changed files with 604 additions and 5 deletions

239
classes/news.go Normal file
View File

@@ -0,0 +1,239 @@
package classes
import (
"bufio"
"encoding/xml"
"io"
"strings"
"time"
"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"
"gorm.io/gorm"
)
// NSNews is the namespace of the Google News sitemap.
const NSNews = "http://www.google.com/schemas/sitemap-news/0.9"
// NewsFile is the name of the News sitemap file.
const NewsFile = "sitemap-news.xml"
// The News sitemap's limits (RESEARCH Pitfall 9): articles first published
// in the last NewsWindow, at most MaxNewsEntries.
const (
NewsWindow = 48 * time.Hour
MaxNewsEntries = 1000
)
// NewsEntry is one <url> of the News sitemap: the article's absolute
// address, the publication name and language, its first publication and
// its title.
type NewsEntry struct {
Loc string
Publication string
Language string
Published time.Time
Title string
}
// The prefixes of the news_sources values (P33-15).
const (
NewsSourceType = models.NewsSourceType
NewsSourceCategory = models.NewsSourceCategory
)
// newsSources are the parsed news_sources values: the article types and
// the parents listed.
type newsSources struct {
types map[string]bool
parents map[models.Ref]bool
}
// parseNewsSources reads the news_sources values; a value of another form
// is ignored (the settings refuse it on save).
func parseNewsSources(values []string) newsSources {
out := newsSources{types: map[string]bool{}, parents: map[models.Ref]bool{}}
for _, v := range values {
switch {
case strings.HasPrefix(v, NewsSourceType):
if key := strings.TrimPrefix(v, NewsSourceType); key != "" {
out.types[key] = true
}
case strings.HasPrefix(v, NewsSourceCategory):
rest := strings.TrimPrefix(v, NewsSourceCategory)
if i := strings.LastIndex(rest, ":"); i > 0 && i < len(rest)-1 {
out.parents[models.Ref{Type: rest[:i], ID: rest[i+1:]}] = true
}
}
}
return out
}
// empty reports whether no source is listed: every article type is.
func (s newsSources) empty() bool { return len(s.types) == 0 && len(s.parents) == 0 }
// matches reports whether an article of typeKey with parents is listed:
// its type or one of its parents is (P33-15).
func (s newsSources) matches(typeKey string, parents []models.Ref) bool {
if s.empty() || s.types[typeKey] {
return true
}
for _, p := range parents {
if s.parents[p] {
return true
}
}
return false
}
// NewsLanguage is the News language code of a locale: the lowercase
// primary subtag (pl-PL: pl), except Chinese, which Google names zh-cn or
// zh-tw.
func NewsLanguage(locale string) string {
l := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(locale), "_", "-"))
if l == "zh-tw" || l == "zh-hant" || strings.HasPrefix(l, "zh-hant-") {
return "zh-tw"
}
if l == "zh" || strings.HasPrefix(l, "zh-") {
return "zh-cn"
}
if i := strings.IndexByte(l, '-'); i > 0 {
return l[:i]
}
return l
}
// NewsPublication is the publication name of the News sitemap: the
// news_publication setting, else the config key app.name.
func NewsPublication(app *backpack.App, s models.Settings) string {
if name := strings.TrimSpace(models.Text(s.NewsPublication)); name != "" {
return name
}
if app != nil && app.Config != nil {
return strings.TrimSpace(app.Config.String("app.name"))
}
return ""
}
// NewsEntries lists the News sitemap's articles (D-31, RESEARCH Pitfall 9):
// listed addresses of content types of kind article first published within
// NewsWindow before now, of the news_sources (every article type when none
// is listed), newest first, at most MaxNewsEntries. An address without a
// locale takes the default_locale setting, else app.locale.
func NewsEntries(db *gorm.DB, app *backpack.App, s models.Settings, now time.Time) ([]NewsEntry, error) {
sources := parseNewsSources(s.Sources())
q := db.Model(&models.URL{}).
Where("kind = ? AND excluded_reason = '' AND published_at IS NOT NULL AND published_at >= ? AND published_at <= ?", pact.ContentKindArticle, now.Add(-NewsWindow), now).
Order("published_at DESC, id DESC")
if sources.empty() {
q = q.Limit(MaxNewsEntries)
}
var rows []models.URL
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
site := SiteURL(app, s)
publication := NewsPublication(app, s)
fallback := models.Text(s.DefaultLocale)
if fallback == "" && app != nil && app.Config != nil {
fallback = app.Config.String("app.locale")
}
out := make([]NewsEntry, 0, min(len(rows), MaxNewsEntries))
for _, r := range rows {
if len(out) == MaxNewsEntries {
break
}
if !sources.matches(r.TypeKey, r.ParentRefs.Get()) {
continue
}
loc := Absolute(site, r.URL)
if loc == "" || r.PublishedAt == nil {
continue
}
locale := r.Locale
if locale == "" {
locale = fallback
}
title := strings.TrimSpace(r.Title)
if title == "" {
title = loc
}
out = append(out, NewsEntry{Loc: loc, Publication: publication, Language: NewsLanguage(locale), Published: r.PublishedAt.UTC(), Title: title})
}
return out, nil
}
// WriteNewsURLSet writes the News sitemap: a <url> per article with its
// <loc> and news:news (news:publication with news:name and news:language,
// news:publication_date and news:title).
func WriteNewsURLSet(w io.Writer, entries []NewsEntry) error {
bw := bufio.NewWriter(w)
bw.WriteString(xml.Header)
bw.WriteString(`<urlset xmlns="` + NSSitemap + `" xmlns:news="` + NSNews + `">`)
for _, e := range entries {
bw.WriteString("\n<url><loc>")
escape(bw, e.Loc)
bw.WriteString("</loc><news:news><news:publication><news:name>")
escape(bw, e.Publication)
bw.WriteString("</news:name><news:language>")
escape(bw, e.Language)
bw.WriteString("</news:language></news:publication><news:publication_date>")
bw.WriteString(lastmod(e.Published))
bw.WriteString("</news:publication_date><news:title>")
escape(bw, e.Title)
bw.WriteString("</news:title></news:news></url>")
}
bw.WriteString("\n</urlset>\n")
return bw.Flush()
}
// NewsSource is one value the news_sources setting may take, with what it
// lists: Type is the content type key, ID the category's record ID ("" for
// a whole type) and Title the category's title.
type NewsSource struct {
Value string `json:"value"`
Type string `json:"type"`
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
}
// maxNewsSourceCategories caps the categories NewsSources lists.
const maxNewsSourceCategories = 500
// NewsSources lists the values news_sources may take (P33-15): type:<key>
// for every content type of kind article, then category:<key>:<id> for
// every parent the listed articles name (their categories), with its title
// from the slot table (the default locale's first), at most 500.
func NewsSources(db *gorm.DB, reg *signpost.Registry, defaultLocale string) ([]NewsSource, error) {
out := []NewsSource{}
if reg != nil {
for _, typ := range reg.ContentTypes() {
if typ.Content != nil && typ.Content.Kind == pact.ContentKindArticle {
out = append(out, NewsSource{Value: NewsSourceType + typ.Key, Type: typ.Key})
}
}
}
var rows []struct {
Type string `gorm:"column:type"`
ID string `gorm:"column:id"`
Title string `gorm:"column:title"`
}
if err := db.Raw(`SELECT DISTINCT ON (p.type, p.id) p.type, p.id, COALESCE(c.title, '') AS title
FROM (SELECT DISTINCT r->>'type' AS type, r->>'id' AS id
FROM golem15_sitemap_urls u, jsonb_array_elements(u.parent_refs) r
WHERE u.kind = ? AND u.excluded_reason = '') p
LEFT JOIN golem15_sitemap_urls c ON c.type_key = p.type AND c.record_id = p.id
ORDER BY p.type, p.id, (c.locale = ?) DESC, c.id
LIMIT ?`, pact.ContentKindArticle, defaultLocale, maxNewsSourceCategories).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
v := NewsSourceCategory + r.Type + ":" + r.ID
if models.ValidNewsSource(v) {
out = append(out, NewsSource{Value: v, Type: r.Type, ID: r.ID, Title: r.Title})
}
}
return out, nil
}

View File

@@ -12,6 +12,7 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
@@ -129,9 +130,76 @@ type reasonRow struct {
// and the index note; files.empty before the first build and
// files.building while a rebuild asked for from the admin runs.
func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
if name != "sitemap_files" {
return nil, nil
switch name {
case "sitemap_files":
return c.filesData(ctx)
case "news_sources":
return c.newsSourcesData(ctx)
}
return nil, nil
}
// sourceRow is one row of the news_sources partial: the value to enter and
// what it lists.
type sourceRow struct {
Value string
Label string
}
// newsSourcesView is the view model of the news_sources partial.
type newsSourcesView struct {
Rows []sourceRow
}
// newsSourcesData lists the values the news_sources taglist takes
// (classes.NewsSources): each article type, then the categories its
// listed articles name, labelled "<type>: <title>".
func (c settingsAdminController) newsSourcesData(ctx context.Context) (any, error) {
app := appOf(c.app)
db, err := database(ctx, c.app)
if err != nil {
return nil, err
}
settings, err := classes.LoadSettings(db)
if err != nil {
return nil, err
}
var reg *signpost.Registry
if app != nil {
reg, _ = app.Lookup[*signpost.Registry]()
}
sources, err := classes.NewsSources(db, reg, models.Text(settings.DefaultLocale))
if err != nil {
return nil, err
}
tr := translator(c.app)
label := func(key string) string {
if reg != nil {
if typ, ok := reg.ContentType(key); ok && typ.Label != "" {
return Phrase(ctx, tr, typ.Label, nil)
}
}
return key
}
view := newsSourcesView{}
for _, src := range sources {
row := sourceRow{Value: src.Value}
if src.ID == "" {
row.Label = Phrase(ctx, tr, "golem15.sitemap::lang.news.source_type", map[string]string{"type": label(src.Type)})
} else {
title := src.Title
if title == "" {
title = src.ID
}
row.Label = label(src.Type) + ": " + title
}
view.Rows = append(view.Rows, row)
}
return view, nil
}
// filesData is the view of the S1 files partial.
func (c settingsAdminController) filesData(ctx context.Context) (any, error) {
app := appOf(c.app)
db, err := database(ctx, c.app)
if err != nil {

View File

@@ -0,0 +1,19 @@
{{- if .Data.Rows -}}
<section>
<h4>{{ trans "golem15.sitemap::lang.news.sources" }}</h4>
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.sitemap::lang.news.source_value" }}</th><th>{{ trans "golem15.sitemap::lang.news.source_label" }}</th></tr></thead>
<tbody>
{{- range .Data.Rows -}}
<tr><td><code>{{ .Value }}</code></td><td>{{ .Label }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
</section>
{{- else -}}
<div class="summer-callout" role="status">
<p class="summer-callout__text">{{ trans "golem15.sitemap::lang.news.none" }}</p>
</div>
{{- end -}}

View File

@@ -12,6 +12,8 @@ errors:
html_path: Start the address with / and use a path only, e.g. /mapa-strony.
page_size: Enter a whole number from 50 to 500.
lastmod: 'Enter a date as YYYY-MM-DD, e.g. 2026-10-28, or leave it empty.'
news_publication: Enter at most 255 characters, without line breaks.
news_source: 'Use the values listed under the field: type:<type key> or category:<type key>:<id>.'
settings:
label: Sitemaps
description: XML sitemaps per content type, images, News, IndexNow and the HTML sitemap.
@@ -69,6 +71,12 @@ files:
index_note: Search engines read the index at :url, and robots.txt points to it. The files are rebuilt after content changes and every night; saving this form rebuilds them now.
empty: No files yet. They are built within a few minutes after the first save.
building: Rebuilding now. Reload the page in a minute.
news:
sources: Values for Types and categories
source_value: Value
source_label: Articles
source_type: 'Every article of :type'
none: No content type publishes articles yet.
indexnow:
key: 'Key: :key, served at :url'
no_key: A key is generated when you turn IndexNow on and save.

View File

@@ -12,6 +12,8 @@ errors:
html_path: Zacznij adres od / i użyj samej ścieżki, np. /mapa-strony.
page_size: Wpisz liczbę całkowitą od 50 do 500.
lastmod: 'Wpisz datę w formacie RRRR-MM-DD, np. 2026-10-28, albo zostaw puste.'
news_publication: Wpisz najwyżej 255 znaków, bez podziału wiersza.
news_source: 'Użyj wartości z listy pod polem: type:<klucz typu> albo category:<klucz typu>:<id>.'
settings:
label: Mapy witryny
description: Mapy XML według typów treści, obrazy, News, IndexNow i mapa HTML.
@@ -69,6 +71,12 @@ files:
index_note: Wyszukiwarki czytają indeks pod :url, a robots.txt go wskazuje. Pliki są odbudowywane po zmianach treści i co noc; zapisanie tego formularza odbudowuje je od razu.
empty: Nie ma jeszcze plików. Powstaną w ciągu kilku minut po pierwszym zapisie.
building: Trwa odbudowa. Odśwież stronę za minutę.
news:
sources: Wartości pola Typy i kategorie
source_value: Wartość
source_label: Artykuły
source_type: 'Wszystkie artykuły: :type'
none: Żaden typ treści nie publikuje jeszcze artykułów.
indexnow:
key: 'Klucz: :key, udostępniany pod :url'
no_key: Klucz zostanie wygenerowany po włączeniu IndexNow i zapisie.

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"}
return []string{"site_url", "file_size", "extra_urls", "images_enabled", "news_enabled", "news_publication", "news_sources"}
}
// Rules is empty: the settings controller runs Validate before a save.
@@ -103,8 +103,13 @@ const (
errFileSize = "golem15.sitemap::lang.errors.file_size"
errURLScheme = "golem15.sitemap::lang.errors.url_scheme"
errLastmod = "golem15.sitemap::lang.errors.lastmod"
errNewsName = "golem15.sitemap::lang.errors.news_publication"
errNewsSrc = "golem15.sitemap::lang.errors.news_source"
)
// MaxNewsPublication is the longest publication name.
const MaxNewsPublication = 255
// Validate answers every problem Problems finds as a validation error on
// its field; the settings controller calls it before a save, so the save
// answers 422 and the row is left as it was.
@@ -140,9 +145,52 @@ func (s Settings) Problems() map[string][]string {
out["extra_urls."+strconv.Itoa(i)+".lastmod"] = []string{errLastmod}
}
}
if name := Text(s.NewsPublication); utf8.RuneCountInString(name) > MaxNewsPublication || strings.ContainsFunc(name, unicode.IsControl) {
out["news_publication"] = []string{errNewsName}
}
for _, v := range s.NewsSources.Get() {
if !ValidNewsSource(strings.TrimSpace(v)) {
out["news_sources"] = []string{errNewsSrc}
break
}
}
return out
}
// The prefixes of the news_sources values (P33-15): type:<type key> and
// category:<type key>:<id>.
const (
NewsSourceType = "type:"
NewsSourceCategory = "category:"
)
// ValidNewsSource reports whether v is a news_sources value:
// type:<type key> or category:<type key>:<id>, each part 1 to 128 letters,
// digits, '-', '_' or '.'.
func ValidNewsSource(v string) bool {
switch {
case strings.HasPrefix(v, NewsSourceType):
return validSourcePart(strings.TrimPrefix(v, NewsSourceType))
case strings.HasPrefix(v, NewsSourceCategory):
rest := strings.TrimPrefix(v, NewsSourceCategory)
i := strings.LastIndex(rest, ":")
return i > 0 && validSourcePart(rest[:i]) && validSourcePart(rest[i+1:])
}
return false
}
func validSourcePart(s string) bool {
if s == "" || len(s) > 128 {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
return false
}
}
return true
}
// AllowedFrontendURL reports whether u may be the site address: the empty
// string, or an absolute http or https URL with a host and without
// whitespace, control characters, a double quote, <, > or &. The value

View File

@@ -48,3 +48,54 @@ fields:
comment: golem15.sitemap::lang.settings.extra_url_lastmod_comment
type: text
span: right
images_enabled:
label: golem15.sitemap::lang.settings.images_enabled
comment: golem15.sitemap::lang.settings.images_enabled_comment
type: switch
default: true
span: full
tab: golem15.sitemap::lang.settings.tab_media
news_enabled:
label: golem15.sitemap::lang.settings.news_enabled
comment: golem15.sitemap::lang.settings.news_enabled_comment
type: switch
default: false
span: full
tab: golem15.sitemap::lang.settings.tab_media
news_publication:
label: golem15.sitemap::lang.settings.news_publication
comment: golem15.sitemap::lang.settings.news_publication_comment
type: text
span: left
tab: golem15.sitemap::lang.settings.tab_media
trigger:
action: hide
field: news_enabled
condition: unchecked
# Taglist options are a fixed list, so the article types and their
# categories are listed by the news_sources partial below instead.
news_sources:
label: golem15.sitemap::lang.settings.news_sources
comment: golem15.sitemap::lang.settings.news_sources_comment
type: taglist
mode: array
span: right
tab: golem15.sitemap::lang.settings.tab_media
trigger:
action: hide
field: news_enabled
condition: unchecked
_news_sources:
type: partial
path: news_sources
span: full
tab: golem15.sitemap::lang.settings.tab_media
trigger:
action: hide
field: news_enabled
condition: unchecked

View File

@@ -35,7 +35,7 @@ const FilesPath = "/_sitemap/files/"
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-[a-z0-9-]+-[0-9]+\.xml`)
r.Where("file", `sitemap-(news|[a-z0-9-]+-[0-9]+)\.xml`)
return nil
}
@@ -111,10 +111,22 @@ GROUP BY image_file_no ORDER BY image_file_no`).Scan(&images).Error; err != nil
}
rows = append(rows, images...)
}
files := make([]classes.IndexFile, 0, len(rows))
files := make([]classes.IndexFile, 0, len(rows)+1)
for _, row := range rows {
files = append(files, classes.IndexFile{Loc: site + "/" + classes.FileName(row.Grp, row.FileNo), LastMod: row.LastMod})
}
if settings.NewsEnabled {
news, err := classes.NewsEntries(db, p.app, settings, time.Now().UTC())
if err != nil {
pluginLog.Error("golem15.sitemap: news index", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(news) > 0 {
newest := news[0].Published
files = append(files, classes.IndexFile{Loc: site + "/" + classes.NewsFile, LastMod: &newest})
}
}
var buf bytes.Buffer
if err := classes.WriteIndex(&buf, files); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -127,6 +139,10 @@ GROUP BY image_file_no ORDER BY image_file_no`).Scan(&images).Error; err != nil
// first seen. An unknown file, or one without a listed address, is a 404.
// sitemap-images-<n>.xml is an image sitemap file (imageFile).
func (p *Plugin) sitemapFile(w http.ResponseWriter, r *http.Request) {
if r.PathValue("file") == classes.NewsFile {
p.newsFile(w, r)
return
}
grp, n, ok := classes.ParseFileName(r.PathValue("file"))
if !ok {
http.NotFound(w, r)
@@ -223,6 +239,39 @@ func (p *Plugin) imageFile(w http.ResponseWriter, r *http.Request, db *gorm.DB,
writeXML(w, buf.Bytes(), true)
}
// newsFile serves sitemap-news.xml (D-31): the articles of the last two
// days (classes.NewsEntries). It is a 404 while the News sitemap is off.
func (p *Plugin) newsFile(w http.ResponseWriter, r *http.Request) {
db, err := database(p.app)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
db = db.WithContext(r.Context())
settings, err := classes.LoadSettings(db)
if err != nil {
pluginLog.Error("golem15.sitemap: load settings", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !settings.NewsEnabled || classes.SiteURL(p.app, settings) == "" {
http.NotFound(w, r)
return
}
entries, err := classes.NewsEntries(db, p.app, settings, time.Now().UTC())
if err != nil {
pluginLog.Error("golem15.sitemap: news file", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := classes.WriteNewsURLSet(&buf, entries); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
writeXML(w, buf.Bytes(), true)
}
// writeXML answers 200 with an XML body. A file carries X-Robots-Tag
// noindex, so search engines read it without indexing it as a page.
func writeXML(w http.ResponseWriter, body []byte, noindex bool) {

View File

@@ -181,3 +181,112 @@ func TestImageSitemap(t *testing.T) {
t.Errorf("image file with the image sitemap off: %d", rec.Code)
}
}
// newsLocs lists the <loc> values of a News sitemap.
func newsLocs(body string) []string { return locs(body) }
// TestNewsSitemap pins D-31's News sitemap (RESEARCH Pitfall 9): with
// news_enabled the index lists sitemap-news.xml with the articles first
// published in the last two days (1 and 47 hours ago, not 49), newest
// first, never a record of another kind; each with the publication name
// (news_publication, else app.name), the language of its locale, the
// publication date and the title; news_sources narrows it to the listed
// types and categories; more than 1000 candidates list the newest 1000;
// the settings form refuses a malformed source and lists the values to
// use; news_enabled off takes the file out of the index and answers 404.
func TestNewsSitemap(t *testing.T) {
e := bootJournal(t)
now := time.Now().UTC().Truncate(time.Second)
fresh := post(1, "fresh", "pl", now.Add(-time.Hour))
fresh.Locales[0].Title = `Grzyby & "huby"`
fresh.Parents = []pact.ContentRef{{Type: categoryType, ID: "3"}}
contentStore.put(postType, fresh)
contentStore.put(postType, post(2, "older", "en", now.Add(-47*time.Hour)))
contentStore.put(postType, post(3, "stale", "en", now.Add(-49*time.Hour)))
cat := now.Add(-time.Hour)
contentStore.put(categoryType, fakeRecord{ID: 3, Published: true, LastMod: cat, PublishedAt: &cat, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/kategoria/lecznicze", Title: "Lecznicze"}}})
e.rebuild(t)
// Off by default: not in the index, the file is a 404.
if index := e.get(t, "/sitemap.xml").Body.String(); strings.Contains(index, "sitemap-news.xml") {
t.Fatalf("index with the News sitemap off:\n%s", index)
}
if rec := e.get(t, "/_sitemap/files/sitemap-news.xml"); rec.Code != http.StatusNotFound {
t.Fatalf("News file while off: %d", rec.Code)
}
// The settings form: the Images and News tab, a malformed source is
// refused, the partial lists the values.
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{`"images_enabled"`, `"news_enabled"`, `"news_publication"`, `"news_sources"`, "Images and News"} {
if !strings.Contains(schema, want) {
t.Errorf("form schema lacks %s", want)
}
}
for _, bad := range []any{[]string{"posts"}, []string{"category:acme-category"}, []string{"type:a b"}} {
if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"news_sources": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"news_sources"`) {
t.Errorf("news_sources %v: %d %s", bad, rec.Code, rec.Body.String())
}
}
rec := e.call(t, http.MethodGet, base+"/partials/news_sources", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "type:acme-post") || !strings.Contains(rec.Body.String(), "category:acme-category:3") || !strings.Contains(rec.Body.String(), "Categories: Lecznicze") {
t.Fatalf("news_sources partial: %d %s", rec.Code, rec.Body.String())
}
if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"news_enabled": true, "news_sources": []string{}}); rec.Code != http.StatusOK {
t.Fatalf("enable News: %d %s", rec.Code, rec.Body.String())
}
index := e.get(t, "/sitemap.xml").Body.String()
if !strings.Contains(index, "<loc>https://api.example.test/sitemap-news.xml</loc>") {
t.Fatalf("index lacks the News file:\n%s", index)
}
rec = e.get(t, "/_sitemap/files/sitemap-news.xml")
body := rec.Body.String()
if rec.Code != http.StatusOK || rec.Header().Get("X-Robots-Tag") != "noindex" || !strings.Contains(body, `xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"`) {
t.Fatalf("News file: %d %v\n%s", rec.Code, rec.Header(), body)
}
if got := newsLocs(body); strings.Join(got, " ") != "https://api.example.test/blog/fresh https://api.example.test/blog/older" {
t.Fatalf("News articles = %v\n%s", got, body)
}
wantFresh := "<url><loc>https://api.example.test/blog/fresh</loc><news:news><news:publication><news:name>sitemap-smoke</news:name><news:language>pl</news:language></news:publication><news:publication_date>" +
now.Add(-time.Hour).Format(time.RFC3339) + "</news:publication_date><news:title>Grzyby &amp; &#34;huby&#34;</news:title></news:news></url>"
if !strings.Contains(body, wantFresh) {
t.Fatalf("News entry of the fresh post:\n%s\nwant %s", body, wantFresh)
}
// The publication name setting and the sources.
e.exec(t, `UPDATE golem15_sitemap_settings SET news_publication = 'Gazeta', news_sources = '["category:acme-category:3"]' WHERE id = 1`)
body = e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String()
if got := newsLocs(body); strings.Join(got, " ") != "https://api.example.test/blog/fresh" || !strings.Contains(body, "<news:name>Gazeta</news:name>") {
t.Fatalf("News with a category source = %v\n%s", got, body)
}
e.exec(t, `UPDATE golem15_sitemap_settings SET news_sources = '["type:acme-post"]' WHERE id = 1`)
if got := newsLocs(e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String()); len(got) != 2 {
t.Fatalf("News with a type source = %v", got)
}
e.exec(t, `UPDATE golem15_sitemap_settings SET news_sources = '["type:acme-category"]' WHERE id = 1`)
if got := newsLocs(e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String()); len(got) != 0 {
t.Fatalf("a collection type in the News sitemap = %v", got)
}
// More than 1000 candidates: the newest 1000.
e.exec(t, `UPDATE golem15_sitemap_settings SET news_sources = '[]' WHERE id = 1`)
for i := 0; i < 1005; i++ {
contentStore.put(postType, post(100+i, fmt.Sprintf("bulk-%d", i), "en", now.Add(-2*time.Hour-time.Duration(i)*time.Minute)))
}
e.rebuild(t)
got := newsLocs(e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String())
if len(got) != 1000 || got[0] != "https://api.example.test/blog/fresh" || got[999] != "https://api.example.test/blog/bulk-998" {
t.Fatalf("News with 1007 candidates lists %d, first %v last %v", len(got), got[:1], got[len(got)-1:])
}
e.exec(t, `UPDATE golem15_sitemap_settings SET news_enabled = FALSE WHERE id = 1`)
if index := e.get(t, "/sitemap.xml").Body.String(); strings.Contains(index, "sitemap-news.xml") {
t.Fatalf("index after turning News off:\n%s", index)
}
if rec := e.get(t, "/_sitemap/files/sitemap-news.xml"); rec.Code != http.StatusNotFound {
t.Fatalf("News file after turning it off: %d", rec.Code)
}
}