Polling lives in internal/ext/watch, per the human's call to keep core under its ceiling rather than raise it a second time — which is what ADR-0041 said a second raise would mean. It is a poller, deletable without trace, and core stayed at 2671/2800. A settled change calls the same `rebuilder` that startup calls, because a reload path that differs from the startup path is a reload path that drifts. The index is an atomic.Pointer swapped whole, so a request reads the site that was current when it arrived instead of one being rebuilt underneath it — the alternative, mutating in place, is a data race with every in-flight request. Names, sizes and modification times, not contents: reading every file to detect a change costs more than the rebuild it triggers. Editor droppings are excluded, because saving in vim writes a swap file, a backup and the number 4913, and each would otherwise look like a change. A change must hold still for a moment first, since one save is often several operations. Verified against the running binary: a page 404s, the file appears, and five seconds later it serves — one "site root changed" in the log. Then three droppings written at once produced no rebuild at all. Two warnings fired and were fixed rather than silenced: `runServe` gave up the rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to `record`, where three exclusions read as a list instead of as nesting. The Dockerfile ships the binary alone. The site root arrives as a volume and is never copied in — it is somebody's content repository with its own history (ADR-0011), so the image is the same for every site.
412 lines
25 KiB
Markdown
412 lines
25 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>/
|
|
site.yaml # what the site declares about itself, optional
|
|
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 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 | 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. 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.
|
|
|
|
## 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: `/tags/{tag}/feed.xml` comes free from the same Query as the listing.
|
|
|
|
## 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.
|
|
|
|
## 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.
|
|
|
|
### Below the site level `[spec]`
|
|
|
|
Section-level and bundle-level settings are **not** built: a bundle's own frontmatter covers it, and nothing
|
|
reads a section override yet. The parked shape is site → section → bundle, nearest explicit value winning
|
|
(`ideas/deferred-decisions.md`).
|
|
|
|
| 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
|
|
|
|
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 reparses templates before each render, so editing a
|
|
template needs no restart. It is off by default and is not a bare boolean: revealing unpublished work should be
|
|
impossible to enable by fumbling an argument.
|
|
|
|
## 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. 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
|
|
|
|
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 every couple of seconds 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. `content/`, `templates/` and `site.yaml` are all watched, but only content takes effect
|
|
without a restart: templates are parsed once unless `-dev on` says otherwise.
|
|
|
|
The engine notices changes. It never fetches them — pulling a git repository is the operator's business, not
|
|
the engine's.
|
|
|
|
## 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 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.
|
|
|
|
`[spec]` Twitter cards, JSON-LD and microformats2 have no consumer yet.
|