diff --git a/docs/content-model.md b/docs/content-model.md index 17384e4..5a7fed7 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -73,7 +73,7 @@ readable by templates (ADR-0002). Never add a required field. | `date` / `updated` | date | Publication; `updated` drives feeds and `Last-Modified` | | `type` | string | Post type; defaults from the top-level section | | `slug` | string | Overrides the derived slug. The engine serves the new path only; the old one 404s unless it appears in `aliases` | -| `aliases` | []string | Additional paths the engine resolves to this bundle, each redirecting permanently to the canonical one (ADR-0008) | +| `aliases` | []string | Paths the engine redirects permanently to this bundle's canonical URL (ADR-0008). A scalar or a list; surrounding slashes optional. An alias naming a real bundle, or claimed by two bundles, is ambiguous — logged and dropped, and the real bundle keeps its URL | | `draft` | bool | Excluded from queries and feeds | | `nocache` | bool | Never cache this bundle's render. Named so absence means cacheable, per ADR-0002 | | `summary` | string | Explicit summary; otherwise derived | diff --git a/docs/state.md b/docs/state.md index fc7e5e6..295c5f5 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `51143d3` on 2026-07-30 — update this line every change. +**Verified against:** `37ccf94` 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 @@ -8,7 +8,7 @@ If this file disagrees with the code, the code is right and this file is a bug. | File | Purpose | LOC | |---|---|---| | `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 | -| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 | +| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index, language fallback, alias index, URL building | 358 | | `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 | | `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — | | `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 | diff --git a/internal/content/content.go b/internal/content/content.go index 91d3f07..b1aa7b5 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -38,6 +38,9 @@ type Bundle struct { // Title is empty when frontmatter omits it. Whether that is legal depends on the type, which // nothing decides yet, so the parser accepts it. Title string + // Aliases are paths that must keep resolving to this bundle, each redirecting to its canonical URL + // (ADR-0008). Additive only: an alias is a promise never withdrawn. + Aliases []string // Body is everything after the frontmatter, unrendered. Body []byte // Extra holds every frontmatter key other than title, exactly as YAML parsed it. @@ -113,9 +116,33 @@ func Parse(name string, data []byte) (Bundle, error) { b.Title = t } delete(b.Extra, "title") + b.Aliases = stringList(b.Extra["aliases"]) + delete(b.Extra, "aliases") return b, nil } +// stringList reads a YAML scalar or sequence of strings as keys: normalised, without surrounding +// slashes. Anything that is not a string is ignored rather than failing the bundle. +func stringList(v any) []string { + var out []string + add := func(x any) { + if str, ok := x.(string); ok { + if k := Normalise(strings.Trim(str, "/")); k != "" { + out = append(out, k) + } + } + } + switch t := v.(type) { + case string: + add(t) + case []any: + for _, x := range t { + add(x) + } + } + return out +} + // Normalise puts s into NFC. // // Every identifier goes through this: Bengali conjuncts have several byte encodings for text that looks @@ -220,18 +247,53 @@ func dropCollisions(all []Bundle) []Bundle { // Site is a set of bundles indexed for lookup by permalink key. type Site struct { byKeyLang map[string]Bundle + aliases map[string]string } // NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan // drops ambiguity before this sees it. func NewSite(bundles []Bundle) *Site { - s := &Site{byKeyLang: make(map[string]Bundle, len(bundles))} + s := &Site{ + byKeyLang: make(map[string]Bundle, len(bundles)), + aliases: map[string]string{}, + } for _, b := range bundles { s.byKeyLang[b.Key+"\x00"+b.Lang] = b } + s.indexAliases(bundles) return s } +// indexAliases maps each alias to the key it redirects to. +// +// An alias that names a real bundle, or that two bundles both claim, is ambiguous: it is logged and +// dropped rather than picking a winner, and the real bundle keeps its URL (ADR-0029). +func (s *Site) indexAliases(bundles []Bundle) { + claimed := map[string][]string{} + for _, b := range bundles { + for _, a := range b.Aliases { + claimed[a] = append(claimed[a], b.Key) + } + } + for alias, keys := range claimed { + if _, isReal := s.byKeyLang[alias+"\x00"+DefaultLang]; isReal { + slog.Error("ignoring alias that names a real bundle", "alias", alias, "claimed_by", keys) + continue + } + if len(keys) > 1 { + slog.Error("ignoring alias claimed by more than one bundle", "alias", alias, "claimed_by", keys) + continue + } + s.aliases[alias] = keys[0] + } +} + +// Alias returns the key an alias redirects to. +func (s *Site) Alias(alias string) (string, bool) { + key, ok := s.aliases[alias] + return key, ok +} + // Lookup returns the best variant of a key for a requested language, and the language actually served. // // The fallback chain is requested → default → any (ADR-0009); "any" is resolved in sorted order so the diff --git a/internal/content/content_test.go b/internal/content/content_test.go index 92e3efa..8eae65b 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -215,3 +215,57 @@ func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) { } } } + +func TestAliasesAreParsedAndIndexed(t *testing.T) { + fsys := fstest.MapFS{ + "content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [/posts/old-name/, posts/older]\n---\n")}, + "content/pages/single.md": {Data: []byte("---\ntitle: Single\naliases: pages/one\n---\n")}, + } + bundles, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + site := NewSite(bundles) + for alias, want := range map[string]string{ + "posts/old-name": "posts/new-name", + "posts/older": "posts/new-name", + "pages/one": "pages/single", + } { + got, ok := site.Alias(alias) + if !ok || got != want { + t.Errorf("Alias(%q) = %q %v, want %q", alias, got, ok, want) + } + } + for _, b := range bundles { + if _, leaked := b.Extra["aliases"]; leaked { + t.Error("aliases should be lifted out of Extra, not duplicated") + } + } +} + +func TestAmbiguousAliasesAreDropped(t *testing.T) { + fsys := fstest.MapFS{ + "content/pages/real.md": {Data: []byte("---\ntitle: Real\n---\n")}, + "content/pages/a.md": {Data: []byte("---\ntitle: A\naliases: [pages/real, pages/shared]\n---\n")}, + "content/pages/b.md": {Data: []byte("---\ntitle: B\naliases: [pages/shared]\n---\n")}, + } + site := NewSite(mustScan(t, fsys)) + if _, ok := site.Alias("pages/real"); ok { + t.Error("an alias naming a real bundle must be dropped, not shadow it") + } + if _, ok := site.Alias("pages/shared"); ok { + t.Error("an alias claimed by two bundles must be dropped, not picked arbitrarily") + } + if _, _, ok := site.Lookup("pages/real", "en"); !ok { + t.Error("the real bundle must keep its URL") + } +} + +func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle { + t.Helper() + b, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/internal/web/web.go b/internal/web/web.go index 9802d62..fc51dab 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -36,6 +36,12 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend return } if !found { + // An alias is a promise that an old URL keeps working, so it answers a permanent redirect to the + // canonical one — and only for an alias that exists, so nothing can be probed by 301. + if canonical, isAlias := site.Alias(res.key); isAlias { + http.Redirect(w, req, content.URL(canonical, res.lang), http.StatusMovedPermanently) + return + } http.NotFound(w, req) return } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index a5c57a8..2ec350d 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -161,3 +161,47 @@ func TestUnknownLanguagePrefixIsNotALanguage(t *testing.T) { t.Errorf("got %d, want 404: fr is not a language this site has", rec.Code) } } + +func aliasHandler(t *testing.T) http.Handler { + t.Helper() + fsys := fstest.MapFS{ + "content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [posts/old-name]\n---\nMoved here.\n")}, + "content/posts/new-name.bn.md": {Data: []byte("---\ntitle: নতুন\n---\nএখানে।\n")}, + } + bundles, err := content.Scan(fsys) + if err != nil { + t.Fatal(err) + } + r, err := render.New() + if err != nil { + t.Fatal(err) + } + return Handler(content.NewSite(bundles), r) +} + +func TestAliasRedirectsToCanonical(t *testing.T) { + h := aliasHandler(t) + for path, want := range map[string]string{ + "/posts/old-name/": "/posts/new-name/", + "/bn/posts/old-name/": "/bn/posts/new-name/", + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusMovedPermanently { + t.Errorf("GET %s = %d, want 301", path, rec.Code) + continue + } + if loc := rec.Header().Get("Location"); loc != want { + t.Errorf("GET %s → %q, want %q", path, loc, want) + } + } +} + +func TestUnknownPathIsStill404NotAnAliasProbe(t *testing.T) { + h := aliasHandler(t) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/never-existed/", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("got %d, want 404", rec.Code) + } +}