The last two words of a paragraph or heading are joined by a non-breaking space, so one word never falls alone onto its own line. Deferred from entry 11 for the right reason: over rendered HTML this cannot tell prose from an escaped code span, so it had to wait for a tree transform. The interesting failure is worth keeping: written against goldmark alone it passed six tests, and did nothing in the real engine. The typographer splits a text run wherever it looks for a substitution, so a paragraph ending "hand." arrives as two text nodes and the last of them holds no space at all. A version that inspects only the last child therefore finds nothing to join. It now takes the whole trailing run of text nodes, stopping at a line break or any markup, and there is a regression test that builds both extensions together — the only configuration that would have caught it. The joined text becomes a String node, which carries its own bytes: a segment is an offset into bytes every node shares, so editing the source in place is not possible. That path still escapes, and a test says so, since otherwise this transform would be an injection route. PhaseMarkup is now empty in extensions.md, and honestly so: everything expected there turned out to belong either earlier or later.
332 lines
20 KiB
Markdown
332 lines
20 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, 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 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.
|
|
|
|
**Widows are prevented**: the last two words of a paragraph or heading are joined by a non-breaking space,
|
|
so a single word never falls alone onto its own line. It works over the parsed tree, which is what keeps it
|
|
out of code spans — a pass over rendered HTML could not tell prose from an escaped one. A block ending in a
|
|
code span, link or emphasis is left alone, because the last "word" is then a construct rather than a word.
|
|
|
|
## 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 `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 `[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.
|