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

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