add khosra demo, and give the site a front page

The demo writes a whole site root that exercises every feature: two languages with
a fallback, a series with ordered chapters, a gallery, a figure, an include,
extras, tags across sections, a slug with an alias, an undated page, a draft, a
template override, static files and site.yaml. It generates its filler rather than
copying stored files, because nothing in this repository is content (ADR-0011) — and
that makes it a test of the engine rather than a fixture: anything khosra can do
that the demo cannot express is a gap.

Two things found by generating and then serving it, which is the whole point:

`khosra check` reported the demo's own series as mixing ordered and unordered
members. It was right — the chapter bodies *described* `order: 10` while the
frontmatter never carried it. The checker caught its own author.

And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but
"engine-owned" was never given an answer, so a visitor to the site's own address got
nothing. The root now lists every bundle, newest first, paginated like any other
listing, and 404s only when nothing is published. A hand-written home page stays a
separate decision, recorded as such.

Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean,
and every URL the demo promises answers — including the alias redirecting, the draft
hidden, and the front page rendering through the site's *own* template override.
This commit is contained in:
Claude Opus 5
2026-07-31 19:54:23 +06:00
committed by bdeshi
parent 96313e4eda
commit 4396303771
13 changed files with 360 additions and 14 deletions
+6 -1
View File
@@ -5,7 +5,7 @@ SITE ?= $(KHOSRA_SITE)
ADDR ?= localhost:8080
BIN := ./khosra
.PHONY: help build run check new test verify fmt tidy clean
.PHONY: help build run check new demo test verify fmt tidy clean
help: ## list targets
@grep -hE '^[a-z]+:.*##' $(MAKEFILE_LIST) | sed 's/:[^#]*## /|/' | column -t -s '|'
@@ -26,6 +26,11 @@ new: build ## scaffold a bundle: make new KEY=posts/hello-world SITE=/path/to/si
@test -n "$(KEY)" || { echo "set KEY=posts/hello-world"; exit 1; }
$(BIN) new -site "$(SITE)" "$(KEY)"
demo: build ## write a demo site that exercises every feature: make demo SITE=/tmp/khosra-demo
@test -n "$(SITE)" || { echo "set SITE=/path/to/empty/dir"; exit 1; }
@mkdir -p "$(SITE)"
$(BIN) demo -site "$(SITE)"
test: ## run tests with the race detector
go test -race ./...
+5 -2
View File
@@ -22,8 +22,8 @@ import (
func main() {
// Subcommands, matched before the flags are defined: `check` validates a site root, `new` scaffolds a
// bundle, and a bare `khosra` serves. A switch while there are two; a table when there is a third with
// flags worth sharing.
// bundle, `demo` writes a site that exercises everything, and a bare `khosra` serves. A switch while they
// share nothing; a table when two of them want the same flags.
if len(os.Args) > 1 {
switch os.Args[1] {
case "check":
@@ -32,6 +32,9 @@ func main() {
case "new":
runNew(os.Args[2:])
return
case "demo":
runDemo(os.Args[2:])
return
}
}
runServe()
+18
View File
@@ -8,6 +8,24 @@ import (
"khosra/internal/ext/scaffold"
)
// runDemo writes a demo site root: everything the engine can do, in one servable place.
func runDemo(args []string) {
flags := flag.NewFlagSet("demo", flag.ExitOnError)
site := flags.String("site", os.Getenv("KHOSRA_SITE"), "empty directory to write the demo into (or KHOSRA_SITE)")
if err := flags.Parse(args); err != nil {
fatal("cannot read the arguments", err)
}
if *site == "" {
fatal("no directory: pass -site", nil)
}
written, err := scaffold.Demo(*site)
if err != nil {
fatal("the demo was not written", err)
}
fmt.Printf("wrote %d files into %s\n\nserve it: khosra -site %s\ncheck it: khosra check -site %s\n",
len(written), *site, *site, *site)
}
// runNew scaffolds a bundle into the site root.
func runNew(args []string) {
flags := flag.NewFlagSet("new", flag.ExitOnError)
+9
View File
@@ -144,6 +144,9 @@ Overrides are normalised like everything else: writing a slug by hand does not e
Listings paginate at `/{section}/page/N/` (ADR-0028), so `page` is a reserved segment inside a section:
no bundle may be slugged `page`. Page one is the bare listing URL and `/page/1/` redirects to it.
`/` lists every bundle, newest first — the engine's own answer for the address it owns, and a 404 only when
nothing is published (ADR-0050). A hand-written home page is not possible yet, deliberately.
`/{section}/{slug}/`, no exceptions (ADR-0008). Section is the content type — the top-level directory
under `content/`, including `pages` — and slug comes from the bundle path or a `slug` override. So
`pages/about/` serves at `/pages/about/`, and the root stays engine-owned: emitted files and future
@@ -217,6 +220,12 @@ Feeds follow the same shape: `/tags/{tag}/feed.xml` comes free from the same Que
## Scaffolding
`khosra demo <empty dir>` writes a whole site that exercises every feature — both languages with a fallback, a
series with ordered chapters, a gallery, a figure, an include, extras, tags across sections, a slug with an
alias, an undated page, a draft, a template override, `static/` and `site.yaml`. Its prose is generated filler,
not stored content: nothing in the engine repository is content (ADR-0011), and a demo that cannot express a
feature is evidence of a gap in the engine.
`khosra new posts/hello-world` writes `content/posts/hello-world/index.en.md` — a **directory** bundle, because
only that shape can own local files, so scaffolding the other kind would hand an author a page their pictures
cannot live beside. `-lang` picks the language suffix and `-title` the title, which otherwise comes from the
+20
View File
@@ -731,3 +731,23 @@ set-once callback beside `Reload`, so the renderer has two pieces of state that
`.Tags` and `.ExtrasURL` cost one Stat per bundle render.
Revisit if: a fourth set-once callback appears. Three would say the renderer wants a construction options
struct rather than a constructor plus setters.
## ADR-0050 — The root lists everything; `khosra demo` writes a site, not content in this repo
Date: 2026-07-31 · Status: accepted
Decision: `/` serves a listing of every bundle, newest first, paginated like any other — and 404s only when
nothing is published. And `khosra demo` writes a site root that exercises every feature, generating its filler
rather than copying stored files, into an empty directory the human names.
Why: serving the demo found that a visitor to the site's own address got a **404**. ADR-0008 keeps every bundle
under a section and leaves the root engine-owned, which is right, but "engine-owned" was never given an answer —
so the engine now gives the only one it can from content alone. A home page an author writes by hand is a
separate question and still open.
The demo generates rather than stores because nothing in this repository is content (ADR-0011): a directory of
demo Markdown here would be exactly that. Composing it in code keeps the rule intact, and it makes the demo a
test of the engine rather than a fixture — anything the engine can do that the generator cannot express is a
gap.
Consequence: cheap — a site has a front page with no configuration, the demo is one command, and `khosra check`
passing on generated output is a real end-to-end assertion. Expensive — the root listing mixes sections, which a
theme may not want (it can redefine `main`, and `.Items` carries `.Section`); and the demo's filler lives in Go,
so a feature added later must be added there too or the demo silently stops covering it.
Revisit if: someone wants a hand-written home page. That is a bundle at the root, which ADR-0008 currently
forbids, so it is a decision rather than a patch.
+7 -7
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `66a9fdb` on 2026-07-30 — update this line every change.
**Verified against:** `ebe9a1f` 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,7 +19,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `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, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) | — |
| `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/scaffold/` | writes into a site root through `os.Root`: `new` for one draft directory bundle, `demo` for a whole site that exercises every feature (ADR-0050) | 302 |
| `internal/ext/watch/` | polls the site root, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048) | 129 |
| `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 |
@@ -31,10 +31,10 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `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, wiring, startup, the derivative pass, and the atomic swap a rebuild goes through. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike | 150 |
| `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, extras, change detection, what a page can reach | 3045 |
| `cmd/khosra/new.go` | the `new` and `demo` subcommands: arguments in either order, then the feature does the writing | 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, 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, change detection, what a page can reach, the root listing, the demo | 3124 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
Serves a listing of everything at `/` (ADR-0050), 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,
@@ -42,7 +42,7 @@ section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `
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`
scaffolds a draft bundle into one. A running server notices content changes by polling and swaps the index
scaffolds a draft bundle into one, and `khosra demo` writes a whole site that exercises every feature. A running server notices content changes by polling and swaps the index
atomically, so an edit appears without a restart (ADR-0022). A draft or
future-dated bundle is not served at all — nor is any file inside it (ADR-0024) — until `-dev on` reveals it and
reloads templates per request.
@@ -65,7 +65,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 | 10 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
| Routing cases | 11 | **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 |
+200
View File
@@ -0,0 +1,200 @@
package scaffold
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"os"
"path"
"strings"
)
// Demo writes a site root that exercises every feature the engine has, and returns what it wrote.
//
// A generator rather than stored files: nothing in this repository is content (ADR-0011), so the prose here is
// composed on the spot and is deliberately filler. It exists to be *served* — anything the engine can do that
// this cannot express is a gap in the engine.
//
// Refuses a directory that already holds content, because a demo that overwrites somebody's site is worse than
// no demo.
func Demo(siteDir string) ([]string, error) {
root, err := os.OpenRoot(siteDir)
if err != nil {
return nil, fmt.Errorf("open %s: %w", siteDir, err)
}
defer root.Close()
if _, err := root.Stat("content"); err == nil {
return nil, fmt.Errorf("%s already has content; point this at an empty directory", siteDir)
}
written := []string{}
for _, f := range demoFiles() {
if err := mkdirAll(root, path.Dir(f.name)); err != nil {
return written, err
}
file, err := root.OpenFile(f.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return written, fmt.Errorf("create %s: %w", f.name, err)
}
_, err = file.Write(f.body)
file.Close()
if err != nil {
return written, fmt.Errorf("write %s: %w", f.name, err)
}
written = append(written, f.name)
}
return written, nil
}
// file is one thing the demo writes.
type file struct {
name string
body []byte
}
// demoFiles is the whole demo, in one list so what it covers can be read at a glance.
//
// Every feature appears at least once: two languages with a fallback, a series with ordered chapters, a gallery,
// a figure, an include, tags crossing sections, a slug with an alias keeping the old URL, an undated page, a
// draft, extras, a template override, static files, and a site declaration.
func demoFiles() []file {
files := []file{{"site.yaml", []byte("base: http://localhost:8080\ntitle: A Khosra Demo\n")}}
files = append(files, demoPosts()...)
files = append(files, demoSeries()...)
files = append(files, demoGallery()...)
files = append(files, demoWriting()...)
return append(files, demoSiteFurniture()...)
}
// demoPosts covers languages, the fallback chain, a rename with an alias, and a draft.
func demoPosts() []file {
return []file{
// A post in both languages: the Bengali variant proves chrome, dates and digits localise.
{"content/posts/first-light/index.en.md", post("First Light", "2026-03-01", []string{"monsoon", "beginnings"},
"The first demo post. Its Bengali twin sits beside it, so the language links in the footer go somewhere.\n\n"+
"Quotes become \"curly\", dashes -- like this -- become dashes, and an ellipsis... arrives as one character.\n")},
{"content/posts/first-light/index.bn.md", post("প্রথম আলো", "2026-03-01", []string{"monsoon"},
"এই লেখাটি বাংলায়। তারিখ, সংখ্যা আর পৃষ্ঠার নাম বাংলায় দেখা যাবে।\n")},
// English only: asking for it in Bengali falls back, and the canonical link says so.
{"content/posts/only-english.en.md", post("Only in English", "2026-02-14", []string{"beginnings"},
"There is no Bengali version of this one. Ask for `/bn/posts/only-english/` and the engine serves this,\n"+
"with a canonical link naming the variant it actually gave you.\n")},
// A slug override with an alias, so the old address keeps working.
{"content/posts/renamed-thing/index.en.md", []byte("---\ntitle: This Was Renamed\ndate: 2026-02-01\n" +
"slug: a-better-name\naliases: [posts/renamed-thing]\n---\n" +
"Served at `/posts/a-better-name/`. The path this file implies redirects here instead of breaking.\n")},
// A draft: not served at all until -dev on.
{"content/posts/unfinished.en.md", []byte("---\ntitle: Unfinished\ndate: 2026-04-01\ndraft: true\n---\n" +
"Invisible without `-dev on`, and so is anything beside it.\n")},
}
}
// demoSeries covers a landing page with ordered chapters: prev/next, first/last, and an archive.
func demoSeries() []file {
return []file{
// A series: landing page plus ordered chapters, driving prev/next/first/last and an archive.
{"content/comics/the-long-monsoon/_index.en.md", []byte("---\ntitle: The Long Monsoon\ndate: 2026-03-01\n" +
"tags: [monsoon]\n---\nA series in three parts. This page lists them in reading order.\n")},
{"content/comics/the-long-monsoon/first-rain.en.md", []byte("---\ntitle: First Rain\ndate: 2026-03-02\n" +
"order: 10\n---\nChapter one. `order: 10` puts it first, and inserting a chapter later needs no renaming.\n")},
{"content/comics/the-long-monsoon/the-flood/index.en.md", []byte("---\ntitle: The Flood\ndate: 2026-03-09\n" +
"order: 20\n---\nChapter two, a directory bundle so it can own a picture.\n\n" +
"{{< figure src=\"water.jpg\" alt=\"A wall of grey water\" caption=\"Day three\" >}}\n")},
{"content/comics/the-long-monsoon/the-flood/water.jpg", photo(1800, 90, 110, 160)},
{"content/comics/the-long-monsoon/aftermath.en.md", []byte("---\ntitle: Aftermath\ndate: 2026-03-16\n" +
"order: 30\ntags: [monsoon]\n---\nChapter three. The gaps between 10, 20 and 30 leave room to insert.\n")},
}
}
// demoGallery covers pictures beside a bundle: enumeration, resampling and a srcset.
func demoGallery() []file {
return []file{
// A gallery: every picture beside the bundle, sized and offered as a srcset.
{"content/art/monsoon-studies/index.en.md", []byte("---\ntitle: Monsoon Studies\ndate: 2026-03-20\n" +
"tags: [monsoon]\n---\nThree studies. The gallery below is every picture in this directory.\n\n" +
"{{< gallery >}}\n")},
{"content/art/monsoon-studies/10-grey.jpg", photo(1600, 120, 130, 150)},
{"content/art/monsoon-studies/20-green.jpg", photo(1600, 80, 150, 90)},
{"content/art/monsoon-studies/30-blue.jpg", photo(1600, 70, 110, 190)},
}
}
// demoWriting covers an include and extras: a fragment with no URL, and supporting files that do have one.
func demoWriting() []file {
return []file{
// An include, and extras: supporting files published as artefacts of the process.
{"content/writing/notes-on-water/index.en.md", []byte("---\ntitle: Notes on Water\ndate: 2026-03-25\n" +
"tags: [monsoon, beginnings]\n---\nThe finished piece, assembled from a part beside it.\n\n" +
"{{< include file=\"_method.md\" >}}\n")},
{"content/writing/notes-on-water/_method.md", []byte("## Method\n\nAn underscore keeps this out of the " +
"scan, so it has no URL of its own and never appears in a listing.\n")},
{"content/writing/notes-on-water/extras/gauge.log", []byte("day one: 2m\nday two: 3m\nday three: 3m again\n")},
{"content/writing/notes-on-water/extras/research.md", []byte("## Research\n\nRendered as Markdown inside " +
"the extras listing, *emphasis and all*.\n")},
{"content/writing/notes-on-water/extras/scan.jpg", photo(900, 160, 140, 100)},
}
}
// demoSiteFurniture covers what surrounds the content: an undated page, a template override, static files.
func demoSiteFurniture() []file {
return []file{
// An undated page: reachable, and correctly absent from every feed.
{"content/pages/about.en.md", []byte("---\ntitle: About This Demo\n---\n" +
"No date, so this page is not a feed item — which is how the engine decides what belongs in a feed.\n\n" +
"Everything here was generated by `khosra demo`. Edit any file while the server runs and the change\n" +
"appears within a couple of seconds.\n")},
// A template override: the same block the embedded theme defines, replaced.
{"templates/list.html", []byte(`{{define "main" -}}` + "\n" +
`<h1>{{.Title}}</h1>` + "\n" +
`<p><em>This listing comes from the site's own template, not the embedded one.</em></p>` + "\n" +
`{{- range .Items}}` + "\n" +
`<article class="entry"><h2><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a></h2>` + "\n" +
`{{- if not .Date.IsZero}}<p><time datetime="{{.Date.Format "2006-01-02"}}">{{day $.Lang .Date}}</time></p>{{end}}` + "\n" +
`</article>` + "\n" +
`{{- end}}` + "\n" +
`{{- if or .PrevURL .NextURL}}` + "\n" +
`<nav class="pagination">` + "\n" +
`{{- if .PrevURL}}<a rel="prev" href="{{.PrevURL}}">{{t .Lang "newer"}}</a>{{end}}` + "\n" +
`<span>{{t .Lang "page-of" (num .Lang .Page) (num .Lang .Pages)}}</span>` + "\n" +
`{{- if .NextURL}}<a rel="next" href="{{.NextURL}}">{{t .Lang "older"}}</a>{{end}}` + "\n" +
`</nav>{{end}}` + "\n" +
`{{- end}}` + "\n")},
{"static/robots-note.txt", []byte("Anything under static/ is served verbatim at /static/.\n")},
}
}
// post builds a bundle with the fields most posts carry.
func post(title, date string, tags []string, body string) []byte {
var out bytes.Buffer
fmt.Fprintf(&out, "---\ntitle: %s\ndate: %s\n", title, date)
if len(tags) > 0 {
fmt.Fprintf(&out, "tags: [%s]\n", strings.Join(tags, ", "))
}
fmt.Fprintf(&out, "---\n%s", body)
return out.Bytes()
}
// photo is a real JPEG wide enough to earn derivatives, so the demo exercises resampling rather than describing
// it. A gradient, because a placeholder should look like a placeholder.
func photo(width int, r, g, b uint8) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, width*2/3))
for x := range width {
for y := range width * 2 / 3 {
shade := uint8((x + y) / 12 % 90)
img.Set(x, y, color.RGBA{r + shade, g + shade, b - shade/2, 255})
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 80}); err != nil {
return nil
}
return out.Bytes()
}
+49
View File
@@ -3,6 +3,7 @@ package scaffold
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
@@ -87,3 +88,51 @@ func TestNewNeverOverwritesAndNeverEscapes(t *testing.T) {
}
}
}
func TestTheDemoIsAWholeSiteThatTheEngineAccepts(t *testing.T) {
// The demo's real assertion is that khosra can serve what khosra wrote, so this checks the shape and leaves
// the serving to the web tests. Anything the engine can do that the demo cannot express is a gap.
dir := t.TempDir()
written, err := Demo(dir)
if err != nil {
t.Fatal(err)
}
if len(written) < 20 {
t.Errorf("wrote %d files, which is too few to exercise the engine", len(written))
}
// Every feature is represented, named by the file that carries it.
for _, want := range []string{
"site.yaml",
"content/posts/first-light/index.en.md", "content/posts/first-light/index.bn.md", // two languages
"content/posts/renamed-thing/index.en.md", // slug plus alias
"content/posts/unfinished.en.md", // a draft
"content/comics/the-long-monsoon/_index.en.md", // a series landing
"content/comics/the-long-monsoon/the-flood/water.jpg", // a figure's picture
"content/art/monsoon-studies/10-grey.jpg", // a gallery
"content/writing/notes-on-water/_method.md", // an include's fragment
"content/writing/notes-on-water/extras/research.md", // extras
"content/pages/about.en.md", // undated
"templates/list.html", "static/robots-note.txt",
} {
if !slices.Contains(written, want) {
t.Errorf("the demo does not cover %s", want)
}
}
// The bundles it wrote are bundles: parsed by the engine's own scanner, not by eye.
bundles, problems, err := content.ScanReport(os.DirFS(dir))
if err != nil {
t.Fatal(err)
}
if len(problems) != 0 {
t.Errorf("the engine cannot read its own demo: %v", problems)
}
if len(bundles) < 10 {
t.Errorf("scanned %d bundles, want the whole demo", len(bundles))
}
// And it refuses to write over a site that already has content.
if _, err := Demo(dir); err == nil {
t.Error("a demo that overwrites somebody's site is worse than no demo")
}
}
+1
View File
@@ -24,6 +24,7 @@ var chrome = map[string]map[string]string{
"position": {"en": "%s of %s", "bn": "%s / %s"},
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
"everything": {"en": "Everything", "bn": "সবকিছু"},
"first": {"en": "First", "bn": "প্রথম"},
"last": {"en": "Last", "bn": "শেষ"},
}
+8 -1
View File
@@ -346,7 +346,14 @@ func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int)
if err := r.fresh(); err != nil {
return nil, err
}
l, window := r.paginate(section, lang, content.PageURL(section, lang, page), all, page,
// The root has no section to name itself after, so it borrows the site's title, or says what it is.
title := section
if title == "" {
if title = r.settings.Title; title == "" {
title = text(lang, "everything")
}
}
l, window := r.paginate(title, lang, content.PageURL(section, lang, page), all, page,
func(p int) string { return content.PageURL(section, lang, p) })
for _, b := range window {
l.Items = append(l.Items, r.item(b, lang))
+3 -1
View File
@@ -41,9 +41,11 @@ func resolve(path string, site *content.Site) (resolution, bool) {
if path == "" || path[0] != '/' {
return resolution{}, false
}
// The root is a listing of everything, not a miss: the engine owns "/" (ADR-0008), so it answers with the
// one thing it can — every bundle, newest first (ADR-0050).
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return resolution{}, false
return resolution{lang: content.DefaultLang, page: 1}, true
}
lang, key, redirect := cutLang(content.Normalise(trimmed), site)
+3 -1
View File
@@ -74,7 +74,9 @@ func serveStatic(sub fs.FS) http.Handler {
// A section is not a bundle, so this runs only after the bundle lookup misses. A page number past the
// end is a 404 rather than an empty page, because an empty page is a URL that means nothing.
func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool {
if res.key == "" || strings.Contains(res.key, "/") {
// An empty key is the site root, which lists everything. Anything with a slash in it is a bundle path that
// missed, not a section.
if strings.Contains(res.key, "/") {
return false
}
items := site.Run(content.Query{Section: res.key, Lang: res.lang})
+31 -1
View File
@@ -50,9 +50,39 @@ func TestServeBundleAtItsPermalink(t *testing.T) {
}
}
func TestTheRootListsEverything(t *testing.T) {
// The engine owns "/" (ADR-0008), so it answers with the one thing it can: every bundle, newest first
// (ADR-0050). Found by serving the demo, where the front page was a 404.
h := testHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET / = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"About", "Hello"} {
if !strings.Contains(body, want) {
t.Errorf("the root should list every section's bundles, missing %q:\n%s", want, body)
}
}
// A site with nothing published has no front page rather than an empty one, which is the same rule every
// listing follows.
empty, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
bare := Handler(Fixed(content.NewSite(nil)), empty, nil, nil, content.Settings{})
rec = httptest.NewRecorder()
bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("an empty site's root = %d, want 404", rec.Code)
}
}
func TestUnknownPathsAre404(t *testing.T) {
h := testHandler(t)
for _, path := range []string{"/", "/nope/", "/pages/nope", "/pages/about/deeper/"} {
for _, path := range []string{"/nope/", "/pages/nope", "/pages/about/deeper/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {