split the content package at its seam
content.go had passed FILE_LOC_WARN, which conventions.md treats as the moment to split rather than a number to ignore: flat until the figure, then split, never pre-partitioned. content.go now parses bundles and builds permalinks; site.go holds the indexed site — lookup with language fallback, aliases, Query and Run. The tests follow the same seam. No behaviour change, and the test-coupling gate was right to demand the tests move: its exemption covers comments and whitespace, not code relocated between files, where an edit could hide.
This commit is contained in:
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
# State
|
# State
|
||||||
|
|
||||||
**Verified against:** `499d107` on 2026-07-30 — update this line every change.
|
**Verified against:** `449850c` 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.
|
If this file disagrees with the code, the code is right and this file is a bug.
|
||||||
|
|
||||||
## Inventory
|
## Inventory
|
||||||
@@ -8,7 +8,9 @@ If this file disagrees with the code, the code is right and this file is a bug.
|
|||||||
| File | Purpose | LOC |
|
| File | Purpose | LOC |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 |
|
| `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, language fallback, alias index, URL building | 358 |
|
| `internal/content/doc.go` | package comment | 5 |
|
||||||
|
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, permalink building | 332 |
|
||||||
|
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections | 191 |
|
||||||
| `internal/render/render.go` | goldmark, per-kind template sets with site override, `Page`/`List`/`head` | 214 |
|
| `internal/render/render.go` | goldmark, per-kind template sets with site override, `Page`/`List`/`head` | 214 |
|
||||||
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `theme.css` (ADR-0026) | — |
|
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `theme.css` (ADR-0026) | — |
|
||||||
| `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 |
|
| `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 |
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// Package content reads a site root into bundles. It knows the disk and nothing about HTTP.
|
|
||||||
//
|
|
||||||
// Every read goes through an [os.Root] (ADR-0031), so no path — from a filename or later from a
|
|
||||||
// request — can escape the site root, even through a symlink.
|
|
||||||
package content
|
package content
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +7,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -299,196 +294,12 @@ func dropCollisions(all []Bundle) []Bundle {
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)),
|
|
||||||
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
|
|
||||||
// same request always answers the same way. A key with no variants at all reports false.
|
|
||||||
func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) {
|
|
||||||
for _, try := range []string{lang, DefaultLang} {
|
|
||||||
if try == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if b, ok = s.byKeyLang[key+"\x00"+try]; ok {
|
|
||||||
return b, try, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, l := range s.Variants(key) {
|
|
||||||
b = s.byKeyLang[key+"\x00"+l]
|
|
||||||
return b, l, true
|
|
||||||
}
|
|
||||||
return Bundle{}, "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variants lists the languages a key exists in, sorted.
|
|
||||||
func (s *Site) Variants(key string) []string {
|
|
||||||
var langs []string
|
|
||||||
for kl := range s.byKeyLang {
|
|
||||||
k, l, found := strings.Cut(kl, "\x00")
|
|
||||||
if found && k == key {
|
|
||||||
langs = append(langs, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(langs)
|
|
||||||
return langs
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasLang reports whether any bundle is written in lang. The resolver needs this to tell a language
|
|
||||||
// prefix from a section that happens to share its name.
|
|
||||||
func (s *Site) HasLang(lang string) bool {
|
|
||||||
for kl := range s.byKeyLang {
|
|
||||||
if _, l, found := strings.Cut(kl, "\x00"); found && l == lang {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len reports how many bundles the site holds.
|
|
||||||
func (s *Site) Len() int { return len(s.byKeyLang) }
|
|
||||||
|
|
||||||
// PerPage is how many entries a listing shows.
|
// PerPage is how many entries a listing shows.
|
||||||
//
|
//
|
||||||
// Changing it renumbers page URLs, which ADR-0028 calls a URL event; it becomes a setting when the
|
// Changing it renumbers page URLs, which ADR-0028 calls a URL event; it becomes a setting when the
|
||||||
// cascade exists rather than being one knob early.
|
// cascade exists rather than being one knob early.
|
||||||
const PerPage = 10
|
const PerPage = 10
|
||||||
|
|
||||||
// Query selects bundles into an ordered list.
|
|
||||||
//
|
|
||||||
// Every grouping in the engine is a Query — sections now, tags and series later. It carries no cache
|
|
||||||
// signature yet: nothing caches, and a signature with no consumer is speculation (architecture.md).
|
|
||||||
type Query struct {
|
|
||||||
// Section is the first path segment of a key. Empty matches every section.
|
|
||||||
Section string
|
|
||||||
// Tag is a tag slug. Empty matches every bundle; set, it matches those carrying the term.
|
|
||||||
Tag string
|
|
||||||
// Lang is the language to serve, with the usual fallback per key (ADR-0009).
|
|
||||||
Lang string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run applies q, newest first, with undated bundles after dated ones and ties broken by key so the same
|
|
||||||
// query always answers in the same order.
|
|
||||||
func (s *Site) Run(q Query) []Bundle {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []Bundle
|
|
||||||
for kl := range s.byKeyLang {
|
|
||||||
key, _, found := strings.Cut(kl, "\x00")
|
|
||||||
if !found || seen[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
b, _, ok := s.Lookup(key, q.Lang)
|
|
||||||
if !ok || !b.hasTag(q.Tag) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, b)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
a, b := out[i], out[j]
|
|
||||||
switch {
|
|
||||||
case !a.Date.Equal(b.Date):
|
|
||||||
return a.Date.After(b.Date)
|
|
||||||
default:
|
|
||||||
return a.Key < b.Key
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasTag reports whether the bundle carries a tag slug. An empty slug matches everything.
|
|
||||||
func (b Bundle) hasTag(slug string) bool {
|
|
||||||
if slug == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, t := range b.Tags {
|
|
||||||
if TagSlug(t) == slug {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section is the first path segment of a bundle's key: its content type by default.
|
|
||||||
func (b Bundle) Section() string {
|
|
||||||
sec, _, nested := strings.Cut(b.Key, "/")
|
|
||||||
if !nested {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return sec
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sections lists every section that holds at least one bundle, sorted.
|
|
||||||
func (s *Site) Sections() []string {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for kl := range s.byKeyLang {
|
|
||||||
key, _, found := strings.Cut(kl, "\x00")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sec, _, nested := strings.Cut(key, "/"); nested {
|
|
||||||
seen[sec] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(seen))
|
|
||||||
for sec := range seen {
|
|
||||||
out = append(out, sec)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the
|
// 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.
|
// default locale (ADR-0008, ADR-0009). Templates never build a path by hand.
|
||||||
func URL(key, lang string) string {
|
func URL(key, lang string) string {
|
||||||
|
|||||||
@@ -160,122 +160,6 @@ func TestOpenSiteRefusesSymlinkEscape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSiteLookupFindsDefaultVariant(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")},
|
|
||||||
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")},
|
|
||||||
}
|
|
||||||
bundles, err := Scan(fsys)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
site := NewSite(bundles)
|
|
||||||
if site.Len() != 2 {
|
|
||||||
t.Fatalf("indexed %d bundles, want 2", site.Len())
|
|
||||||
}
|
|
||||||
b, served, ok := site.Lookup("pages/about", "en")
|
|
||||||
if !ok || b.Title != "About" || served != "en" {
|
|
||||||
t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok)
|
|
||||||
}
|
|
||||||
if _, _, ok := site.Lookup("pages/missing", "en"); ok {
|
|
||||||
t.Error("Lookup invented a bundle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupFallsBackThroughLanguages(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")},
|
|
||||||
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")},
|
|
||||||
"content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")},
|
|
||||||
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")},
|
|
||||||
}
|
|
||||||
bundles, err := Scan(fsys)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
site := NewSite(bundles)
|
|
||||||
cases := []struct{ key, want, served string }{
|
|
||||||
{"pages/about", "পরিচিতি", "bn"}, // the requested language exists
|
|
||||||
{"posts/plain", "Plain", "en"}, // falls back to the default
|
|
||||||
{"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
b, served, ok := site.Lookup(c.key, "bn")
|
|
||||||
if !ok || b.Title != c.want || served != c.served {
|
|
||||||
t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" {
|
|
||||||
t.Errorf("Variants = %v, want [bn en]", got)
|
|
||||||
}
|
|
||||||
if !site.HasLang("bn") || site.HasLang("fr") {
|
|
||||||
t.Error("HasLang misreported")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) {
|
|
||||||
cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"}
|
|
||||||
for lang, want := range cases {
|
|
||||||
if got := URL("pages/about", lang); got != want {
|
|
||||||
t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) {
|
func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"Long Monsoon": "long-monsoon",
|
"Long Monsoon": "long-monsoon",
|
||||||
@@ -289,29 +173,3 @@ func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFiltersByTagAndSection(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"content/posts/a.md": {Data: []byte("---\ntitle: A\ndate: 2026-01-03\ntags: [Monsoon, prose]\n---\n")},
|
|
||||||
"content/posts/b.md": {Data: []byte("---\ntitle: B\ndate: 2026-01-02\ntags: [prose]\n---\n")},
|
|
||||||
"content/comics/c.md": {Data: []byte("---\ntitle: C\ndate: 2026-01-01\ntags: [monsoon]\n---\n")},
|
|
||||||
"content/writing/d.md": {Data: []byte("---\ntitle: D\n---\n")},
|
|
||||||
}
|
|
||||||
site := NewSite(mustScan(t, fsys))
|
|
||||||
got := func(q Query) []string {
|
|
||||||
var titles []string
|
|
||||||
for _, b := range site.Run(q) {
|
|
||||||
titles = append(titles, b.Title)
|
|
||||||
}
|
|
||||||
return titles
|
|
||||||
}
|
|
||||||
if titles := got(Query{Tag: "monsoon", Lang: "en"}); len(titles) != 2 || titles[0] != "A" || titles[1] != "C" {
|
|
||||||
t.Errorf("global tag query = %v, want [A C] — a tag spans sections and case does not matter", titles)
|
|
||||||
}
|
|
||||||
if titles := got(Query{Section: "posts", Tag: "monsoon", Lang: "en"}); len(titles) != 1 || titles[0] != "A" {
|
|
||||||
t.Errorf("section-narrowed tag query = %v, want [A]", titles)
|
|
||||||
}
|
|
||||||
if titles := got(Query{Tag: "nothing", Lang: "en"}); titles != nil {
|
|
||||||
t.Errorf("unknown tag = %v, want none", titles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package content reads a site root into bundles. It knows the disk and nothing about HTTP.
|
||||||
|
//
|
||||||
|
// Every read goes through an [os.Root] (ADR-0031), so no path — from a filename or later from a
|
||||||
|
// request — can escape the site root, even through a symlink.
|
||||||
|
package content
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package content
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
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
|
||||||
|
// same request always answers the same way. A key with no variants at all reports false.
|
||||||
|
func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) {
|
||||||
|
for _, try := range []string{lang, DefaultLang} {
|
||||||
|
if try == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b, ok = s.byKeyLang[key+"\x00"+try]; ok {
|
||||||
|
return b, try, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range s.Variants(key) {
|
||||||
|
b = s.byKeyLang[key+"\x00"+l]
|
||||||
|
return b, l, true
|
||||||
|
}
|
||||||
|
return Bundle{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variants lists the languages a key exists in, sorted.
|
||||||
|
func (s *Site) Variants(key string) []string {
|
||||||
|
var langs []string
|
||||||
|
for kl := range s.byKeyLang {
|
||||||
|
k, l, found := strings.Cut(kl, "\x00")
|
||||||
|
if found && k == key {
|
||||||
|
langs = append(langs, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(langs)
|
||||||
|
return langs
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLang reports whether any bundle is written in lang. The resolver needs this to tell a language
|
||||||
|
// prefix from a section that happens to share its name.
|
||||||
|
func (s *Site) HasLang(lang string) bool {
|
||||||
|
for kl := range s.byKeyLang {
|
||||||
|
if _, l, found := strings.Cut(kl, "\x00"); found && l == lang {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len reports how many bundles the site holds.
|
||||||
|
func (s *Site) Len() int { return len(s.byKeyLang) }
|
||||||
|
|
||||||
|
// Query selects bundles into an ordered list.
|
||||||
|
//
|
||||||
|
// Every grouping in the engine is a Query — sections now, tags and series later. It carries no cache
|
||||||
|
// signature yet: nothing caches, and a signature with no consumer is speculation (architecture.md).
|
||||||
|
type Query struct {
|
||||||
|
// Section is the first path segment of a key. Empty matches every section.
|
||||||
|
Section string
|
||||||
|
// Tag is a tag slug. Empty matches every bundle; set, it matches those carrying the term.
|
||||||
|
Tag string
|
||||||
|
// Lang is the language to serve, with the usual fallback per key (ADR-0009).
|
||||||
|
Lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run applies q, newest first, with undated bundles after dated ones and ties broken by key so the same
|
||||||
|
// query always answers in the same order.
|
||||||
|
func (s *Site) Run(q Query) []Bundle {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []Bundle
|
||||||
|
for kl := range s.byKeyLang {
|
||||||
|
key, _, found := strings.Cut(kl, "\x00")
|
||||||
|
if !found || seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
b, _, ok := s.Lookup(key, q.Lang)
|
||||||
|
if !ok || !b.hasTag(q.Tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
a, b := out[i], out[j]
|
||||||
|
switch {
|
||||||
|
case !a.Date.Equal(b.Date):
|
||||||
|
return a.Date.After(b.Date)
|
||||||
|
default:
|
||||||
|
return a.Key < b.Key
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasTag reports whether the bundle carries a tag slug. An empty slug matches everything.
|
||||||
|
func (b Bundle) hasTag(slug string) bool {
|
||||||
|
if slug == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, t := range b.Tags {
|
||||||
|
if TagSlug(t) == slug {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section is the first path segment of a bundle's key: its content type by default.
|
||||||
|
func (b Bundle) Section() string {
|
||||||
|
sec, _, nested := strings.Cut(b.Key, "/")
|
||||||
|
if !nested {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return sec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections lists every section that holds at least one bundle, sorted.
|
||||||
|
func (s *Site) Sections() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for kl := range s.byKeyLang {
|
||||||
|
key, _, found := strings.Cut(kl, "\x00")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sec, _, nested := strings.Cut(key, "/"); nested {
|
||||||
|
seen[sec] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for sec := range seen {
|
||||||
|
out = append(out, sec)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package content
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSiteLookupFindsDefaultVariant(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")},
|
||||||
|
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")},
|
||||||
|
}
|
||||||
|
bundles, err := Scan(fsys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
site := NewSite(bundles)
|
||||||
|
if site.Len() != 2 {
|
||||||
|
t.Fatalf("indexed %d bundles, want 2", site.Len())
|
||||||
|
}
|
||||||
|
b, served, ok := site.Lookup("pages/about", "en")
|
||||||
|
if !ok || b.Title != "About" || served != "en" {
|
||||||
|
t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := site.Lookup("pages/missing", "en"); ok {
|
||||||
|
t.Error("Lookup invented a bundle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupFallsBackThroughLanguages(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")},
|
||||||
|
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")},
|
||||||
|
"content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")},
|
||||||
|
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")},
|
||||||
|
}
|
||||||
|
bundles, err := Scan(fsys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
site := NewSite(bundles)
|
||||||
|
cases := []struct{ key, want, served string }{
|
||||||
|
{"pages/about", "পরিচিতি", "bn"}, // the requested language exists
|
||||||
|
{"posts/plain", "Plain", "en"}, // falls back to the default
|
||||||
|
{"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
b, served, ok := site.Lookup(c.key, "bn")
|
||||||
|
if !ok || b.Title != c.want || served != c.served {
|
||||||
|
t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" {
|
||||||
|
t.Errorf("Variants = %v, want [bn en]", got)
|
||||||
|
}
|
||||||
|
if !site.HasLang("bn") || site.HasLang("fr") {
|
||||||
|
t.Error("HasLang misreported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) {
|
||||||
|
cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"}
|
||||||
|
for lang, want := range cases {
|
||||||
|
if got := URL("pages/about", lang); got != want {
|
||||||
|
t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFiltersByTagAndSection(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"content/posts/a.md": {Data: []byte("---\ntitle: A\ndate: 2026-01-03\ntags: [Monsoon, prose]\n---\n")},
|
||||||
|
"content/posts/b.md": {Data: []byte("---\ntitle: B\ndate: 2026-01-02\ntags: [prose]\n---\n")},
|
||||||
|
"content/comics/c.md": {Data: []byte("---\ntitle: C\ndate: 2026-01-01\ntags: [monsoon]\n---\n")},
|
||||||
|
"content/writing/d.md": {Data: []byte("---\ntitle: D\n---\n")},
|
||||||
|
}
|
||||||
|
site := NewSite(mustScan(t, fsys))
|
||||||
|
got := func(q Query) []string {
|
||||||
|
var titles []string
|
||||||
|
for _, b := range site.Run(q) {
|
||||||
|
titles = append(titles, b.Title)
|
||||||
|
}
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
if titles := got(Query{Tag: "monsoon", Lang: "en"}); len(titles) != 2 || titles[0] != "A" || titles[1] != "C" {
|
||||||
|
t.Errorf("global tag query = %v, want [A C] — a tag spans sections and case does not matter", titles)
|
||||||
|
}
|
||||||
|
if titles := got(Query{Section: "posts", Tag: "monsoon", Lang: "en"}); len(titles) != 1 || titles[0] != "A" {
|
||||||
|
t.Errorf("section-narrowed tag query = %v, want [A]", titles)
|
||||||
|
}
|
||||||
|
if titles := got(Query{Tag: "nothing", Lang: "en"}); titles != nil {
|
||||||
|
t.Errorf("unknown tag = %v, want none", titles)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user