Files
khosra/harness/content-model.md
T
bdeshiandClaude Opus 5 30c26bd1ac resolve relative links against the disk, serve them as addresses
Item 2 of the order of work. An author writes `../day-01.en.md` — the path an
editor preview resolves — and the engine emits `/posts/day-01/`.

The larger effect is durability. Resolution goes through key → route, and a slug
moves the route while never moving the key (ADR-0035), so a relative link survives
a rename that a hand-written /posts/a-better-name/ does not. The demo proves it:
`../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote
the filename and got the slugged address.

This is the engine altering authored markup, which ADR-0045 polices, so the test
that matters is what it declines to touch. Fourteen cases must survive exactly as
written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative
path, a bare fragment, a bare query, a name climbing out of content/, and every
relative path whose extension is not .md. That last line is what keeps cover.jpg
working — a bundle's assets already resolve because its URL mirrors its directory,
so rewriting them would break what works. Nine rewrite cases sit beside them.

Key derivation goes through content.KeyFromName, exported for this: the
language-suffix rule is the part that would drift between two copies, so it lives
in one place while the five lines of joining are duplicated in check.

khosra check now reports a relative .md link resolving to no bundle, as fatal —
verified by mistyping one and watching exit 1. Only the .md form: an extensionless
relative path may be an asset, and a checker that calls a working link broken gets
ignored wholesale.

Two debts this change paid rather than deferred.

render.go reached the file-length advisory, so theme parsing moved to theme.go —
414 and 105 lines, one topic each, since parsing runs per rebuild and rendering
runs per request. Not a _helpers.go shard.

And the demo's coverage test bound its renderer with a *copy* of the rebuilder's
wiring, so it missed this feature entirely while the real binary served it
correctly. Navigation had already drifted the same way. Both now call one bind(),
which is exactly what ADR-0072 was written about — and the test failing is the only
reason the copy was found.

Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is
in ext where it belongs.

18 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:44:48 +06:00

34 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.

This describes what the parser accepts today. It is not a plan: a shape nobody has asked for lives in ideas/exploration.md, which is storage and out of context by default. So there is nothing here to build speculatively from, and no marker saying so.

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. Shortcode fragments may be one templates/shortcodes.html or a templates/shortcodes/ directory, or both (ADR-0071).

<site>/
  site.yaml                  # what the site declares about itself, optional
  content/                   # bundles — the disk contract below
  static/                    # verbatim, served under /static/
  root/                      # verbatim, served at / — exact paths (ADR-0081), optional
  templates/                 # html/template overrides, optional

<site>/content/
  posts/                     # general blog
    2026-03-hello-world/     # directory bundle
      index.en.md
      index.bn.md
      cover.jpg              # local asset, referenced relatively
  comics/                    # webcomics
    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
  writing/                   # short stories, poems
  status/                    # short IndieWeb-style notes
    2026-07-28-1030.md       # single-file bundle, no folder
  pages/                     # about, contact, now
    about/index.en.md

Bundle rules

  • A bundle is either a directory containing index.<lang>.md or 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, so about.bn.md carries a language and my.post.md does not. Fallback chain: requested → default → any → 404.
  • One key and language may be claimed by only one file. about.md, about.en.md and about/index.md all name the same variant, so any two of them together are ambiguous and none is served — logged, not fatal (ADR-0029).
  • _index.<lang>.md in a directory containing other bundles makes that directory itself a bundle (a section or series landing page) rather than a plain container.
  • Local assets sit beside the body of a directory bundle, referenced relatively, and are served under that bundle's URL: a page at /art/monsoon/ linking 10-first.jpg is served from content/art/monsoon/10-first.jpg. Assets never live in frontmatter. Moving a bundle moves its assets — the entire point of bundles.
  • A single-file bundle owns no assets. Its neighbours belong to its section rather than to it, and its slash-terminated URL has nothing beneath it, so an author with assets writes a directory bundle.
  • A .md inside a bundle directory is never served as an asset: it is a bundle with its own URL, or a fragment that was never addressable.
  • A directory starting with _ other than _index is ignored, and so is a file: _tools.md is a fragment, not a bundle. That is how a file meant only to be included avoids taking a URL of its own, appearing in its section's listing, and turning its bundle into a one-member series.
  • draft: true is 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 on is 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 This bundle's own CSS and JS, living beside the body and served under its URL. A scalar or a list. Bundle-relative only: a name containing .. or starting with / is dropped and logged, so a page cannot ship a template or reach another bundle's files (ADR-0079). Scripts are emitted with defer
use []string Theme assets to pull in without calling the shortcode that would (ADR-0079). Names the theme resolves through its assets:<name> fragments, never file paths — a name the theme does not define contributes nothing
lang string Explicit language when the filename cannot carry it
include string embed makes each ::include file its own document, with its footnote ids namespaced and its notes rendered where it sits. Absent — the default — splices the files in before parsing, so the page is one document: one footnote list at the end, abbreviations reaching every part, every heading in the contents list (ADR-0066, ADR-0076)

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.

A declaration file carrying per-type defaults is not built; the trigger is in ideas/deferred-decisions.md. 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 slug in frontmatter — the engine then serves that path, and the previous one only if aliases lists 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.

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.

/ 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 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.

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.

A draft or future-dated member is not in the sequence at all: membership resolves through the same Lookup every other path to a bundle uses, and that is where unpublished bundles are hidden (ADR-0024) — one rule in one place rather than a second check here. So prev/next closes over the gap, and -dev on reveals the member in its proper position without changing anything else.

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.

A link written relative to the file's place on disk becomes the address that file is served at (ADR-0087), so [a sibling](../day-01.en.md) resolves in an editor preview and on the site alike. Output is root-relative, like every link between pages (ADR-0039).

It resolves through the key, so a slug rename does not break it: the address moves and the key does not (ADR-0035). A hand-written /posts/a-better-name/ breaks the next time that slug changes; the relative form does not.

  • Three shapes resolve: ../day-01.en.md, ../day-01.md, and ../day-01 with no suffix. A directory bundle is ../notes-on-water/ or its index.en.md.
  • The language being rendered is preferred, falling back the way every lookup does — a Bengali page links the Bengali variant.
  • Anything that is not a bundle is left exactly as written: an absolute URL, a root-relative path, a bare fragment or query, a mailto:, and any relative path whose extension is not .md. A bundle's own assets already resolve, because a bundle's URL mirrors its directory, so rewriting them would break what works.
  • A name climbing above content/ is refused and logged, the rule an include and a code block's file= already follow.
  • khosra check reports a relative link ending in .md that resolves to no bundle as fatal. Only that form: an extensionless relative path may legitimately be an asset, and a checker that guessed would call a working link broken.

Files served at the URL root

root/ holds files that must answer at an exact address somebody else specified — /.well-known/security.txt, /humans.txt, a public key at /pubkey (ADR-0081). The path a file occupies is the path it answers at, so root/.well-known/security.txt needs no configuration to reach its address. This is not static/: that directory answers under /static/, and nothing in it can take a root-level path.

  • Engine-owned paths win. /, /robots.txt, /sitemap.xml, /static/ and /derived/ are already answered; a file in root/ claiming one is skipped and logged at startup rather than overriding it. It is a warning rather than a silent loss because the alternative is a server that will not start.
  • A leading underscore is not addressable, here as everywhere: root/_drafts/ is not walked and root/_headers.yaml is not served.
  • .tmpl opts a file into templating and is stripped from its URL, so security.txt.tmpl answers at security.txt. Only the suffix decides — never the content type — so a key, an image or a signature is never rewritten by accident.
  • A template is filled with what the site declares about itself and nothing else: {{.Base}} and {{.Title}}, so a file naming its own canonical URL does not repeat site.yaml. It is rendered as text, not HTML, so an & or a " in plain text or JSON survives intact.
  • A template that fails to render serves its own source and logs, because a promised address answering nothing is worse than one answering unrendered (ADR-0029).

root/_headers.yaml declares response headers per exact served path. Exact paths only, no globs:

/pubkey:
  Content-Type: text/plain; charset=utf-8
/.well-known/webfinger:
  Content-Type: application/jrd+json
  Access-Control-Allow-Origin: "*"

Without a declaration the type is derived from the filename, which is enough for .txt and .json and nothing at all for an extensionless /pubkey — the case the manifest exists for. A manifest that does not parse is logged and ignored; the files still serve.

The set of paths is fixed at startup, because a pattern cannot be added to a running mux. File contents are read per request, so editing a served file takes effect at once and only adding or removing one needs a restart — the same bargain site.yaml makes (ADR-0055).

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.

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 .md inside 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; ?raw returns the file itself. Selecting is an ordinary link and a full page, so no JavaScript is involved.
  • An entry must be one the listing found, so a path that walks out of the tree is simply not there.
  • Excluded from queries, feeds, sitemaps, the derivative pass and (later) search. extras is therefore a name no child of a bundle may use.
  • The name is fixed. Renaming it per section — process/ for a comic, notes/ for a story — waits for the section-level settings cascade, which nothing reads yet.

Visibility of everything inside a bundle

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 polls every 250ms instead of every two seconds so an edit lands promptly — unless -poll was passed, in which case the operator's interval wins (ADR-0056). It is off by default and is not a bare boolean: revealing unpublished work should be impossible to enable by fumbling an argument.

Code blocks

A fenced block is highlighted server-side, with no script (ADR-0075). Its info string carries the language first and then options:

Option Means
title="main.go" a caption above the block
numbers=yes draw the line-number gutter
start=42 number the first line 42
hl=3,7-9 tint those lines, in the numbering the reader sees
file=server.go take the content from that file beside the bundle instead of the fence
lines=5-30 with file=, only those lines — and the gutter shows the file's own numbering

So a post may quote several parts of one program, each keeping the line numbers it really has, without the copies drifting from the file. A name containing .. is refused, exactly as an include is: a block cannot publish a template or a dotfile.

The Markdown dialect

CommonMark, plus a named set of extensions and nothing else (ADR-0058). Every one is parse-phase, so none of them is a render transform, and the list lives in cmd/khosra/wire.go where features are enabled.

Enabled Syntax
Tables GFM pipe tables
Footnotes text[^1], then [^1]: the note
Definition lists a term, then : definition on the next line
Strikethrough ~~struck~~
Subscript H~2~O
Superscript 10^6^
Highlight ==marked==
Abbreviations *[HTML]: HyperText Markup Language on its own line; every whole-word use expands
Heading ids automatic from the heading's text, or declared: ## Title {#stable-anchor}
Task lists - [ ] and - [x] in a list item, rendered as a disabled checkbox — static markup, nothing to click and nothing stored (ADR-0078)

Declare an id when the anchor must outlive the wording: a derived id changes with the text, so rewording a heading breaks every link to it (ADR-0066). Two headings with the same words get suffixed rather than duplicated — #description, then #description-1 — and that holds across included files under either include model, because the fragments share the page's set of ids. The last five marks are khosra's own (ADR-0061, ADR-0062), because goldmark's strikethrough claims a single tilde as well as a double and would read H~2~O as struck text. A subscript or superscript may not contain a space — it holds a formula, not a phrase — which is what keeps x^2 + y^2 prose. Its content is taken literally, so there is no emphasis inside one.

An abbreviation's definition may sit anywhere in the file, before or after the uses it explains, and renders nothing itself. It applies to that document only: a term defined in a page does not reach an included fragment, which is parsed on its own bytes exactly as footnotes are.

Deliberately absent, so their absence is a decision rather than an oversight: linkify (it rewrites an author's plain text into markup, which is the line ADR-0034 draws), CJK line breaking (wrong script family — it does nothing for Bengali), and the GFM bundle, which would drag linkify in with the tables it is wanted for. Task lists were on this list until ADR-0078 and are now enabled on their own, which is the distinction: one named extension is not a bundle.

Footnotes inside an included file get ids namespaced by that file — _method-fn:1 rather than fn:1 — because an include is converted on its own bytes (ADR-0038) and would otherwise number from one all over again, leaving two id="fn:1"s on the page and a reference that jumps to the wrong note. A consequence worth knowing: the fragment's notes are listed where the include sits, not with the page's.

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. This is the leaf form of the generic directive syntax the wider Markdown world uses, not an invention of this engine (ADR-0059). Quotes are needed only where a value contains spaces: ::figure{src=cover.jpg alt="A red moon"}. A call with no arguments needs no braces: ::gallery. A malformed call stays visible as text instead of half-working.

Three colons open a container directive — :::name{…}, body, ::: on its own line — for calls that wrap content (ADR-0064). The body is Markdown and renders as such. One level: a ::: inside a container closes it rather than opening another, the same limit an include carries. note, warn and tip exist, plus details — an expandable panel, and tabs when siblings share a group — and aside, a margin note. A kind the theme does not define renders its body unwrapped rather than losing it.

Merging is the default. Fragments are spliced into the source before anything is parsed, which is what a page assembled from several files nearly always wants: one endnote list at the end rather than one per part (ADR-0066, ADR-0076). The cost is that a fragment is not contained — an unclosed code fence in a part affects the whole page, as textual inclusion always does. include: embed asks for containment instead.

Migrating from the retired form. {{< name key="value" >}} is no longer a call and renders as literal text. khosra check reports every one as fatal and names the replacement, so a site root is migrated by running it until it exits zero. The engine cannot rewrite a site root — that is the author's data (ADR-0011).

Arguments are data, never markup: the call renders through a theme template of the same name (theme-contract.md), so an argument is escaped for wherever the template puts it — which holds whatever the page around it is allowed to do (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.

::toc renders a table of contents from the document's headings, or ::toc{depth=2} for the shallower ones only (ADR-0065, ADR-0066). The author places it; the theme decides what it looks like. An entry carries the heading's level, its words without markup, and the id the engine assigned, so indentation is a CSS decision.

Icons are the one inline call: :warn: renders through the theme's icon fragment (ADR-0063). A name starts with a letter and holds letters, digits, hyphens and underscores, and neither colon may touch an alphanumeric — so 10:30:15, key:value:pair, Note: this and a URL are all left alone, and a code span is never touched. Which names exist is the theme's decision; a name it does not know is written back exactly as typed rather than disappearing.

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 file argument 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 on an interval — -poll, two seconds by default, -poll 0 to stop watching altogether for an immutable deployment (ADR-0022) — 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. The theme is swapped the same way at the same moment, so an edited template takes effect without a restart, and a template with a typo in it keeps the last working theme instead of taking the site down (ADR-0055).

One snapshot, whole site. A swap replaces the theme for every page at once — bundles, listings, tag listings, extras alike — so the site is never partly updated: no render path reparses on its own, and there is no state in which one page shows an edited template and its neighbour shows the old one (ADR-0056).

content/ and templates/ are watched. site.yaml is not: it applies at startup only, because the settings are copied by value into the renderer, the handler, the feeds and the sitemap, and a rebuild that updated some of them would be worse than one that updates none (ADR-0055). Editing it needs a restart.

The engine notices changes. It never fetches them — pulling a git repository is the operator's business, not the engine's.

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.

Twitter cards, JSON-LD and microformats2 have no consumer yet.