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 }