publish a bundle's extras as a browsable tree
Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.
Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.
Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.
Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.
Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
This commit is contained in:
+15
-10
@@ -262,19 +262,24 @@ returns early on content *shape* ("no images here"), which has nothing to do wit
|
||||
This is also how a template is chosen: type default, then section override, then the bundle's `view`
|
||||
(ADR-0019).
|
||||
|
||||
## Extras: enumerated local assets `[spec]`
|
||||
## Extras: enumerated local assets
|
||||
|
||||
A bundle may hold a directory of supporting files — drafts, notes, logs, scans, media. Default `extras/`,
|
||||
renamed by a cascade key so a comic can use `process/` and a story `notes/`. Recorded intent (`ideas/deferred-decisions.md`).
|
||||
A bundle may hold `extras/` — drafts, notes, logs, scans, media — published as artefacts of the process
|
||||
(ADR-0047).
|
||||
|
||||
- The bundle scanner **skips it entirely**. A `.md` inside is an asset, not a bundle: no frontmatter, no
|
||||
identity, no language variants.
|
||||
- Enumerated as a tree, sorted by filename — the sparse numeric-prefix convention orders entries without
|
||||
putting numbers in URLs (ADR-0016).
|
||||
- Each entry is classified by extension: `markdown`, `text`, `image`, `pdf`, `audio`, `video`, `other`.
|
||||
Markdown and text are rendered; everything else is served as bytes.
|
||||
- `…/extras/{path}` renders the listing with that entry selected; `?raw` returns the bytes.
|
||||
- Excluded from feeds, queries and search.
|
||||
identity, no language variants, and no URL of its own.
|
||||
- Enumerated as a tree, sorted by path — the sparse numeric-prefix convention orders entries without putting
|
||||
numbers in URLs (ADR-0016).
|
||||
- Each entry is classified by extension: `markdown`, `text`, `image`, `pdf`, `audio`, `video`, `other`, `dir`.
|
||||
Markdown and text are rendered; everything else is offered as bytes.
|
||||
- `…/extras/` lists the tree; `…/extras/{path}` lists it with that entry selected; `?raw` returns the file
|
||||
itself. Selecting is an ordinary link and a full page, so no JavaScript is involved.
|
||||
- An entry must be one the listing found, so a path that walks out of the tree is simply not there.
|
||||
- Excluded from queries, feeds, sitemaps, the derivative pass and (later) search. `extras` is therefore a name
|
||||
no child of a bundle may use.
|
||||
- The name is fixed. Renaming it per section — `process/` for a comic, `notes/` for a story — waits for the
|
||||
section-level settings cascade, which nothing reads yet.
|
||||
|
||||
## Visibility of everything inside a bundle
|
||||
|
||||
|
||||
@@ -668,3 +668,29 @@ is a slightly larger contract than "engine decides", and a theme that ignores `.
|
||||
`.Items` exists.
|
||||
Revisit if: a verdict here is contradicted by a feature that cannot be built under it. Then the verdict was
|
||||
wrong, and it is amended by name rather than worked around.
|
||||
|
||||
## ADR-0047 — Extras: a bundle's supporting files, enumerated and browsable
|
||||
Date: 2026-07-31 · Status: accepted (re-adopts the parked extras entry in `ideas/deferred-decisions.md`, with two
|
||||
deviations named below)
|
||||
Decision: a bundle may hold `extras/`, which the scanner skips entirely — a `.md` in there is an asset, never a
|
||||
bundle. The engine enumerates the tree, sorts it by path, classifies each entry by extension, renders markdown
|
||||
and text, and serves anything else as bytes. One route with two behaviours: `…/extras/{path}` renders the
|
||||
listing with that entry selected, and `?raw` returns the file. The bundle is looked up first, so an unpublished
|
||||
bundle hides its extras exactly as it hides its body (ADR-0024). Entries are excluded from queries, feeds,
|
||||
sitemaps and the derivative pass.
|
||||
Two deviations from the parked shape. The directory name is fixed rather than a cascade key, because the
|
||||
section-level cascade is still parked and nothing reads one. And a request resolves an entry against the
|
||||
*enumeration* rather than against the filesystem, so a path that walks out of the tree is simply not found —
|
||||
`os.Root` would refuse an escape anyway, but not being in the listing is a stronger and cheaper answer.
|
||||
Why: drafts, notes, logs and scans are worth publishing as artefacts of the process, and they are not bundles —
|
||||
no frontmatter, no identity, no language variants. Everything needed already existed: the scanner had an
|
||||
exclusion rule for directories, `Assets()` knew which bundles own a directory, and `Lookup` already decided
|
||||
visibility. `?raw` is a parameter rather than another path because it is a second *representation* of one entry,
|
||||
not a second entry.
|
||||
Consequence: cheap — no new primitive, no JavaScript, and selecting an entry is an ordinary link with a full
|
||||
re-render, so a sidebar-and-pane layout is the theme's business and works without scripting. Expensive —
|
||||
`extras` becomes a name no child of a bundle may use, one more template kind exists, and a large extras
|
||||
directory is walked per request, which the render benchmark says costs nothing at this size but is the next
|
||||
thing a cache would want.
|
||||
Revisit if: extras need per-file metadata — a caption, an order, a date. Then they are bundles after all, and
|
||||
this decision was wrong.
|
||||
|
||||
+11
-8
@@ -1,6 +1,6 @@
|
||||
# State
|
||||
|
||||
**Verified against:** `d7543a7` on 2026-07-30 — update this line every change.
|
||||
**Verified against:** `73d539d` 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
|
||||
@@ -11,30 +11,33 @@ If this file disagrees with the code, the code is right and this file is a bug.
|
||||
| `internal/content/doc.go` | package comment | 5 |
|
||||
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, partial files, permalink building | 381 |
|
||||
| `internal/content/clock.go` | the one place the engine reads the wall clock, which `verify.sh` enforces by filename | 12 |
|
||||
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) | 96 |
|
||||
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) | 59 |
|
||||
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility | 424 |
|
||||
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 459 |
|
||||
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods | 422 |
|
||||
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` | 117 |
|
||||
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 |
|
||||
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
|
||||
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
|
||||
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) | 564 |
|
||||
| `internal/ext/scaffold/` | writes a new bundle into the site root through `os.Root`: a directory bundle, a draft, never an overwrite | 102 |
|
||||
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering | 216 |
|
||||
| `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/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect | 168 |
|
||||
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup | 96 |
|
||||
| `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 |
|
||||
| `internal/web/web.go` | handler: `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure | 206 |
|
||||
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass. `main` dispatches subcommands, `runServe` assembles the server — still the only place anything is wired | 116 |
|
||||
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 |
|
||||
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing | 42 |
|
||||
| `*_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, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding | 2732 |
|
||||
| `*_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, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras | 2887 |
|
||||
|
||||
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/`, Atom feeds per site,
|
||||
section and tag, plus `/robots.txt` and `/sitemap.xml`.
|
||||
section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `/sitemap.xml`.
|
||||
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
|
||||
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
|
||||
`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new`
|
||||
@@ -58,7 +61,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 and shortcodes 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 | 9 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
|
||||
| Routing cases | 10 | **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 — **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 |
|
||||
|
||||
+12
-8
@@ -167,18 +167,22 @@ Breaking the contract is not a feature — it is a new contract version, and it
|
||||
|
||||
## Extras view
|
||||
|
||||
For a request under a bundle's extras directory (`ideas/deferred-decisions.md`) the engine additionally provides:
|
||||
A request under a bundle's extras directory renders `templates/extras.html`, whose `main` block receives
|
||||
(ADR-0047):
|
||||
|
||||
| Field | Contents |
|
||||
|---|---|
|
||||
| `.Extras.Entries` | the tree: `Name`, `Path`, `URL`, `RawURL`, `Kind`, `Size`, `IsDir`, `ModTime` |
|
||||
| `.Extras.Selected` | nil on the bare listing; otherwise the chosen entry |
|
||||
| `.Extras.Selected.HTML` | rendered output for `markdown` and `text` kinds; empty otherwise |
|
||||
| `.Extras.Selected.RawURL` | always present — for `<img src>`, `<object data>`, or a download link |
|
||||
| `.Bundle` | the page these files belong to, as an `.Items` entry — so a theme has its title and a way back |
|
||||
| `.Entries` | the tree, sorted by path: `.Name`, `.Path`, `.Kind`, `.Size`, `.IsDir` |
|
||||
| `.Selected` | absent on the bare listing; otherwise the entry the URL named |
|
||||
| `.Selected.HTML` | the rendered file for a `markdown` or `text` entry, empty for anything else |
|
||||
| `.Selected.RawURL` | always present — for `<img src>`, `<object data>`, or a download link |
|
||||
|
||||
`.Bundle` is the parent, so a theme has its title, language and breadcrumb. Selecting an entry is an
|
||||
ordinary link and a full re-render, so a sidebar-plus-pane layout needs no JavaScript; swapping the pane
|
||||
client-side is a later enhancement over working markup, never a requirement.
|
||||
`.Kind` is one of `markdown`, `text`, `image`, `pdf`, `audio`, `video`, `other`, `dir`, classified by
|
||||
extension. An entry's `.Path` is relative to the extras listing, so a template can link it directly.
|
||||
|
||||
Selecting an entry is an ordinary link and a full page, so a sidebar-plus-pane layout needs no JavaScript.
|
||||
Swapping the pane client-side is a later enhancement over working markup, never a requirement.
|
||||
|
||||
## What the engine does not provide
|
||||
|
||||
|
||||
@@ -94,6 +94,10 @@ type declaration, so this and declared types land together.
|
||||
|
||||
## Extras
|
||||
|
||||
Status: **adopted → ADR-0047**, with the shape discovered by building it. Two things changed on contact: the
|
||||
directory name is fixed rather than a cascade key (nothing reads a section-level setting yet), and an entry is
|
||||
resolved against the enumeration rather than the filesystem.
|
||||
|
||||
Deferred because not in the MVP, and its shape should be discovered by building it.
|
||||
|
||||
## Extras: local assets, enumerated and browsable
|
||||
|
||||
@@ -336,9 +336,14 @@ func isPartial(base string) bool {
|
||||
return strings.HasPrefix(name, "_") && name != "_index"
|
||||
}
|
||||
|
||||
// skipDir reports whether a directory is not content: hidden, or underscore-prefixed.
|
||||
// skipDir reports whether a directory is not content: hidden, underscore-prefixed, or a bundle's extras.
|
||||
//
|
||||
// Extras are skipped entirely, so a `.md` in there is an asset and never a bundle — no frontmatter, no identity,
|
||||
// no language variants, and no URL of its own beyond the extras route (content-model.md).
|
||||
func skipDir(base string) bool {
|
||||
return strings.HasPrefix(base, ".") && base != "." || strings.HasPrefix(base, "_")
|
||||
return strings.HasPrefix(base, ".") && base != "." ||
|
||||
strings.HasPrefix(base, "_") ||
|
||||
base == ExtrasDir
|
||||
}
|
||||
|
||||
// splitFrontmatter separates a leading --- delimited YAML block from the body. A file without one is
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtrasDir is the directory inside a bundle holding supporting files — drafts, notes, logs, scans.
|
||||
//
|
||||
// A fixed name for now. The parked settings cascade would let a comic call it `process/` and a story `notes/`,
|
||||
// which is a section-level setting and nothing reads one yet (`ideas/deferred-decisions.md`).
|
||||
const ExtrasDir = "extras"
|
||||
|
||||
// Entry is one file or directory inside a bundle's extras.
|
||||
type Entry struct {
|
||||
// Name is the entry's own name; Path is its path within the extras directory, which is what a URL carries.
|
||||
Name, Path string
|
||||
// Kind classifies the entry for a theme: markdown, text, image, pdf, audio, video, other, or dir.
|
||||
Kind string
|
||||
// Size is the file's length in bytes, zero for a directory.
|
||||
Size int64
|
||||
// IsDir is true for a directory, which a theme renders as something to open rather than to show.
|
||||
IsDir bool
|
||||
}
|
||||
|
||||
// Extras lists everything inside a bundle's extras directory, depth first, sorted by path.
|
||||
//
|
||||
// Sorted so the sparse numeric-prefix convention orders a set without putting numbers in URLs (ADR-0016), and
|
||||
// because a directory read has no order worth relying on. A bundle with no extras returns nothing, which is not
|
||||
// an error — most bundles have none.
|
||||
func Extras(fsys fs.FS, b Bundle) []Entry {
|
||||
assets, hasAssets := b.Assets()
|
||||
if !hasAssets || fsys == nil {
|
||||
return nil
|
||||
}
|
||||
root := path.Join(assets, ExtrasDir)
|
||||
var found []Entry
|
||||
err := fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || p == root {
|
||||
// A missing extras directory is the common case, not a problem to report.
|
||||
return nil
|
||||
}
|
||||
rel := strings.TrimPrefix(p, root+"/")
|
||||
entry := Entry{Name: d.Name(), Path: rel, IsDir: d.IsDir(), Kind: "dir"}
|
||||
if !d.IsDir() {
|
||||
entry.Kind = KindOf(d.Name())
|
||||
if info, statErr := d.Info(); statErr == nil {
|
||||
entry.Size = info.Size()
|
||||
}
|
||||
}
|
||||
found = append(found, entry)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool { return found[i].Path < found[j].Path })
|
||||
return found
|
||||
}
|
||||
|
||||
// KindOf classifies a filename for a theme, by extension.
|
||||
//
|
||||
// By extension and not by content: reading every file to sniff it would turn listing a directory into reading
|
||||
// it, and the answer only decides how a theme presents the entry.
|
||||
func KindOf(name string) string {
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".md", ".markdown":
|
||||
return "markdown"
|
||||
case ".txt", ".log", ".csv", ".json", ".yaml", ".yml", ".toml":
|
||||
return "text"
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif":
|
||||
return "image"
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".mp3", ".ogg", ".wav", ".flac", ".m4a":
|
||||
return "audio"
|
||||
case ".mp4", ".webm", ".mov":
|
||||
return "video"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// ExtrasURL is the address of a bundle's extras listing, or of one entry within it.
|
||||
func ExtrasURL(route, lang, entry string) string {
|
||||
base := URL(route, lang) + ExtrasDir + "/"
|
||||
if entry == "" {
|
||||
return base
|
||||
}
|
||||
return base + entry
|
||||
}
|
||||
@@ -47,6 +47,11 @@ func Derive(siteFS fs.FS, cacheDir string) (int, error) {
|
||||
if err != nil || d.IsDir() || !derivable(p) {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(p, "/"+content.ExtrasDir+"/") {
|
||||
// Extras are supporting material, not published pictures: deriving sizes nothing references would
|
||||
// only slow startup down (content-model.md).
|
||||
return nil
|
||||
}
|
||||
data, err := fs.ReadFile(siteFS, p)
|
||||
if err != nil {
|
||||
slog.Error("skipping unreadable picture", "path", p, "err", err)
|
||||
|
||||
@@ -17,11 +17,13 @@ import (
|
||||
// A phrase may carry %s placeholders, filled in order by the caller. Templates reach these through the
|
||||
// `t`, `num` and `day` functions; nothing here is content, and content never comes from here.
|
||||
var chrome = map[string]map[string]string{
|
||||
"newer": {"en": "Newer", "bn": "নতুন"},
|
||||
"older": {"en": "Older", "bn": "পুরোনো"},
|
||||
"empty": {"en": "Nothing here yet.", "bn": "এখনও কিছু নেই।"},
|
||||
"page-of": {"en": "Page %s of %s", "bn": "পৃষ্ঠা %s / %s"},
|
||||
"position": {"en": "%s of %s", "bn": "%s / %s"},
|
||||
"newer": {"en": "Newer", "bn": "নতুন"},
|
||||
"older": {"en": "Older", "bn": "পুরোনো"},
|
||||
"empty": {"en": "Nothing here yet.", "bn": "এখনও কিছু নেই।"},
|
||||
"page-of": {"en": "Page %s of %s", "bn": "পৃষ্ঠা %s / %s"},
|
||||
"position": {"en": "%s of %s", "bn": "%s / %s"},
|
||||
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
|
||||
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
|
||||
}
|
||||
|
||||
// months are Gregorian month names per language, indexed by [time.Month]-1.
|
||||
|
||||
+54
-91
@@ -12,7 +12,6 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
@@ -24,91 +23,6 @@ import (
|
||||
//go:embed templates
|
||||
var themeFS embed.FS
|
||||
|
||||
// head is what every kind of page shares: the document shell the base template needs. Absence is the
|
||||
// zero value — a template reads what exists and never fails on a missing field (invariant 1).
|
||||
type head struct {
|
||||
// Title may be empty for a bundle; a listing always has one.
|
||||
Title string
|
||||
// Lang is the locale being served.
|
||||
Lang string
|
||||
// Canonical is the permalink of what was actually served, which differs from the URL requested when
|
||||
// the fallback chain supplied another language (ADR-0009).
|
||||
Canonical string
|
||||
// Alternates lists every language this key exists in, for hreflang.
|
||||
Alternates []Alternate
|
||||
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
||||
Style template.CSS
|
||||
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
|
||||
Site content.Settings
|
||||
}
|
||||
|
||||
// Page is one bundle rendered.
|
||||
type Page struct {
|
||||
head
|
||||
// Key is the bundle's identity, useful for building links.
|
||||
Key string
|
||||
// HTML is the rendered body.
|
||||
HTML template.HTML
|
||||
// Extra carries every frontmatter key the parser does not name (ADR-0002).
|
||||
Extra map[string]any
|
||||
// Sequence is the series this page sits in, nil when it sits in none.
|
||||
Sequence *Sequence
|
||||
}
|
||||
|
||||
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
|
||||
// (ADR-0033).
|
||||
type Sequence struct {
|
||||
// Title is the series' title, empty when the landing page omits one; URL is its permalink.
|
||||
Title, URL string
|
||||
// Members are every entry in reading order — ascending, unlike a dated listing.
|
||||
Members []Item
|
||||
// Index is this page's 1-based position, zero when this page is the series landing itself. Count is
|
||||
// how many members there are.
|
||||
Index, Count int
|
||||
// Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
|
||||
// the *earlier* entry, the opposite sense of a listing's PrevURL.
|
||||
Prev, Next *Item
|
||||
// First and Last are the ends of the series, set whenever it has members.
|
||||
First, Last *Item
|
||||
}
|
||||
|
||||
// List is a collection page: the result of a Query, one page of it.
|
||||
type List struct {
|
||||
head
|
||||
// Items are the entries on this page, in the Query's order.
|
||||
Items []Item
|
||||
// Page is 1-based; Pages is the total, at least 1 even when empty.
|
||||
Page, Pages int
|
||||
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
|
||||
PrevURL, NextURL string
|
||||
// Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
|
||||
// are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
|
||||
Groups []Group
|
||||
}
|
||||
|
||||
// Group is a named run of entries within a listing.
|
||||
type Group struct {
|
||||
Name string
|
||||
Items []Item
|
||||
}
|
||||
|
||||
// Item is one entry in a listing.
|
||||
type Item struct {
|
||||
Title string
|
||||
Key string
|
||||
URL string
|
||||
Date time.Time
|
||||
// Section is the entry's top-level section, so a flat listing can label where an entry came from without
|
||||
// the engine deciding that it must be grouped (ADR-0046).
|
||||
Section string
|
||||
}
|
||||
|
||||
// Alternate is one language a bundle exists in.
|
||||
type Alternate struct {
|
||||
Lang string
|
||||
URL string
|
||||
}
|
||||
|
||||
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
|
||||
// per request (conventions.md).
|
||||
type Renderer struct {
|
||||
@@ -118,8 +32,10 @@ type Renderer struct {
|
||||
list *template.Template
|
||||
// partials are named fragments a feature renders through, so no feature decides markup (ADR-0036).
|
||||
partials *template.Template
|
||||
md goldmark.Markdown
|
||||
style template.CSS
|
||||
// extras is the set for a bundle's supporting-file listing.
|
||||
extras *template.Template
|
||||
md goldmark.Markdown
|
||||
style template.CSS
|
||||
// files is the site root, handed to features through Origin. Nil when there is none.
|
||||
files fs.FS
|
||||
// settings are the site's declarations, constant for the life of the process.
|
||||
@@ -209,12 +125,16 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("partial templates: %w", err)
|
||||
}
|
||||
extras, err := parseSet(siteFS, "templates/base.html", "templates/extras.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extras templates: %w", err)
|
||||
}
|
||||
css, err := readStyle(siteFS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS, settings: settings,
|
||||
siteFS: siteFS, extend: extend}
|
||||
r := &Renderer{page: page, list: list, partials: partials, extras: extras, style: css, files: siteFS,
|
||||
settings: settings, siteFS: siteFS, extend: extend}
|
||||
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
|
||||
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
|
||||
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
|
||||
@@ -263,7 +183,8 @@ func (r *Renderer) fresh() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.page, r.list, r.partials, r.style, r.md = next.page, next.list, next.partials, next.style, next.md
|
||||
r.page, r.list, r.partials, r.extras = next.page, next.list, next.partials, next.extras
|
||||
r.style, r.md = next.style, next.md
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -322,6 +243,48 @@ func readStyle(siteFS fs.FS) (template.CSS, error) {
|
||||
return template.CSS(data), nil
|
||||
}
|
||||
|
||||
// Extras renders a bundle's supporting files, with one entry selected or none.
|
||||
//
|
||||
// The engine enumerates, classifies and renders what it can; how a tree and a selected file look is the theme's
|
||||
// (ADR-0046). A file it cannot render still arrives with a RawURL, because "cannot show it inline" is not
|
||||
// "cannot offer it".
|
||||
func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error) {
|
||||
if err := r.fresh(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := b.Title
|
||||
if title == "" {
|
||||
title = b.Key
|
||||
}
|
||||
x := Extras{
|
||||
head: r.head(title, served, content.ExtrasURL(b.Route, served, "")),
|
||||
Bundle: r.item(b, served),
|
||||
Entries: entries,
|
||||
}
|
||||
if selected != nil {
|
||||
x.Selected = selected
|
||||
x.head.Canonical = r.absolute(content.ExtrasURL(b.Route, served, selected.Path))
|
||||
}
|
||||
return r.execute(r.extras, x, b.Key+"/"+content.ExtrasDir)
|
||||
}
|
||||
|
||||
// RenderText converts a markdown or plain-text file for display inside an extras listing.
|
||||
//
|
||||
// Markdown goes through the same converter as a body, so an included note reads the way the author wrote it.
|
||||
// Anything else is shown as preformatted text, escaped — a log file is not markup.
|
||||
func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error) {
|
||||
if kind == "markdown" {
|
||||
var out bytes.Buffer
|
||||
if err := r.md.Convert(data, &out); err != nil {
|
||||
return "", fmt.Errorf("markdown: %w", err)
|
||||
}
|
||||
return template.HTML(out.String()), nil
|
||||
}
|
||||
var escaped bytes.Buffer
|
||||
template.HTMLEscape(&escaped, data)
|
||||
return template.HTML("<pre>" + escaped.String() + "</pre>"), nil
|
||||
}
|
||||
|
||||
// Bundle renders one bundle into a complete page.
|
||||
//
|
||||
// served is the language actually chosen by the fallback chain, and variants every language the key
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "main" -}}
|
||||
<article>
|
||||
<h1>{{if .Bundle.Title}}{{.Bundle.Title}}{{else}}{{.Bundle.Key}}{{end}} · {{t .Lang "extras"}}</h1>
|
||||
<p><a href="{{.Bundle.URL}}">{{t .Lang "back-to-page"}}</a></p>
|
||||
{{- if .Entries}}
|
||||
<ul class="extras">
|
||||
{{- range .Entries}}
|
||||
<li>{{if .IsDir}}{{.Name}}/{{else}}<a href="{{.Path}}">{{.Name}}</a> <span class="kind">{{.Kind}}</span>{{end}}</li>
|
||||
{{- end}}
|
||||
</ul>
|
||||
{{- else}}
|
||||
<p>{{t .Lang "empty"}}</p>
|
||||
{{- end}}
|
||||
{{- with .Selected}}
|
||||
<section class="selected">
|
||||
<h2>{{.Name}}</h2>
|
||||
{{- if .HTML}}
|
||||
{{.HTML}}
|
||||
{{- else if eq .Kind "image"}}
|
||||
<img src="{{.RawURL}}" alt="{{.Name}}">
|
||||
{{- else}}
|
||||
<p><a href="{{.RawURL}}">{{.Name}}</a></p>
|
||||
{{- end}}
|
||||
</section>
|
||||
{{- end}}
|
||||
</article>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,117 @@
|
||||
// The types in this file are the theme contract in Go: what a template receives, nothing about how it is
|
||||
// produced. Split from render.go when that file crossed the size warning — the seam was already here, since
|
||||
// docs/theme-contract.md describes exactly this and nothing else.
|
||||
|
||||
package render
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
// head is what every kind of page shares: the document shell the base template needs. Absence is the
|
||||
// zero value — a template reads what exists and never fails on a missing field (invariant 1).
|
||||
type head struct {
|
||||
// Title may be empty for a bundle; a listing always has one.
|
||||
Title string
|
||||
// Lang is the locale being served.
|
||||
Lang string
|
||||
// Canonical is the permalink of what was actually served, which differs from the URL requested when
|
||||
// the fallback chain supplied another language (ADR-0009).
|
||||
Canonical string
|
||||
// Alternates lists every language this key exists in, for hreflang.
|
||||
Alternates []Alternate
|
||||
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
||||
Style template.CSS
|
||||
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
|
||||
Site content.Settings
|
||||
}
|
||||
|
||||
// Page is one bundle rendered.
|
||||
type Page struct {
|
||||
head
|
||||
// Key is the bundle's identity, useful for building links.
|
||||
Key string
|
||||
// HTML is the rendered body.
|
||||
HTML template.HTML
|
||||
// Extra carries every frontmatter key the parser does not name (ADR-0002).
|
||||
Extra map[string]any
|
||||
// Sequence is the series this page sits in, nil when it sits in none.
|
||||
Sequence *Sequence
|
||||
}
|
||||
|
||||
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
|
||||
// (ADR-0033).
|
||||
type Sequence struct {
|
||||
// Title is the series' title, empty when the landing page omits one; URL is its permalink.
|
||||
Title, URL string
|
||||
// Members are every entry in reading order — ascending, unlike a dated listing.
|
||||
Members []Item
|
||||
// Index is this page's 1-based position, zero when this page is the series landing itself. Count is
|
||||
// how many members there are.
|
||||
Index, Count int
|
||||
// Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
|
||||
// the *earlier* entry, the opposite sense of a listing's PrevURL.
|
||||
Prev, Next *Item
|
||||
// First and Last are the ends of the series, set whenever it has members.
|
||||
First, Last *Item
|
||||
}
|
||||
|
||||
// Extras is a bundle's supporting files, browsable (ADR-0047).
|
||||
type Extras struct {
|
||||
head
|
||||
// Bundle is the page these files belong to, so a theme has its title and a way back.
|
||||
Bundle Item
|
||||
// Entries is the whole tree, sorted by path.
|
||||
Entries []content.Entry
|
||||
// Selected is the entry the URL named, nil on the bare listing.
|
||||
Selected *Selected
|
||||
}
|
||||
|
||||
// Selected is the entry a request picked out of the tree.
|
||||
type Selected struct {
|
||||
content.Entry
|
||||
// HTML is the rendered file for a markdown or text entry, empty for anything else.
|
||||
HTML template.HTML
|
||||
// RawURL always works: it is how a theme embeds an image, links a PDF, or offers a download.
|
||||
RawURL string
|
||||
}
|
||||
|
||||
// List is a collection page: the result of a Query, one page of it.
|
||||
type List struct {
|
||||
head
|
||||
// Items are the entries on this page, in the Query's order.
|
||||
Items []Item
|
||||
// Page is 1-based; Pages is the total, at least 1 even when empty.
|
||||
Page, Pages int
|
||||
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
|
||||
PrevURL, NextURL string
|
||||
// Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
|
||||
// are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
|
||||
Groups []Group
|
||||
}
|
||||
|
||||
// Group is a named run of entries within a listing.
|
||||
type Group struct {
|
||||
Name string
|
||||
Items []Item
|
||||
}
|
||||
|
||||
// Item is one entry in a listing.
|
||||
type Item struct {
|
||||
Title string
|
||||
Key string
|
||||
URL string
|
||||
Date time.Time
|
||||
// Section is the entry's top-level section, so a flat listing can label where an entry came from without
|
||||
// the engine deciding that it must be grouped (ADR-0046).
|
||||
Section string
|
||||
}
|
||||
|
||||
// Alternate is one language a bundle exists in.
|
||||
type Alternate struct {
|
||||
Lang string
|
||||
URL string
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// serveExtras answers a bundle's supporting files: the tree, one entry selected, or an entry's raw bytes.
|
||||
//
|
||||
// The bundle is looked up first, so an unpublished bundle hides its extras exactly as it hides its assets and
|
||||
// its body — one rule, one place (ADR-0024).
|
||||
func serveExtras(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
||||
siteFS fs.FS, res resolution) bool {
|
||||
if siteFS == nil {
|
||||
return false
|
||||
}
|
||||
key, live := site.KeyFor(res.key)
|
||||
if !live {
|
||||
return false
|
||||
}
|
||||
b, served, found := site.Lookup(key, res.lang)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
assets, hasAssets := b.Assets()
|
||||
if !hasAssets {
|
||||
return false
|
||||
}
|
||||
entries := content.Extras(siteFS, b)
|
||||
if len(entries) == 0 {
|
||||
return false
|
||||
}
|
||||
if res.entry == "" {
|
||||
return renderExtras(w, r, b, served, entries, nil)
|
||||
}
|
||||
|
||||
entry, ok := find(entries, res.entry)
|
||||
if !ok || entry.IsDir {
|
||||
return false
|
||||
}
|
||||
name := path.Join(assets, content.ExtrasDir, entry.Path)
|
||||
// ?raw is a representation of the same entry rather than a different one, which is why it is a parameter
|
||||
// and not another path (ADR-0047).
|
||||
if req.URL.Query().Has("raw") {
|
||||
http.ServeFileFS(w, req, siteFS, name)
|
||||
return true
|
||||
}
|
||||
selected := &render.Selected{Entry: entry, RawURL: content.ExtrasURL(b.Route, served, entry.Path) + "?raw"}
|
||||
if entry.Kind == "markdown" || entry.Kind == "text" {
|
||||
data, err := fs.ReadFile(siteFS, name)
|
||||
if err != nil {
|
||||
slog.Error("extras entry unreadable", "path", name, "err", err)
|
||||
} else if html, err := r.RenderText(entry.Kind, data); err != nil {
|
||||
slog.Error("extras entry unrenderable", "path", name, "err", err)
|
||||
} else {
|
||||
selected.HTML = html
|
||||
}
|
||||
}
|
||||
return renderExtras(w, r, b, served, entries, selected)
|
||||
}
|
||||
|
||||
// renderExtras writes the listing, degrading like every other render failure.
|
||||
func renderExtras(w http.ResponseWriter, r *render.Renderer, b content.Bundle, served string,
|
||||
entries []content.Entry, selected *render.Selected) bool {
|
||||
out, err := r.Extras(b, served, entries, selected)
|
||||
if err != nil {
|
||||
slog.Error("extras failed", "key", b.Key, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
write(w, out, b.Key+"/"+content.ExtrasDir)
|
||||
return true
|
||||
}
|
||||
|
||||
// find locates an entry by its path within the tree.
|
||||
//
|
||||
// Chosen from the enumeration rather than probed on disk: an entry a request names has to be one the listing
|
||||
// showed, so a path that walks out of the tree is not found rather than refused.
|
||||
func find(entries []content.Entry, want string) (content.Entry, bool) {
|
||||
want = strings.TrimSuffix(want, "/")
|
||||
for _, e := range entries {
|
||||
if e.Path == want {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return content.Entry{}, false
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
func extrasFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"content/writing/story/index.md": {Data: []byte("---\ntitle: A Story\ndate: 2026-01-01\n---\nThe story itself.\n")},
|
||||
"content/writing/story/extras/notes.md": {Data: []byte("## Notes\n\nWith *emphasis*.\n")},
|
||||
"content/writing/story/extras/log.txt": {Data: []byte("day one: <not markup>\n")},
|
||||
"content/writing/story/extras/scan.jpg": {Data: []byte("jpeg bytes")},
|
||||
"content/writing/story/extras/drafts/v1.md": {Data: []byte("first attempt\n")},
|
||||
"content/writing/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-01-02\n---\nx\n")},
|
||||
}
|
||||
}
|
||||
|
||||
func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
|
||||
t.Helper()
|
||||
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, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestExtrasAreNotBundles(t *testing.T) {
|
||||
// The scanner skips the directory entirely, so a .md in there has no URL, no identity and never appears in
|
||||
// a listing (content-model.md).
|
||||
fsys := extrasFS()
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range bundles {
|
||||
if strings.Contains(b.Key, "extras") {
|
||||
t.Errorf("%q was scanned as a bundle", b.Key)
|
||||
}
|
||||
}
|
||||
if len(bundles) != 2 {
|
||||
t.Errorf("scanned %d bundles, want 2", len(bundles))
|
||||
}
|
||||
h := extrasHandler(t, fsys)
|
||||
for _, path := range []string{"/writing/story/extras/notes/", "/writing/story/extras/drafts/v1/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "<h1>") {
|
||||
t.Errorf("GET %s rendered a page for something that is not a bundle", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheExtrasListingShowsTheTreeClassified(t *testing.T) {
|
||||
h := extrasHandler(t, extrasFS())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"notes.md", "log.txt", "scan.jpg", "drafts", "markdown", "text", "image"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("listing missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Sorted by path, so the order is the same on every request and a numeric prefix orders a set.
|
||||
if strings.Index(body, "drafts") > strings.Index(body, "log.txt") {
|
||||
t.Errorf("entries should be sorted by path:\n%s", body)
|
||||
}
|
||||
// A bundle with no extras has no listing at all.
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/plain/extras/", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("a bundle without extras = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectingAnEntryRendersWhatItCanAndOffersTheRest(t *testing.T) {
|
||||
h := extrasHandler(t, extrasFS())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/notes.md", nil))
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "<h2>Notes</h2>") || !strings.Contains(body, "<em>emphasis</em>") {
|
||||
t.Errorf("markdown should be rendered:\n%s", body)
|
||||
}
|
||||
// The tree is still there: selecting is a link, and the page is a full re-render, so no JavaScript is needed.
|
||||
if !strings.Contains(body, "log.txt") {
|
||||
t.Errorf("the listing should still be shown beside the selection:\n%s", body)
|
||||
}
|
||||
|
||||
// A text file is shown as text, escaped — a log is not markup.
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/log.txt", nil))
|
||||
body = rec.Body.String()
|
||||
if !strings.Contains(body, "<not markup>") {
|
||||
t.Errorf("a text entry must be escaped, not interpreted:\n%s", body)
|
||||
}
|
||||
|
||||
// A picture cannot be rendered inline as text, so it arrives with a raw URL instead.
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/scan.jpg", nil))
|
||||
if body := rec.Body.String(); !strings.Contains(body, "scan.jpg?raw") {
|
||||
t.Errorf("an image entry should offer its bytes:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawReturnsTheBytesAndNothingElse(t *testing.T) {
|
||||
h := extrasHandler(t, extrasFS())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/scan.jpg?raw", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "jpeg bytes" {
|
||||
t.Errorf("body = %q, want the file itself", got)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtrasCannotBeUsedToWanderOrToReadAnUnpublishedBundle(t *testing.T) {
|
||||
fsys := extrasFS()
|
||||
fsys["content/writing/secret/index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Secret\ndraft: true\n---\nx\n")}
|
||||
fsys["content/writing/secret/extras/plan.md"] = &fstest.MapFile{Data: []byte("the plan\n")}
|
||||
h := extrasHandler(t, fsys)
|
||||
for _, path := range []string{
|
||||
"/writing/story/extras/../../../etc/passwd",
|
||||
"/writing/story/extras/nothing.md",
|
||||
"/writing/story/extras/drafts", // a directory is not an entry to show
|
||||
"/writing/secret/extras/", // draft: the whole bundle is hidden (ADR-0024)
|
||||
"/writing/secret/extras/plan.md",
|
||||
"/writing/secret/extras/plan.md?raw",
|
||||
} {
|
||||
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())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "the plan") {
|
||||
t.Fatalf("GET %s leaked an unpublished bundle's extras", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-12
@@ -22,6 +22,10 @@ type resolution struct {
|
||||
redirect string
|
||||
// feed means the request named a feed of whatever scope key and tag describe (ADR-0043).
|
||||
feed bool
|
||||
// extras means the request named a bundle's supporting files; entry is the one it picked, or "" for the
|
||||
// listing (ADR-0047).
|
||||
extras bool
|
||||
entry string
|
||||
}
|
||||
|
||||
// resolve maps a request path to a bundle key and language.
|
||||
@@ -42,18 +46,9 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
return resolution{}, false
|
||||
}
|
||||
|
||||
lang := content.DefaultLang
|
||||
key := content.Normalise(trimmed)
|
||||
if head, rest, found := strings.Cut(key, "/"); found && head != "" {
|
||||
switch {
|
||||
case head == content.DefaultLang:
|
||||
// /en/… is a second spelling of the root form; send the client to the real one.
|
||||
return resolution{redirect: content.URL(rest, content.DefaultLang)}, true
|
||||
case site.HasLang(head):
|
||||
lang, key = head, rest
|
||||
}
|
||||
} else if key == content.DefaultLang {
|
||||
return resolution{redirect: "/"}, true
|
||||
lang, key, redirect := cutLang(content.Normalise(trimmed), site)
|
||||
if redirect != "" {
|
||||
return resolution{redirect: 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
|
||||
@@ -65,6 +60,12 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
return resolution{key: rest, lang: lang, feed: true}, true
|
||||
}
|
||||
|
||||
// An `extras` segment inside a key names a bundle's supporting files, and everything after it is one
|
||||
// entry's path — which may contain slashes, so it is taken whole (ADR-0047).
|
||||
if bundle, entry, isExtras := cutExtras(key); isExtras {
|
||||
return resolution{key: bundle, entry: entry, lang: lang, extras: 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.
|
||||
@@ -91,6 +92,38 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
return resolution{key: key, lang: lang, page: page}, true
|
||||
}
|
||||
|
||||
// cutLang splits a leading language prefix off a key.
|
||||
//
|
||||
// A prefix wins over a section of the same name, so a site with Bengali content cannot also have a section
|
||||
// called `bn` (content-model.md). `/en/…` is never live: it is a second spelling of the root form, and the
|
||||
// second return value is the redirect that collapses it (ADR-0009).
|
||||
func cutLang(key string, site *content.Site) (lang, rest, redirect string) {
|
||||
if head, after, found := strings.Cut(key, "/"); found && head != "" {
|
||||
switch {
|
||||
case head == content.DefaultLang:
|
||||
return "", "", content.URL(after, content.DefaultLang)
|
||||
case site.HasLang(head):
|
||||
return head, after, ""
|
||||
}
|
||||
} else if key == content.DefaultLang {
|
||||
return "", "", "/"
|
||||
}
|
||||
return content.DefaultLang, key, ""
|
||||
}
|
||||
|
||||
// cutExtras splits a key at its `extras` segment, into the bundle before it and the entry path after.
|
||||
func cutExtras(key string) (bundle, entry string, ok bool) {
|
||||
const marker = "/" + content.ExtrasDir
|
||||
switch {
|
||||
case strings.HasSuffix(key, marker):
|
||||
return strings.TrimSuffix(key, marker), "", true
|
||||
case strings.Contains(key, marker+"/"):
|
||||
before, after, _ := strings.Cut(key, marker+"/")
|
||||
return before, after, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -140,12 +140,27 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
|
||||
}
|
||||
return
|
||||
}
|
||||
if res.extras {
|
||||
if !serveExtras(w, req, site, r, siteFS, res) {
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
return
|
||||
}
|
||||
if res.tag != "" {
|
||||
if !serveTags(w, req, site, r, res) {
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
return
|
||||
}
|
||||
serveBundle(w, req, site, r, siteFS, res)
|
||||
}
|
||||
|
||||
// serveBundle answers a request that named a bundle, a section listing, or a file inside a bundle.
|
||||
//
|
||||
// Split from serve when that function crossed the length warning: serve decides *what kind* of thing was asked
|
||||
// for, this one answers the commonest kind.
|
||||
func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
||||
siteFS fs.FS, res resolution) {
|
||||
// A redirect target only exists for a path that resolves, so check the bundle before sending one:
|
||||
// otherwise a nonexistent page answers 301 and confirms nothing.
|
||||
// A request path is a route: a slug may have moved a bundle there, and moved another away (ADR-0035).
|
||||
|
||||
Reference in New Issue
Block a user