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.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
func feedHandler(t *testing.T, settings content.Settings) http.Handler {
|
||||
t.Helper()
|
||||
fsys := fstest.MapFS{
|
||||
"content/posts/newer.md": {Data: []byte("---\ntitle: Newer\ndate: 2026-03-08\ntags: [monsoon]\n---\nx\n")},
|
||||
"content/posts/older.md": {Data: []byte("---\ntitle: Older\ndate: 2026-02-01\n---\nx\n")},
|
||||
"content/posts/newer.bn.md": {Data: []byte("---\ntitle: নতুন\ndate: 2026-03-08\n---\nx\n")},
|
||||
"content/comics/strip.md": {Data: []byte("---\ntitle: Strip\ndate: 2026-01-15\ntags: [monsoon]\n---\nx\n")},
|
||||
"content/pages/colophon.md": {Data: []byte("---\ntitle: Colophon\n---\nno date at all\n")},
|
||||
"content/posts/renamed.md": {Data: []byte("---\ntitle: Renamed\ndate: 2026-03-01\nslug: ekti\n---\nx\n")},
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, settings, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, settings)
|
||||
}
|
||||
|
||||
func fetchFeed(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, atom) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
var doc atom
|
||||
if rec.Code == http.StatusOK {
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("GET %s did not produce parseable XML: %v\n%s", path, err, rec.Body.String())
|
||||
}
|
||||
}
|
||||
return rec, doc
|
||||
}
|
||||
|
||||
func TestTheFeedCarriesDatedBundlesNewestFirst(t *testing.T) {
|
||||
h := feedHandler(t, content.Settings{Base: "https://khosra.example", Title: "Khosra"})
|
||||
rec, doc := fetchFeed(t, h, "/feed.xml")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
var titles []string
|
||||
for _, e := range doc.Entries {
|
||||
titles = append(titles, e.Title)
|
||||
}
|
||||
want := []string{"Newer", "Renamed", "Older", "Strip"}
|
||||
if strings.Join(titles, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("entries = %v, want %v (newest first, every section)", titles, want)
|
||||
}
|
||||
// Undated bundles are not feed items, and drop out because they have no date rather than by declaration.
|
||||
if strings.Contains(rec.Body.String(), "Colophon") {
|
||||
t.Error("an undated bundle must not appear in a feed")
|
||||
}
|
||||
// Identity has to mean something in a reader that has never seen the site.
|
||||
for _, e := range doc.Entries {
|
||||
if !strings.HasPrefix(e.ID, "https://khosra.example/") {
|
||||
t.Errorf("entry id %q is not absolute", e.ID)
|
||||
}
|
||||
}
|
||||
// A slugged bundle appears at its address, not its key.
|
||||
if !strings.Contains(rec.Body.String(), "https://khosra.example/posts/ekti/") {
|
||||
t.Errorf("a renamed bundle must be linked at its route:\n%s", rec.Body.String())
|
||||
}
|
||||
if doc.Title != "Khosra" {
|
||||
t.Errorf("feed title = %q", doc.Title)
|
||||
}
|
||||
// Asserted on the bytes, because a namespaced attribute does not round-trip through the same struct tag
|
||||
// that marshals it — and the bytes are what a reader sees.
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `xml:lang="en"`) {
|
||||
t.Errorf("the feed should declare its language:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "example//") {
|
||||
t.Errorf("a doubled slash makes every identity wrong:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedsNarrowBySectionTagAndLanguage(t *testing.T) {
|
||||
h := feedHandler(t, content.Settings{Base: "https://khosra.example", Title: "Khosra"})
|
||||
for path, want := range map[string][]string{
|
||||
"/comics/feed.xml": {"Strip"},
|
||||
"/tags/monsoon/feed.xml": {"Newer", "Strip"},
|
||||
"/posts/tags/monsoon/feed.xml": {"Newer"},
|
||||
"/bn/feed.xml": {"নতুন", "Renamed", "Older", "Strip"}, // bn where it exists, fallback elsewhere
|
||||
} {
|
||||
rec, doc := fetchFeed(t, h, path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200", path, rec.Code)
|
||||
continue
|
||||
}
|
||||
var titles []string
|
||||
for _, e := range doc.Entries {
|
||||
titles = append(titles, e.Title)
|
||||
}
|
||||
if strings.Join(titles, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("GET %s = %v, want %v", path, titles, want)
|
||||
}
|
||||
}
|
||||
// The self link names the feed that was actually asked for.
|
||||
_, doc := fetchFeed(t, h, "/comics/feed.xml")
|
||||
var self string
|
||||
for _, l := range doc.Links {
|
||||
if l.Rel == "self" {
|
||||
self = l.Href
|
||||
}
|
||||
}
|
||||
if self != "https://khosra.example/comics/feed.xml" {
|
||||
t.Errorf("self link = %q", self)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFeedNeedsABaseAndSomethingToCarry(t *testing.T) {
|
||||
// Without a base an entry's identity would be a path no reader can resolve, so the file does not exist.
|
||||
h := feedHandler(t, content.Settings{})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/feed.xml", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("no base: got %d, want 404", rec.Code)
|
||||
}
|
||||
h = feedHandler(t, content.Settings{Base: "https://khosra.example"})
|
||||
for _, path := range []string{"/pages/feed.xml", "/tags/nothing/feed.xml", "/nosuch/feed.xml"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404: a feed for nothing is nothing", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFeedPathIsAFileNotAPage(t *testing.T) {
|
||||
// No trailing-slash canonicalisation: /feed.xml/ is not the feed, and /feed.xml must not redirect.
|
||||
h := feedHandler(t, content.Settings{Base: "https://khosra.example"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/feed.xml", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got %d, want 200 without a redirect", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type resolution struct {
|
||||
// redirect is the canonical path when the request named a non-canonical one. Non-empty means answer
|
||||
// with a permanent redirect and nothing else.
|
||||
redirect string
|
||||
// feed means the request named a feed of whatever scope key and tag describe (ADR-0043).
|
||||
feed bool
|
||||
}
|
||||
|
||||
// resolve maps a request path to a bundle key and language.
|
||||
@@ -54,6 +56,15 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
return resolution{redirect: "/"}, true
|
||||
}
|
||||
|
||||
// A trailing feed.xml names a feed of the scope before it. It is a file rather than a page, so none of
|
||||
// the trailing-slash canonicalisation below applies to it (ADR-0043).
|
||||
if rest, isFeed := cutFeed(key); isFeed {
|
||||
if tag, section, isTag := cutTag(rest); isTag {
|
||||
return resolution{key: section, tag: tag, lang: lang, feed: true}, true
|
||||
}
|
||||
return resolution{key: rest, lang: lang, feed: true}, true
|
||||
}
|
||||
|
||||
page := 1
|
||||
// A trailing /page/N/ is pagination, not part of the key (ADR-0028). Page one is the bare listing
|
||||
// URL, so /page/1/ is a second spelling and redirects.
|
||||
@@ -80,6 +91,18 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
return resolution{key: key, lang: lang, page: page}, true
|
||||
}
|
||||
|
||||
// cutFeed strips a trailing feed.xml, reporting whether one was there. What remains is the scope: empty for
|
||||
// the whole site, a section, or a tag path.
|
||||
func cutFeed(key string) (rest string, ok bool) {
|
||||
if key == feedFile {
|
||||
return "", true
|
||||
}
|
||||
if trimmed, found := strings.CutSuffix(key, "/"+feedFile); found {
|
||||
return trimmed, true
|
||||
}
|
||||
return key, false
|
||||
}
|
||||
|
||||
// cutTag splits a tag listing key into its term and the section it is narrowed to.
|
||||
func cutTag(key string) (tag, section string, ok bool) {
|
||||
if rest, found := strings.CutPrefix(key, content.TagsSegment+"/"); found {
|
||||
|
||||
+8
-2
@@ -18,7 +18,7 @@ import (
|
||||
func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
||||
serve(w, req, site, r, siteFS)
|
||||
serve(w, req, site, r, siteFS, settings)
|
||||
})
|
||||
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
|
||||
// bundle can own them, since a key always sits under a section.
|
||||
@@ -128,12 +128,18 @@ func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
|
||||
}
|
||||
|
||||
// serve resolves one request and writes its bundle.
|
||||
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS) {
|
||||
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings) {
|
||||
res, ok := resolve(req.URL.Path, site)
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
if res.feed {
|
||||
if !serveFeed(w, req, site, res, settings) {
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
return
|
||||
}
|
||||
if res.tag != "" {
|
||||
if !serveTags(w, req, site, r, res) {
|
||||
http.NotFound(w, req)
|
||||
|
||||
Reference in New Issue
Block a user