From 2f253f94dd83ead9183dd4ca1d585a94f3bdf1b5 Mon Sep 17 00:00:00 2001 From: bdeshi Date: Thu, 30 Jul 2026 01:45:59 +0600 Subject: [PATCH] honour aliases as permanent redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An alias is a promise that an old URL keeps working, so it answers 301 to the canonical one rather than serving the same content twice (ADR-0008). Frontmatter takes a scalar or a list and tolerates surrounding slashes, because authors write both. Ambiguity is dropped, not resolved: an alias naming a real bundle, or claimed by two bundles, is logged and ignored so the real bundle keeps its URL. Aliases compose with language prefixes for free, since the resolver splits the language before the key is looked up. The redirect still fires only for an alias that exists, so a nonexistent path cannot be probed by 301 — the property prompt 3 established. Evidence: /pages/bio/ and /about/ both 301 to /pages/about/, /bn/pages/bio/ 301s to /bn/pages/about/, and /pages/nothing/ is 404. --- docs/content-model.md | 2 +- docs/state.md | 4 +- internal/content/content.go | 64 +++++++++++++++++++++++++++++++- internal/content/content_test.go | 54 +++++++++++++++++++++++++++ internal/web/web.go | 6 +++ internal/web/web_test.go | 44 ++++++++++++++++++++++ 6 files changed, 170 insertions(+), 4 deletions(-) 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) + } +}