track the demo as a real site in examples/, gated like the docs

The human asked for a demo extensive enough to review by hand, tracked as files,
and kept current the way docs are. Generated filler cannot be reviewed — you cannot
read a Go function and see what a reader sees — so the generator is deleted and
`examples/demo-site/` is an ordinary site: 33 Markdown files, six pictures,
site.yaml, a template override and static files.

My reason for generating it was ADR-0011, and I had that rule wrong: it puts the
*author's site root* outside this repository, not fixtures. conventions.md already
keeps golden files in testdata/, and this is the same category one size up. Keeping
both a generator and files would have been two sources of truth, so one had to go.

Kept true by two gates rather than by good intentions. A table-driven test in
internal/web serves the directory through the real handler with one case per
feature — thirty-five of them, each naming what it proves — and verify.sh runs
`khosra check` over it and fails on anything fatal. Adding a feature now means
adding it to the demo and adding its case, and the build says so if you don't.

Extensive on purpose: eleven dated posts so a section actually paginates, a
four-chapter series so first/last are not the neighbours, a gallery with three
JPEGs and an SVG so both the resampled and the untouched paths show, a Bengali-only
bundle, a titleless status, a draft, a future date, an include, a nested extras
tree, and a typography page that demonstrates what the engine will not do.

Two expectations of mine were wrong and the demo corrected them: the site's own
list template overrides *tag* listings too, so there are no group headings there —
which turns out to be the better demonstration of ADR-0046, since the theme picking
the flat shape is exactly the point. And template literal text is not escaped, so
an apostrophe stays an apostrophe.
This commit is contained in:
2026-08-01 02:23:37 +06:00
parent 771a491188
commit 8900aabc57
55 changed files with 543 additions and 281 deletions
+5
View File
@@ -52,6 +52,11 @@ must do — not the theme. `verify.sh` fails if the embedded reference theme cha
changing, because in practice those two drift together — and it fails on a `<script>` tag in that theme,
because a reference theme that grows taste stops being a reference (ADR-0026).
**The demo is gated like the docs.** `examples/demo-site/` is a real site, tracked as files so you can read it,
edit it and serve it with `make demo`. It stays true by two gates: a coverage test in `internal/web` serves it
through the real handler with one case per feature, and `verify.sh` runs `khosra check` over it. A feature added
without a case there is a feature the demo does not show, and the build says so (ADR-0051).
**The agent names the layer as well as the primitive.** Content on disk, engine, theme, browser — and it builds
at the outermost layer that can do the job (`CLAUDE.md` §2, `docs/architecture.md`). This exists because a whole
feature was built at the wrong one: widow prevention as a Markdown transform that inserted a non-breaking space
+2 -4
View File
@@ -26,10 +26,8 @@ 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)"
demo: build ## serve the demo site in examples/, which exercises every feature
$(BIN) -site examples/demo-site -addr "$(ADDR)"
test: ## run tests with the race detector
go test -race ./...
+2 -5
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, `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.
// bundle, and a bare `khosra` serves. A switch while there are two; a table when there is a third with
// flags worth sharing.
if len(os.Args) > 1 {
switch os.Args[1] {
case "check":
@@ -32,9 +32,6 @@ func main() {
case "new":
runNew(os.Args[2:])
return
case "demo":
runDemo(os.Args[2:])
return
}
}
runServe()
-18
View File
@@ -8,24 +8,6 @@ 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)
+19
View File
@@ -751,3 +751,22 @@ theme may not want (it can redefine `main`, and `.Items` carries `.Section`); an
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.
## ADR-0051 — The demo is a tracked site in `examples/`, kept true by gates
Date: 2026-07-31 · Status: accepted (replaces ADR-0050's generator: the Go generator is deleted)
Decision: the demonstration site lives in `examples/demo-site/` as ordinary Markdown, images, templates and
`site.yaml` — read, edited and served like any site. `make demo` serves it. Two gates keep it current: a test in
`internal/web` serves this directory through the real handler with one case per feature, and `verify.sh` runs
`khosra check` over it and fails on anything fatal. Adding a feature means adding it here and adding its case.
Why: the human asked for a demo extensive enough to review by hand, and generated filler cannot be reviewed —
you cannot read a Go function and see what a reader would see. My reason for generating it was ADR-0011, but that
rule is about the *author's site root* being external, not about fixtures: `conventions.md` already keeps golden
files in `testdata/`, and this is the same category one size up. Keeping both a generator and files would have
been two sources of truth for the same thing, so the generator went.
Consequence: cheap — the demo is reviewable, editable, and servable in one command; the coverage test turns "the
demo is out of date" from a thing nobody notices into a failing build; and 30 bundles across six sections
exercise pagination, fallback, sequences and galleries at realistic size. Expensive — about 200KB of committed
JPEGs, and a feature added without a case in the coverage test is still invisible, so the test is now part of
what "done" means.
Revisit if: the example grows big enough to slow the test suite, or someone wants several examples — then this
is `examples/<name>/` with the coverage test parameterised, not a second mechanism.
+9 -5
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `ebe9a1f` on 2026-07-30 — update this line every change.
**Verified against:** `8a2ef08` 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 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/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite | 102 |
| `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,8 +31,8 @@ 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` 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 |
| `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, the root listing, and the example site end to end | 3233 |
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
@@ -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, and `khosra demo` writes a whole site that exercises every feature. A running server notices content changes by polling and swaps the index
scaffolds a draft bundle into one. 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.
@@ -53,6 +53,10 @@ Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`,
`content-model.md`'s table — including `draft` and `type` — lands in `Extra` unread, so that table
is the accepted format, not a list of what runs.
`examples/demo-site/` is a complete site kept in the repository to be read and served — 30 bundles across six
sections, one case per feature in `TestTheExampleSiteExercisesEveryFeature`, and `khosra check` run over it by
`verify.sh` (ADR-0051). `make demo` serves it.
A `Dockerfile` ships the binary alone: the site root is a mounted volume, never copied in (ADR-0010, ADR-0011).
Dependencies: four, all allowlisted — `goldmark`, `golang.org/x/text`, `golang.org/x/image`, `gopkg.in/yaml.v3`.
+46
View File
@@ -0,0 +1,46 @@
# examples/
`demo-site/` is a complete khosra site, kept here to be **read and served** — the demonstration a person walks
through to see what the engine does, and the fixture the engine tests itself against (ADR-0051).
```bash
make demo # serves it at localhost:8080
khosra check -site examples/demo-site
```
It is content, not a fixture in disguise: every file is what an author would write. Two things keep it honest,
both in `verify.sh`:
- `TestTheExampleSiteExercisesEveryFeature` (in `internal/web`) serves this directory through the real handler
and asserts one case per feature. A feature that changes shape breaks it.
- `khosra check` runs over it and must report nothing fatal.
**Adding a feature means adding it here**, with a case in that test. A feature the example does not show is a
feature nobody can see working, and the gates are what stop that from being optional.
## What each part is for
| Path | Demonstrates |
|---|---|
| `site.yaml` | the two site-level settings: `base` and `title` |
| `content/posts/day-01…11` | pagination — eleven dated posts against a page size of ten |
| `content/posts/first-light/` | two languages, a directory bundle owning a picture, a figure with a caption |
| `content/posts/only-english` | the fallback chain, and a canonical link naming the variant actually served |
| `content/posts/renamed-thing/` | `slug` moving an address, `aliases` keeping the old one working |
| `content/posts/unfinished` | a draft: invisible without `-dev on` |
| `content/posts/scheduled` | a future date: published by the clock, with nothing to run |
| `content/comics/the-long-monsoon/` | a series — structural membership, sparse `order`, prev/next/first/last, an archive, and a landing page in two languages |
| `content/art/monsoon-studies/` | a gallery: three JPEGs sized into a `srcset`, and an SVG left exactly as drawn |
| `content/writing/notes-on-water/` | an include whose fragment has no URL, and an `extras/` tree with a nested directory |
| `content/writing/typography` | what the engine does to prose, and what it deliberately does not |
| `content/pages/*` | undated bundles: reachable, and absent from every feed |
| `content/pages/bengali-only` | a bundle that exists in one non-default language only |
| `content/status/*` | a titleless bundle — legal, and the one `check` warning this site expects |
| `templates/list.html` | overriding a single named block and inheriting the document |
| `static/note.txt` | files served verbatim |
## The one expected warning
`khosra check` reports the titleless status note. That is deliberate: titleless is legal, the theme falls back
to the bundle's key, and the warning exists so an author who did not mean it finds out. A *fatal* finding here
would fail `verify.sh`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 267" role="img" aria-label="A rising line">
<rect width="400" height="267" fill="#eceae4"/>
<polyline points="20,240 120,180 220,200 380,40" fill="none" stroke="#1a4d7a" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

@@ -0,0 +1,12 @@
---
title: Monsoon Studies
date: 2026-03-20
tags: [monsoon]
---
Three studies and a diagram. The gallery below is every picture in this directory, in filename order — the
numeric prefixes do the ordering without appearing in any URL.
{{< gallery >}}
The three JPEGs carry generated widths in a `srcset`. The SVG does not: nothing here can resample it, so it is
rendered exactly as it was drawn rather than dropped for being inconvenient.
@@ -0,0 +1,6 @@
---
title: দীর্ঘ বর্ষা
date: 2026-03-01
tags: [monsoon]
---
চার পর্বের একটি ধারাবাহিক। কোন লেখা কোন ধারাবাহিকের অংশ, তা ঠিক হয় ফোল্ডারের অবস্থান দেখে।
@@ -0,0 +1,9 @@
---
title: The Long Monsoon
date: 2026-03-01
tags: [monsoon]
---
A series in four parts. Membership is *structural*: every bundle nested under this one is a chapter, so
nothing in frontmatter has to agree with the directory.
Below this text the theme renders the archive — every chapter in reading order, which the engine supplies.
@@ -0,0 +1,6 @@
---
title: Aftermath
date: 2026-03-16
order: 30
---
Chapter three. Nothing here but prose — most chapters are.
@@ -0,0 +1,9 @@
---
title: First Rain
date: 2026-03-02
order: 10
---
Chapter one. `order: 10` puts it first; the gaps between 10, 20, 30 and 40 mean inserting a chapter later is
one edit and never a rename.
This page has no `prev`, so the theme renders nothing where a previous link would go.
@@ -0,0 +1,11 @@
---
title: The Flood
date: 2026-03-09
order: 20
tags: [monsoon]
---
Chapter two, written as a directory bundle so it can own the picture below.
{{< figure src="water.jpg" alt="A wall of grey water" caption="Day three" >}}
It has a chapter on either side, so the sequence nav shows both, plus its position in the series.
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -0,0 +1,8 @@
---
title: What Remained
date: 2026-03-23
order: 40
tags: [monsoon]
---
Chapter four, and the last. With four chapters the theme's *first* and *last* links have somewhere to go
that is not simply the neighbour.
@@ -0,0 +1,9 @@
---
title: About This Demo
---
No date, so this page is not a feed item — the engine decides feed membership by asking whether something has
a publication date, which is a fact already on disk.
Every file under `examples/demo-site/` in the khosra repository is here to be *served* and read. If the engine
can do something this site does not show, that is a gap in the demo; if the demo asks for something the engine
cannot do, that is a gap in the engine.
@@ -0,0 +1,5 @@
---
title: শুধু বাংলায়
---
এই পৃষ্ঠাটির ইংরেজি রূপ নেই। `/pages/bengali-only/` চাইলে ইঞ্জিন এটিই দেবে — কারণ শেষ উপায় হিসেবে
যেকোনো একটি ভাষা 404-এর চেয়ে ভালো।
@@ -0,0 +1,8 @@
---
title: Colophon
---
Built by khosra: one Go binary, four dependencies, no JavaScript anywhere in the reference theme.
The stylesheet is inlined into every page, so a site root with no `static/` still renders. A real theme would
replace it, and `templates/list.html` in this site root shows how — that one file overrides a single named
block and inherits the rest of the document.
@@ -0,0 +1,6 @@
---
title: Day 01
date: 2026-02-01
tags: [journal]
---
The 01th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 02
date: 2026-02-02
tags: [journal]
---
The 02th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 03
date: 2026-02-03
tags: [journal]
---
The 03th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 04
date: 2026-02-04
tags: [journal]
---
The 04th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 05
date: 2026-02-05
tags: [journal]
---
The 05th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 06
date: 2026-02-06
tags: [journal]
---
The 06th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 07
date: 2026-02-07
tags: [journal]
---
The 07th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 08
date: 2026-02-08
tags: [journal]
---
The 08th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 09
date: 2026-02-09
tags: [journal]
---
The 09th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 10
date: 2026-02-10
tags: [journal]
---
The 10th entry, here so the section listing paginates: eleven posts against a page size of ten.
@@ -0,0 +1,6 @@
---
title: Day 11
date: 2026-02-11
tags: [journal]
---
The 11th entry, here so the section listing paginates: eleven posts against a page size of ten.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,9 @@
---
title: প্রথম আলো
date: 2026-03-01
tags: [monsoon]
---
এই লেখাটি বাংলায়। তারিখ, সংখ্যা, আর পৃষ্ঠার নাম — সব বাংলায় দেখা যাবে, কারণ ইঞ্জিন নিজের কথাগুলো
অনুবাদ করে রাখে।
লেখকের লেখা কখনও অনুবাদ হয় না; কেবল ইঞ্জিনের নিজের শব্দগুলো।
@@ -0,0 +1,13 @@
---
title: First Light
date: 2026-03-01
tags: [monsoon, beginnings]
---
This post exists in two languages, so the footer's language links go somewhere and `hreflang` has something
to point at. Its Bengali twin is `index.bn.md` beside this file.
It also owns a picture, which only a *directory* bundle can do:
{{< figure src="cover.jpg" alt="A grey-green gradient standing in for a photograph" caption="A caption, rendered by the theme's figure fragment" >}}
The picture above is served from this bundle's own directory, and the `srcset` on it names generated widths.
@@ -0,0 +1,8 @@
---
title: Only in English
date: 2026-02-20
tags: [beginnings]
---
There is no Bengali version of this one. Ask for `/bn/posts/only-english/` and the engine serves this instead
of a 404 — the fallback chain is requested, then the default locale, then any variant at all. The canonical
link in the head names the variant you actually got, not the one you asked for.
@@ -0,0 +1,10 @@
---
title: This Was Renamed
date: 2026-02-25
slug: a-better-name
aliases: [posts/renamed-thing]
---
This file is at `content/posts/renamed-thing/`, but `slug:` moves its address to `/posts/a-better-name/`.
The old path still works, because `aliases:` promises it will — it answers a permanent redirect.
The bundle's *key* never moved, which is why the series and cache machinery could not care less.
@@ -0,0 +1,6 @@
---
title: Scheduled for Later
date: 2099-01-01
---
Dated in the future, so it is not published yet. The engine reads the clock per request, which means this
appears by itself the moment its date arrives — nothing to restart, nothing to invalidate.
@@ -0,0 +1,7 @@
---
title: Unfinished
date: 2026-04-10
draft: true
---
A draft. Not served at all: not here, not in a listing, not in a feed or a sitemap — and neither is any file
beside it. `khosra -dev on` is the only thing that reveals it.
@@ -0,0 +1,6 @@
---
date: 2026-03-30
tags: [journal]
---
A short note with no title at all. Titleless is legal — the theme falls back to the bundle's key rather than
failing — and `khosra check` warns about it on purpose, which is the one finding this demo expects.
@@ -0,0 +1,4 @@
## Method
Gauge readings, transcribed each evening. *Emphasis and links survive*, because an include is parsed as
Markdown rather than pasted as text.
@@ -0,0 +1,3 @@
First attempt, abandoned. Kept because the process is worth publishing.
Nested one level deeper, so the extras tree has a directory in it.
@@ -0,0 +1,3 @@
day one: 2m, rising
day two: 3m, holding <not markup>
day three: 3m, falling
@@ -0,0 +1,4 @@
## Research
Rendered as Markdown *inside* the extras listing, with the tree still beside it. Selecting an entry is an
ordinary link and a whole page, so none of this needs JavaScript.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,13 @@
---
title: Notes on Water
date: 2026-03-25
tags: [monsoon, journal]
---
The finished piece. Below, a part of it lives in a separate file and is included here:
{{< include file="_method.md" >}}
That fragment starts with an underscore, so the scanner never treats it as a bundle: it has no URL of its own
and appears in no listing. An included file cannot itself include — one level, deliberately.
This bundle also has an `extras/` directory, so the theme offers a link to it at the foot of the page.
@@ -0,0 +1,17 @@
---
title: Typography and Its Limits
date: 2026-03-28
tags: [journal]
---
"Quotes become curly," she said -- and dashes become dashes, while an ellipsis... becomes one character. That
is the whole of what the engine does to an author's words: a Markdown parser option, nothing more.
Inside a code span none of it happens: `"quotes" -- and ellipses...` survive byte for byte, because the
typographer works on the parsed tree rather than on the text.
Line breaking is not the engine's business at all. This paragraph is deliberately long enough to wrap several
times on a narrow screen, which is where you would notice a lonely last word — and the stylesheet asks the
browser to avoid one with `text-wrap: pretty`, because only the browser knows where the lines actually fall.
A code block, indented.
Also untouched.
+3
View File
@@ -0,0 +1,3 @@
# Everything a site may declare about itself (ADR-0039). Both keys are optional.
base: http://localhost:8080
title: A Khosra Demo
+2
View File
@@ -0,0 +1,2 @@
Anything under static/ in the site root is served verbatim under /static/, with no processing at all.
Directory paths answer 404 rather than listing what is here.
+24
View File
@@ -0,0 +1,24 @@
{{define "main" -}}
<h1>{{.Title}}</h1>
<p><em>This listing is rendered by the site's own template, parsed after the embedded one. It redefines a
single named block and inherits the whole document around it.</em></p>
{{- if .Items}}
{{- range .Items}}
<article class="entry">
<h2><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a></h2>
{{- if not .Date.IsZero}}
<p class="meta"><time datetime="{{.Date.Format "2006-01-02"}}">{{day $.Lang .Date}}</time>{{if .Section}} · {{.Section}}{{end}}</p>
{{- end}}
</article>
{{- end}}
{{- else}}
<p>{{t .Lang "empty"}}</p>
{{- end}}
{{- if or .PrevURL .NextURL}}
<nav class="pagination">
{{- if .PrevURL}}<a rel="prev" href="{{.PrevURL}}">{{t .Lang "newer"}}</a>{{end}}
<span>{{t .Lang "page-of" (num .Lang .Page) (num .Lang .Pages)}}</span>
{{- if .NextURL}}<a rel="next" href="{{.NextURL}}">{{t .Lang "older"}}</a>{{end}}
</nav>
{{- end}}
{{- end}}
-200
View File
@@ -1,200 +0,0 @@
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,7 +3,6 @@ package scaffold
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
@@ -88,51 +87,3 @@ 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")
}
}
+163
View File
@@ -0,0 +1,163 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/yuin/goldmark"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
// exampleSite serves examples/demo-site the way the binary does.
//
// The real site root, not a fixture: this is what makes the demo a test rather than a brochure. When a feature
// changes shape and the example stops showing it, this fails — which is the only way a demo stays current
// (ADR-0051).
func exampleSite(t *testing.T) http.Handler {
t.Helper()
const dir = "../../examples/demo-site"
if _, err := os.Stat(dir); err != nil {
t.Fatalf("the example site is missing: %v", err)
}
fsys := os.DirFS(dir)
bundles, problems, err := content.ScanReport(fsys)
if err != nil {
t.Fatal(err)
}
if len(problems) != 0 {
t.Fatalf("the engine cannot read its own example site: %v", problems)
}
settings, err := content.LoadSettings(fsys)
if err != nil {
t.Fatal(err)
}
// Derivatives into a throwaway directory: the example must never gain generated files of its own.
if _, err := shortcodes.Derive(fsys, t.TempDir()); err != nil {
t.Fatal(err)
}
r, err := render.New(fsys, settings, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{shortcodes.New(p)}
})
if err != nil {
t.Fatal(err)
}
site := content.NewSite(bundles)
r.Navigation(site.Sections)
return Handler(Fixed(site), r, fsys, nil, settings)
}
func get(t *testing.T, h http.Handler, path string) (int, string) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec.Code, rec.Body.String()
}
// featureCase is one thing the example site must still show.
type featureCase struct {
what, path string
code int
expect []string
absent []string
}
// exampleFeatures is the demo's contract with the engine: one case per feature, each naming what it proves.
//
// A feature added without a case here is a feature the demo does not show; a case that fails is a demo that has
// gone stale. Both are the point, and both are why this is a table rather than prose in a README (ADR-0051).
var exampleFeatures = []featureCase{
{what: "the root lists everything", path: "/", code: 200,
expect: []string{"A Khosra Demo", `href="/comics/`, `href="/writing/`}},
{what: "a section listing paginates", path: "/posts/", code: 200,
expect: []string{`rel="next" href="/posts/page/2/"`, "Day 11"}},
{what: "page two links back", path: "/posts/page/2/", code: 200,
expect: []string{`rel="prev" href="/posts/"`}},
{what: "a site template overrides one block and inherits the document", path: "/posts/", code: 200,
expect: []string{"rendered by the site's own template", "<!doctype html>", `class="entry"`}},
{what: "navigation, feed discovery and OpenGraph are in the head", path: "/posts/first-light/", code: 200,
expect: []string{`<nav class="sections">`, `type="application/atom+xml"`, `property="og:title"`}},
{what: "a figure renders through the theme with a srcset and dimensions", path: "/posts/first-light/", code: 200,
expect: []string{"<figure>", "srcset=", `width="1400"`, "<figcaption>"}},
{what: "a bundle's own picture is served", path: "/posts/first-light/cover.jpg", code: 200},
{what: "tags link their listings", path: "/posts/first-light/", code: 200,
expect: []string{`rel="tag" href="/tags/monsoon/"`}},
{what: "language links are relative while hreflang is absolute", path: "/posts/first-light/", code: 200,
expect: []string{`href="/bn/posts/first-light/" hreflang="bn"`, `hreflang="bn" href="http://localhost:8080/bn/`}},
{what: "a Bengali variant localises chrome", path: "/bn/posts/first-light/", code: 200,
expect: []string{`lang="bn"`, "প্রথম আলো"}},
{what: "a missing variant falls back and says which it served", path: "/bn/posts/only-english/", code: 200,
expect: []string{"Only in English", `rel="canonical" href="http://localhost:8080/posts/only-english/"`}},
{what: "a bundle that exists only in Bengali is still served", path: "/pages/bengali-only/", code: 200,
expect: []string{"শুধু বাংলায়"}},
{what: "a slug moves the address", path: "/posts/a-better-name/", code: 200},
{what: "an alias keeps the old address working", path: "/posts/renamed-thing/", code: 301},
{what: "a draft is not served", path: "/posts/unfinished/", code: 404},
{what: "a future-dated bundle is not served", path: "/posts/scheduled/", code: 404},
{what: "a series landing lists its chapters in order", path: "/comics/the-long-monsoon/", code: 200,
expect: []string{"First Rain", "The Flood", "Aftermath", "What Remained"}},
{what: "a chapter has neighbours, a position, and the ends", path: "/comics/the-long-monsoon/the-flood/", code: 200,
expect: []string{`rel="prev"`, `rel="next"`, "2 of 4", `class="ends"`}},
{what: "a gallery sizes what it can and leaves alone what it cannot", path: "/art/monsoon-studies/", code: 200,
expect: []string{`class="gallery"`, "10-grey.jpg", "srcset=", `src="40-line.svg"`},
absent: []string{`src="40-line.svg" srcset`}},
{what: "an include is parsed as Markdown, and its fragment has no page of its own", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<h2>Method</h2>", "<em>Emphasis and links survive</em>"}},
{what: "a fragment is not a bundle", path: "/writing/notes-on-water/_method/", code: 404},
{what: "a page offers its extras only when it has them", path: "/writing/notes-on-water/", code: 200,
expect: []string{`href="/writing/notes-on-water/extras/"`}},
{what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200,
expect: []string{"research.md", "gauge.log", "scan.jpg", "drafts", "markdown", "text", "image"}},
{what: "a nested extras entry renders", path: "/writing/notes-on-water/extras/drafts/v1.md", code: 200,
expect: []string{"abandoned"}},
{what: "a text entry is escaped, not interpreted", path: "/writing/notes-on-water/extras/gauge.log", code: 200,
expect: []string{"&lt;not markup&gt;"}},
{what: "raw returns the bytes", path: "/writing/notes-on-water/extras/gauge.log?raw", code: 200,
expect: []string{"day one: 2m, rising"}, absent: []string{"<!doctype"}},
{what: "typography is smoothed in prose and not in code", path: "/writing/typography/", code: 200,
expect: []string{"&ldquo;Quotes", "&ndash;", "&hellip;", `<code>&quot;quotes&quot; -- and ellipses...</code>`}},
{what: "a titleless bundle still renders", path: "/status/2026-03-30-1400/", code: 200},
// The engine offers a tag listing in two shapes and the theme picks (ADR-0046). This site's template picks
// the flat one and labels each entry with its section, which is why there are no group headings here.
{what: "a tag listing spans sections, in whichever shape the theme chose", path: "/tags/monsoon/", code: 200,
expect: []string{"· comics", "· posts", "· art", "· writing"}, absent: []string{"<h2>comics</h2>"}},
{what: "a tag listing narrows to a section", path: "/comics/tags/monsoon/", code: 200,
absent: []string{"First Light"}},
{what: "the feed carries dated bundles and nothing else", path: "/feed.xml", code: 200,
expect: []string{"<feed", `xml:lang="en"`, "http://localhost:8080/posts/a-better-name/"},
absent: []string{"About This Demo", "Unfinished", "Scheduled"}},
{what: "a section has its own feed", path: "/comics/feed.xml", code: 200,
expect: []string{"The Flood"}, absent: []string{"Day 11"}},
{what: "the sitemap lists every variant", path: "/sitemap.xml", code: 200,
expect: []string{"/posts/first-light/", "/bn/posts/first-light/"}, absent: []string{"unfinished"}},
{what: "robots points at the sitemap", path: "/robots.txt", code: 200,
expect: []string{"Sitemap: http://localhost:8080/sitemap.xml"}},
{what: "static files are served verbatim", path: "/static/note.txt", code: 200,
expect: []string{"served verbatim"}},
}
func TestTheExampleSiteExercisesEveryFeature(t *testing.T) {
h := exampleSite(t)
for _, c := range exampleFeatures {
code, body := get(t, h, c.path)
if code != c.code {
t.Errorf("%s: GET %s = %d, want %d", c.what, c.path, code, c.code)
continue
}
for _, want := range c.expect {
if !strings.Contains(body, want) {
t.Errorf("%s: GET %s is missing %q", c.what, c.path, want)
}
}
for _, unwanted := range c.absent {
if strings.Contains(body, unwanted) {
t.Errorf("%s: GET %s should not contain %q", c.what, c.path, unwanted)
}
}
}
}
+12
View File
@@ -114,6 +114,18 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then
pass "raw HTML stays disabled"
fi
# The example site is a demonstration that has to stay true (ADR-0051). `check` over it catches the class of
# rot a test cannot: a content rule that changed while the example kept the old shape. Findings are allowed —
# the example carries one on purpose — but nothing fatal is.
if [ -d examples/demo-site ]; then
if go run ./cmd/khosra check -site examples/demo-site >/tmp/example-check.log 2>&1; then
pass "the example site still checks out"
else
bad "examples/demo-site has a fatal finding — the demo must stay a site the engine accepts"
sed 's/^/ /' /tmp/example-check.log
fi
fi
# Dangling references. Every one of these found a real stale pointer when run by hand.
refs=$(grep -rhoE '`(docs|scripts|ideas|reference|\.claude)/[A-Za-z0-9_./-]+`' \
docs CLAUDE.md HARNESS.md ideas reference .claude scripts 2>/dev/null | tr -d '`' | sort -u)