Files
khosra/docs/content-model.md
T
bdeshi b14658094e add tag listings, global and section-narrowed
One global namespace (ADR-0018): /tags/{term}/ spans every section and
/{section}/tags/{term}/ narrows it. Listings group by section so one busy term
stays readable, which needed List.Groups alongside Items — list.html renders
whichever is set.

This is Query's second use, so it gained a Tag field rather than being generalised
on speculation: one filter, two callers. Tag slugs lowercase and hyphenate,
preserving script, so "Long Monsoon" and "long monsoon" are one term while Bengali
passes through unchanged. Hand-chosen slugs per term still wait for the type
declaration that owns overrides.

`tags` is reserved at the top level and inside every section, alongside `page` and
the language prefixes. A tag listing redirects to its canonical URL only once it is
known to exist, matching the rule bundles already followed — otherwise a canonical
URL for nothing confirms what is not there.

One stale test expectation fixed rather than worked around: it asserted tags land
in Extra, which stopped being true when tags became a named field.

Evidence: /tags/monsoon/ lists Hello World under posts and First Rain under comics;
/comics/tags/monsoon/ shows one; /tags/monsoon 301s; /tags/nothing/ and /tags/ 404.
2026-08-01 02:23:34 +06:00

15 KiB

Content model

Engine specification — the format the parser accepts and the URLs the engine emits. This is not a rule about how anyone organises their files: the site root belongs to its owner (ADR-0011), and every statement here describes engine behaviour, not an obligation on content.

The most expensive thing in the engine to change, because published URLs are permanent and the files it reads are somebody's database.

Markers are build order, not status: [arc1] build now · [spec] recorded intent, shape decided by whatever builds it. What exists is state.md's job. Never build a [spec] section because it is written here.

The site root

The engine is pointed at a site root outside this repository (-site <dir>, or KHOSRA_SITE), with its own git history (ADR-0011). Nothing in the engine repo is content. templates/ in the site root overrides the defaults the binary embeds, so a bare root still renders.

<site>/
  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/            # slug is the name, never the position (ADR-0016)
        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. The engine serves the new path only; the old one 404s unless it appears in aliases
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)
series / order string / int Series membership and position. Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016)
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.

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.

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, galleries, collections [spec]

All of these are a Query over bundle metadata with a stable sort; none justifies a new primitive.

A sequence is the one that needs naming, because comics, serial fiction and multi-part essays all reduce to it (ADR-0016). Membership is series; position is whatever the type declares as its order rule — date, sequence, or manual. Position lives in frontmatter, sparse, and never in the path, so inserting a chapter between two others is a single edit with no renames, no changed bundle keys and no aliases. Resolution — first, prev, next, last, index, count — is defined once, honours draft, and respects the language fallback chain, so a missing Bengali chapter does not break Bengali prev/next.

A gallery is a Query plus an image View. Related posts are a Query with a scoring function, not a stored graph. Pagination is a Query parameter plus a permalink rule for page 2+, still undecided.

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.

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.