Confirms ADR-0009's "same path" clause against the alternative that was on the table: a Bengali variant at its own Bengali address. Chosen: one path per bundle, prefixed per language, so identity stays stable across translations (invariant 3) and the resolver keeps one lookup direction. The declined option is named in the ADR so it is not re-proposed as new. Cost is stated too — a Bengali reader sees a Latin address, and reversing this later needs an alias for every published path. No code moves: `slug` is still unread. Recorded now because nothing is published yet, which is the only cheap moment a permalink decision has.
298 lines
18 KiB
Markdown
298 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.
|
|
|
|
## Includes and shortcodes `[spec]`
|
|
|
|
Shortcodes are a Stage running on trusted content only (ADR-0003), never on comments. File inclusion
|
|
resolves relative to the including bundle and may not escape the site root. Transclusion of another
|
|
bundle's body is Arc 4 and needs a cycle guard on the first attempt.
|
|
|
|
## 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.
|