A feature now learns which bundle is rendering: render.Bundle puts an Origin —
the bundle's directory plus the rooted fs.FS — on the parse context, and
render.OriginFrom reads it back. Available while parsing, not while rendering,
which decides where a feature does its filesystem work: goldmark hands the
context to a block parser and not to a node renderer, so gallery gathers its
filenames at parse time and carries them on the node.
Reads stay inside the site root because Origin passes the fs.FS rather than a
path to join (ADR-0031).
Fragment{Args, Items} lands with it (ADR-0037), so figure's template now reads
.Args.src. Authored arguments and engine-gathered items stay in separate fields:
a src argument beside a src the engine found would otherwise silently pick one.
A gallery is pictures beside the bundle, in filename order, skipping
subdirectories and anything a browser cannot show. Filename order is what makes
the sparse numeric-prefix convention work without numbers in URLs (ADR-0016).
New latent row: the reference theme's images carry no width/height and a
gallery's carry no alt, which is below the output floor conventions.md states.
Nothing can supply either yet — dimensions need the image read, and a filename is
not alt text. Queue 13 computes dimensions and brings structured items with it.
314 lines
18 KiB
Markdown
314 lines
18 KiB
Markdown
# 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>/
|
|
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>.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, referenced relatively. Assets never live in frontmatter. Moving a
|
|
bundle moves its assets — the entire point of bundles.
|
|
- A directory starting with `_` other than `_index` is ignored. `draft: true` is excluded from queries
|
|
and feeds but reachable at its own URL in dev.
|
|
|
|
## 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 | Overrides the derived slug for the bundle in **every** language (ADR-0035). The engine serves the new path only; the old one 404s unless it appears in `aliases`. Two variants declaring different slugs 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 `slug` in frontmatter — the engine then serves that path, and the previous one only if
|
|
`aliases` lists it
|
|
- 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: `/feed.xml` carries every type declared `primary`, `/{section}/feed.xml`
|
|
carries a section, and `/tags/{tag}/feed.xml` comes free from the same Query.
|
|
|
|
## The settings cascade `[spec]`
|
|
|
|
Recorded intent (`ideas/deferred-decisions.md`), not built. Until it exists, settings come from a bundle's own frontmatter.
|
|
The shape: site → section → bundle, nearest explicit value winning.
|
|
|
|
| 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 `[spec]`
|
|
|
|
A bundle may hold a directory of supporting files — drafts, notes, logs, scans, media. Default `extras/`,
|
|
renamed by a cascade key so a comic can use `process/` and a story `notes/`. Recorded intent (`ideas/deferred-decisions.md`).
|
|
|
|
- The bundle scanner **skips it entirely**. A `.md` inside is an asset, not a bundle: no frontmatter, no
|
|
identity, no language variants.
|
|
- Enumerated as a tree, sorted by filename — the sparse numeric-prefix convention orders entries without
|
|
putting numbers in URLs (ADR-0016).
|
|
- Each entry is classified by extension: `markdown`, `text`, `image`, `pdf`, `audio`, `video`, `other`.
|
|
Markdown and text are rendered; everything else is served as bytes.
|
|
- `…/extras/{path}` renders the listing with that entry selected; `?raw` returns the bytes.
|
|
- Excluded from feeds, queries and search.
|
|
|
|
## 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.
|
|
|
|
## 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.
|
|
|
|
Widow prevention is not implemented: doing it safely needs a transform over the parsed tree rather than a
|
|
pass over rendered HTML, which cannot tell prose from an escaped code span. It waits for the Stage
|
|
pipeline.
|
|
|
|
## 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` is `[spec]`: file inclusion will resolve relative to the including bundle, may not escape the site
|
|
root, and needs a depth limit on the first attempt, since a file that includes itself is otherwise a crash.
|
|
Transclusion of another bundle's body is Arc 4 and needs a cycle guard too.
|
|
|
|
## Images `[spec]`
|
|
|
|
Optimisation and sizing are a Stage plus emitted derivative files, content-addressed by source hash
|
|
and target width so rebuilds are idempotent and cheap. Emit width/height into the markup to prevent
|
|
layout shift. Never mutate the author's original. Prefer stdlib decoders; a small dependency only
|
|
with an ADR.
|
|
|
|
## 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 output `[spec]`
|
|
|
|
OpenGraph, Twitter cards, JSON-LD, microformats2, canonical links, and `hreflang` are Stages reading
|
|
only fields that already exist on the page. SEO adds no new disk fields; if it seems to need one, the
|
|
field belongs in the model for its own sake.
|