darkmown.com · Markdown that runs.
Darkmown is a Markdown-native web framework. Two formats, one rule: .md stays plain CommonMark forever, and renaming a file to .wd ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship zero framework JavaScript; reactive pages share one runtime around ~7.7 KB gzipped, CI-enforced under 8 KB.
Five complete apps, each a readable .wd file — see them at darkmown.com/showcase:
- Folio — a boutique storefront whose cart persists across pages, reloads, and browser tabs (
:store), with live search and a checkout form. - Pulse — a service dashboard driven by
:fetch: loading / error / empty states, live refresh (:every), and status badges, with no hand-written JavaScript. - Forge — a plan configurator where
:computedrecomputes the price as you toggle features and seats, with conditional upsell hints. - Compass — a product-finder quiz: a branching state machine expressed entirely as
:ifsteps over scored answers. - Ledger — a spreadsheet-grade expense table: clickable-header reactive sort, a live filter, running totals via
sum/avg/max,| money/| dateformat pipes, and a:themetoggle.
Plus focused feature demos: a draggable, keyboard-navigable Swiper (the wd.subscribe escape hatch) and a zero-JS Media page (:video / :audio / :embed).
npx @zvndev/darkmown init my-site
cd my-site
npm install
npm run devOr add it to an existing project:
npm install -D @zvndev/darkmown
npx darkmown devThe package is @zvndev/darkmown; the command it installs is plain darkmown.
Prefer to see it run before you install? The browser playground
compiles .wd/.md live in your browser — the same compiler the CLI ships — with
no install and no build step.
npm install
npm test
npm run smoke # pack, install, scaffold, and build a temporary consumer app
npm run dev # live demo site — the same site that runs darkmown.comdarkmown init [dir] [--template <name>]scaffolds a new site from a template (starter,blog,store,dashboard,landing).darkmown devstarts the live compiler with browser reload, an in-browser error overlay, and a local runner forapi/*functions. Rebuilds are incremental: each dev build records a per-route dependency map (page file, includes, colocated assets, loop data, collections), so editing one file recompiles only the routes that depend on it — with a full rebuild on any uncertainty (a new/deleted/renamed file, or a frameworksrc/change). Rebuilds run one at a time; edits landing mid-build batch into the next one.darkmown build [--target cloudflare] [--drafts]writes static output todist(plussitemap.xml/rss.xml/robots.txt);--draftsincludesdraft: truepages for staging.darkmown deploy <vercel|cloudflare> [--prod]builds and deploys via the platform CLI.darkmown servepreviews the builtdistlocally.darkmown catalog [--llms]prints the machine-readable directive catalog (JSON), or with--llmsa compact cheatsheet — see AI authoring.darkmown versionprints the installed package version.darkmown helpprints CLI usage.
site/pagesis the route tree..mdand.wdfiles become pages..mdis strict CommonMark (real parser: ordered lists, tables, blockquotes, images, the lot). Directives stay plain text, and the build hints when it spots.wdsyntax in a.mdfile.- Files or folders starting with
.,-, or_are hidden from routing. site/_is the include shelf for@include /name.wd.- Matching
page.skinandpage.jscolocate styling and behavior by basename. - Static pages ship zero Darkmown runtime. Reactive pages share
/__wd/runtime.js(currently ~7.7 KB gzipped, CI-enforced under 8 KB). - Shelf
.jsonfiles are published at/__wd/data/so:fetchworks on any static host.
One syntax everywhere: { name } or { name.path }.
- In-scope static values (include arguments, loop values) resolve at build time.
- Declared
:statebecomes a live binding. - The page's own frontmatter is in scope as
meta—{ meta.title }prints a field. - Anything else stays literal text — braces in prose never break a page or pull in the runtime.
- Build-time values also resolve inside a link or image destination —
[{ item.label }]({ item.url })or— so an@loopcan drive anhref/srcand the page stays static. (Reactive:statecannot live in a destination.)
Shape a value for display with a pipe, Liquid/Angular style: { price | money }, { joinedAt | date:"medium" }, { ratio | percent }. Pipes chain left to right ({ name | trim | capitalize }) and take literal arguments ({ total | money:"EUR" }, { bio | truncate:80 }). They work in static prose (folded at build time, zero-JS) and on live :state/:store bindings (re-applied on every render) — identical syntax either way. The pipe list is a fixed whitelist compiled to safe calls; there is no custom-function hook and nothing is eval'd.
A pipe shapes a value that is in scope — a loop variable, :state/:store, an include argument, or meta. The examples below read a field off a loop row p (e.g. inside @loop products into p, with p.price == 89, p.joined == "2026-06-22", …). A bare literal like { 89 | money } is not a name in scope, so — like any unresolved { … } — it is left as literal text, braces and all; pipes only transform a value the page can resolve.
| Pipe | Example | Result |
|---|---|---|
money[:currency[:locale]] |
{ p.price | money } |
$89.00 |
number[:decimals] |
{ p.units | number } |
1,234.5 |
percent[:decimals] |
{ p.ratio | percent } |
8% |
round[:decimals] |
{ p.pi | round:2 } |
3.14 |
date / time / datetime [:style] |
{ p.joined | date:"medium" } |
Jun 22, 2026 |
upper / lower / capitalize |
{ p.status | capitalize } |
Draft |
truncate:n / trim |
{ p.bio | truncate:80 } |
…the first 80 chars… |
pluralize:"item"[:"plural"] |
{ p.qty | pluralize:"item" } |
3 items |
default:"—" |
{ p.nickname | default:"—" } |
— |
(A date-only value like p.joined == "2026-06-22" formats in UTC, so date:"medium" renders the written calendar date on any build machine.)
The five aggregates double as pipes over a list — { cart \| count }, { cart \| sum:"price" \| money }, plus avg / min / max — and join:", ":"name" flattens a list of rows to a string. date/time/datetime are Intl-backed (short / medium / long); there is deliberately no relative "time ago" formatter, so builds stay byte-for-byte reproducible. An unknown pipe name is a compile error with the valid list.
YAML-style key: value frontmatter between --- fences. Values are strings, plus inline arrays:
---
title: Customers
tags: [sales, revenue, "q1, q2"]
---
{ meta.title }prints a scalar;{ meta.tags }prints an array joined with,.@loop meta.tags into tagiterates an array field at build time (stays static, zero-JS).- Arrays are inline flow only (
[a, b]); quoted items keep internal commas ("q1, q2"). A value without a leading[stays a plain string.
Three keys also drive the document <head>: title sets <title>, description adds the meta description plus Open Graph / Twitter tags, and image (an absolute URL) sets the social-share preview (og:image / twitter:image and a summary_large_image card). lang: sets the document language on <html lang> (lang: fr, lang: pt-BR, …) — it defaults to en.
A few reserved keys drive drafts and feeds (see SEO & feeds below): draft: true excludes a page from production builds; site_url on the home page turns on sitemap.xml + rss.xml; date: marks a page as a blog post (it lands in rss.xml and sets the page's <lastmod>); excerpt: is the RSS summary. The same frontmatter is queryable when the page is an entry in a content collection — { post.date }, { post.excerpt }, { post.tags }, and any custom key resolve in an @loop over the folder.
transitions: true opts a page into instant, flash-free navigation — zero JavaScript, all declarative. It emits a directional fade+slide view transition for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a <script type="speculationrules"> prerender hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors prefers-reduced-motion. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with transitions: false. Mark a link {.no-prefetch} to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
Images, fonts, icons, and other non-page files live on the include shelf at site/_/ and are copied into the build untouched:
- Any non-
.md/.wdfile insite/_/→ served at/__wd/media/<path>(e.g.site/_/logo.svg→/__wd/media/logo.svg). .jsonfiles → served at/__wd/data/<name>(this is what:fetchreads).
Reference them with a normal URL: . The preview/serve layer returns the correct Content-Type per extension.
Images are hardened at compile time. Every <img> the compiler emits is stamped with its intrinsic width/height (read from the source file, so the browser reserves space and the page doesn't reflow as images decode), decoding="async", and a load-priority split — the first image on the page stays eager with fetchpriority="high" (the LCP candidate), the rest get loading="lazy". Author-set attributes always win, and remote or unreadable sources just skip the dimensions. The compiler measures, it doesn't resize — keep source images web-sized (modern formats, sensible dimensions).
Or colocate an asset next to the page that uses it. Any non-page file under site/pages/ (anything but .md/.wd routes and the colocated .skin/.js) is copied to dist/ at its own path, so a clean relative URL just works:
site/pages/logo.svg→dist/logo.svg→site/pages/blog/cover.png→dist/blog/cover.png→
Hidden/private page paths stay private: any site/pages asset below a path segment starting with ., -, or _ is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: .env, _private/secret.txt, or -draft/notes.txt.
Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.
darkmown build emits the crawler files a site needs — all build-time, zero client JS — driven by a little frontmatter. There is no config file: the home page (site/pages/index.md or .wd) carries the site's identity.
---
title: My Blog
description: Notes on shipping Markdown that runs.
site_url: https://example.com
---
site_url(absolute origin, no trailing slash) turns onsitemap.xmlandrss.xmland is the absolute prefix for every URL in them. The hometitle/descriptionbecome the RSS channel title/description.robots.txtis always emitted (User-agent: * / Allow: /); theSitemap:line is added only whensite_urlis set.sitemap.xmllists every built page (reactive pages included — they're indexable HTML). Each<lastmod>is the page's frontmatterdate:if set, else its git last-commit date, else the file's mtime. No<priority>/<changefreq>.rss.xmlsyndicates your posts — any page with adate:in its frontmatter. Newest first, capped at the 20 most recent. Each item's<description>is the page'sexcerpt:, else itsdescription:, else (for a plain.mdpost) its first paragraph. Every page links the feed with<link rel="alternate" type="application/rss+xml">so readers can autodiscover it.
---
title: Hello, Darkmown
date: 2026-01-15
excerpt: Why I rewrote my blog as plain Markdown files.
---
# Hello, Darkmown
…Without site_url, robots.txt still emits and the build prints a one-line hint telling you which field to set; it never crashes.
Mark any page draft: true to keep it out of production:
---
title: Work in progress
draft: true
---darkmown buildexcludes drafts everywhere — no HTML indist, no entry inroutes.json,sitemap.xml, orrss.xml(even a draft that also has adate:never reaches a feed).darkmown devbuilds and serves drafts so you can preview them, with a visible "DRAFT" banner that exists only in the dev server — never in any shipped HTML.darkmown build --draftsincludes drafts everywhere — for a staging deploy.
Drafts are filtered at one point — route discovery — so nothing downstream ever sees them. This is separate from the permanent ./-/_ filename hiding: a hidden name (-notes.wd) is private forever; draft: is a toggle you flip when the page is ready to ship.
The build summary reports what it emitted: Built 14 routes, sitemap (14 urls), rss (6 posts) into dist.
@loop <things> into <thing> is the only loop. The source decides the behavior:
A JSON-file loop is unrolled at build time; includes inside inherit the loop value. A :state list loop is reactive and patched by key.
@loop /features.json into card
@include /feature-card.wd
@endloop
:state todos = [{"id": 1, "title": "Route pages"}]
@loop todos into todo
- { todo.title }
@endloop
Loops nest, dotted paths reach into rows, and @include ... with x={ row.field } reassigns values Liquid-style.
Add where <predicate> to filter a loop. Conditions compare a loop-item field against a number, a string, or another value, and join with and / or:
@loop /products.json into p where p.featured == true and p.price < 80
- { p.name }
@endloop
Operators: == != < <= > >=, plus contains for case-insensitive substring match. The predicate is a compile-time-validated whitelist — only item paths, declared :state, numbers, and "strings" are allowed (no arbitrary expressions). Raw user content is never evaluated; the validated predicate compiles to a compact AST that the runtime interprets — no eval, no new Function.
The source decides reactivity, just like the loop itself. If the predicate only reads the row, the filter runs at build time and the page stays zero-JS. If the predicate reads a :state value, the loop becomes reactive and re-filters live as that state changes — a live search in pure Markdown:
: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 — typing updates the state, and the state reflects back into the field. It accepts type= (default text), placeholder=, autocomplete=, and the required / autofocus flags.
Shape a loop without writing JavaScript. Clauses come in a fixed order after into:
@loop <src> into <item> [where …] [sort by <key> [asc|desc]] [reverse] [offset <N>] [limit <N>]
@loop /posts.json into post sort by post.date desc limit 5
{ $number }. { post.title }
@endloop
sort by <key> [asc|desc]—<key>must start with the loop item (post.date, notdate). Numbers sort numerically; everything else sorts as text.ascis the default.- Reactive sort — the field and the direction can each be a
{ state }reference, so a clickable column header re-sorts the table live without any JavaScript:sort by { sortKey } { sortDir }. Drive them with:state sortKey = "amount"/:state sortDir = "desc"and:button "Amount" -> sortKey = "amount" ; sortDir = "desc". The bare{ sortKey }resolves to a field on the loop item at render time. reverse— reverse the (already sorted) order.offset <N>/limit <N>—<N>is a non-negative integer or a:state/:storekey, which makes pagination reactive:
:state pageSize = 10
@loop products into product limit pageSize
- { product.name }
@endloop
Each row exposes five meta variables, relative to the rendered slice:
| Variable | Value |
|---|---|
{ $index } |
0-based position |
{ $number } |
1-based position ($index + 1) |
{ $first } |
true on the first row |
{ $last } |
true on the last row |
{ $count } |
number of rendered rows |
They work in interpolation and in :if:
@loop products into product
:if $first
**Top pick:**
:endif
{ $number } of { $count } — { product.name }
@endloop
Add an @empty branch to show a fallback when the loop renders no rows (after where, limit, and the rest):
@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 over a page whose frontmatter omits tags (say, an optional tags: string[]? schema field) loops zero rows and renders the @empty branch. A field that is present but not a list (a string, a number) is still a compile error with the file:line.
Note: All of these clauses stay build-time when the source and every clause argument are static — a sorted, limited loop over a JSON file ships zero JavaScript. The loop becomes reactive only when the source is
:state/:store/:fetchdata, or a clause reads reactive state (likelimit pageSize).
A :button inside a reactive @loop can act on its own row. cart += product carries the current row into another list; cart remove line drops the current row from the looped list:
: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 += <item>appends a copy of the current row to another:statelist, so adding the same product twice gives two independent lines.<list> remove <item>removes the current row from the list being looped. The<list>must be that loop's own:statesource and<item>must be the loop variable — both checked at compile time. Removal targets the exact row, so it stays correct even when the loop is filtered withwhere.
That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
New in 0.19.0. A reactive @loop may contain an inner reactive @loop over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (order.lines), so each row renders its own list and both stay live as state changes:
:state orders = [
{"id": 1, "ref": "A-100", "lines": [{"sku": "x1", "qty": 2}, {"sku": "x2", "qty": 1}]},
{"id": 2, "ref": "A-101", "lines": [{"sku": "y9", "qty": 5}]}
]
@loop orders into order
::: card
**{ order.ref }**
@loop order.lines into line
- { line.sku } × { line.qty }
@endloop
:::
@endloop
- The inner loop is keyed and reconciled per outer row, like any reactive loop.
- Interpolation inside the inner body resolves the inner item first, then the outer item —
{ line.qty }and{ order.ref }are both in scope. - Build-time loops over JSON/frontmatter already nest freely (the "loops nest" behavior above); what's new is reactive-inside-reactive over a
:state/:store/:fetchrow field.
Honest caveat: nesting is one level only — an inner reactive loop that itself contains a third reactive loop is a compile error with a corrective message, not a silent runtime failure. Build-time loop nesting is unaffected by this limit.
Per-row actions inside an inner loop:
cart += member(append-row) works from an inner loop — it carries the inner item into a top-level:state/:storelist. But a per-rowremoveneeds a top-level list as its source, and an inner loop's source is a path off the outer row (team.members), so deleting an inner row in place is not supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
Any folder under site/pages/ is a queryable collection — referenced in @loop by its bare name. There is no content/ root to opt into and no marker file to add: a site/pages/blog/ directory of posts is the blog collection.
@loop blog into post sort by post.date desc
- [{ post.title }]({ post.url }) — { post.date }
@endloop
It's the same one loop, with the same clauses (where, sort by, reverse, offset, limit, format pipes). The collection resolves at build time, so a pure listing ships zero JavaScript (runtime: false).
Each entry's frontmatter becomes a row, plus three fields the framework derives:
| Field | Value |
|---|---|
{ post.url } |
the entry's route, e.g. /blog/hello/ |
{ post.slug } |
the filename without extension (an index.md uses its folder name) |
{ post.excerpt } |
the frontmatter excerpt:, else the first paragraph of a .md body |
Scalar frontmatter is coerced for querying, so where post.featured == true and numeric sorts behave like they do over a JSON file. Drafts never leak: a draft: true entry is excluded from a default build's listing (included only under darkmown build --drafts), exactly like routing and feeds.
Drop a _schema.wd at a collection's root to validate every entry's frontmatter at build time. It's frontmatter-shaped — one field: type rule per line:
---
title: string
date: date
description: string
excerpt: string?
tags: string[]?
---
The vocabulary is small and closed: string, number, boolean, date, string[], each with a trailing ? to mark it optional. A missing required field, a wrong type, an unknown extra field (a typo guard), or an unknown type token in the schema itself each fail the build with a file:line and the offending field. Validation is opt-in — no _schema.wd, no validation.
Add paginate N (collections only) to split a listing into static pages: page 1 keeps the listing's own route, and pages 2+ live at /<route>/page/2/, /<route>/page/3/, … Every generated page is static HTML and appears in routes.json and sitemap.xml.
@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
A page pager is exposed to the whole page:
| Variable | Value |
|---|---|
{ page.current } |
1-based current page number |
{ page.total } |
total number of pages |
{ page.prev } |
URL of the previous page, or "" on page 1 |
{ page.next } |
URL of the next page, or "" on the last page |
The pager is plain <a href> links to the generated routes — zero JavaScript. paginate is for collections only (route-multiplication only makes sense for a folder of entries) and can't combine with offset/limit (it owns the slice).
See the live blog demo — the whole index, paginated and sorted, is one
@loopoversite/pages/blog/, validated by a_schema.wd, shipping no framework JS.
::: section #cart .dark
:state count = 0
Cart has { count } items.
:button "Add" -> count++
:::
State declared inside a section is scoped to it — two sections can both own a count. Bindings and actions resolve to the nearest scope.
A container named nav or main emits the real landmark element (<nav class="nav">, <main class="main">) instead of a <div>, so scaffolded pages keep proper landmarks — a skip link skips a ::: nav, and ::: main becomes the page's <main id="main">. Any other name stays a <div> with that class.
A container class can be toggled by a predicate. Static .class tokens are unchanged; add when <predicate> to make one reactive:
@loop products into p
::: card .product .on-sale when p.price < 50 .featured when p.featured
**{ p.name }** — ${ p.price }
:::
@endloop
The predicate uses the same whitelist as :if — item fields, declared :state/:store, numbers, strings, the ==/!=/>/</>=/<=/contains operators, and and/or/not. A bare path (.featured when p.featured) reads as truthy. (@loop … where is the comparison-only subset of this grammar.) A predicate over only static values folds at build time into a plain class; one that reads state or the loop item stays reactive and ships with the runtime.
:state count = 0
Count: { count }
:button "Increment" -> count++
:if count >= 10
Count is high.
:else if count > 0
Count has changed.
:else
Count is still zero.
:endif
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 — operators with and/or.) Chain with :else if (any number; an optional bare :else must be the last branch):
: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 stays reactive (or folds at build time when every value is static) exactly like a single :if.
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated .js files.
A :button "Label" -> action mutates one :state or :store value. The same vocabulary works on both:
| Action | Syntax | Effect |
|---|---|---|
| Increment | n++ |
add 1 |
| Decrement | n-- |
subtract 1 |
| Add | n += 5 |
add a number |
| Subtract | n -= 2 |
subtract a number |
| Set | name = value |
assign a literal |
| Toggle | flag toggle |
flip a boolean |
| Append | list append v or list += v |
add to the end of an array |
| Prepend | list prepend v |
add to the front |
| Member toggle | list toggle v |
add v if absent, else remove it |
| Remove value | list remove v |
remove a value from an array |
| Clear | name clear |
empty an array or object |
| Merge | obj merge other |
shallow-merge an object (key or inline {…}) |
| Delete | obj delete "key" |
remove a key |
| Reset | name reset |
restore the declared starting value |
Values are literals: a "string", number, true/false/null, or inline JSON ({…} / […]).
Targets can be dotted paths, so a button can reach into nested state:
:state cart = {"count": 0, "total": 0}
:button "Add item" -> cart.count++
One button can run several actions with ;. They apply in order, then the page renders once:
:button "Add to cart" -> cart.count++ ; cart.total += 9
Pitfall:
list toggle vandlist remove vmatch members by value (===). That is exact for strings, numbers, and booleans, but not reliable for object members — two equal-looking objects are different values. To remove a row object, loop the list and use the per-rowremoveaction below.
:computed name = <expression> derives state from other state with a compile-time-checked expression — names, numbers, arithmetic (+ - * /), comparisons, and the five aggregates over a list: sum(list, field), avg(list, field), min(list, field), max(list, field), and count(list). It recomputes whenever an input changes and reads like any other binding (so it pairs naturally with format pipes):
:store cart = [{"price": 89}, {"price": 12}]
:computed subtotal = sum(cart, price)
:computed tax = subtotal * 0.08
:computed total = subtotal + tax
Subtotal: { subtotal | money } · Tax: { tax | money } · **{ total | money }**
The aggregate's field argument is a bare key on each row (price, not item.price). There are no function calls beyond the five aggregates and no property access beyond dotted state paths — anything richer belongs in a colocated .js behavior.
:every <duration> -> <actions> runs a :button-style action on an interval — the one piece of time the framework owns. Durations are <n>ms / <n>s / <n>m, and the actions are the same ;-chained vocabulary as :button/:effect (including name refetch to re-run a :fetch):
:fetch board from "/status.json"
:every 10s -> board refetch # live-refresh a dashboard
:state secs = 0
:every 1s -> secs++ # a ticking counter
Intervals pause while the tab is hidden (via visibilitychange) and resume on return, so a backgrounded dashboard stops firing requests and draining battery.
:effect <watched> -> <actions> runs actions whenever a watched state path changes. The actions are the same :button vocabulary (;-chained) — this is the escape hatch for side effects beyond :computed (which derives state) and :fetch deps (which auto-refetch):
:state q = ""
:state searches = 0
:effect q -> searches++
Effects run after a render, against settled state, and an effect that mutates state triggers another pass — bounded by a 10-pass settle cap that warns (and stops) if an effect never settles. They do not fire on the initial load, only on a real change.
:fetch name from "url" declares state and fills it from JSON over the network:
:fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>] [refresh=<url>]
Each fetch automatically declares four state keys you can branch on:
| State | Type | Meaning |
|---|---|---|
name |
the data | null until the response arrives |
name_loading |
boolean | true while the request is in flight |
name_error |
string | the error message, or null |
name_empty |
boolean | true when the data is null, [], or {} |
Cover loading, errors, empty, and data — @empty (from the loops above) absorbs the empty case:
:fetch roster from "/__wd/data/team.json" timeout=8000 retry=2
:if roster_loading
Loading…
:else if roster_error
Couldn't load the team: { roster_error }
:else
@loop roster into member
- { member.name }
@empty
No team members yet.
@endloop
:endif
The lifecycle regions announce themselves: a bare :if name_loading compiles with role="status" aria-live="polite" and :if name_error with role="alert", so assistive tech hears the flips with no extra markup. Author-supplied role/aria-live inside a region always wins.
method=—GET(default),POST,PUT,PATCH, orDELETE.when=—load(default) fires on page load;visiblewaits until the spot scrolls into view.timeout=<ms>— abort and setname_errorif the response is too slow.retry=<N>— retry on network failure or a 5xx response before surfacing the error.headers=<key>— a:state/:storekey holding an object, sent as request headers.body=<key>— a:state/:storekey, JSON-serialized as the request body (for non-GET).refresh=<url>— a token-refresh endpoint; on a401, renew theheaders=token and retry once (see below).
URL safety. A
:fetch/refreshURL must be a relative path, anhttp(s)://URL, or a leading{ state }interpolation. A protocol-relative//hostor a non-http(s) scheme (file:,data:,javascript:, …) is a compile error.
headers=<key> sends a state object as request headers — pair it with :store to persist a bearer token across reloads:
:store session = { "Authorization": "Bearer …" }
:fetch feed from "/api/feed" headers=session
Add refresh="<url>" and Darkmown manages the token lifecycle: when a request comes back 401, it POSTs the current headers object to the refresh URL, writes the returned headers (the new token) back into the session state — persisting it, since session is a :store — and retries the original request once. Concurrent 401s sharing a refresh URL are de-duplicated into a single in-flight refresh.
:store session = { "Authorization": "Bearer …" }
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
refresh= requires headers= (it needs a state key to renew). The refresh endpoint should accept the current headers as a JSON body and reply with the new headers object.
A URL can interpolate state with { }. The fetch re-runs automatically when that state changes (and skips while the value is still empty):
:state userId = ""
:fetch profile from "/api/users/{ userId }"
Trigger a reload by hand with the refetch action:
:button "Reload" -> roster refetch
Loop a sub-path of fetched (or any) state with a dotted source:
:fetch org from "/__wd/data/org.json"
@loop org.members into member
- { member.name }
@endloop
Note: Shelf
.jsonfiles are published at/__wd/data/, so:fetchworks on any static host. Thedarkmown devserver also ships a/__wd/echoendpoint for demos.
:state is local to its page (and section). :store is global, durable, and shared across tabs — the right home for a cart, a theme, or a signed-in user.
:store cart = []
:button "Add" -> cart += {"id": 1, "name": "Aurora"}
You have { cart } items.
-
Durable by default. A store is saved to
localStorageunderwd:store:<name>and reloaded on the next visit. -
Shared across tabs. Change a store in one tab and every other tab on the same site updates live.
-
Global by name. A bare
{ cart }reads the same store everywhere — stores are never section-scoped. -
Same value grammar as
:state— string, number, boolean,null, array, or object — and the same button actions (cart += …,count++,theme = "dark", and so on). -
Multi-line seeds. An array or object literal may span several lines for readability — open the
[/{on the:store/:stateline and let it run until it closes::store rows = [ {"id": 1, "label": "One"}, {"id": 2, "label": "Two"} ]The literal must balance with no blank line inside it (a blank line ends the value); an unterminated literal is a compile error. Quote genuinely literal bracket text —
:state tag = "[draft]".
The declared value is a seed: it is used only the first time, when the store is absent from storage. After that the persisted value wins, so visitors keep their data.
Add ephemeral for an in-memory store that resets on reload and does not sync across tabs:
:store sidebarOpen = false ephemeral
Pitfall: A store name must be unique. Declaring the same name as both a
:storeand a:stateon one page is a compile error.
Fetched data and a form live happily on the same page:
:fetch team from "/__wd/data/team.json"
@loop team into member
- { member.name }
@endloop
:form into profile
:input name placeholder="Your name" required
:select topic
- General
- Billing
:checkbox channels
- Email
- SMS
:radio plan
- Basic
- Pro
:textarea note placeholder="Anything else?"
:submit "Save"
:endform
:state cart = [] persist
:form into namecaptures submits straight into state (no backend).:form action="/url"emits a plain native form instead — zero JS, full progressive enhancement. Form actions use the same URL scheme guard as:fetch: relative paths, explicithttp(s)://, or leading{ state }interpolation; protocol-relative and non-http(s) schemes are compile errors.:form action="/url" into replydoes both: with JS the submit posts urlencoded via fetch and the JSON reply lands in statereply(reply_erroron failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.- Field directives:
:input,:textarea name [rows=N],:select name,:checkbox name, and:radio name(the last three take- Labeloption lines) all capture into:form intostate the same way. A:checkboxgroup captures every checked value as an array; a:radiogroup captures a single value. Each derives a non-visualaria-labelfrom its placeholder, else a humanized field name, unless you supplyaria-label/aria-describedby. :state x = [] persistkeeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for:storeinstead.):computed total = items.length * 4derives state from state with a compile-time-checked expression — names, numbers, arithmetic, comparisons, and list aggregates (sum/avg/min/max/count). See Computed values.:if item.pathworks inside reactive loops for per-row branches, and nests — an inner:ifresolves after the outer branch and stays reactive.
Darkmown builds 100% static, CDN-cacheable HTML — never per-request server rendering. Reactive pages hydrate from data-attributes client-side; dynamic data arrives via :fetch. When you need a backend, you don't learn a new syntax — you write a plain serverless function, and it just comes with wherever you deploy.
A backend endpoint is a plain-JS Web-standard handler in a top-level api/ directory:
// api/subscribe.js → /api/subscribe
export const config = { runtime: "edge" }; // Vercel runs api/ as Edge Functions
export default async function (request, context) {
const { email } = await request.json(); // context.params for /api/users/[id]
return Response.json({ ok: true, email });
}- One shape, every host.
export default (request) => Responseis exactly what Vercel Edge, Cloudflare Pages, and Netlify Edge run.api/users/[id].js→/api/users/:id. - Local parity.
darkmown devruns a local runner, so:fetch /api/subscribeand:form action="/api/subscribe"behave the same as production before you ever deploy. - Deploy in one command.
darkmown deploy vercel(functions run natively) ordarkmown deploy cloudflare(the build emits adist/_worker.jsthat routes/api/*and serves the rest fromenv.ASSETS). It prints your URL, or the login to run if the platform CLI isn't signed in. - Custom server / remote backend. Point
:fetch/:format an absolutehttps://…URL and widen the CSPconnect-src(andform-actionfor native form POSTs). Darkmown owns no server — it adapts to yours.
Templates get you to a running, deployable app fast: darkmown init shop --template store ships a cart and an api/checkout.js; --template dashboard ships a :fetch view and an api/metrics.js; --template blog ships a typed posts collection (_schema.wd + one @loop over the folder) — adding a post is adding a .md file.
Rich interactions are pay-for-what-you-use: :sortable/:carousel compile to a tiny /__wd/behaviors/<name>.js module injected only on pages that use them, budgeted separately from the ≤8 KB core runtime. :slider is compile-time only — zero extra JS.
:slider volume = 50 min=0 max=100 step=5
Volume: { volume }
:store tasks = ["Draft", "Review", "Ship"]
@loop tasks into t sortable
- { t }
@endloop
:carousel autoplay=4000
::: slide
First slide
:::
::: slide
Second slide
:::
:endcarousel
:slider name = v min max steprenders a range input two-way bound through:bind; range values coerce to Number so:computed/math see a number. Ships no behavior module.:sortable(a@loopclause) drag-reorders the underlying:state/:storelist via Pointer Events (mouse + touch), with full keyboard support (Arrow Up/Down on a focused row, screen-reader instructions + a live "Moved to position N of M" announcement), rewriting the list through the publicwindow.wdAPI so the keyed loop repaints. Valid only on a plain reactive loop (nowhere/sort/reverse/offset/limit).:carousel [autoplay=N]treats each direct child block as one slide (wrap each in its own block, e.g.::: slide, and size it in your skin), using native CSS scroll-snap (touch swipe is free) plus prev/next buttons, dot navigation, and mouse drag.autoplayis suppressed underprefers-reduced-motion.
A trailing {.class .class #id} attaches classes / an id to the inline element directly before it — most often to style a link as a button without a wrapper:
[Get started](/start/){.btn .lg}
{.brand}
The block must follow the element with no space, and works on links, images, emphasis, and inline code. It never collides with { name } interpolation — an interpolation always starts with a name, never a . or #.
Headings get anchors for free. Every markdown heading carries a stable, GitHub-style slug id at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with -1/-2 suffixes across the whole document), so any section of any page is deep-linkable with a plain #the-slug fragment — zero JS. The docs page's "On this page" table of contents is just markdown links to those anchors.
New in 1.0. Three one-line directives replace hand-written <video> / <iframe> markup. They are compile-time only — they emit no data-wd-*, so a media-only page still ships zero framework JavaScript.
:video /clip.mp4 poster=/clip.jpg controls
:audio /track.mp3 controls
:embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
:video/:audiocompile to a hardened HTML5 player.preload="metadata"andcontrolsare added by default; flags (controls,autoplay,loop,muted,playsinline) and attributes (poster,width,height,preload) are validated against a per-element whitelist, andautoplaysilently impliesmuted(browsers block sound-on autoplay). Thesrc/posterURLs run through the same scheme guard as:fetch— relative orhttp(s)only.:embedrewrites a YouTube or Vimeo URL to its no-cookie / player form, wraps it in a responsive16/9box, and marks the iframeloading="lazy"with a locked-downreferrerpolicy. Any otherhttp(s)URL becomes a generic lazy iframe. Addtitle="…"for the accessible name.
Darkmown's shipped CSP pre-authorizes exactly the two embed origins (youtube-nocookie.com, player.vimeo.com) and media-src 'self' https:, so embeds and remote media work out of the box on the bundled server, Cloudflare _headers, and Vercel.
New in 1.3. Fenced code blocks with a language are highlighted at build time — HTML and CSS only, no client JavaScript. Tag the fence with a language and it just works:
```js
const greeting = "Darkmown"; // highlighted at build time
```The highlighter is highlight.js and is not configurable (one closed default, like the rest of the framework). Its token classes map onto your skin's $code-* tokens, so highlighted code dark-modes for free through the same tokens dark / :theme system below — no extra wiring. Tune the palette (or rely on the built-in default set) in your .skin:
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
- Pay-for-what-you-use. The stylesheet (
/__wd/highlight.css) is emitted and linked only on pages that actually contain a highlighted block — a page with no code ships nothing extra. - Zero runtime. Highlighting is build-time output, so a page of prose plus code stays
runtime: false(no/__wd/runtime.js). It never pulls in the reactive runtime. - Graceful degradation. 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 it recolor live on the Syntax highlighting demo — flip the theme toggle and every block recolors at once.
New in 0.19.0. A colocated .skin file already declares its palette in a tokens block (name value pairs referenced elsewhere as $name). Add a second tokens dark block to override any of those tokens under the visitor's OS dark preference — it compiles to a @media (prefers-color-scheme: dark) :root { … } rule, so the page follows the system theme with zero JavaScript.
tokens
paper #ffffff
ink #171717
tokens dark
paper #0b0b0f
ink #f4f4f5
page
bg $paper
color $ink
A single tokens dark block powers both theming paths: it compiles to :root[data-theme="dark"] { … } (the manual toggle below) and @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } } (the OS preference, unless a visitor has explicitly forced light). The base tokens block stays the light default, so a skin with no tokens dark block is unchanged.
New in 1.0. For an explicit light/dark switch alongside (or instead of) the OS preference, declare :theme once and drive it with ordinary buttons. It registers a durable theme store and reflects its value onto <html data-theme="…">:
:theme
:button "Auto" -> theme = "auto"
:button "Light" -> theme = "light"
:button "Dark" -> theme = "dark"
Because tokens dark already emits the [data-theme="dark"] rule, no extra skin block is needed — the same palette drives the toggle. "light" forces the light palette even under OS dark; "auto" clears the attribute and follows the OS again; theme persists across reloads and tabs like any :store. (:theme name = "light" renames the store and seeds a different default. The older tokens [data-theme=dark] block — a manual-only override — still works for bespoke setups.)
New in 1.4.0. By default a colocated .skin is global — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called .card, global CSS makes them fight. Opt a skin into scoping so its selectors only ever match the component it ships with: make the first line of the .skin file the word scoped.
scoped
.card
padding 1.5rem
bg $panel
radius $radius
Scoping is pure compile time — a short, path-derived id (e.g. wd-7c21) is stamped onto the component's HTML (data-wd-scope="wd-7c21") and appended to each of the skin's selectors:
.card[data-wd-scope="wd-7c21"] { padding: 1.5rem; }
.card[data-wd-scope="wd-7c21"] { background: var(--panel); }A second component's .card gets a different id, so the two never collide. There is no runtime cost and no class renaming in your markup — you still write class="card"; the framework adds the attribute during the build. A static page with a scoped skin stays zero-JS.
What scopes, what stays global:
In a scoped skin |
Result |
|---|---|
A selector rule (.card, .card:hover, .card .title, h2, h3) |
Scoped — the attribute lands on the subject (rightmost) selector, before any :hover/::before so it stays valid CSS |
A descendant selector (.card .title) |
Only .title (the subject) is scoped; .card matches inside the subtree |
tokens / tokens dark / tokens [data-theme=…] |
Global — design tokens always emit on :root, so $accent and dark mode keep working site-wide |
:global(.toast) (whole selector) |
Opts back out — emits a plain, unscoped .toast { … } |
@media / @supports wrappers |
Untouched; only the inner rule's selector is scoped |
page / * / html / body / ::selection |
Compile error — page-level styles belong in a global skin, not a scoped one |
Where the scope applies depends on where the skin lives:
- A skin colocated with a page (
about.skinnext toabout.wd) scopes that page's body. - A skin colocated with an include (
card.skinnext tocard.wd) scopes just that include's subtree wherever it's@included — so the same scoped component can appear many times on a page without leaking into its neighbours.
Caveats (honest limits this release):
- Whole-selector
:global()only. A descendant:global(.card :global(.x)) is not supported yet — use a whole-selector:global(.x). - Unused selectors warn, they aren't removed. If a scoped selector's class/element/id never appears in the stamped subtree, the build prints
hint: scoped selector ".badge" in card.skin matches no element— but the rule is kept (a colocated.jsmay add the class at runtime). It's a typo nudge, not a dead-code remover. - Scoping is opt-in. Every existing
.skin(noscopedmarker) is byte-for-byte unchanged.
Reactive pages expose window.wd so a colocated .js file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
| Method | Purpose |
|---|---|
wd.get(key) |
read a state value |
wd.set(key, value) |
write it and re-render |
wd.subscribe(key, cb) |
run cb(value) now and on every settled change; returns an unsubscribe |
wd.state |
the live state object |
wd.render() |
force a render |
Section-scoped keys are addressed as sectionId:name. The bridge for "behaviors" is subscribe — it primes the callback immediately, then fires whenever the value settles, so an imperative widget stays in lockstep with declarative state:
// index.js, colocated beside the page. Loads after the runtime, so wd is ready.
const track = document.querySelector("[data-track]");
wd.subscribe("slide", (i) => { // framework state → imperative view
track.style.transform = `translateX(${-i * 100}%)`;
});
document.querySelector("[data-next]")
.addEventListener("click", () => wd.set("slide", wd.get("slide") + 1));That is the whole contract: the framework reconciles state, text, classes, and loops; your behavior owns the gestures; they meet at one shared key. (See the Swiper demo — a draggable, keyboard-navigable carousel built exactly this way.)
Set window.wd.debug = true (it defaults to false) to log any :computed or @loop … where expression that fails to evaluate to the console — useful while authoring reactive pages.
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 → source — no node:fs, so the whole compile path bundles for the browser (or any non-Node host):
import { compileFromMemory } from "@zvndev/darkmown";
const { html, assets } = compileFromMemory(
{
"site/pages/index.wd": "---\ntitle: Hi\nhtml: true\n---\n<main>\n\n:state n = 0\n\nN is { n }.\n\n:button \"+\" -> n++\n\n</main>\n",
"site/_/nav.wd": "..." // @include targets resolve from the same map
},
"site/pages/index.wd"
);Includes, colocated .skin/.js detection, and @loop JSON-data 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 error DX is identical. This is exactly the entry point the browser playground is built on: markdown-it and the compiler bundled to one asset, compiling .wd/.md on every keystroke and rendering the result into an iframe. On disk, compilePage(file, paths) is the same compile with a filesystem reader injected for you.
Every compile error is still a plain Error with the same file:line message, but it now also carries a machine-readable mirror on err.wd — so an AI edit loop (or an editor) gets the facts without re-parsing the prose:
import { compileFromMemory } from "@zvndev/darkmown";
try {
compileFromMemory({ "site/pages/index.wd": ":state x" }, "site/pages/index.wd");
} catch (err) {
err.message; // "Malformed :state in …/index.wd:1: :state x. Use: :state name = value [persist] — e.g. :state count = 0"
err.wd; // { file: "…/index.wd", line: 1, hint: ":state name = value [persist] — e.g. …", example: ":state count = 0" }
}example is always a concrete, compilable line (never a [placeholder]), and every corrective Use: hint that contains bracket-placeholders ends with a matching — e.g. <valid line>. This is a deliberate affordance for small local models, which otherwise copy [optional]-style placeholders into source verbatim.
Darkmown ships its own description of the .wd language — the same tables the compiler validates against — so a tool can teach a model to write .wd and constrain its output to what compiles.
- Directive catalog —
darkmown catalogprints structured JSON: every directive,@loopclause, 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. Importable too:import { directiveCatalog } from "@zvndev/darkmown/catalog". - Cheatsheet (
llms.txt) —darkmown catalog --llmsprints a compact (~90-line) markdown cheatsheet generated from the same data — the artifact you stuff into a model's system prompt. Every build also writes it todist/llms.txt. - GBNF grammar —
grammar/wd-directives.gbnfis a generated GBNF grammar for.wddirective lines. Point llama.cpp (or any grammar-constrained decoder) at it to make invalid directive lines — JS idioms likecart.filter(...), HTML muscle-memory like::: card class="…"— literally unrepresentable during generation. Regenerate withnode scripts/gen-grammar.mjs.
darkmown catalog --llms > system-prompt.md # paste into a model's context
darkmown build # also emits dist/llms.txtBecause the catalog, cheatsheet, grammar, and error examples are all generated from the compiler's own tables, they cannot drift from what actually compiles (enforced by tests).
A VS Code extension in editors/vscode gives .wd and .skin files syntax highlighting, snippets, and folding — so a .wd file reads as Markdown-plus-directives, never as broken Markdown.
Install from a .vsix today:
npm run pack:extension # builds editors/vscode/darkmown-<version>.vsix
code --install-extension editors/vscode/darkmown-*.vsixOr inside VS Code: Extensions panel → … menu → Install from VSIX… and pick the built file. A Visual Studio Marketplace listing is pending (see editors/vscode/PUBLISHING.md); until it lands, the .vsix route above is the supported install.
Every compiled page ships with landmark-and-announcement basics baked in at build time — zero runtime JS, so static pages stay 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 existingidwins as the skip target; an id-less<main>getsid="main"stamped on), and a page without one is wrapped in<main id="main">. Restyle it via.wd-skip-linkin a skin. - Document language.
lang:frontmatter sets<html lang>per page (defaulten). - Live
:fetchregions. A bare:if name_loadingregion over a:fetchkey compiles torole="status" aria-live="polite"and:if name_errortorole="alert", so screen readers announce loading and error flips for free. Write your ownrole/aria-liveinside the region and Darkmown adds nothing. - Accessible names on form controls. Generated
:input/:bind/:textarea/:selectcontrols without an author-suppliedaria-label/aria-describedbyget anaria-labelderived from the placeholder or field name;:sliderand choice groups do the same.
Darkmown is a trusted-author site generator: you compile content you wrote, the same way you trust your own source code. Three assumptions hold the model together — know them before you point Darkmown at content from anyone else.
- Compile only trusted, author-written content. Do not compile
.md/.wdfiles you did not write — user-generated content, third-party docs, form input — without sanitizing it first. - Raw HTML is escaped by default;
html: trueopts a page back in. Darkmown runsmarkdown-itwithhtml: false, so a raw<script>or event-handler attribute in content renders as inert escaped text instead of executing — multi-author content (blog collections, contributed docs) is stored-XSS-safe out of the box. A page whose author writes their own HTML setshtml: truein its frontmatter to pass raw HTML through verbatim. There is still no built-in sanitizer: on anhtml: truepage, untrusted content executes in the visitor's browser. :fetchand:form action=have no host allowlist. A fetch/form URL is taken 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, reactive pages no longer need'unsafe-eval': the runtime interprets a validated expression AST instead of building anew Function, so reactive and static pages share the same strict, eval-free CSP.)
The single biggest footgun: do not put
html: trueon a page whose content is even partly out of your hands, and do not compile untrusted or user-submitted Markdown without sanitizing it first — there is no built-in sanitizer (see SECURITY.md).
Directive actions and :computed/@loop … where expressions are never eval'd as raw user content — they compile to a whitelisted grammar (item paths, declared :state, numbers, strings), serialized to a compact AST that the runtime interprets with a closed evaluator (no eval, no new Function). That is why reactive pages ship under a strict CSP with no 'unsafe-eval'. See SECURITY.md for the full security model.
Builds emit security response headers so a deployed site gets sane defaults without hand-writing a config. The build writes a dist/_headers file (Cloudflare Pages format), and the Vercel/local serve paths apply the equivalent. On every page:
Content-Security-Policy— no'unsafe-inline'and no'unsafe-eval'onscript-srcfor any page: the only inline script the framework emits that CSP gates (thetransitions: truespeculationrules 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. Since 2.1, static and reactive pages get the same strict, eval-free policy — the runtime interprets validated:computed/@loop … where/.class whenexpressions from a compact AST instead of building anew Function, so it needs no'unsafe-eval'. A raw inline<script>you write into anhtml: truepage is blocked by the shipped CSP — put it in a colocated.jsfile (same-origin, allowed by'self') or widenscript-srcdeliberately.X-Content-Type-Options: nosniff,Referrer-Policy, and aframe-ancestorsdirective (clickjacking protection) on every page.
If you use :fetch or :form action= against another host, widen connect-src. The shipped CSP allows same-origin connections; calling a third-party API is otherwise blocked by the policy. Add the host to connect-src in your deploy config (it is not auto-derived from your page sources). The CSP is a defense-in-depth layer — it does not replace the trust-boundary rules above; see SECURITY.md for tightening guidance.
See docs/spec-alignment.md for the deep alignment audit against the original vision.