Files
khosra/internal/web/feed.go
T
bdeshi 62e31215ec serve Atom feeds for the site, a section and a tag
Membership is a publication date, not a type declaration (ADR-0043). The parked
feed shape said "every type declared primary", which would have made feeds wait on
declared types a third time — but the thing that distinguishes a feed item is
already on disk. Pages and section landings drop out because they have no date,
which is the right reason. The parked idea stays parked with a sharper trigger:
someone wanting a *dated* bundle kept out.

Built with encoding/xml from typed structs, never a template: XML in html/template
is escaping for the wrong grammar, and that is a correctness trap rather than a
matter of taste.

A bug the evidence found, older than feeds: content.URL("", lang) built "//", so a
whole-site feed's id and alternate link were https://khosra.example// — every
entry identity wrong in every reader. The root is "/" now, with a test, and the
hand-built "/" the resolver carried for the same reason can follow later.

Two counters re-scoped rather than incremented, the same way transforms was:

Views now counts *per-bundle selection* — the thing architecture.md means by the
View layer, still at zero consumers. Output formats are not it: HTML, sitemap XML
and Atom are three functions with nothing to share, so an interface over them
would have one member and no leverage.

Effects stays at 1. A feed is generated per request like the sitemap, so it is not
a second Effect and the runner is not yet due — the next thing that writes files
off the request path is.
2026-08-01 02:23:36 +06:00

126 lines
4.0 KiB
Go

package web
import (
"encoding/xml"
"log/slog"
"net/http"
"time"
"khosra/internal/content"
)
// feedFile is the name a feed answers at, in whatever scope precedes it.
const feedFile = "feed.xml"
// feedMax is how many entries a feed carries. A reader wants the recent past, not the archive, and the
// archive is what the paginated listings are for.
const feedMax = 20
// atom is the document, as Atom (RFC 4287) rather than RSS: it is the stricter specification, it carries a
// language per entry, and every reader accepts it.
//
// Marshalled from structs with encoding/xml, never a template. XML in html/template is escaping for the wrong
// grammar, which is a correctness trap rather than a matter of taste (ADR-0043).
type atom struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
Lang string `xml:"xml:lang,attr"`
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Links []atomLink `xml:"link"`
Entries []atomEntry `xml:"entry"`
}
type atomLink struct {
Rel string `xml:"rel,attr"`
Type string `xml:"type,attr,omitempty"`
Href string `xml:"href,attr"`
}
type atomEntry struct {
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Links []atomLink `xml:"link"`
}
// serveFeed answers a feed for whatever scope the request named, reporting whether it handled the request.
//
// Absolute URLs are not optional in a feed: an entry's identity has to mean the same thing in a reader that
// has never seen the site, so without a declared base the honest answer is that this file does not exist
// (ADR-0039, ADR-0043).
func serveFeed(w http.ResponseWriter, req *http.Request, site *content.Site, res resolution, settings content.Settings) bool {
if settings.Base == "" {
slog.Warn("no feed: the site declares no base URL", "file", content.SettingsFile)
return false
}
items := dated(site.Run(content.Query{Section: res.key, Tag: res.tag, Lang: res.lang}))
if len(items) == 0 {
return false
}
if len(items) > feedMax {
items = items[:feedMax]
}
self := content.URL(res.key, res.lang) + feedFile
if res.tag != "" {
self = content.TagURL(res.key, res.tag, res.lang, 1) + feedFile
}
page := content.Absolute(settings.Base, content.PageURL(res.key, res.lang, 1))
doc := atom{
Lang: res.lang,
Title: feedTitle(settings, res),
ID: page,
Updated: items[0].Date.UTC().Format(time.RFC3339),
Links: []atomLink{
{Rel: "self", Type: "application/atom+xml", Href: content.Absolute(settings.Base, self)},
{Rel: "alternate", Type: "text/html", Href: page},
},
}
for _, b := range items {
link := content.Absolute(settings.Base, content.URL(b.Route, b.Lang))
doc.Entries = append(doc.Entries, atomEntry{
Title: b.Title,
ID: link,
Updated: b.Date.UTC().Format(time.RFC3339),
Links: []atomLink{{Rel: "alternate", Type: "text/html", Href: link}},
})
}
out, err := xml.MarshalIndent(doc, "", " ")
if err != nil {
slog.Error("feed failed", "scope", res.key, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return true
}
writeAs(w, "application/atom+xml; charset=utf-8", append([]byte(xml.Header), out...), "feed")
return true
}
// dated keeps the bundles a feed is for.
//
// A publication date is what makes something an item in a feed, so a page, a colophon or a section landing
// drops out because it has no date rather than because a declaration excluded it (ADR-0043).
func dated(all []content.Bundle) []content.Bundle {
kept := make([]content.Bundle, 0, len(all))
for _, b := range all {
if !b.Date.IsZero() {
kept = append(kept, b)
}
}
return kept
}
// feedTitle names the scope, falling back to the site's own address when nothing is declared.
func feedTitle(settings content.Settings, res resolution) string {
title := settings.Title
if title == "" {
title = settings.Base
}
switch {
case res.tag != "":
return title + " · #" + res.tag
case res.key != "":
return title + " · " + res.key
}
return title
}