---
html: true
title: Darkmown docs
description: Learn Darkmown routing, directives, state, loops, forms, and the static-to-reactive model.
transitions: true
schema: TechArticle
---

@include /nav.wd
@include /base.wd

<main class="docs">

<header class="docs-hero">
<span class="eyebrow">Documentation</span>

# The Darkmown docs

Darkmown is a Markdown-native framework for mostly-static sites with tiny reactive islands. Pages are plain files: `.md` stays pure CommonMark, and `.wd` ("whateverdown") is the same Markdown plus first-party directives.
</header>

<nav class="docs-toc" aria-label="On this page">

**On this page**

- [Install](#install)
- [The two formats](#the-two-formats)
- [Routing rules](#routing-rules)
- [Directive lines](#directive-lines)
- [Includes](#includes)
- [Loops](#loops)
- [Content collections](#content-collections)
- [Interpolation](#interpolation)
- [Frontmatter](#frontmatter)
- [SEO & feeds](#seo--feeds)
- [Canonical URLs](#canonical-urls)
- [Structured data](#structured-data)
- [AI crawlers](#ai-crawlers)
- [Drafts](#drafts)
- [Instant navigation](#instant-navigation)
- [Images](#images)
- [Static assets](#static-assets)
- [Sections](#sections)
- [Reactive directives](#reactive-directives)
- [Fetching data](#fetching-data)
- [Global state — `:store`](#global-state--store)
- [URL as state](#url-as-state-from-url)
- [Forms and persistence](#forms-and-persistence)
- [Backends & deploy](#backends--deploy)
- [Interactions](#interactions--slider-sortable-carousel)
- [Theme toggle — `:theme`](#theme-toggle--theme)
- [Scoped styles — `scoped`](#scoped-styles--scoped)
- [Skin at-rules](#skin-at-rules-font-face-and-keyframes)
- [Inline attributes](#inline-attributes)
- [Media directives](#media--video-audio-embed)
- [Syntax highlighting](#syntax-highlighting)
- [Colocation](#colocation)
- [Programmatic compile](#programmatic-compile-compilefrommemory)
- [AI authoring](#ai-authoring)
- [Compile error codes](#compile-error-codes)
- [CLI reference](#cli-reference)
- [Editor support](#editor-support)
- [Benchmarks](#benchmarks)
- [Accessibility](#accessibility)
- [Security](#security)
- [Limits](#limits)
- [Spec status](#spec-status)
</nav>

Every heading on every page gets a stable, GitHub-style `id` at build time (lowercased, punctuation stripped, duplicates deduped), so the links above — and deep links into any Darkmown page — are plain `#` anchors with zero JavaScript.

@include ./-relative-note.wd

## Install

```sh
npx @zvndev/darkmown init my-site
cd my-site
npm install
npm run dev
```

Or add Darkmown to an existing project with `npm install -D @zvndev/darkmown`. The package is scoped; the command it installs is plain `darkmown`.

Want to try the syntax before installing anything? The [playground](/playground/) compiles `.wd` and `.md` live in your browser — the same compiler the CLI ships — and shows the real `file:line` error when you break something.

For editing, there is a VS Code extension for `.wd` and `.skin` files: see [Editor support](#editor-support).

`darkmown dev` rebuilds incrementally: it tracks which routes depend on each file (includes, colocated assets, loop data, collections), so editing one page or include recompiles only the routes it affects — and falls back to a full rebuild whenever a file is added, removed, or renamed. `routes.json`, the sitemap, and the RSS feed are regenerated on every rebuild, so they always describe the whole site.

## The two formats

- `.md` is strict CommonMark. Directives, includes, and `{ bindings }` stay plain text, and the build prints a hint if it spots `.wd` syntax in a `.md` file.
- `.wd` is Markdown plus directives. Renaming a file from `.md` to `.wd` is the upgrade path — nothing else changes.

## Routing rules

- `site/pages/index.wd` becomes `/`.
- `site/pages/docs/index.wd` becomes `/docs/`.
- `.secret.wd`, `-draft.wd`, and hidden folders do not become pages.
- `site/_/` is the include shelf, never a route.

## Directive lines

A directive is a whole line, written flush against the left margin. Four rules cover what the compiler will and will not accept there.

- **One directive per line, unindented.** An indented `:state` (inside a list item, say) is prose, and a directive inside a fenced code block is code.
- **A bare keyword is an error.** A line that is exactly `:state`, `:button`, `@loop`, or any other directive that takes arguments used to render as literal text with no warning at all. It now reaches that directive's own handler and fails with that directive's coded message and `Use:` hint, so a lone `:state` gives `[WD201]` and a lone `:button` gives `[WD301]`. Three keywords stay valid bare, because they mean something bare: `:::` opens or closes a container, `:theme` declares the theme store, and `:carousel` opens a carousel with no autoplay.
- **To show a directive name as text, escape it.** Writing `\:fetch` renders the literal text `:fetch`. It is the ordinary CommonMark backslash escape, so it works anywhere Markdown does.
- **There is no comment syntax.** A trailing `#` or arrow note on a directive line is parsed as part of the value, not as a comment: `html: true  (required)` seeds the string `true  (required)`, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.

## Includes

- `@include /nav.wd` resolves from `site/_`.
- `@include ./-relative-note.wd` resolves beside the current page.
- `@include /card.wd with title="Hello" count=3` passes values into the include.
- `with title={ feature.title }` passes a value already in scope — including whole objects.
- Includes inside a loop inherit the loop value automatically.

An include declares fallbacks for its `with` arguments in its own frontmatter with `defaults: [title="Untitled", cta="Learn more"]`. The caller's `with` value always wins; the default fills in when the caller is silent; an argument with neither renders as literal `{ title }` text, as before. Values are strings (frontmatter is string-typed), entries split on top-level commas only (so `cta="Learn more, today"` works), and a malformed `defaults:` is `[WD613]`.

The block form turns an include into a wrapper component. The lines between the call and `@endinclude` compile once in the **caller's** scope (its `:state`, and the loop item inside a `@loop`) and land wherever the include puts a standalone `{ children }` line:

```wd
@include /card.wd with title="Pricing"

Everything in **this paragraph** is the card body.

@endinclude
```

with `site/_/card.wd`:

```wd
---
defaults: [title="Untitled"]
---

::: card
## { title }

{ children }
:::
```

The slot is a standalone line only (mid-paragraph or fenced `{ children }` stays literal; on a top-level page it is literal too; a `.md` include cannot host one). Called without a block, the slot renders empty; a block given to an include that never uses `{ children }` is dropped with a build warning. `@endinclude` closes the nearest preceding unpaired `@include`, so an inline `@include` written between opener and closer consumes the pairing; one level of block nesting is supported (deeper is `[WD616]`), and a stray closer is `[WD614]`. There is deliberately no unclosed-block error: an unpaired opener is the inline form, and the inline form must stay legal. Per-row components in a loop are the payoff, and stay zero-JS over a static source:

```wd
@loop /products.json into product
@include /card.wd with title={ product.name }

Price: { product.price | money }

@endinclude
@endloop
```

## Loops

`@loop <things> into <thing>` is the only loop. The body is normal Markdown (or more directives) and repeats once per row:

```wd-fragment
@loop /features.json into card
@include /feature-card.wd
@endloop
```

The source decides how the loop behaves:

- A JSON file path (`/features.json`, `./data.json`) unrolls at build time — pure static HTML.
- An in-scope value (an include argument or an outer loop value) also unrolls at build time.
- A `:state` list compiles to a reactive region the runtime patches by key.

Loops nest, and `{ card.title }` style dotted paths reach into each row.

### Filtering with `where`

Add `where <predicate>` to filter a loop. Conditions join with `and` / `or`:

```wd-fragment
@loop /products.json into p where p.featured == true and p.price < 80
- { p.name }
@endloop
```

Operators are `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item fields, declared `:state`, numbers, or `"strings"` — a validated whitelist, never arbitrary code.

The predicate decides reactivity exactly like the source does: read only the row and the filter runs at build time (zero JS); read a `:state` value and the loop re-filters live. Pair it with `:bind` for a search box in pure Markdown:

```wd
:state products = [{"id":1,"name":"Aurora Lamp"},{"id":2,"name":"Briza Fan"}]
:state q = ""

:bind q placeholder="Search"

@loop products into p where p.name contains q
- { p.name }
@endloop
```

`:bind <state>` renders an `<input>` wired two-way to a `:state` value — type and the list filters as you go.

### Editable lists with per-row actions

A `:button` inside a reactive loop can act on its own row — the basis for carts and to-do lists:

```wd
:state products = [{"id": 1, "name": "Aurora", "price": 49}]
:state cart = []

@loop products into product
::: card
**{ product.name }** — ${ product.price }
:button "Add to cart" -> cart += product
:::
@endloop

@loop cart into line
::: card
{ line.name }
:button "Remove" -> cart remove line
:::
@endloop
```

`cart += product` appends a copy of the current row to another `:state` list; `cart remove line` removes the current row from the list being looped. Both are checked against the enclosing loop at compile time — no JavaScript.

### Sorting, paging, and meta variables

Shape a loop with clauses in a fixed order after `into`: `where`, then `sort by`, `reverse`, `offset`, `limit`.

```wd-fragment
@loop /posts.json into post sort by post.date desc limit 5
{ $number }. { post.title }
@endloop
```

- `sort by <key> [asc|desc]` — the key must start with the loop item (`post.date`). Numbers sort numerically; anything else sorts as text. `asc` is the default.
- `reverse` flips the order; `offset N` / `limit N` slice it. `N` can be an integer or a `:state`/`:store` key, which makes paging reactive (`limit pageSize`).

Every row exposes meta variables — `{ $index }` (0-based), `{ $number }` (1-based), `{ $first }`, `{ $last }`, `{ $count }` — usable in interpolation and `:if`:

```wd-fragment
@loop products into product
:if $first
**Top pick:**
:endif
{ $number } of { $count } — { product.name }
@endloop
```

### Empty lists with `@empty`

Add an `@empty` branch to render a fallback when the loop produces no rows (after filtering and slicing):

```wd-fragment
@loop todos into todo
- { todo.title }
@empty
Nothing left to do.
@endloop
```

A **missing** in-scope source is an empty list, not an error — `@loop meta.tags into tag` on a page whose frontmatter omits `tags` (an optional field) loops zero rows and renders the `@empty` branch. A value that is present but not a list is still a compile error with the file and line.

These clauses stay build-time when the source and every argument are static — a sorted, limited loop over a JSON file ships zero JS. The loop turns reactive only when the source or a clause reads `:state`/`:store`/`:fetch` data.

### Table rows from a loop

A **static** loop whose body is bare `| … |` cells fills a Markdown table. Write the header in prose and let the loop supply the rows:

```wd
| Item | Price |
| --- | --- |
@loop /products.json into row
| **{ row.name }** | { row.price } |
@endloop
```

That renders one `<table>` with one `<tr>` per row, and each cell holds ordinary inline Markdown, links included. A format pipe works too, but its `|` has to be escaped inside a cell: `{ row.price \| money }`. Put the whole table inside the loop instead and you get a headerless table of the same shape; give the loop body its own `| --- |` separator and each row is a complete table, collapsed into one when the headers match.

Two limits to know before building on it:

- A pipe row written in prose **after** `@endloop` (a totals row) does not join the table. It stays a paragraph.
- A **reactive** loop over pipe rows is `[WD191]`, not silent breakage. A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so there is no correct HTML to emit. Loop a static source for a Markdown table, or build reactive rows out of containers (`::: trow` / `::: td`) and style them with `display: table-row` / `table-cell`.

## Content collections

Any folder under `site/pages/` is a **collection** — loop it by its bare name. No `content/` root, no marker file: a `site/pages/blog/` folder of posts *is* the `blog` collection. The whole [blog on this site](/blog/) is one such loop.

```wd-fragment
@loop blog into post sort by post.date desc
- [{ post.title }]({ post.url }) — { post.date }
@endloop
```

Each entry's frontmatter is a row, plus three derived fields: `{ post.url }` (the route), `{ post.slug }` (the filename), and `{ post.excerpt }` (the frontmatter `excerpt:`, else the first paragraph of a `.md` body). It's the same loop with the same clauses, resolved at build time — a pure listing ships zero JS. Drafts never appear in a default-build listing (only under `darkmown build --drafts`).

### Typed schema — `_schema.wd`

Drop a `_schema.wd` at a collection's root to validate every entry at build time. It's frontmatter-shaped, one `field: type` per line:

```wd
---
title: string
date: date
excerpt: string?
tags: string[]?
---
```

Closed types: `string`, `number`, `boolean`, `date`, `string[]`, each with `?` for optional. A missing required field, a wrong type, an unknown extra field (typo guard), or an unknown type token fails the build with a `file:line`. No `_schema.wd` means no validation — it's opt-in.

### Pagination — `paginate N`

Add `paginate N` (collections only) to split a listing into static pages. Page 1 keeps the listing route; pages 2+ are at `/<route>/page/2/`, `/page/3/`, … all static HTML.

```wd-fragment
@loop blog into post sort by post.date desc paginate 5
- [{ post.title }]({ post.url })
@endloop

Page { page.current } of { page.total }
:if page.prev
[← Newer]({ page.prev })
:endif
:if page.next
[Older →]({ page.next })
:endif
```

The pager — `{ page.current }`, `{ page.total }`, `{ page.prev }`, `{ page.next }` (URLs, empty at the ends) — is exposed to the whole page as plain links. Zero JS. `paginate` can't combine with `offset`/`limit` (it owns the slice).

## Interpolation

One syntax everywhere: `{ name }` or `{ name.path }`.

- If the name is a static value in scope (include argument, loop value), it is resolved at build time.
- If the name is declared `:state`, it becomes a live binding.
- The page's frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
- Otherwise the text is left exactly as written — braces in prose never break a page or pull in the runtime.
- Values resolve **inside a link or image destination** too, and every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)` works.
- Values resolve in **raw HTML** as well (attributes and html blocks, HTML-escaped) on an `html: true` page, but those are painted once at build time rather than bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `` `[x](/p/{ p.slug }/)` `` as syntax.
- `{ $build.year }`, `{ $build.date }`, and `{ $build.datetime }` fold the build's date at compile time, in prose, raw HTML, and link destinations. They always fold, so a page using only `$build` bindings stays zero-JS. An unknown `$build.*` name stays literal text, and `.md` files are never folded.

### Destinations that bind

A destination that reads `:state`, `:store`, `:computed`, a reactive loop row, or a loop meta variable is a **live binding**. The compiler emits the destination as a small template and the runtime rebuilds the value on every render:

```wd
:state region = "eu"

:button "EU" -> region = "eu"
:button "US" -> region = "us"

[Open the docs for this region](/docs/{ region }/)
```

Clicking a button rewrites the `href`. The build-time paint is still the seed (`/docs/eu/`), so the link works before the runtime loads and a crawler sees a real URL. Per-row destinations work the same way inside a reactive `@loop`: `- [{ p.name }](/products/{ p.slug }/)`.

Three rules worth knowing:

- **Values in URL position are percent-encoded**, so a `)`, a space, or an angle bracket coming from your data cannot end the link and hand the remainder back to the Markdown parser.
- **Dangerous schemes are refused twice.** The compiler vets what you wrote; the runtime re-checks what it assembled (control characters stripped first) and applies an **empty** attribute rather than a half-applied one when the value resolves to `javascript:`, `data:`, or `vbscript:`.
- **Raw HTML attributes do not bind.** `<a href="{ url }">` is painted once, with a build warning that names the position that does bind. Move the value into a Markdown destination, or drive the attribute from a colocated `.js` with `wd.subscribe`.

See it live on the [reactive links demo](/attr-binding/).

### Format pipes — `{ value | name:arg }`

Shape a value for display with a pipe: `{ price | money }`, `{ joinedAt | date:"medium" }`, `{ ratio | percent }`. Pipes chain (`{ name | trim | capitalize }`) and take literal arguments (`{ total | money:"EUR" }`, `{ bio | truncate:80 }`). The same syntax works on static values (folded at build time, zero-JS) and live bindings (re-applied on render).

The value being piped must be **in scope** — a loop variable, `:state`/`:store`, an include argument, or `meta`. A bare literal like `{ 89 | money }` is not a name in scope, so it stays literal text (braces and all), exactly like any unresolved `{ … }`. A date-only value (`"2026-06-22"`) formats in UTC, so `date` prints the written calendar date on any build machine.

The whitelist: `money`, `number`, `percent`, `round`, `date` / `time` / `datetime` (Intl-backed; `short` / `medium` / `long`), `upper` / `lower` / `capitalize`, `truncate:n` / `trim`, `pluralize:"item"`, and `default:"—"`. The five aggregates double as list pipes — `{ cart | count }`, `{ cart | sum:"price" | money }`, plus `avg` / `min` / `max`. No custom functions, nothing `eval`'d; an unknown pipe is a compile error. There is deliberately no relative "time ago" formatter, so builds stay reproducible.

Pipes also fold inside **raw HTML** on an `html: true` page: a static value (a collection row, a JSON row, `meta`) in an attribute or html block formats at build time, so a static loop with pipes in raw HTML stays zero-JS. A reactive value there can never fold (raw HTML is a one-time paint), so it is painted once with a build hint, instead of staying silently literal as before 2.8.

## Frontmatter

Frontmatter sits between `---` fences. Values are strings, plus inline arrays:

```wd
---
title: Customers
tags: [sales, revenue, "q1, q2"]
---
```

`{ meta.title }` prints a scalar and `{ meta.tags }` joins an array with `, `. You can loop a frontmatter array at build time — `@loop meta.tags into tag` — and the page stays static. Arrays are inline flow only (`[a, b]`); quoted items keep their commas.

`lang:` sets the document language on `<html lang>` per page (`lang: fr`, `lang: pt-BR`, …); it defaults to `en`. Every page also ships a visually-hidden-until-focused **skip-to-content link** targeting the main landmark — your own `<main>` is reused (an existing `id` wins as the target; an id-less one gets `id="main"`), and a page without one is wrapped in `<main id="main">`. Restyle it via `.wd-skip-link` in a skin. All compile-time — static pages stay zero-JS.

More head and publishing keys arrived in 2.8 and 2.11:

- **`icon: /path`** sets the favicon verbatim (`<link rel="icon">`, with a `type` for `.svg`/`.png`/`.ico`). Without it, the first of `site/pages/icon.svg`, `icon.png`, or `favicon.ico` wins; without either, the built-in mark stays the fallback.
- **`noindex: true`** builds the page but removes it from discovery: a robots `noindex,nofollow` meta, plus exclusion from `sitemap.xml`, `rss.xml`, and the generated `llms.txt`/`llms-full.txt`. On a collection entry with a `_schema.wd`, declare it as `noindex: boolean?`.
- **`skins: [/base.skin]`** (an inline array) loads shared `.skin` files for that page only, resolved like `@include` paths; a missing file, a non-`.skin` file, or a path outside `site/pages`/`site/_` is `[WD017]`. A shared skin whose first line is `scoped` scopes to the whole page body, so keep to one scoped skin per page.
- **`md_source: true`** (home page only) publishes every page's byte-exact markdown source at `<route>.md` (`/docs/` also answers `/docs.md`, with a `<link rel="alternate" type="text/markdown">` head tag and a `text/markdown` content type). A page opts out with `md_source: false`; drafts never emit, and the sitemap and feeds never list the `.md` files. Off by default.

**Cascade order changed in 2.8.** Stylesheet links emit the highlight sheet first (code pages), then include skins, then `skins:` entries in listed order, and **the page's own colocated `.skin` last**, so the page wins ties at equal specificity. Before 2.8 the page's skin came first; a page rule that unknowingly relied on losing to an include rule now wins.

## SEO & feeds

`darkmown build` writes `sitemap.xml`, `rss.xml`, and `robots.txt` — all build-time, zero client JS. There's no config file: the **home page** frontmatter carries the site's identity. Add `site_url` (absolute origin, no trailing slash) and the sitemap and feed switch on, prefixed with that origin and titled with the home `title`/`description`.

```wd
---
title: My Blog
description: Notes
site_url: https://example.com
---
```

A page becomes an **RSS post** by carrying a `date:`; `excerpt:` is its feed summary (else `description:`, else — for a plain `.md` — its first paragraph). The sitemap's `<lastmod>` is the `date:` if set, else the file's git/last-modified date. `robots.txt` is always written; the `Sitemap:` line only appears with a `site_url`. Without `site_url`, robots still emits and the build prints a hint — it never breaks.

The feed carries the 20 newest posts by default. Set `rss_limit: 50` on the home page to change that. It takes digits only (a positive whole number of items), so `1e3`, `0x10`, and `7.0` are refused with `[WD950]` rather than silently coerced.

A sitemap over 50,000 URLs shards automatically into `sitemap-N.xml` files behind a `<sitemapindex>`, and a `date:` that is not a real `yyyy-mm-dd` is omitted from `<lastmod>` with a build hint instead of writing a broken value.

## Canonical URLs

With `site_url` set, every page states its own absolute URL twice, as `<link rel="canonical">` and as `og:url`, both built from the **same route string** the sitemap and your internal links use, so nothing can drift out of agreement:

```html
<link rel="canonical" href="https://example.com/docs/">
<meta property="og:url" content="https://example.com/docs/">
```

Darkmown routes are **trailing-slashed** (`/docs/`), matching the `dist/docs/index.html` the build writes. `darkmown deploy vercel` writes `trailingSlash: true` so the host serves exactly that form. Host it elsewhere and configure the same: a host that redirects `/docs/` to `/docs` puts a redirect hop on every internal navigation and makes your own sitemap advertise URLs that redirect.

A paginated listing canonicalises **each page to itself** (`/blog/page/2/` points at `/blog/page/2/`), never back to page one. Pages 2+ hold different content, and pointing them at page one asks a crawler to drop them.

`og:type` is `article` for a page with a `date:` (or an article `schema:`), `website` otherwise. There is no `twitter:title`/`twitter:description`: X's card parser falls back to the Open Graph tags, so they would be duplicate bytes on every page.

## Structured data

One frontmatter key emits a JSON-LD block into the head. This is ordinary indexing hygiene for Google's conventional **rich results**; Google's own generative-search guidance says there is no special AI schema and that structured data is not required for generative results, so Darkmown does not sell it as an AI-citation lever.

```md
---
title: Zero JavaScript, by default
date: 2026-06-25
author: Ada Lovelace
schema: BlogPosting
---
```

The types are a compile-time whitelist, like every other Darkmown vocabulary: `Article`, `BlogPosting`, `TechArticle`, `WebSite`, `Organization`. Anything else is a compile error that names every valid type. Pass a list for a page that is honestly both: `schema: [WebSite, Organization]`. Supporting keys are `author:` (a name or a list) and `updated:` for articles, `organization:` and `logo:` for `Organization`; everything else is reused from `title`, `description`, `image`, `date`, `lang`, and the canonical URL.

**Only what the page has.** A key you did not write produces no property at all: never a blank one, never a guess. There is deliberately no way to emit `aggregateRating`, `review`, or `offers`: fabricated ratings are a manual-action risk and Darkmown cannot know them. `FAQPage` is not supported either, because it would mean inferring a Q&A structure out of prose.

**`BreadcrumbList` is automatic** for nested routes (two or more path segments) once `site_url` is set, built from routes that actually exist with their real titles, so a crumb never links a page you never wrote. JSON-LD is an inert data block, so a static page stays static and ships no JavaScript.

## AI crawlers

`robots.txt` names every major AI crawler and answer engine explicitly, grouped by operator, each group linking the documentation its tokens were verified against and noting what that operator's crawlers actually do:

```
# OpenAI: https://developers.openai.com/api/docs/bots
# OAI-SearchBot = ChatGPT Search crawling and citations. GPTBot = potential model training. …
User-agent: OAI-SearchBot
User-agent: GPTBot
User-agent: ChatGPT-User
Allow: /
```

Covered: OpenAI, Anthropic, Google (`Google-Extended`), Apple (`Applebot-Extended`), Perplexity, Meta, Mistral, Amazon, and Common Crawl. Search crawling, model training, and live user-triggered fetches are **different** permissions from the same operator, which is why each group says which is which.

Flip the whole set to `Disallow: /` from the home page with `ai_crawlers: deny`. `allow` is the default. A value that is neither fails the build rather than defaulting. The intent behind a typo like `ai_crawlers: block` is almost always to opt *out*, and silently allowing would be the one unrecoverable outcome.

## Drafts

Mark a page `draft: true` and it stays out of production: `darkmown build` leaves it out of `dist`, `routes.json`, the sitemap, and the feed — even if it has a `date:`. `darkmown dev` still builds and serves it (with a "DRAFT" banner you only see in dev), and `darkmown build --drafts` includes drafts for a staging deploy. This is separate from the permanent `.`/`-`/`_` filename hiding — a hidden name is private forever; `draft:` is a switch you flip when the page is ready.

## Instant navigation

Add `transitions: true` to a page's frontmatter for **instant, flash-free navigation**: zero JavaScript. It emits a view transition with no root animation, so the page swap is an instant cut with no possible compositing artifact (animated defaults were measured to double-expose or be replaced by the browser's own cross-fade), and a `<script type="speculationrules">` prerender hint, so the browser renders the next same-origin page on hover/pointerdown and the click activates an already-painted page (no white render-gap; the prerender is what makes navigation feel smooth). Authors who want a fade can add `::view-transition-old`/`::view-transition-new` rules in their own skin. and only same-origin pages that both opt in transition. Off by default; opt out with `transitions: false`, or exclude a single link from prerendering with `{.no-prefetch}`. (Chrome turns prerendering off while DevTools is open, so test the built site with DevTools closed.)

## Images

You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.

**Responsive variants, generated at build time.** With sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), every raster photo in `site/pages` over 700px wide is resized to 640w and 1280w siblings in `dist/` (only widths below the intrinsic size, never upscaled, EXIF orientation applied), and the Markdown image gains a `srcset` with the original kept as `src`. Export `name@<width>.ext` yourself and your file wins that width verbatim, measured from its own header; generation fills only the widths you left uncovered, and other sizes are a hand export away (the compiler probes 64 through 3840). Skipped silently: small images, `@width` files, hidden or symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes are cached in `node_modules/.cache/darkmown-images/` by content hash + width + format, so second builds and dev rebuilds do no resizing for unchanged images, and the build summary reports `image variants (N generated, M cached)` when variants materialized. The build never invents a `sizes` value, but you can set one on the Markdown image itself: `![hero](hero.jpg){sizes="(max-width: 640px) 100vw, 640px"}` passes it through onto the `<img>`, composing with the wired `srcset` (raw `<img srcset sizes>` on an `html: true` page remains the escape hatch). Bound (`![a]({ photo })`), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. A `.md` page gets the hardening but no srcset.

## Static assets

Non-page files live on the include shelf at `site/_/` and are copied into the build untouched:

- Any non-`.md`/`.wd` shelf file is served at `/__wd/media/<path>`, so `site/_/logo.svg` becomes `/__wd/media/logo.svg`. Reference it with a normal URL: `![logo](/__wd/media/logo.svg)`.
- Shelf `.json` files are served at `/__wd/data/<name>`, which is what `:fetch` reads. That is why `:fetch` works on any plain static host, with no server.
- A file colocated under `site/pages/` copies to `dist/` at its own path instead (see [Colocation](#colocation)), so `site/pages/logo.svg` is served at `/logo.svg`.

Use the shelf for assets shared across many pages, and colocate the ones a single page owns. Both ship with the right content-type.

## Sections

`::: section #id .class` opens a container and `:::` closes it. Sections scope state: a `:state` declared inside a section belongs to that section, so two sections can both declare `count` without colliding. Bindings and buttons resolve to the nearest scope.

A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) so pages keep proper landmarks without raw HTML. Any other name stays a `<div>` with that class.

### Accessibility attributes

Darkmown has no general attribute syntax on purpose: styling is `.class` tokens and behavior is `->` actions, which is what keeps output predictable and static pages script-free. That left one thing genuinely unreachable, the ARIA vocabulary a screen reader needs. Exactly three attribute names compile, on exactly two directives (`:::` and `:button`), always with a **double-quoted static value**:

```wd
:state open = false

::: card .note role="region" aria-label="Release notes" title="What changed"
Notes go here.
:::

::: nav .menu role="navigation" aria-label="Main"
[Docs](/docs/)
:::

:button "Menu" aria-expanded="false" aria-controls="m" -> open toggle
```

- On a container they interleave freely with `.class` and `#id` tokens and coexist with `.class when <predicate>`.
- On a `:button` they sit between the label and the `->`. An arrow inside a quoted value is safe: attributes are peeled before the action arrow is looked for.
- Values are HTML-escaped on emit, so nothing you write can close the attribute or open another.
- Anything outside the whitelist is `[WD650]`: `onclick=`, `style=`, `href=`, `class=`, `id=`, and `data-*` are refused, and so is `ARIA-LABEL` (the match is case-sensitive) or a single-quoted value. A whitelisted name with no double-quoted value is `[WD651]`.
- **Values are static text.** There is no `{ state }` interpolation inside an aria value in this release: `aria-label="{ who }"` emits the literal braces. For a live accessible name, write the element in raw HTML on an `html: true` page, or set it from a colocated `.js`.

Compile-time only: zero runtime bytes, and a static page carrying attributes stays `runtime: false`.

## Reactive directives

Reactive pages opt into `/__wd/runtime.js`, a minified build of the readable `src/runtime.js` (~6.6 KB gzipped, under a CI-enforced 8 KB budget), with an external sourcemap beside it at `/__wd/runtime.js.map` carrying the original source, so DevTools shows real names and comments. Development serves the same bytes as production. Static pages ship none of it.

```wd
:state count = 0

The count is { count }.

:button "Increment" -> count++

:if count
Count has changed.
:else
Count is still zero.
:endif

:state todos = [{"id": 1, "title": "Route pages"}]

@loop todos into todo
- { todo.title }
@endloop

:button "Add" -> todos += {"id": 2, "title": "Live compile"}
```

Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in a colocated `.js` file.

### Computed values and aggregates

`:computed name = expr` derives state from state — arithmetic, comparisons, and the five list aggregates `sum` / `avg` / `min` / `max` / `count`. It recomputes when an input changes and reads like any binding, so it pairs with format pipes:

```wd
:store cart = [{"price": 89}, {"price": 12}]
:computed subtotal = sum(cart, price)
:computed total = subtotal * 1.08

Total: { total | money }
```

The aggregate's field is a bare row key (`price`, not `item.price`).

### Timers — `:every`

`:every <duration> -> <actions>` runs a `:button`-style action on an interval (`ms` / `s` / `m`). Intervals pause while the tab is hidden and resume on return.

```wd
:fetch board from "/status.json"
:every 10s -> board refetch

:state secs = 0
:every 1s -> secs++
```

`:every` is a **page-level** registration. A reactive `@loop` compiles its body once into a template and the runtime clones it per row, so a timer inside that body would be registered once per row (three rows, three intervals) and a removed row's interval would keep firing. That placement is `[WD315]`. Declare the timer once outside the loop, at page level or inside the `::: section`, and act on the whole list (`:every 5s -> rows refetch`). It stays legal at page level, inside a `:::`, inside a **static** loop (N literal copies that never churn), and in a reactive loop's `@empty` branch. A `:button` inside a reactive loop is unaffected.

### Multi-branch conditionals

A condition reads the same predicate grammar as `.class when` — a bare path (truthy), or the comparisons `==` `!=` `<` `<=` `>` `>=` `contains`, joined with `and`, `or`, and `not`. (`@loop … where` is the comparison-only subset.) Chain with `:else if` (any number; a bare `:else` must come last):

```wd-fragment
:if plan == "pro" or seats >= 5
Pro plan
:else if trialDays > 0 and not expired
Trial — { trialDays } days left
:else
Free plan
:endif
```

A whole chain compiles to nested conditional regions, so it folds at build time when every value is static and stays reactive otherwise — exactly like a single `:if`.

**Declarations inside a closed branch are live.** A `:state`, `:store`, or `:theme` written inside an `:if` branch that starts closed is hydrated the moment the branch opens, `persist` included. The seed is claimed once per **key**, not per node, so closing and re-opening the branch does not reset the value the reader set, and the claimed seed is that key's `reset` baseline. A `:computed` inside a closed branch stays dormant until the branch opens, by design. See the [closed-branch demo](/branch-state/).

### Reactive classes — `.class when …`

A container class can react to state or the loop item. Static `.class` tokens stay as-is; add `when <predicate>` for a reactive one:

```wd-fragment
@loop products into p
::: card .product .on-sale when p.price < 50
**{ p.name }** — ${ p.price }
:::
@endloop
```

The predicate uses the `:if` whitelist (item fields, `:state`, numbers, strings, comparisons, `and`/`or`/`not`, `contains`). A static predicate folds to a plain class at build; a state or loop-item predicate stays reactive.

### Effects — `:effect`

`:effect <watched> -> <actions>` runs actions (the `:button` vocabulary, `;`-chained) whenever the watched state changes — for side effects beyond `:computed` and fetch deps:

```wd
:state q = ""
:state searches = 0

:effect q -> searches++
```

Effects run after a render against settled state, never on the initial load, with a 10-pass settle cap guarding against loops. Like `:every`, an `:effect` is page-level: written inside a reactive `@loop` body it is `[WD315]`, because an effect watches a top-level state key and its actions target one, so there is no per-row meaning to give it.

### Button actions

A `:button "Label" -> action` mutates one `:state` or `:store` value. The vocabulary is the same for both:

- Numbers: `n++`, `n--`, `n += 5`, `n -= 2`
- Set / toggle: `name = value`, `flag toggle`
- Arrays: `list append v` (or `list += v`), `list prepend v`, `list toggle v`, `list remove v`, `list clear`
- Objects: `obj merge other`, `obj delete "key"`
- Universal: `name reset` restores the declared starting value

Values are literals — a `"string"`, number, `true`/`false`/`null`, or inline JSON. Targets may be dotted paths (`cart.count++`), and one button can run several actions separated by `;` (applied in order, rendered once):

```wd-fragment
:button "Add to cart" -> cart.count++ ; cart.total += 9
```

A `:button` also accepts the three [accessibility attributes](#accessibility-attributes), written between the label and the `->`: `:button "Menu" aria-expanded="false" -> open toggle`.

`list toggle v` and `list remove v` match by value, which is exact for strings and numbers but not for object members — remove row objects with the per-row `remove` action instead.

## Fetching data

`:fetch name from "url"` declares state and fills it from JSON over the network. Shelf `.json` files are served at `/__wd/data/`. Each fetch auto-declares five states you can branch on: `name`, `name_loading`, `name_error`, `name_error_body`, and `name_empty`.

```wd
:fetch team from "/__wd/data/team.json" timeout=8000 retry=2

:if team_loading
Loading…
:else if team_error
Couldn't load the team: { team_error }
:else
@loop team into member
- { member.name }
@empty
No team members yet.
@endloop
:endif
```

The lifecycle regions announce themselves to assistive tech: a bare `:if name_loading` compiles with `role="status" aria-live="polite"` and `:if name_error` with `role="alert"` — compile-time attributes, no extra runtime JS. Write your own `role`/`aria-live` inside a region and nothing is added.

Options after the URL: `method=` (default `GET`), `when=load|visible`, `timeout=<ms>`, `retry=<N>`, `headers=<key>`, and `body=<key>` (the last two name a `:state`/`:store` key). A URL can interpolate state (`"/api/users/{ userId }"`) and re-fetches automatically when that state changes; trigger a reload by hand with `:button "Reload" -> team refetch`. Loop a sub-path of fetched data with a dotted source — `@loop team.members into member`. URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation — other schemes are rejected at compile time.

### What the server said

A real API explains why it refused, and Darkmown surfaces that instead of a status line. When the failing response has a JSON body:

- **`name_error`** is the body's own `error` field, then its `message` field, falling back to `HTTP <status>` when the body carries neither or is not JSON at all.
- **`name_error_body`** is the whole parsed body (`null` when the response was not JSON), so per-field messages render without a line of your own JavaScript.

Given a `422` whose body is `{"error": "Pick a file first.", "fields": {"photo": "No file was attached."}}`:

```wd
:fetch signup from "/api/signup" method=POST

:if signup_error
**{ signup_error }**
:if signup_error_body
Photo: { signup_error_body.fields.photo }
:endif
:endif
```

The first line renders *Pick a file first.*, not *Error: HTTP 422*. Both keys are declared automatically by `:fetch` and by a round-trip `:form` (one with both `into` and `action=`), and both are cleared at the start of the next request, so a stale message never outlives the failure that produced it. See the [server errors demo](/api-errors/).

### Authenticated requests

`headers=<key>` sends a state object as request headers; pair it with `:store` to persist a bearer token. Add `refresh="<url>"` and a `401` triggers a token refresh: Darkmown POSTs the current headers to the refresh URL, writes the renewed token back into the `headers` state, and retries the request once (concurrent `401`s share one refresh).

```wd
:store session = { "Authorization": "Bearer …" }
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
```

## Global state — `:store`

`:store` is global, durable, and shared across browser tabs — the right home for a cart, a theme, or a signed-in user. Unlike `:state`, it is never section-scoped and persists by default.

```wd
:store cart = []

:button "Add" -> cart += {"id": 1, "name": "Aurora"}

You have { cart } items.
```

- Saved to localStorage under `wd:store:<name>` and reloaded on the next visit; changes sync live to other open tabs.
- The declared value is a **seed**, used only the first time — afterward the persisted value wins.
- Same value grammar and the same button actions as `:state`.
- An array/object seed may span **multiple lines** for readability — open the `[`/`{` on the directive line and let it run until it closes (no blank line inside; an unterminated literal is a compile error). Quote literal bracket text: `:state tag = "[draft]"`.
- On a multi-line seed, a `persist`/`ephemeral` token goes **after the closing bracket on the last line**, not on the declaration line.
- `persist` means "survives a reload", `ephemeral` means "does not", and both are accepted on `:state`, `:store`, and `:theme`. The keyword only picks the default. `:store sidebarOpen = false ephemeral` is an in-memory store; `:state cart = [] persist` is a persisted one. Writing the token that matches the default is redundant but never wrong.
- `:computed` takes neither: computed values are derived rather than stored, so persist the state they derive from instead (`WD211`).
- There is a third word, [`from-url`](#url-as-state-from-url), which says the value also lives in the query string. It composes with `persist` and belongs to `:state` only. Writing `persist` and `ephemeral` on one line (or the same word twice) is `[WD261]` rather than a token silently folded into the value.

## URL as state: `from-url`

A filter nobody can link to is half a feature. Add `from-url` to a `:state` and the value lives in the query string as well as in memory: a reload keeps it, a shared link arrives with it applied, and the back button walks through it.

```wd
:state q = "" from-url
:state tier = "all" from-url

:bind q placeholder="Search products"

:radio tier
- all
- budget
- premium

:state products = [{"name": "Aurora Lamp", "tier": "budget"}]

@loop products into p where p.name contains q
- **{ p.name }** ({ p.tier })
@empty
Nothing matches that search.
@endloop
```

Type in the box and the address bar becomes `?q=aurora`. Reload, and the search comes back. Copy the URL into a new tab and it opens on the same view.

The rules:

- **The parameter is named after the state key.** A section-scoped key like `cart:items` becomes the parameter `cart.items`, so the name stays readable and stays unique.
- **A value equal to its declared seed drops its parameter,** so the default page keeps a clean URL.
- **Writes go through `history.replaceState`,** so filtering never fills the back button with one entry per keystroke. `popstate` re-reads on back and forward, and a parameter that is gone restores the seed.
- **It composes with `persist`,** and the boot precedence is URL, then stored value, then seed. A link somebody sent you beats what this browser remembers. When back navigation lands on a clean URL, the stored value follows the seed too.
- **Strings stay strings.** For any other seed type the parameter is JSON-parsed, falling back to the raw string.
- **`from-url` is `:state` only.** A `:store` is shared by every page and every tab while a query parameter belongs to one page's address, so `from-url` on a `:store` or a `:theme` is `[WD260]` rather than a guess. On a `:computed` it is `[WD211]`: derive the value from a state key that does come from the URL.

Live demo: [/url-state/](/url-state/).

## Forms and persistence

- `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead. Form actions follow the same scheme rules as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
- `:input field placeholder="…" required` and `:submit "Label"` build the form body.
- `:state cart = [] persist` survives reloads via localStorage on a single page; use `:store` for state shared across pages and tabs.
- `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
- Reactive pages expose `window.wd` (`get`, `set`, `subscribe`, `state`, `render`) so colocated `.js` can do anything directives can't. Section state is addressed as `sectionId:name`.

### Bound controls outside a form

`:select`, `:radio`, and `:checkbox` mean two different things, and **where they sit decides which**. Inside a `:form` they are form fields, submitted by name (unchanged). Outside one they bind to a declared `:state` or `:store` of that name, exactly like `:bind` and `:slider`: move the control and the state changes, change the state and the control moves.

```wd
:state density = "Comfortable"
:state previews = true

:select density
- Compact
- Comfortable
- Spacious

:checkbox previews
- Show image previews

:if density == "Compact"
Rows sit tight together.
:endif
```

- **The state has to exist first.** A bound field naming state that is not declared is `[WD450]`, which spells out both readings: declare the state, or move the field inside a `:form`.
- **A bound `:checkbox` is a single boolean,** so it takes exactly one `- Label` line (the label shown beside it). Several options is `[WD451]`; for a set of choices use a `:radio` group. The multi-value checkbox group is the in-form behavior and is unchanged.
- **A bound `:radio` group keeps its shared `name`,** which is what makes the browser treat it as mutually exclusive, and carries the chosen option's text.

See the [settings demo](/settings/).

### File upload

A `:form` that contains a file field posts **multipart**, so the file itself travels:

```wd
:form into reply action="/api/upload/"
:input photo type=file required
:input caption placeholder="A caption (optional)"
:submit "Upload"
:endform

:if reply
Uploaded **{ reply.name }**, { reply.size } bytes.
:endif

:if reply_error
**{ reply_error }**
:endif
```

The compiler writes `enctype="multipart/form-data"` for the browser's native submit, and the runtime sends real `FormData` with **no** content type of its own so the browser writes the boundary. A raw `<input type="file">` on an `html: true` page counts as a file field too.

Bound controls inside the form carry no `name`, so `FormData` would never see them; on the multipart path they are appended by their state key instead of being silently dropped. A file field on a `method="get"` form is `[WD452]`: a GET request has no body, so only the file's name would ever travel, which is the kind of failure that looks like it worked. See the [upload demo](/upload/).

See it all live on the [Data & Forms page](/data/).

## Backends & deploy

Darkmown builds 100% static HTML — never per-request rendering. When you need a server, you write a plain serverless function; there is no Darkmown backend syntax to learn.

- A backend endpoint is a Web-standard handler in a top-level `api/` directory: `export default (request, context) => Response`. `api/echo.js` → `/api/echo`; `api/users/[id].js` → `/api/users/:id`.
- One shape runs everywhere: `darkmown dev` (local runner), Vercel (native Edge Functions), and Cloudflare Pages (the build emits a `dist/_worker.js`). `:fetch`/`:form` point at `/api/*` with no extra config.
- If your host rewrites trailing slashes, point at the form it will settle on. Vercel's `trailingSlash: true` redirects `/api/echo` to `/api/echo/`, and the local runner answers both, so a missing slash costs a redirect that only appears once deployed. The redirect is a `308`, so a form POST still arrives intact; you are paying a round trip, not losing data.
- `darkmown deploy vercel` or `darkmown deploy cloudflare` builds and ships in one command, printing your URL.
- For a remote/custom backend, point `:fetch`/`:form` at an absolute URL and widen the CSP `connect-src` / `form-action`. Darkmown owns no server — it adapts to yours.

`darkmown init shop --template store` scaffolds a cart **and** its `api/checkout.js`; `--template dashboard` ships a `:fetch` view **and** `api/metrics.js`.

## Interactions — `:slider`, `sortable`, `:carousel`

Pay-for-what-you-use: `sortable`/`:carousel` compile to a tiny `/__wd/behaviors/*.js` module loaded only where used, budgeted separately from the ≤8 KB core runtime. `:slider` is compile-time only.

- `:slider name = v min=0 max=100 step=5` — a range input bound through `:bind`; range values coerce to Number.
- `sortable` (a bare `@loop` clause, written without a leading colon) — drag-to-reorder a `:state`/`:store` list via Pointer Events, with keyboard (Arrow Up/Down) + screen-reader announcements; valid on a plain reactive loop only.
- `:carousel [autoplay=N]` — each direct child block is one slide (wrap each in its own block, e.g. `::: slide`); native scroll-snap plus prev/next, dots, and mouse drag. Autoplay respects `prefers-reduced-motion`.

See all three live on the [Interactions page](/interactions/).

## Theme toggle — `:theme`

For an explicit light/dark switch alongside the OS preference, declare `:theme` once and drive it with buttons. It registers a durable `theme` store and reflects onto `<html data-theme>`:

```wd
:theme
:button "Auto"  -> theme = "auto"
:button "Light" -> theme = "light"
:button "Dark"  -> theme = "dark"
```

A single `tokens dark` block in your `.skin` already powers both the OS `prefers-color-scheme` query and this toggle — no extra block needed. `"auto"` clears the attribute and follows the OS again. See it on the [Ledger demo](/ledger/).

## Scoped styles — `scoped`

A colocated `.skin` is **global** by default. Make its **first line** `scoped` and its selectors only ever match the component it ships with — so two components can both use `.card` without colliding. It's pure compile time: a path-derived id (e.g. `wd-7c21`) is stamped onto the HTML and appended to each selector.

```skin
scoped

.card
  padding 1.5rem
  bg $panel
```

compiles to `.card[data-wd-scope="wd-7c21"] { … }`, and the component's markup is stamped `data-wd-scope="wd-7c21"`. You still write `class="card"` — no renaming, no runtime, **zero JS** on a static page. A page skin scopes the page body; an **include** skin scopes just that include's subtree wherever it's used.

| In a `scoped` skin | Result |
|---|---|
| A selector rule | Scoped — attribute on the subject, before any `:hover`/`::before` |
| `tokens` / `tokens dark` | **Global** on `:root` — `$vars` and dark mode keep working site-wide |
| `:global(.toast)` (whole selector) | Opts back out — plain unscoped `.toast { … }` |
| `@font-face` / `@keyframes` | **Global**, never stamped, same contract as `tokens` |
| `page` / `*` / `html` / `body` / `::selection` | Compile error — page-level styles belong in a global skin |

Honest limits this release: only **whole-selector** `:global()` (no `.card :global(.x)` yet), and an **unused** scoped selector **warns but is not removed** (a colocated `.js` may add the class). See the side-by-side [Scoped demo](/scoped/).

## Skin at-rules: `@font-face` and `@keyframes`

Two CSS at-rules compile as self-contained blocks in a `.skin`, because their indented children are font descriptors and keyframe selectors rather than page selectors:

```skin
@font-face
  font-family "Newsreader"
  src url("/fonts/newsreader.woff2") format("woff2")
  font-weight 200 800
  font-display swap

@keyframes fade-in
  from
    opacity 0
  50%, 75%
    opacity .5
  to
    opacity 1
```

`@font-face` takes plain declarations, several blocks per skin allowed. `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its declarations one level deeper, and anything else is a compile error, so a keyframe selector is never misread as a declaration. Aliases and `$token` refs work inside both, both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards), and both stay **global** in a `scoped` skin, same as `tokens` blocks.

**Build-time font subsetting.** With the wasm toolchain installed (`npm i -D harfbuzzjs wawoff2`, both optional dependencies), a post-pass subsets every project-local `.ttf`/`.otf`/`.woff` referenced by an `@font-face src url(...)` to the site's character set (every code point in the emitted HTML, plus a base set of ASCII, Latin-1, punctuation, arrows, and ★ ☆ ✓ ✕ ✗), encodes it as woff2, emits `<name>-subset.woff2` beside the original, and rewrites the dist CSS so `src:` lists the subset first; the source `.skin` is never touched, and layout features and variable axes survive. Without the toolchain the build is unchanged plus one hint per build, a font that fails to parse ships as-is with its own hint, and the build never fails over a font. Subsets cache in `node_modules/.cache/darkmown-fonts/` keyed by font and charset hashes, and the CSS rewrite is idempotent, so incremental dev builds are safe. The build summary reports `fonts (3 subsetted, 0 cached)` when the post-pass did work. The same pass also injects one `<link rel="preload" as="font" crossorigin>` per project font into every page whose own stylesheets declare it (leading with the subset woff2 when subsetting ran, else the original), so first paint skips the fallback-font swap.

## Inline attributes

A trailing `{.class .class #id}` attaches classes or an id to the inline element directly before it, most often to style a link as a button without a wrapper:

```wd
[Get started](/start/){.btn .lg}
```

The block must follow the element with no space. It works on links, images, and emphasis, and it does **not** attach to inline code. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.`, `#`, or `sizes=`. On an image the block also accepts `sizes="…"` (double-quoted, media conditions with spaces and commas fine), passed through HTML-escaped alongside the classes and id, with or without a wired `srcset`; on a link or emphasis the token is ignored. This page uses it: a link marked `{.no-prefetch}` is excluded from the `transitions: true` prerender hint.

**Consecutive raw-HTML lines collapse into one paragraph.** On an `html: true` page, a run of lines that each start with an inline-level or unknown tag (`<a class="card">`, `<picture>`, a custom element) parses as ONE paragraph, so the elements render inline inside a single `<p>` instead of as layout siblings; a card grid collapses to one column this way. Lines starting with a block-level tag (`<div>`, `<section>`, …) never hit this, because CommonMark hoists them into an HTML block. The compiler warns once per run when 2+ consecutive lines of one paragraph each open with a component-looking tag (one carrying a `class` or `id`; classless prose links stay quiet):

```
site/pages/index.wd:6: 16 sibling HTML block lines will render inline in one <p>; wrap them in a container (e.g. a raw <div>)
```

The fix is the wrap: put the run inside a raw `<div class="grid">…</div>`. The hint stays silent inside a raw container, inside fenced code, on blank-line-separated lines, on `{ state }`-led lines, and on `.md` pages; a run inside a `:::` directive container DOES warn, because directive containers are not raw HTML.

## Media — `:video`, `:audio`, `:embed`

Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):

```wd
:video /clip.mp4 poster=/clip.jpg controls
:audio /track.mp3 controls
:embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
```

`:video`/`:audio` emit a hardened HTML5 player with `preload="metadata"` and `controls` by default, whitelisted flags (`autoplay` implies `muted`), and the `:fetch` URL scheme guard. `:embed` rewrites a YouTube/Vimeo link to its no-cookie/player form in a lazy, responsive `16/9` iframe, and takes three sizing and permission attributes: `aspect=4:3` swaps the ratio box, `height=400px` pins the height instead (units `px`/`rem`/`em`/`vh`/`vw`/`%`, wins over `aspect=`), and `allow="payment"` replaces the default iframe permissions with tokens from a fixed whitelist (`payment`, `autoplay`, `encrypted-media`, `fullscreen`, `picture-in-picture`, `clipboard-write`, `camera`, `microphone`, `geolocation`). A bad token is `[WD706]`, a bad ratio or length `[WD707]`. See the zero-JS [Media demo](/media/).

## Syntax highlighting

Fenced code blocks with a language are highlighted at **build time** — HTML and CSS only, no client JavaScript. Tag the fence with a language:

````md
```js
const greeting = "Darkmown"; // highlighted at build time
```
````

The highlighter is [highlight.js](https://highlightjs.org/) and isn't configurable (one closed default, like everything else). It maps onto your skin's `$code-*` tokens, so highlighted code **dark-modes for free** through the same `tokens dark` / `:theme` system — no extra wiring. Define the palette once:

```
tokens
  code-bg #1b2420
  code-fg #e9efe7
  code-keyword #d9a8e0
  code-string #97d892
  code-comment #859289
  code-function #88c4ee
  code-number #ecae78
  code-punctuation #c1ccc6
tokens dark
  code-bg #100d0a
  code-keyword #e2b9e8
```

The framework ships a small default `$code-*` set, so highlighting looks right out of the box. The stylesheet is **pay-for-what-you-use**: it's emitted and linked only on pages that actually contain a highlighted block — a page with no code ships nothing extra. A fence with an unknown or absent language renders as plain escaped `<code>` (no highlighting, no error); inline `` `code` `` is never highlighted, and there are deliberately no line numbers (they break copy-paste). See the live [Syntax highlighting demo](/highlight/).

## Colocation

- A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
- A matching `.js` file attaches page behavior. It loads **after** the runtime, so `window.wd` is ready: `wd.get(key)`, `wd.set(key, value)`, and `wd.subscribe(key, cb)` (runs now and on every settled change) bridge declarative state to imperative widgets — the gestures the framework leaves out (keyboard, drag/touch, canvas). The [Swiper demo](/carousel/) is a draggable carousel built this way.
- Both work for included fragments too, by basename.
- Any other file under `site/pages/` (an image, font, PDF — anything that isn't a `.md`/`.wd` route or a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type. So `site/pages/logo.svg` is served at `/logo.svg`. Assets below hidden path segments (`.`, `-`, or `_`) and symlinked page assets are skipped, matching the routing privacy rule. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.

## Programmatic compile: `compileFromMemory`

`darkmown build` reads from disk, but the compiler itself is filesystem-free. `compileFromMemory(files, entryPath, options)` compiles a page from an in-memory map of project-relative path to source, so the whole compile path bundles for the browser or any other non-Node host:

```js
import { compileFromMemory } from "@zvndev/darkmown";

const { html, assets } = compileFromMemory(
  { "site/pages/index.wd": "---\ntitle: Hi\n---\n\n:state n = 0\n" },
  "site/pages/index.wd"
);
```

Includes, colocated `.skin`/`.js` detection, and `@loop` JSON reads all resolve against the map (anything not in it is simply absent), and it throws the same `file:line` compile errors as the CLI. The [playground](/playground/) is built on exactly this entry point: markdown-it plus the compiler bundled into one asset, recompiling on every keystroke into an iframe. On disk, `compilePage(file, paths)` is the same compile with a filesystem reader injected for you.

## AI authoring

Darkmown ships a machine-readable description of its own language, so an AI tool can learn `.wd` and be constrained to output that compiles. It is all generated from the compiler's own tables, so it never drifts from the real grammar.

- **`darkmown catalog`** prints structured JSON: every directive, `@loop` clause, loop variable, button action, format pipe, and predicate operator — each with a syntax template, a one-line description, one concrete example, and whether it needs the reactive runtime. Import it too: `import { directiveCatalog } from "@zvndev/darkmown/catalog"`.
- **`darkmown catalog --llms`** prints a compact cheatsheet, the artifact you paste into a model's system prompt. Every build writes it to `dist/llms.txt` too, followed by a one-line index of every page on your site and a pointer to the full corpus.
- **`darkmown catalog --llms-full`** prints the complete reference that index points at: every directive with its full syntax and example, every clause, action, pipe, operator, and frontmatter key, and every compile-error code with its cause and fix. Every build writes it to `dist/llms-full.txt`, with the full source text of every page appended.
- **`grammar/wd-directives.gbnf`** is a generated GBNF grammar for `.wd` directive lines. Point a grammar-constrained decoder (llama.cpp and friends) at it to make invalid directive lines — JS idioms, HTML muscle-memory — impossible to generate.

```sh
darkmown catalog --llms > system-prompt.md
darkmown catalog --llms-full            # the complete reference + every error code
```

Compile errors help here too: every corrective `Use:` hint ends with a concrete `— e.g. <valid line>`, and each thrown error carries a structured `err.wd = { file, line, hint, example }` mirror alongside its message — so an edit-and-retry loop gets the fix without parsing prose.

### Agent tools

`@zvndev/darkmown/tools` turns that same description into six functions a model calls instead of rewriting a whole file. Everything runs in memory over a plain `{ path: contents }` object, so a full compile is milliseconds:

- **`outline(files, entry)`** lists what the page declares, with line numbers and block spans.
- **`refs(files, entry, name)`** finds every declare, write, and read of one symbol.
- **`deps(files, entry)`** reports what the page pulls in, and what pulls it in.
- **`grammar(categories)`** returns only the cheatsheet rows this edit needs.
- **`apply(files, entry, edits)`** makes a targeted edit, addressed by `line`, `symbol`, or `anchor`.
- **`validate(files, entry)`** compiles it for real, or says exactly what broke.

Every tool answers `{ ok, text, data }`, and a refusal carries a sentence the model can act on rather than throwing. `Session` holds the files so `apply` and `outline` always see one snapshot:

```js
import { Session } from "@zvndev/darkmown/tools";

const session = new Session({ "site/pages/index.wd": source }, "site/pages/index.wd");
session.call("outline", {});
session.call("apply", { edits: [{ op: "replace", symbol: "state:count", text: ":state count = 5" }] });
session.call("validate", {});
```

### MCP server

`darkmown mcp` runs a hand-rolled, zero-dependency MCP server on stdio (protocol 2024-11-05), so an MCP-capable agent gets the language as tools instead of shelling out: `catalog`, `grammar`, `validate` (compile-check `.wd` source in memory with coded errors), `error` (a WD code's cause, fix, and example), and `doc` (fuzzy section search over the shipped README/AGENTS.md), plus the shipped docs as `darkmown://docs/{readme,agents,changelog,errors,cli}` resources. stdout is protocol frames only. Point a client at it with `{ "mcpServers": { "darkmown": { "command": "npx", "args": ["-y", "@zvndev/darkmown", "mcp"] } } }`.

## Compile error codes

Every author-facing compile error opens with a stable `WDxxx` code, so it is searchable and matchable by tooling without parsing prose:

```
[WD201] Malformed :state in site/pages/index.wd:1: :state x.
        Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
```

The code is mirrored on the thrown error as `err.wd.code`, alongside `file`, `line`, `hint`, and a compilable `example`. Codes are grouped by subsystem — `WD0xx` source and frontmatter, `WD1xx` loops, `WD2xx` state and expressions, `WD3xx` actions, `WD4xx` forms, `WD5xx` fetching, `WD6xx` includes and structure, `WD7xx` media, `WD8xx` skins, `WD9xx` project and CLI.

A shipped code is a public contract: never renumbered, never reused. The full reference — cause, fix, and an example per code — is generated from the compiler's own registry, so it cannot drift from what actually throws.

## CLI reference

The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool — every command is one word:

- `darkmown init [dir] [--template <name>]` scaffolds a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`). It also writes agent context at the project root: `AGENTS.md` (the full directive reference, copied from the installed package so it can never teach syntax the compiler rejects), a `CLAUDE.md` pointing at it, and a `.gitignore`. Existing files are never overwritten.
- `darkmown dev` — the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner for `api/*` functions.
- `darkmown build [--target cloudflare] [--drafts]` — write 100% static output to `dist/` (plus `sitemap.xml` / `rss.xml` / `robots.txt`); `--drafts` includes `draft: true` pages for a staging deploy.
- `darkmown validate [path]` — compile-check every route under `site/pages/` (drafts included), or one file or directory, without writing `dist/`. Each page reports `compiles.` with its static/reactive status, warnings as bullets, or the coded `WD` error with file, line, and fix hint; the exit code is 1 when any page fails, so it gates CI.
- `darkmown serve`: preview the built `dist/` locally. It runs the project's `api/*` functions with the same contract as dev and the production hosts (so `:form` posts and `:fetch` demos against `/api/…` work in the preview), and it compresses text responses, brotli preferred then gzip, negotiated off `Accept-Encoding` with `Vary: Accept-Encoding` set.
- `darkmown catalog [--llms|--llms-full]`: print the `.wd` directive catalog as JSON, a compact cheatsheet for priming an AI model, or the complete reference including every error code (see [AI authoring](#ai-authoring)).
- `darkmown deploy <vercel|cloudflare> [--prod]` — build (target-aware) and deploy through the platform CLI, printing your URL; an unauthenticated CLI surfaces the login to run first.
- `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.

## Editor support

A [VS Code extension](https://github.com/ZVN-DEV/darkmown/tree/master/editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding, so a `.wd` file reads as Markdown-plus-directives instead of broken Markdown. Build a `.vsix` from the framework repo and install it with `code --install-extension`; a Marketplace listing is pending.

## Benchmarks

A live production marketing site (Next.js 16, React 19, Tailwind, on Vercel) was rebuilt in Darkmown with the same copy, images, and CMS content pipeline, then both sides were measured (median of three loads, four matched routes): 225-227 KB of compressed JavaScript on every Next.js page vs 18-23 KB total in Darkmown, 46-70 requests vs 11-20, and full builds of 16.6 s cold / 23.2 s warm vs 2.7-3.6 s for all 67 routes. The rebuild lost two categories honestly: raw TTF fonts (1.07 MB vs 158 KB of subset woff2) and unoptimized photos (1,045 KB vs 147 KB of CDN width variants), both addressed by the 2.8/2.9 font recipe and build-time responsive images. The full tables, the per-route breakdown, and the methodology are at [/benchmarks/](/benchmarks/).

## Accessibility

Landmark and announcement basics are baked in at build time, so they cost zero JavaScript and a static page stays static:

- **Skip link.** The first focusable element on every page is a visually-hidden-until-focused "Skip to content" link. The shell guarantees it has a target: your own `<main>` is reused (an existing `id` wins, an id-less `<main>` gets `id="main"` stamped on), and a page without one is wrapped in `<main id="main">`. Restyle it through `.wd-skip-link` in a skin.
- **Document language.** `lang:` frontmatter sets `<html lang>` per page, defaulting to `en`.
- **Live `:fetch` regions.** A bare `:if name_loading` region compiles with `role="status" aria-live="polite"` and `:if name_error` with `role="alert"`, so screen readers announce the flips for free. A `role`/`aria-live` you write inside the region always wins.
- **Accessible names on controls.** `:input`, `:bind`, `:textarea`, `:select`, `:slider`, and the choice groups derive an `aria-label` from the placeholder, else a humanized field name, unless you supply one.
- **`role`, `aria-*`, and `title` on containers and buttons.** `::: card role="region" aria-label="Notes"` and `:button "Menu" aria-expanded="false" -> open toggle` compile without raw HTML. Values are static, double-quoted, and escaped on emit: see [Accessibility attributes](#accessibility-attributes).

There is still a ceiling, and it is stated in [Limits](#limits): those three names are the whole attribute vocabulary, and their values are static text, so a live accessible name needs raw HTML or a colocated `.js`.

## Security

### Trust boundary

Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.

- **Compile only content you authored.** Don't run `.md`/`.wd` you didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first.
- **Raw HTML is escaped by default.** Markdown renders with `html: false`, so a stray `<script>` in content becomes inert text — safe for multi-author collections out of the box. Set `html: true` in a page's frontmatter to pass your own raw HTML through verbatim; there is no built-in sanitizer, so keep it off pages with content you didn't write.
- **`:fetch` and `:form action=` have no host allowlist.** Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Since 2.1 the runtime **interprets** validated `:computed` / `@loop … where` / `.class when` expressions from a compact AST instead of building a `new Function`, so **reactive pages need no `'unsafe-eval'`** — they run under the same strict, eval-free CSP as static pages.

See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) in the package for the full security model.

### Shipped security headers

Builds emit security response headers, so a deployed site gets sane defaults without a hand-written config. The build writes `dist/_headers` (Cloudflare Pages format), and the Vercel and local `serve` paths apply the equivalent:

- **`Content-Security-Policy`** with no `'unsafe-inline'` and no `'unsafe-eval'` on `script-src`, on every page. The one inline script the framework emits that CSP gates (the `transitions: true` speculationrules block) is authorized by a build-time `'sha256-…'` hash, and the inline state seed is a non-executable JSON data block CSP does not gate. Static and reactive pages get the same strict policy.
- **`X-Content-Type-Options: nosniff`**, a **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
- A raw inline `<script>` you write into an `html: true` page is blocked by that policy. Put it in a colocated `.js` file (same-origin, allowed by `'self'`) or widen `script-src` deliberately.
- Calling another host with `:fetch` or `:form action=`? Widen `connect-src` (and `form-action` for a native form POST) in your deploy config. It is not derived from your page sources.
- The two built-in embed origins (`youtube-nocookie.com`, `player.vimeo.com`) are pre-authorized on every page, and any other `:embed` host is unioned into `frame-src` for exactly the pages that embed it, so a third-party booking or checkout iframe works under the shipped headers. `media-src 'self' https:` is unchanged, so `:embed` and remote media work out of the box. The per-route union ships in Cloudflare's `dist/_headers` and is honored by `darkmown serve`; Vercel reads `vercel.json` before the build, so on Vercel you widen `frame-src` in your own `vercel.json` for a non-YouTube/Vimeo host.

## Limits

Darkmown is small on purpose. Here is what it deliberately refuses and what it genuinely cannot do today, collected in one place so you can decide before you build rather than after.

**Refusals that will not change:**

- **`.md` never gets directives.** The extension is the feature gate, and it holds transitively: an `.md` page that includes a `.wd` still ships `runtime: false` and inert text.
- **No `eval`, and a closed expression vocabulary.** Predicates, `:computed`, and directive actions are compile-time whitelists interpreted by a closed evaluator. There are no custom format pipes and no user-supplied functions anywhere in the language.
- **No backend DSL.** `api/` is plain Web-standard JavaScript. Darkmown owns no server and never will.
- **No built-in sanitizer.** The model is trusted-author; see [Security](#security).

**Structural limits, true today:**

- **No per-request rendering.** Everything is build time. A `:state`-seeded `@loop` *does* bake its rows into the initial HTML, so it is indexable, but anything behind `:fetch` is invisible to crawlers and to the sitemap. No personalization, no draft preview URLs.
- **Auth is a public shell with `api/`-gated data.** A `:if session` gate ships *both* branches into the HTML, and a `:state`/`:store` seed is inlined into public HTML, so a token must never be written into a page. The workable architecture is a public page whose data is gated behind your `api/` endpoint.
- **No layout or shell inheritance.** Every page includes its own nav and footer. There is no template a page extends.
- **No arbitrary `<head>` content.** The document head is what frontmatter drives; you cannot inject your own tags.
- **No route generation from data.** Collections are folders of files. A JSON array cannot become routes. `paginate N` is the one route multiplier, and only over an existing collection listing.
- **Only accessibility attributes on containers and buttons.** `:::` and `:button` accept `role="…"`, `aria-…="…"`, and `title="…"`, and nothing else: `class=`, `id=`, `style=`, `data-*`, and event handlers are compile errors by design. Style with `.class` tokens, act with `->` actions, and reach for raw HTML on an `html: true` page when you genuinely need another attribute.
- **Aria values are static text.** There is no `{ state }` interpolation inside `role`/`aria-*`/`title`, so a live accessible name means raw HTML or a colocated `.js`.
- **Raw HTML attributes do not bind.** A Markdown link or image destination does ([Destinations that bind](#destinations-that-bind)), but `<a href="{ url }">` on an `html: true` page is painted once at build time and warned about. There is no element for the compiler to mark up.
- **Reactive rows cannot fill a Markdown table.** A reactive row is cloned into a `<div>`, which is not a legal child of `<table>`, so `[WD191]` refuses it. Static loops fill tables fine; for reactive rows, build them from containers (`::: trow` / `::: td`) and style them as a table.
- **No i18n beyond `lang:`.** No `hreflang`, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
- **Every framework asset URL is absolute** (`/__wd/...`), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
- **Includes are macros, not components.** One slot (`{ children }`), string-typed `defaults:` fallbacks, and no props interface beyond `with`; a missing argument with no default still renders `{ title }` literally rather than erroring.
- **No `darkmown test`.** There is no built-in way to assert your own site's behavior. Use [`compileFromMemory`](#programmatic-compile-compilefrommemory) or a browser test runner.

**Migrating out is easy, and that is on purpose.** `dist/` is portable static HTML plus one runtime file of ~6.6 KB gzipped. No server to port, no proprietary component format, no framework baked into the output. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working.

## Spec status

The implementation is faithful to the original core thesis: Markdown-first authoring, no component ceremony, zero runtime on static pages, and tiny direct-DOM reactivity only when declared. `:store`, the full `:fetch` lifecycle (loading/error/empty states, dynamic URLs, `refetch`, dotted loop sources, authenticated `headers=` + `refresh=` token renewal), `:form` (including server round-trips), loop ergonomics (`sort by`/`limit`/`offset`/`reverse`/`@empty` and `$index`/`$number`/`$first`/`$last`/`$count`), `:if … :else if … :else`, reactive `.class when …`, `:effect`, `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/) and the [Reactive page](/reactive/). Still on the roadmap: HTML-fragment `swap` semantics and cart server sync. (The serverless `api/` model already shipped in 1.2.0 — see [Backends & deploy](#backends--deploy) above; a `.wd` backend DSL remains a deliberate non-goal.) See `docs/spec-alignment.md` in the package for the full audit.

See these directives composed into real apps on the [showcase](/showcase/).

</main>

@include /footer.wd
