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:
@@ -212,8 +212,7 @@ not noise. Term slugs are derived by locale and overridable by hand (ADR-0015).
|
||||
type — `series` for ordering, `medium` for art, `genre` for writing. These never enter the tag pool. The
|
||||
test: a tag is free-form and cross-cutting, a declared taxonomy has known terms and drives behaviour.
|
||||
|
||||
Feeds follow the same shape: `/feed.xml` carries every type declared `primary`, `/{section}/feed.xml`
|
||||
carries a section, and `/tags/{tag}/feed.xml` comes free from the same Query.
|
||||
Feeds follow the same shape: `/tags/{tag}/feed.xml` comes free from the same Query as the listing.
|
||||
|
||||
## Site settings
|
||||
|
||||
|
||||
@@ -575,3 +575,23 @@ core carries a generic "serve this directory of derived files" — which is the
|
||||
feature wants output of its own.
|
||||
Revisit if: startup time becomes noticeable on a large site — then the pass wants a manifest and a change
|
||||
check rather than a stat per candidate.
|
||||
|
||||
## ADR-0043 — A feed carries dated bundles; membership needs no type declaration
|
||||
Date: 2026-07-30 · Status: accepted (chooses a simpler rule than the parked feed shape in
|
||||
`ideas/deferred-decisions.md`, which waited on declared types)
|
||||
Decision: `/feed.xml` carries every **dated** bundle, newest first, capped at the most recent 20.
|
||||
`/{section}/feed.xml` and `/tags/{term}/feed.xml` narrow it through the same Query, and a `/{lang}/` prefix
|
||||
selects a language like anywhere else. The format is Atom, built with `encoding/xml` from typed structs rather
|
||||
than a template. A feed needs the site's `base`, and answers 404 without one. Entries carry title, link,
|
||||
identity and date — not the body.
|
||||
Why: the parked direction was "every type declared `primary`", which would have made feeds wait for declared
|
||||
types a third time. But the thing that actually distinguishes a feed item is already on disk: a publication
|
||||
date. Pages, colophons and section landings have none and drop out for the right reason rather than by
|
||||
declaration, and nothing new has to be invented or kept in sync. `encoding/xml` over a template because XML in
|
||||
`html/template` is escaping for the wrong grammar — a correctness trap, not a style preference.
|
||||
Consequence: cheap — one Query, one marshaller, and no new content concept; a `[spec]` idea stays parked
|
||||
instead of being half-built. Expensive — "in the feed" and "has a date" cannot yet be separated, so a dated
|
||||
bundle an author wants out of the feed has no way to say so; and entries without bodies mean a reader shows
|
||||
titles only, until `summary` is parsed.
|
||||
Revisit if: someone wants a dated bundle excluded, or one section kept out of the main feed. *That* is the
|
||||
real trigger for declared types, and it is now a sharper one than "feeds exist".
|
||||
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
# State
|
||||
|
||||
**Verified against:** `f4ba695` on 2026-07-30 — update this line every change.
|
||||
**Verified against:** `420458c` on 2026-07-30 — update this line every change.
|
||||
If this file disagrees with the code, the code is right and this file is a bug.
|
||||
|
||||
## Inventory
|
||||
@@ -20,15 +20,17 @@ If this file disagrees with the code, the code is right and this file is a bug.
|
||||
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) | 20 |
|
||||
| `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 |
|
||||
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) | 58 |
|
||||
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) | 125 |
|
||||
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
|
||||
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 |
|
||||
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`), wiring, startup including the derivative pass — the only place things are assembled | 88 |
|
||||
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, 404 | 2170 |
|
||||
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404 | 2334 |
|
||||
|
||||
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
|
||||
key (ADR-0035) — a paginated listing per section, tag listings global and
|
||||
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, a directory bundle's own files under its
|
||||
URL, generated derivatives under `/derived/`, plus `/robots.txt` and `/sitemap.xml`.
|
||||
URL, generated derivatives under `/derived/`, Atom feeds per site,
|
||||
section and tag, plus `/robots.txt` and `/sitemap.xml`.
|
||||
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
|
||||
smoothing and widow prevention (ADR-0034). This repo holds engine source only — the site root is external and passed with
|
||||
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
|
||||
@@ -48,9 +50,9 @@ this change*.
|
||||
| Counter | Now | Extraction due at | What it buys |
|
||||
|---|---|---|---|
|
||||
| Render transforms — **page-level only** | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`). Parse-phase work does *not* count and must not: goldmark's extender list is already an ordered pipeline for it, so typography, shortcodes and widows compose there (`cmd/khosra/wire.go`) and a second pipeline beside it would be pure duplication. This counts transforms over the assembled page, which nothing hosts yet — OpenGraph and JSON-LD (queue 15) are the first candidates |
|
||||
| Routing cases | 8 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
|
||||
| Routing cases | 9 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
|
||||
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
|
||||
| Views / output formats | 2 | **2** — due | Two template sets exist (bundle, listing); the View layer is Arc 2's third item |
|
||||
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
|
||||
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first is the derivative pass (ADR-0042), called straight from `cmd` at startup — one call needs no runner, and startup is the only change signal until queue 21 |
|
||||
| Extensions | 2 | **3** | Extension registry (`extensions.md`). The wire file arrived with the first feature rather than the registry — `cmd/khosra/wire.go`, one line, no struct |
|
||||
| Interface implementations | — | **2** | The interface itself |
|
||||
|
||||
@@ -114,6 +114,17 @@ The set is overlaid the same way as the page kinds: a site's `templates/shortcod
|
||||
the embedded one, so redefining `figure` replaces it and any fragment left alone is inherited. Argument
|
||||
names are contract, added but never renamed.
|
||||
|
||||
## Feed discovery
|
||||
|
||||
The engine serves Atom at `/feed.xml`, per section and per tag (`content-model.md`). A theme advertises it from
|
||||
the document head, which is what a reader looks for:
|
||||
|
||||
```
|
||||
<link rel="alternate" type="application/atom+xml" href="{{.Site.Base}}/feed.xml" title="{{.Site.Title}}">
|
||||
```
|
||||
|
||||
Only with a declared `base`: without one there is no feed to point at.
|
||||
|
||||
## The stability rule
|
||||
|
||||
Fields and names are **added, never renamed or removed**. Absence is always legal: a template reading a
|
||||
|
||||
@@ -364,11 +364,18 @@ const PerPage = 10
|
||||
|
||||
// URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the
|
||||
// default locale (ADR-0008, ADR-0009). Templates never build a path by hand.
|
||||
//
|
||||
// An empty key is the site root, which is "/" and not "//" — the case a feed for the whole site asks for.
|
||||
func URL(key, lang string) string {
|
||||
if lang == "" || lang == DefaultLang {
|
||||
return "/" + key + "/"
|
||||
prefix := ""
|
||||
if lang != "" && lang != DefaultLang {
|
||||
prefix = lang
|
||||
}
|
||||
return "/" + lang + "/" + key + "/"
|
||||
segments := path.Join(prefix, key)
|
||||
if segments == "" {
|
||||
return "/"
|
||||
}
|
||||
return "/" + segments + "/"
|
||||
}
|
||||
|
||||
// TagURL is the permalink of a tag listing, optionally narrowed to a section (ADR-0018).
|
||||
|
||||
@@ -65,6 +65,16 @@ func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) {
|
||||
t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want)
|
||||
}
|
||||
}
|
||||
// The site root is "/", not "//" — what a whole-site feed asks for, and what a doubled slash would put in
|
||||
// front of every reader as a broken identity.
|
||||
for lang, want := range map[string]string{"en": "/", "": "/", "bn": "/bn/"} {
|
||||
if got := URL("", lang); got != want {
|
||||
t.Errorf("URL(\"\", %q) = %q, want %q", lang, got, want)
|
||||
}
|
||||
}
|
||||
if got := PageURL("", "en", 2); got != "/page/2/" {
|
||||
t.Errorf("PageURL at the root = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasesAreParsedAndIndexed(t *testing.T) {
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
{{- if .Site.Title}}
|
||||
<meta property="og:site_name" content="{{.Site.Title}}">
|
||||
{{- end}}
|
||||
{{- if .Site.Base}}
|
||||
<link rel="alternate" type="application/atom+xml" href="{{.Site.Base}}/feed.xml" title="{{.Site.Title}}">
|
||||
{{- end}}
|
||||
<style>{{.Style}}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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