Polling lives in internal/ext/watch, per the human's call to keep core under its ceiling rather than raise it a second time — which is what ADR-0041 said a second raise would mean. It is a poller, deletable without trace, and core stayed at 2671/2800. A settled change calls the same `rebuilder` that startup calls, because a reload path that differs from the startup path is a reload path that drifts. The index is an atomic.Pointer swapped whole, so a request reads the site that was current when it arrived instead of one being rebuilt underneath it — the alternative, mutating in place, is a data race with every in-flight request. Names, sizes and modification times, not contents: reading every file to detect a change costs more than the rebuild it triggers. Editor droppings are excluded, because saving in vim writes a swap file, a backup and the number 4913, and each would otherwise look like a change. A change must hold still for a moment first, since one save is often several operations. Verified against the running binary: a page 404s, the file appears, and five seconds later it serves — one "site root changed" in the log. Then three droppings written at once produced no rebuild at all. Two warnings fired and were fixed rather than silenced: `runServe` gave up the rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to `record`, where three exclusions read as a list instead of as nesting. The Dockerfile ships the binary alone. The site root arrives as a volume and is never copied in — it is somebody's content repository with its own history (ADR-0011), so the image is the same for every site.
25 KiB
Content model
Engine specification — the format the parser accepts and the URLs the engine emits. This is not a rule about how anyone organises their files: the site root belongs to its owner (ADR-0011), and every statement here describes engine behaviour, not an obligation on content.
The most expensive thing in the engine to change, because published URLs are permanent and the files it reads are somebody's database.
Markers are build order, not status: [arc1] build now · [spec] recorded intent, shape decided by
whatever builds it. What exists is state.md's job. Never build a [spec] section because it is written here.
The site root
The engine is pointed at a site root outside this repository (-site <dir>, or KHOSRA_SITE), with
its own git history (ADR-0011). Nothing in the engine repo is content. templates/ in the site root
overrides the defaults the binary embeds, so a bare root still renders.
<site>/
site.yaml # what the site declares about itself, optional
content/ # bundles — the disk contract below
static/ # verbatim, served as-is
templates/ # html/template overrides, optional
<site>/content/
posts/ # general blog [arc1]
2026-03-hello-world/ # directory bundle
index.en.md
index.bn.md
cover.jpg # local asset, referenced relatively
comics/ # webcomics [spec]
the-long-monsoon/
_index.en.md # the series bundle; also the cascade point for it
first-rain/ # a chapter because it is nested here (ADR-0033); slug is never the position
index.en.md
page.png
art/ # single images or sets [spec]
writing/ # short stories, poems [spec]
status/ # short IndieWeb-style notes [spec]
2026-07-28-1030.md # single-file bundle, no folder
pages/ # about, contact, now [spec]
about/index.en.md
Bundle rules
- A bundle is either a directory containing
index.<lang>.mdor a single file<slug>.<lang>.md. Nothing else. A single file is the degenerate case, not a special case. - The bundle key is its path relative to
<site>/content/, minus language and extension. It is the cache key, the dependency key, and the identity in ADR-0004. It never changes silently. - The language suffix is optional and its absence means the default locale, always — not only while one
language exists (ADR-0021). A suffix is two or three lowercase letters before
.md, soabout.bn.mdcarries a language andmy.post.mddoes not. Fallback chain: requested → default → any → 404. - One key and language may be claimed by only one file.
about.md,about.en.mdandabout/index.mdall name the same variant, so any two of them together are ambiguous and none is served — logged, not fatal (ADR-0029). _index.<lang>.mdin a directory containing other bundles makes that directory itself a bundle (a section or series landing page) rather than a plain container.- Local assets sit beside the body of a directory bundle, referenced relatively, and are served under
that bundle's URL: a page at
/art/monsoon/linking10-first.jpgis served fromcontent/art/monsoon/10-first.jpg. Assets never live in frontmatter. Moving a bundle moves its assets — the entire point of bundles. - A single-file bundle owns no assets. Its neighbours belong to its section rather than to it, and its slash-terminated URL has nothing beneath it, so an author with assets writes a directory bundle.
- A
.mdinside a bundle directory is never served as an asset: it is a bundle with its own URL, or a fragment that was never addressable. - A directory starting with
_other than_indexis ignored, and so is a file:_tools.mdis a fragment, not a bundle. That is how a file meant only to be included avoids taking a URL of its own, appearing in its section's listing, and turning its bundle into a one-member series. draft: trueis not served at all: not in a listing, a feed or a sitemap, and not at its own URL. Neither is any file inside its bundle (ADR-0024).-dev onis the only thing that changes that.
Frontmatter
YAML (ADR-0020), minimal, all fields optional except title. Unknown keys land in the Extra bag and are
readable by templates (ADR-0002). Never add a required field.
| Field | Type | Meaning |
|---|---|---|
title |
string | Only required field |
date / updated |
date | Publication; updated drives feeds and Last-Modified. An unquoted 2026-07-30 or an RFC 3339 timestamp; undated bundles sort after dated ones |
type |
string | Post type; defaults from the top-level section |
slug |
string | Renames the bundle's final path segment in every language (ADR-0035). One segment: a slash would move the bundle to another section, which is a move rather than a rename. The key does not change, so identity, caching and series membership are untouched; only the address moves. The old path 404s unless aliases lists it. Two variants declaring different slugs, or a slug landing where another bundle already answers, is ambiguous — logged, dropped, derived path kept |
aliases |
[]string | Paths the engine redirects permanently to this bundle's canonical URL (ADR-0008). A scalar or a list; surrounding slashes optional. An alias naming a real bundle, or claimed by two bundles, is ambiguous — logged and dropped, and the real bundle keeps its URL |
draft |
bool | Excluded from queries and feeds |
nocache |
bool | Never cache this bundle's render. Named so absence means cacheable, per ADR-0002 |
summary |
string | Explicit summary; otherwise derived |
tags |
[]string | Flat, case- and script-preserved as written. A scalar or a list. The URL form is lowercased with spaces hyphenated, so Long Monsoon and long monsoon are one term; scripts without case pass through unchanged (ADR-0018) |
order |
int | Position within the series this bundle is nested in (ADR-0033). Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016). Absent, the bundle orders by name, after every sibling carrying one |
cover |
string | Relative path to the lead image |
view |
string | Per-bundle View override (Arc 2) |
styles / scripts |
[]string | Page-specific assets, relative to the bundle |
lang |
string | Explicit language when the filename cannot carry it |
Post types
A type is a frontmatter value, never a code path: it may change the default View and default query
membership and nothing else. Adding one must not add a branch to the core. Absent type, it defaults
from the top-level section directory.
[spec] A declaration file (types: in site.yaml) carrying per-type defaults — view, ordering, feed
membership, required fields, titleless legality, taxonomies — is recorded intent (ideas/deferred-decisions.md), not built.
The set below is what the engine knows without one.
| Type | Section | Order | Feeds | Distinguishing need |
|---|---|---|---|---|
post |
posts/ |
date | primary | The baseline |
comic |
comics/ |
sequence | primary | Ordered within a series; prev/next; image is the content |
art |
art/ |
date | section | Image-first; gallery membership; caption over body |
writing |
writing/ |
sequence | primary | Long-form; series-aware; print View eventually |
page |
pages/ |
manual | none | Standalone, dateless |
status |
status/ |
date | primary | Titleless, timestamp-slugged, syndication source |
Titleless is legal for status: the derived title is empty and Views must not assume one exists — a
direct consequence of the open page object. Nothing in the engine may assume the type list is fixed.
Identifiers, normalisation and slugs
Two separate things (ADR-0015).
Normalisation is unconditional. Every string that acts as an identifier — filename, bundle key,
taxonomy term, frontmatter slug, request path — is normalised to NFC where it enters the engine, with
no opt-out. Bengali conjuncts have several byte encodings for identical-looking text and macOS yields
NFD, so without this two visually identical files take different bundle keys and a request never matches
the page it names. Unfixable after publication except by accumulating aliases.
Derivation is locale-aware and overridable. Default slug rules come from the site's default locale; Unicode is preserved rather than transliterated. Any derived slug may be replaced by hand:
- a bundle, with
slugin frontmatter — the engine then serves that path, and the previous one only ifaliaseslists it. The key stays as derived: a slug is an address, not an identity, which is what lets a series landing page be renamed without orphaning the chapters nested under it (ADR-0033, ADR-0035) - a taxonomy term or section segment, with a term-to-slug mapping in the type declaration, so a Bengali tag can carry a chosen URL form instead of a derived one
Overrides are normalised like everything else: writing a slug by hand does not exempt it.
Permalinks
tags is reserved at the top level and inside every section, so no bundle may be slugged tags.
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.
/{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
routes like /tags/ can never collide with a bundle.
Trailing slash is canonical, the slashless form redirects permanently — one rule, applied once. A
published URL never changes meaning; renames add aliases and emit permanent redirects. Moving a
bundle between sections changes the URL the engine emits, and the old path resolves only through
aliases — so a section list is effectively permanent once anything is published.
No path segment is ever localised. Reserved segments stay page and tags in every language, page
numbers stay ASCII digits, and a slug is whatever the key says: /bn/lekha/page/2/, never a translated
spelling of it. Localisation is for the words around the content, never the address of it (ADR-0034). A
Bengali variant is therefore reached at the same key under a prefix — /bn/posts/hello-world/. A slug
override renames the bundle in every language at once (ADR-0035); nothing gives one variant an address of
its own. The parser does not read slug yet.
A language prefix wins over a section of the same name, so a site with Bengali content may not also have
a section called bn. The engine treats a leading segment as a language only when some bundle is written
in it.
Language routing is decided (ADR-0009): English at root, other languages under /<lang>/ on the same
path — /pages/about/ and /bn/pages/about/. /en/… permanently redirects to the root form and is
never live.
Emit hreflang and canonical from the variants that actually exist.
Sequences
Comics, serial fiction and multi-part essays all reduce to a sequence, which is why it is the one grouping with a name (ADR-0016).
Membership is structural (ADR-0033): a bundle belongs to the series it is nested under, so
comics/the-long-monsoon/first-rain is a chapter of comics/the-long-monsoon. The engine reads no
series field. A bundle with bundles nested under it is a landing page and they are its members; a
landing page nested inside another series reports its own members rather than its siblings, since the
deeper series is what the page is about. Membership therefore rides on the bundle key, which is permanent
— re-parenting a chapter changes its URL and needs an alias like any other move.
Order is order ascending where set, then by name. A member without order sorts after every member
carrying one, the same way an undated bundle sorts after dated ones. Position lives in frontmatter, sparse
by convention, and never in the path, so inserting a chapter between two others is a single edit with no
renames, no changed keys and no aliases.
Resolution — first, prev, next, last, index, count — is defined once, and members resolve through the language fallback chain, so a chapter missing in Bengali still appears in Bengali reading order rather than breaking prev/next. Reading order is ascending, the opposite of a dated listing: a sequence's "prev" is the earlier entry.
draft is not honoured yet, because no bundle carries it and nothing else excludes drafts either; the
feature that adds the field adds it here in the same change.
Galleries, collections [spec]
Both are a Query over bundle metadata with a stable sort; neither justifies a new primitive. A gallery is a Query plus an image View. Related posts are a Query with a scoring function, not a stored graph.
Taxonomies [spec]
Two kinds, deliberately. Recorded intent (ideas/deferred-decisions.md), not built.
tags are one global namespace across every type. /tags/{tag}/ lists everything carrying the tag;
/{section}/tags/{tag}/ narrows it; listing views group results by type so a busy tag stays readable.
Cross-type discovery is the point — one tag spanning a comic, a poem and a photo essay is a feature here,
not noise. Term slugs are derived by locale and overridable by hand (ADR-0015).
Declared taxonomies are structural: a fixed term set that changes engine behaviour, declared on the
type — series for ordering, medium for art, genre for writing. These never enter the tag pool. The
test: a tag is free-form and cross-cutting, a declared taxonomy has known terms and drives behaviour.
Feeds follow the same shape: /tags/{tag}/feed.xml comes free from the same Query as the listing.
Scaffolding
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
last path segment.
What it writes is a draft: title, today's date, and draft: true. A tool that publishes the moment it runs
publishes by accident. Nothing is ever overwritten, and a key containing .. is refused — it names a place
under content/, not a path to walk.
Site settings
site.yaml at the site root declares the site (ADR-0039). Declared keys only — absent is fine, since a bare
site root still serves; malformed is a fatal startup error, because unlike one bad bundle it misconfigures
every page.
| Key | Meaning |
|---|---|
base |
The canonical origin, https://khosra.example. A trailing slash is trimmed. Absent, the engine emits root-relative paths and nothing absolute |
title |
Names the site. A theme may use it in a document title or a feed |
-base overrides the file, so a staging host needs no edit to content.
A canonical link, an hreflang and an OpenGraph URL become absolute as soon as base is declared, because
each is read by something that resolves neither against the page. Links between pages stay root-relative.
Below the site level [spec]
Section-level and bundle-level settings are not built: a bundle's own frontmatter covers it, and nothing
reads a section override yet. The parked shape is site → section → bundle, nearest explicit value winning
(ideas/deferred-decisions.md).
| Level | Where it lives |
|---|---|
| site | site.yaml at the site root |
| section, and any enclosing section | that directory's _index.<lang>.md frontmatter |
| bundle | the bundle's own frontmatter |
The cascade carries stage toggles, view selection, taxonomy defaults, cache flags, and metadata
defaults — declared keys only, never arbitrary engine internals. Stages run on everything by default and
are switched off by a cascade key, not by a predicate inside the stage: a stage that finds nothing to do
returns early on content shape ("no images here"), which has nothing to do with type.
This is also how a template is chosen: type default, then section override, then the bundle's view
(ADR-0019).
Extras: enumerated local assets
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
.mdinside is an asset, not a bundle: no frontmatter, no 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;?rawreturns 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.
extrasis 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
Every byte served from inside a bundle inherits that bundle's publish status — body, cover image, any local
asset, everything under extras (ADR-0024). An unpublished bundle answers 404 for itself and all its
assets; 403 would confirm the work exists. -dev reveals drafts and future-dated bundles with their
assets, defaults off, and is the only thing that changes the answer.
For a future-dated bundle the 404 expires at its publish time, so it becomes visible exactly when the bundle becomes public — the clock is read per request, so nothing has to be restarted or invalidated for a scheduled post to appear.
-dev on reveals drafts and future-dated bundles and reparses templates before each render, so editing a
template needs no restart. It is off by default and is not a bare boolean: revealing unpublished work should be
impossible to enable by fumbling an argument.
Typography and localisation
The line is drawn by who wrote the words (ADR-0034).
Authored body text gets one change and no others: the Markdown typographer smooths quotes, dashes and
ellipses. It works on the parsed tree, so a code span keeps its straight quotes and its --. Body text is
never localised — Bengali numerals inside a sentence are the author's decision, and a title stays exactly
as typed.
Chrome — every word the engine puts on a page that the author did not write — is localised: labels,
page counts, positions, month names and digits, from an engine-owned table reached by the template
functions in theme-contract.md. A Bengali page reads পৃষ্ঠা ২ / ২ and ৮ মার্চ ২০২৬.
Machine-readable output never localises. A datetime attribute, a URL, or anything a parser reads
stays ASCII in every locale.
Line breaking is not the engine's. Widows and orphans are a layout problem the browser solves with
text-wrap: pretty and text-wrap: balance, and the engine has no business inserting characters into an
author's prose to influence it (ADR-0045). The reference theme's stylesheet does it.
Shortcodes
A shortcode is {{< name key="value" >}} alone on a line — the whole line, or it is prose. Every
argument is key="value"; there is one spelling, so nothing is guessed and a malformed call stays visible
as text instead of half-working.
Arguments are data, never markup: the call renders through a theme template of the same name
(theme-contract.md), and raw HTML in a body remains dropped, so the only HTML on a page came from a
template the site owns (ADR-0036). A call naming a shortcode the theme has no template for renders nothing
and logs it — one typo does not take a page down (ADR-0029).
Shortcodes run on site-root content only (ADR-0003), never on anything untrusted.
figure and gallery exist. {{< gallery >}} takes no arguments: it lists the pictures sitting beside the
bundle, in filename order, which is why the sparse numeric-prefix convention orders a set without putting
numbers in URLs (ADR-0016). A subdirectory is not part of the gallery, and neither is a file the browser
cannot show.
{{< include file="notes.md" >}} renders another file from the bundle as Markdown, in place. Three rules,
all of them consequences of ADR-0038:
- The name is relative to the bundle and stays inside it. A name containing
..is refused, so an include cannot reach a template, a dotfile, or anything else in the site root that is not this bundle's. - One level. An include inside an included file renders nothing and is logged. A file that includes itself is therefore a log line, not a crash.
- A missing file, an unreadable one, or a call with no
fileargument renders nothing and logs. The page still serves (ADR-0029).
A gallery inside an included file still resolves against the same bundle.
Name an included file with a leading underscore — {{< include file="_tools.md" >}} — or it is a bundle too,
with its own URL.
Sharing one fragment between bundles is deliberately not possible yet: it needs somewhere to keep shared parts, which is a decision about the disk contract rather than a missing feature. Transclusion of another bundle's body is Arc 4 and needs a cycle guard of its own.
Images
Sized derivatives are generated by a pass over the content before the first request, into a cache
directory outside the site root (-cache, defaulting under the user cache dir). Each is named by its
source's content hash plus the target width, so the pass is idempotent, an edited picture yields a new name,
and nothing stale is ever served (ADR-0042). The engine never writes into the site root and never touches an
original.
Three widths — 480, 960, 1440 — and never an upscale: a picture already narrower than a target gets no
derivative for it. Markup carries the original as src, the derivatives as srcset closed by the original at
its own width, and width/height from the original so a page reserves the box before any bytes arrive.
JPEG, PNG, GIF and WebP are decoded and resampled (ADR-0040). SVG needs no resizing and AVIF has no decoder
available, so both are rendered as they are, with no size and no srcset — a picture the engine cannot
optimise is still the author's picture, and is never dropped from a gallery.
/derived/ is engine-owned, like /tags/ and /robots.txt: nothing an author writes is addressed there.
Noticing changes
A running server polls the site root every couple of seconds and rebuilds its index when something settles —
names, sizes and modification times, not contents, because reading every file to detect a change costs more than
the rebuild it triggers (ADR-0022). Editor droppings are ignored: swap files, backups, ~ copies, atomic-write
temporaries, and the number vim writes to test a directory. Saving a file is often several operations, so a
change has to hold still for a moment before it counts.
The index is swapped whole, so a request sees the content that was current when it arrived rather than a
half-rebuilt one. content/, templates/ and site.yaml are all watched, but only content takes effect
without a restart: templates are parsed once unless -dev on says otherwise.
The engine notices changes. It never fetches them — pulling a git repository is the operator's business, not the engine's.
Time-dependent presentation [spec]
Anything derivable from a page plus the current clock is computed by a Stage, never stored in content:
an "this article is old" banner compares date to now, relative dates likewise. Because renders are
cached (ADR-0005), such a Stage also declares how long its output stays true — a banner until
date+threshold, a relative date for a minute — and the cached entry expires then. Future-dated
publication is the same shape seen from the other side: the page becomes reachable at a moment nobody is
requesting it.
Metadata and discovery
Canonical links, hreflang and OpenGraph are emitted from fields that already exist on the page — the theme
composes them, so none of it is a transform and none of it adds a disk field. SEO adds no frontmatter; if it
seems to need some, the field belongs in the model for its own sake.
/robots.txt and /sitemap.xml are served at those exact paths. No bundle can collide with them, since a
key always sits under a section. A robots.txt at the site root is served verbatim, because a site that
ships one has said something deliberate; otherwise the engine emits the minimum that is true. The sitemap
lists every bundle in every language it exists in — each variant is separately reachable, so each is its own
entry — with lastmod only where a bundle carries a date. Both need base; without it the sitemap answers
404 rather than listing paths no crawler can resolve.
[spec] Twitter cards, JSON-LD and microformats2 have no consumer yet.