serve the files a bundle owns

Every figure and gallery shipped so far emitted links a browser could not fetch: a
relative src resolves under the page's URL, and nothing answered there. Found by
fetching the pages' own links rather than by reading their markup — the evidence
runs had been checking that the right src appeared, never that it worked.

A directory bundle's files are now served under its URL. The bundle is looked up
first and the file is read only from the directory that bundle owns, never from a
path assembled out of the request: ADR-0024 requires that no route serve bundle
bytes by path alone, since every byte inside a bundle inherits its publish status.
When drafts arrive at queue 19 the filter belongs beside that lookup and nowhere
else, which is why the ordering is written down in the comment.

A single-file bundle owns nothing: its neighbours belong to the section, and its
slash-terminated URL has nothing beneath it. An author with assets writes a
directory bundle, now stated in content-model.md.

A .md inside a bundle directory is never an asset — it is a bundle with its own URL
or a fragment that was never addressable, and serving either raw would publish
source. http.ServeFileFS handles content type, conditional requests and ranges,
none of which is worth reimplementing here.
This commit is contained in:
2026-08-01 02:23:36 +06:00
parent 98a973db83
commit a49639e938
6 changed files with 195 additions and 9 deletions
+8 -2
View File
@@ -58,8 +58,14 @@ overrides the defaults the binary embeds, so a bare root still renders.
not fatal (ADR-0029).
- `_index.<lang>.md` in a directory containing other bundles makes that directory itself a bundle (a
section or series landing page) rather than a plain container.
- Local assets sit beside the body, referenced relatively. Assets never live in frontmatter. Moving a
bundle moves its assets — the entire point of bundles.
- Local assets sit beside the body of a **directory** bundle, referenced relatively, and are served under
that bundle's URL: a page at `/art/monsoon/` linking `10-first.jpg` is served from
`content/art/monsoon/10-first.jpg`. Assets never live in frontmatter. Moving a bundle moves its assets —
the entire point of bundles.
- A **single-file** bundle owns no assets. Its neighbours belong to its section rather than to it, and its
slash-terminated URL has nothing beneath it, so an author with assets writes a directory bundle.
- A `.md` inside a bundle directory is never served as an asset: it is a bundle with its own URL, or a
fragment that was never addressable.
- A directory starting with `_` other than `_index` is ignored, and so is a **file**: `_tools.md` is a
fragment, not a bundle. That is how a file meant only to be included avoids taking a URL of its own,
appearing in its section's listing, and turning its bundle into a one-member series.
+6 -5
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `ebf63d1` on 2026-07-30 — update this line every change.
**Verified against:** `5e49094` 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
@@ -19,15 +19,16 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/ext/widows/` | second feature: joins the last two words of a paragraph or heading with a non-breaking space, over the tree so code spans are safe | 108 |
| `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/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`), wiring, startup — the only place things are assembled | 60 |
| `*_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, 404 | 1946 |
| `*_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, 404 | 2050 |
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, plus `/robots.txt` and
`/sitemap.xml`.
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, a directory bundle's own files under its
URL, 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
@@ -47,7 +48,7 @@ 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 | 7 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
| Routing cases | 8 | **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 |
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
+13
View File
@@ -147,6 +147,19 @@ func Parse(name string, data []byte) (Bundle, error) {
return b, nil
}
// Assets is the directory holding a bundle's own local files, and false for a bundle that has none.
//
// Only a directory bundle has one. A single-file bundle's neighbours belong to its section rather than to it,
// and its URL ends in a slash that no file beside it sits under — so an author with assets writes a directory
// bundle (content-model.md).
func (b Bundle) Assets() (string, bool) {
base := path.Base(b.Path)
if strings.HasPrefix(base, "index.") || strings.HasPrefix(base, "_index.") {
return path.Dir(b.Path), true
}
return "", false
}
// 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 {
+58
View File
@@ -0,0 +1,58 @@
package web
import (
"io/fs"
"net/http"
"path"
"strings"
"khosra/internal/content"
)
// serveAsset answers a file that lives inside a bundle's own directory, reporting whether it handled the
// request.
//
// This is how a relative `src` in a body actually resolves: a page at /art/monsoon/ links `10-first.jpg`,
// the browser asks for /art/monsoon/10-first.jpg, and the bytes come from that bundle's directory.
//
// The bundle is looked up *first*, and the file is only then read from the directory that bundle owns — never
// from a path assembled out of the request. That ordering is what ADR-0024 requires: every byte inside a
// bundle inherits the bundle's publish status, so no route may serve bundle bytes by path alone. When drafts
// arrive (queue 19) the filter belongs here, beside the lookup, and nowhere else.
func serveAsset(w http.ResponseWriter, req *http.Request, site *content.Site, siteFS fs.FS, res resolution) bool {
if siteFS == nil {
return false
}
dir, file := path.Split(res.key)
route := strings.TrimSuffix(dir, "/")
switch {
case route == "" || file == "":
return false
case strings.Contains(file, "/") || file == "." || file == "..":
return false
case strings.HasSuffix(strings.ToLower(file), ".md"):
// A .md inside a bundle is content or a fragment, never an asset. Serving it raw would publish the
// partials an author never addressed (content-model.md).
return false
}
key, live := site.KeyFor(route)
if !live {
return false
}
b, _, found := site.Lookup(key, res.lang)
if !found {
return false
}
assets, hasAssets := b.Assets()
if !hasAssets {
return false
}
name := path.Join(assets, file)
if info, err := fs.Stat(siteFS, name); err != nil || info.IsDir() {
return false
}
// ServeFileFS handles the content type, conditional requests and ranges — an image wants all three, and
// none of them is this engine's business to reimplement.
http.ServeFileFS(w, req, siteFS, name)
return true
}
+104
View File
@@ -0,0 +1,104 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func assetHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{
// A directory bundle owns the files beside it.
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n{{< gallery >}}\n")},
// A Bengali variant, so /bn/ is a language prefix at all: the engine treats a leading segment as a
// language only when some bundle is written in it (content-model.md).
"content/art/monsoon/index.bn.md": {Data: []byte("---\ntitle: বর্ষা\n---\nx\n")},
"content/art/monsoon/10-first.jpg": {Data: []byte("\xff\xd8\xff-not-really-a-jpeg")},
"content/art/monsoon/_notes.md": {Data: []byte("a fragment\n")},
"content/art/monsoon/notes.md": {Data: []byte("---\ntitle: Notes\n---\nx\n")},
// A single-file bundle has no directory of its own.
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\nx\n")},
"content/posts/loose.jpg": {Data: []byte("bytes")},
// A series landing page, whose directory also holds child bundles.
"content/comics/series/_index.md": {Data: []byte("---\ntitle: Series\n---\nx\n")},
"content/comics/series/cover.png": {Data: []byte("png")},
"content/comics/series/one.md": {Data: []byte("---\ntitle: One\n---\nx\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
}
func TestABundlesOwnFilesAreServed(t *testing.T) {
h := assetHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/art/monsoon/10-first.jpg", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200 — this is what a relative src in a body resolves to", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
t.Errorf("content-type = %q, want image/jpeg from the extension", ct)
}
// A landing page's directory works the same way.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/series/cover.png", nil))
if rec.Code != http.StatusOK {
t.Errorf("a series landing page owns its files too: got %d", rec.Code)
}
// Under a language prefix as well: an image is not translated.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/art/monsoon/10-first.jpg", nil))
if rec.Code != http.StatusOK {
t.Errorf("prefixed request for the same file: got %d", rec.Code)
}
}
func TestMarkdownInsideABundleIsNeverAnAsset(t *testing.T) {
// Serving these raw would publish fragments an author never addressed, and hand out the source of a
// bundle that has its own rendered URL.
h := assetHandler(t)
for _, path := range []string{"/art/monsoon/_notes.md", "/art/monsoon/notes.md"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
}
}
}
func TestASingleFileBundleOwnsNoDirectory(t *testing.T) {
// Its neighbours belong to the section, not to it — so nothing is served under its slash-terminated URL.
h := assetHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/plain/loose.jpg", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
}
func TestAnAssetRequestCannotWanderOffItsBundle(t *testing.T) {
h := assetHandler(t)
for _, path := range []string{
"/art/monsoon/../../posts/loose.jpg",
"/art/nonexistent/10-first.jpg",
"/art/monsoon/missing.jpg",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
}
}
}
+6 -2
View File
@@ -18,7 +18,7 @@ import (
func Handler(site *content.Site, r *render.Renderer, siteFS 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)
serve(w, req, site, r, siteFS)
})
// 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.
@@ -122,7 +122,7 @@ 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) {
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS) {
res, ok := resolve(req.URL.Path, site)
if !ok {
http.NotFound(w, req)
@@ -151,6 +151,10 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
http.Redirect(w, req, content.URL(site.RouteOf(canonical), res.lang), http.StatusMovedPermanently)
return
}
// A file inside a bundle's directory: how a relative src in a body resolves (ADR-0024).
if serveAsset(w, req, site, siteFS, res) {
return
}
if serveListing(w, req, site, r, res) {
return
}