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 ~6.6 KB gzipped, CI-enforced under 8 KB.
Who it is for. Content sites that need a few interactive islands: documentation, blogs, marketing sites, changelogs, small storefronts and dashboards. You get real Markdown files, real static HTML, and a handful of directives when a page needs to react. It is not a general-purpose application framework, and the Limits section says exactly where the line is before you build against it.
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.
Every merge to master also publishes a prerelease to the next tag, so you can try a
fix before it ships stable: npm install -D @zvndev/darkmown@next. Use @latest (the
default) for anything you care about.
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.
On this page
- Quick start · Showcase · Benchmarks · Working from this repo · Commands
- Authoring: Authoring model · Directive lines · Includes · Interpolation · Frontmatter · Static assets · SEO & feeds · Loops · Content collections · Sections
- Reactivity: Reactive directives · Fetching data · Global state · URL as state · Forms and persistence · Backends & deploy · Interactions
- Presentation: Inline attributes · Media · Syntax highlighting · Dark mode · Scoped styles · Skin at-rules
- Extending: The escape hatch · Programmatic compile · Compile error codes · AI authoring · Editor support
- Before you commit: Accessibility · Security · Limits · Spec status
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).
We rebuilt a live production marketing site (Next.js 16, React 19, Tailwind, on Vercel) in Darkmown: same copy, same images, same CMS content pipeline, 67 routes. Then we measured both sides, median of three loads per route. Full tables, losses included, at darkmown.com/benchmarks:
- JavaScript per page: 225-227 KB compressed across 16 identical chunks on every Next.js page, vs 18-23 KB total in Darkmown (the whole framework runtime is 6.6 KB gzipped).
- Requests per page: 46-70 vs 11-20. The live site also loads ~357 KB of third-party JS (GTM, DoubleClick); the rebuild loads zero.
- Build time (same machine): 16.6 s cold / 23.2 s warm vs 2.7-3.6 s for all 67 routes, with no build cache.
The honest losses, because a benchmark that only wins is marketing: the rebuild shipped 1.07 MB of raw TTF fonts against next/font's 158 KB of subset woff2, and 1,045 KB of unoptimized photos against 147 KB of CDN width variants. Fonts now have a documented woff2 + subsetting recipe (2.8), and responsive image variants are generated at build time (2.9).
Private example repos under ZVN-DEV. Each one rebuilds a live Next.js marketing site in Darkmown, pulls content from the same production CMS at build time, and leaves the live site untouched. Each repo's commit history includes Darkmown friction notes for the framework.
darkmown-toothdocs: ToothDocs rebuild, 67 routes. The benchmark subject above.darkmown-7thst: 7th Street Orthodontics rebuild, 36 routes.darkmown-eetf: East End Tooth Ferry rebuild, 31 routes.darkmown-dafko: DAFKO rebuild, 40 routes.
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). It also dropsAGENTS.md(the full directive reference), aCLAUDE.mdpointing at it, and a.gitignoreinto the project root, so a coding agent working in your new project has the directive vocabulary in context from the first prompt.darkmown dev [--port <n>]starts 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 validate [path]compile-checkssite/pages(or one file or directory) against the real compiler without writingdist. Each page reportscompiles.with its static/reactive status, warnings as bullets, or the coded error with its file, line, and fix hint; drafts are included and the exit code is 1 when any page fails, so it gates CI.darkmown deploy <vercel|cloudflare> [--prod]builds and deploys via the platform CLI.darkmown serve [--port <n>]previews the builtdistlocally. It also runs the project'sapi/functions (same handler contract and routing asdarkmown devand the production hosts, so:form action="/api/…"posts and:fetch … from "/api/…"demos work in the preview), and it compresses text responses: brotli preferred, then gzip, negotiated off the request'sAccept-EncodingwithVary: Accept-Encodingset. Since 2.12 it also sends cache validators (ETag/Last-Modified) and honors conditional requests with 304s, so reloads revalidate instead of re-downloading every asset.darkmown devis deliberately uncompressed and validator-free.darkmown catalog [--llms|--llms-full]prints the machine-readable directive catalog (JSON), a compact cheatsheet with--llms, or the complete reference including every error code with--llms-full. See AI authoring.darkmown mcpruns a zero-dependency MCP server on stdio, exposing the catalog, grammar, validation, error codes, and docs as agent tools. See MCP server.darkmown versionprints the installed package version.darkmown helpprints CLI usage.
Both local servers bind 127.0.0.1: dev on port 5173, serve on 4173. --port <n> (or --port=<n>) chooses the port and wins over the PORT environment variable. --port 0 takes any free port, and the ready line always prints the port the server really bound. A port that is already taken stops the server with one line that names another to try, such as Port 5173 is already in use. Use: darkmown dev --port 5174, and exits 1. dev checks the port before its first build, so nothing is left running.
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..wdadds read-only task lists:- [ ] itemand- [x] itemrender as checkboxes, with no runtime. For a checkbox the reader can tick, use:checkbox.- 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, a minified build of the readablesrc/runtime.js(currently ~6.6 KB gzipped, CI-enforced under 8 KB). An external sourcemap ships beside it at/__wd/runtime.js.mapwith the original source inlined, so DevTools shows real names and real comments. Development serves the same bytes as production. - Shelf
.jsonfiles are published at/__wd/data/so:fetchworks on any static host.
A directive is a whole line, written flush against the left margin. Four rules cover everything 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 needs 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 andUse:hint, so:statealone gives[WD201]and:buttonalone gives[WD301]. Three keywords stay valid bare, because they mean something bare::::opens or closes a container,:themedeclares the theme store, and:carouselopens a carousel with no autoplay. - To show a directive name as text, escape it.
\:fetchrenders the literal text:fetch. That is the ordinary CommonMark backslash escape, so it works anywhere Markdown does, including inside a container. - 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 ← requiredseeds the stringtrue ← required, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.
@include /nav.wd inlines a file from the site/_/ shelf (or site/pages/); @include ./-note.wd resolves beside the current page. Arguments pass Liquid-style: @include /card.wd with title="Hello" count=3, and with title={ feature.title } passes a value already in scope, including whole objects. Includes inside a loop inherit the loop value automatically. Includes are compile-time macros: they expand at build time, so a static page stays static and there is no runtime marker.
An include declares fallbacks for its with arguments in its own frontmatter, so every optional argument has a value even when the caller passes nothing:
---
defaults: [title="Untitled", cta="Learn more"]
---
::: banner
## { title }
[Read the docs](/docs/) — { cta }
:::
- A caller's
with title="…"always wins; the default fills in only when the caller is silent. An argument with neither a caller value nor a default keeps the old behavior: it renders as literal{ title }text. - Values are strings, because frontmatter is string-typed:
count="3"is the string"3", while a caller can still pass a typedwith count=5and it wins as-is. - Entries split on top-level commas only, so a value may contain a comma (
cta="Learn more, today"); single quotes work too (cta='Go'). - Defaults scope like
witharguments: they apply inside the include that declares them, including includes nested inside its body. - A malformed
defaults:(not an inline array, or an entry that is notname="value") is[WD613].
The block form turns an include into a real wrapper component. The lines between the call and @endinclude compile once, in the caller's context (its scope, its :state, and the loop item when the call sits inside a @loop), and land wherever the include puts a standalone { children } line:
@include /card.wd with title="Pricing"
Everything in **this paragraph** is the card body.
@endinclude
with site/_/card.wd:
---
defaults: [title="Untitled"]
---
::: card
## { title }
{ children }
:::
Rules worth knowing:
- Caller scope, build time. Children lines compile as if written where the call is, so
Price: { product.price | money }inside a loop's block sees the loop row, and the whole thing stays zero-JS when the source is static. One edge to know:witharguments resolve at expansion time, so they cannot read a REACTIVE loop's row ([WD604]); a component inside a reactive loop reads the loop variable directly in its children lines instead. - The slot is a standalone line.
{ children }written mid-paragraph is literal text, one inside a fenced code block is documentation rather than a slot, and one on a top-level page (not an include) stays literal too. A.mdinclude cannot host a slot at all. - Called without a block, the slot renders empty. An include may place
{ children }more than once; the children still compile once, so state a child line declares is declared once in the caller. - A block given to an include that never uses
{ children }is dropped with a build warning, so a forgotten slot is loud rather than silent. - Pairing:
@endincludecloses the nearest preceding unpaired@include. The caveat that follows: an inline@includewritten between a block opener and its@endincludeis itself the nearest unpaired opener and consumes the closer, so keep inline includes outside a block's span. One level of block nesting is supported (a block inside children, or inside a nested include's body); deeper is[WD616]. A closer with no unpaired opener is[WD614]. - There is deliberately no "unclosed block" error. An
@includethat never pairs with an@endincludeIS the inline form, and the inline form must stay legal (every pre-feature page is full of@include /nav.wdfollowed by prose, which is syntactically identical to an unclosed block), so the two cannot be distinguished without false positives. Unpaired opener = inline include, unpaired closer =[WD614], a slotted include called without a block renders its slot empty. Treat "close every block" as an authoring rule, not an error.
A component per loop row is the payoff pattern, and it stays zero-JS over a static source:
@loop /products.json into product
@include /card.wd with title={ product.name }
Price: { product.price | money }
@endinclude
@endloop
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.
- Values resolve inside a link or image destination too (
[{ item.label }]({ item.url }),), so an@loopcan drive anhref/srcand the page stays static. Every brace in the destination resolves, not just one filling the whole value:[Docs](/docs/{ region }/)works. A reactive value there becomes a live binding; see Destinations that bind below. - Values resolve in raw HTML too (attributes and html blocks, HTML-escaped) on an
html: truepage. Those are painted once at build time, not 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 (2026,2026-10-13, and a full ISO timestamp), in prose, raw HTML, link destinations, and directive lines alike. They always fold, so a page using only$buildbindings stays zero-JS, and a footer like© { $build.year }is the standard use. An unknown$build.*name stays literal text, and.mdfiles are not folded (they have no interpolation at all).
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.
: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:
:state products = [{"slug": "aurora", "name": "Aurora Lamp"}]
@loop products into p
- [{ p.name }](/products/{ p.slug }/)
@endloop
Three rules worth knowing:
- Values in URL position are percent-encoded. 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 if the value resolves to
javascript:,data:, orvbscript:. - 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.jswithwd.subscribe.
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.
Pipes also fold inside raw HTML on an html: true page (an attribute value or an html block), with the same chaining and literal arguments as prose. A static value there, a collection row, a JSON-file row, or meta, formats at build time and stays zero-JS:
@loop blog into post sort by post.date desc
<div class="card">
<h2>{ post.title }</h2>
<p>{ post.date | date:"medium" }</p>
</div>
@endloop
A reactive value (:state/:store, a reactive loop row) can never fold in raw HTML, because raw HTML is a one-time paint with no element for the runtime to mark. Since 2.8 that hits a build hint naming the position (a :state pipe paints its seed once; a reactive row pipe stays literal) instead of the old behavior, which emitted the whole { … | pipe } expression as literal text with no diagnostic. Move the value into ordinary text or a Markdown destination to make it live.
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.
Two more head keys arrived in 2.8:
icon: /pathsets the favicon, emitted verbatim as<link rel="icon" href="…">with atypefor the known extensions (.svg,.png,.ico). Without it, a convention file insite/pageswins, first hit in the ordericon.svg,icon.png,favicon.ico(page-colocated assets already copy todist/, so/icon.svgjust resolves). Without either, the built-in data-URI mark stays the fallback.noindex: truekeeps a built page out of every discovery surface: it emits<meta name="robots" content="noindex,nofollow">and excludes the route fromsitemap.xml,rss.xml, and the generatedllms.txt/llms-full.txt. The page itself still builds and serves; it is only removed from discovery. (Anoindex:field on a collection entry with a_schema.wdmust be declared in the schema asnoindex: boolean?, same as any other key.)
And one key moves styling into frontmatter: skins: [/base.skin, /marketing.skin] loads extra .skin files for just that page. Entries resolve like @include paths (a leading / checks the site/_/ shelf then site/pages/; a relative path checks beside the page first), must exist, must end in .skin, and must stay inside the project sandbox, with violations reported as [WD017]. A shared skin whose first line is scoped scopes to the whole page body, so the practical rule is one scoped skin per page.
Cascade order changed in 2.8 (behavior change). A page's stylesheet links now emit in this order: the framework highlight sheet (code pages only), then include skins in the order their includes compiled, then the page's 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 own skin came first and an override of an include rule needed a higher-specificity selector. A page rule that unknowingly relied on losing to an include rule now wins; the migration is to delete specificity hacks that only existed to beat the old order.
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; rss_limit: on the home page caps how many posts the feed carries; 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.
Markdown sources: md_source: true on the home page opts the whole site in to publishing every page's byte-exact source beside its HTML: /docs/ also answers /docs.md (the exact .md/.wd file the author wrote, frontmatter and CRLF endings included) with a text/markdown content type, and each published page carries <link rel="alternate" type="text/markdown" href="/docs.md" title="Markdown source">, emitted exactly when the file is, so the link never 404s. A page opts back out with md_source: false; a page-level true without the home flag does nothing, drafts never emit a source, and sitemap/rss/robots never list the .md files (they are alternates, not routes). Off by default, and a site that never sets it builds byte-identical output.
transitions: true opts a page into instant, flash-free navigation: zero JavaScript, all declarative. 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; an instant swap is the only artifact-free choice), 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, and this 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. It honors prefers-reduced-motion trivially, since nothing animates. 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).
Responsive variants, generated at build time. Drop one photo into site/pages and the build does the rest: with sharp installed (npm i -D sharp, an optional dependency; every build works without it), each raster image large enough to matter is resized to the default widths 640 and 1280, written into dist/ as name@<width>.ext siblings, and the Markdown image that references the original gains a srcset:
<img src="/doctors/dr-aurora.jpg" alt="Dr. Aurora"
srcset="/doctors/dr-aurora@640.jpg 640w, /doctors/dr-aurora@1280.jpg 1280w, /doctors/dr-aurora.jpg 2400w"
width="2400" height="1600" decoding="async" fetchpriority="high">- Only widths below the intrinsic size are generated (never upscale), and variants keep the source format: webp re-encodes at quality 82, jpeg at quality 82 progressive, png stays png. EXIF orientation is applied before resizing, so rotated phone photos come out upright. (The build is synchronous and sharp is not, so the resizes run in one child process per build; a fully cached build spawns nothing.)
- Skipped silently: images at or under 700px wide (already small everywhere),
name@<width>.extfiles themselves (output, never sources), hidden path segments (.,-,_) and symlinks (matching what the build publishes), and formats that are not resized (svg, gif, avif). The generator scanssite/pagesonly; shelf images undersite/_are not resized, though author-provided shelf variants still wire through the convention below. - Without sharp, the build behaves exactly as before plus one hint per build:
hint: 5 images could gain responsive variants (site/pages/carousel/slide-1.jpg, ...). Run npm i -D sharp to generate them at build time.A corrupt image (dimensions unreadable) or a sharp failure on one file prints a hint naming that file, and that image ships without variants; the build never fails because of an image. - Resizes are cached in
node_modules/.cache/darkmown-images/, keyed by content hash + width + format. A second build does zero sharp work for unchanged images and does not even load sharp when everything is cached (cached variants ship even if sharp is later removed). The cache lives undernode_modules, which every project's.gitignorealready excludes. Dev rebuilds recompute the variant map from the cache (no sharp spawn), so a partially rebuilt page is byte-identical to a full rebuild, srcset included; changing an image itself forces a full rebuild, which re-runs the pre-pass. - The build summary reports the pre-pass when variants materialized:
Built 14 routes, sitemap (14 urls), rss (6 posts), image variants (2 generated, 5 cached) into dist.
Author-provided variants win (the @width convention). Export dr-aurora@640.jpg yourself, same directory, same extension, and the build does not touch that width: your file is copied verbatim and measured from its own header (a @640 file really holding 600px emits 600w). Generation fills only the widths you did not provide, and the convention picks up any width on the compiler's fixed probe list (64, 96, 128, …, 3840), so other sizes are a hand export away; an off-list width like x@500.webp is a documented miss, never an error. Variant files (yours or generated) are ordinary colocated assets: they copy to dist/ at their own path, and an @-suffixed filename is never a route. Everything else about the wiring is unchanged: descriptor widths are measured from file headers, candidates sort ascending with the original joining at its intrinsic width, and a page with no resizable images emits exactly what it did before.
The build still never invents a sizes value, but since 2.12 you can set one on the Markdown image itself with the inline-attribute block: {sizes="(max-width: 640px) 100vw, 640px"} passes sizes through onto the <img>, composing with .class/#id and with the wired srcset. Without sizes, browsers assume 100vw and can fetch the full-size original on desktop, which makes the variants dead weight, so a hero image should carry one. Raw <img srcset sizes> on an html: true page remains the escape hatch (raw-HTML images are author territory: nothing is added or rewritten there).
Never wired, by design: a bound src ( or a loop row's , which the runtime repaints), remote (https://, protocol-relative) and data: sources, srcs with a query/hash, page-relative or extensionless srcs, and raw-HTML <img> tags. .md caveat: variant wiring runs in the .wd compile path, so a plain .md page gets the width/height/lazy hardening but no srcset.
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. The shelf follows the same rule: site/_/.env.backup is never published, and a symlink inside site/_/ is never copied to dist (it still works as an @include/@loop source, unrolling into pages at build time; it just no longer appears as a file at /__wd/media/ or /__wd/data/).
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.xml,rss.xml, and every page's canonical URL, and is the absolute prefix for every URL in them. The hometitle/descriptionbecome the RSS channel title/description.robots.txtis always emitted; theSitemap:line is added only whensite_urlis set. See AI crawlers below for the explicit per-crawler groups.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.rss_limit:on the home page changes that cap:rss_limit: 50carries the 50 newest posts. It takes digits only (a positive whole number of items), so1e3,0x10, and7.0are refused with[WD950]rather than silently coerced. Leave it out for the default of 20.
A post is just a page that carries a date:. Add excerpt: to control the summary the feed shows for it:
---
title: Hello, Darkmown
date: 2026-01-15
excerpt: Why I rewrote my blog as plain Markdown files.
---
# Hello, Darkmown
…With site_url set, every page's <head> states its own absolute URL twice, as <link rel="canonical"> and og:url:
<link rel="canonical" href="https://example.com/docs/">
<meta property="og:url" content="https://example.com/docs/">Both are built from the same route string the sitemap and your internal links use, so the three can never disagree about which URL form is canonical. Darkmown routes are trailing-slashed (/docs/, matching the dist/docs/index.html the build writes), and darkmown deploy vercel writes trailingSlash: true so the host serves that same form directly. If you host it yourself, configure the host to match: 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, because pages 2..N 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:), and 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. twitter:card has no Open Graph equivalent, so it is stated.
Opt a page into JSON-LD with one frontmatter key. This is ordinary indexing hygiene for Google's conventional rich results. Google's own generative-search guidance is explicit that "there's no special schema.org markup you need to add" and that structured data "isn't required for generative AI search", so this is not an AI-citation lever and is not sold as one.
---
title: Zero JavaScript, by default
description: A Darkmown page ships no framework JavaScript unless it declares reactive behavior.
date: 2026-06-25
author: Ada Lovelace
schema: BlogPosting
---<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting",
"headline":"Zero JavaScript, by default","datePublished":"2026-06-25",
"author":{"@type":"Person","name":"Ada Lovelace"},"url":"https://example.com/blog/zero-js/"}</script>- Types (a compile-time whitelist, like every other Darkmown vocabulary):
Article,BlogPosting,TechArticle,WebSite,Organization. Anything else is a compile error naming every valid type. Pass a list for a page that is honestly both:schema: [WebSite, Organization]. - Supporting keys:
author:(a name or a list) andupdated:for articles;organization:andlogo:forOrganization. Everything else is reused fromtitle,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 and never a guess. There is deliberately no way to emit
aggregateRating,review, oroffers: fabricated ratings are a manual-action risk, and Darkmown cannot know them.FAQPageis likewise not supported, because it would mean inferring a Q&A structure out of prose. BreadcrumbListis automatic for nested routes (two or more path segments) oncesite_urlis set. The trail is built from routes that actually exist, with their real titles, so a crumb never links a page you never wrote:/vs/markdoc/on a site with no/vs/page yieldsHome > vs Markdoc.- Zero runtime cost. JSON-LD is an inert data block, so a static page stays static (
runtime: false) and ships no JavaScript. It is verified in a real browser to survive Darkmown's strict,unsafe-inline-freescript-src.
robots.txt names every major AI crawler and answer engine explicitly, grouped by operator, with a link to the documentation each token was verified against:
# 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 (OAI-SearchBot, GPTBot, ChatGPT-User), Anthropic (Claude-SearchBot, ClaudeBot, Claude-User), Google (Google-Extended), Apple (Applebot-Extended), Perplexity (PerplexityBot, Perplexity-User), Meta (meta-externalagent), Mistral (MistralAI-Index, MistralAI-Training, MistralAI-User), Amazon (Amazonbot), and Common Crawl (CCBot). Search crawling, model training, and live user-triggered fetches are different permissions from the same operator, so each group carries a comment saying which is which.
Flip the whole set to Disallow: / from the home page:
---
site_url: https://example.com
ai_crawlers: deny
---allow is the default (and matches what User-agent: * already grants). A value that is neither allow nor deny 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.
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, with an image variants (2 generated, 5 cached) clause appended whenever the image pre-pass produced or served cached responsive variants (see Static assets).
@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 products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
: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:
:state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
@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):
:state todos = []
@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 static loop whose body is bare | … | cells fills a Markdown table. Write the header in prose and let the loop supply the rows:
| 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 (| [buy](/buy/{ row.id }/) |). A format pipe works too, but its | has to be escaped inside a table 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 you build 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 withdisplay: table-row/table-cell.
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.
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.
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 now compile, on exactly two directives (::: and :button), always with a double-quoted static value:
: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, attributes interleave freely with
.classand#idtokens 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=,data-*are all refused, and so isARIA-LABEL(the match is case-sensitive) or a single-quoted value. A name on the whitelist 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 anhtml: truepage, or set it from a colocated.js.
This is compile-time only. It costs zero runtime bytes, and a static page carrying attributes stays runtime: false.
A container class can be toggled by a predicate. Static .class tokens are unchanged; add when <predicate> to make one reactive:
:state products = [{"id": 1, "name": "Aurora", "price": 49, "featured": true}]
@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.
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.)
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
A :button also accepts the three accessibility attributes, written between the label and the ->: :button "Menu" aria-expanded="false" -> open toggle.
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
:state secs = 0
:every 1s -> secs++
The first line live-refreshes a dashboard; the second is a ticking counter. There is no comment syntax on a directive line, so a trailing # note would be parsed as part of the action.
Intervals pause while the tab is hidden (via visibilitychange) and resume on return, so a backgrounded dashboard stops firing requests and draining battery.
:every is a page-level registration, not a per-row one. A reactive @loop compiles its body once into a template and the runtime clones it per row, so a timer written 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 (which produces N literal copies that never churn), and in a reactive loop's @empty branch. :button inside a reactive loop is unaffected.
: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.
Like :every, :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. Declare it once outside the loop.
: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_error_body |
object | the parsed JSON error body, 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.
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_erroris the body's ownerrorfield, then itsmessagefield, falling back toHTTP <status>when the body carries neither or is not JSON at all.name_error_bodyis the whole parsed body (nullwhen 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."}}:
: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.
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 keep the token across reloads.
Seed the store empty. A :state/:store value is a build-time seed: it is inlined into the page's public HTML, so a literal token written into a .wd file ships to every visitor. The token has to arrive at runtime, from your own api/ endpoint. Since 2.10.1 the compiler backs this up with a build hint (never an error) when a seed looks secret-shaped: a private-key header, a known token prefix with a real token body, a JWT, or a long mixed-case unbroken token:
:store session = {}
:form action="/api/login" into login
:input email type=email required
:input password type=password required
:submit "Sign in"
:endform
:effect login -> session merge login
: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 = {}
: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]".A
persist/ephemeraltoken goes after the closing bracket on the last line, not on the declaration line::state products = [ {"name": "Mug"} ] persist
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.
persist means "survives a reload" and ephemeral means "does not". Both are accepted on :state, :store, and :theme; the keyword only picks the default:
| Declaration | Persisted? |
|---|---|
:state count = 0 |
no (the default) |
:state count = 0 persist |
yes, in localStorage for this page |
:store cart = [] |
yes (the default), and synced across tabs |
:store sidebarOpen = false ephemeral |
no, in-memory for the session |
Writing the token that matches the default is redundant but never wrong, so :store cart = [] persist compiles and means exactly what it says. If a value genuinely ends in one of these words, quote it: :state note = "0 persist" seeds the string.
There is a third word, 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.
:computed takes neither: computed values are derived rather than stored, so persist the state they derive from instead (that mistake is a compile error, WD211).
Pitfall: A store name must be unique. Declaring the same name as both a
:storeand a:stateon one page is a compile error.
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.
: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:itemsbecomes the parametercart.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.popstatere-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-urlis:stateonly. A:storeis shared by every page and every tab while a query parameter belongs to one page's address, sofrom-urlon a:storeor a:themeis[WD260]rather than a guess. On a:computedit is[WD211]: derive the value from a state key that does come from the URL.
Live demo: darkmown.com/url-state/.
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.
: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.
: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
:checkboxis a single boolean, so it takes exactly one- Labelline (the label shown beside it). Several options is[WD451]; for a set of choices use a:radiogroup. The multi-value checkbox group is the in-form behavior and is unchanged. - A bound
:radiogroup keeps its sharedname, which is what makes the browser treat it as mutually exclusive, and carries the chosen option's text.
A :form that contains a file field posts multipart, so the file itself travels:
: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.
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 bare@loopclause, written without a leading colon) 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, and emphasis. It does not attach to inline code: `x`{.hl} leaves the braces in the output as literal text. 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="…", in any order alongside the classes and id, double-quoted and free to contain media conditions with spaces and commas: {sizes="(max-width: 640px) 100vw, 640px"} passes the attribute through, HTML-escaped, whether or not the image has a wired srcset (with variants it rides alongside them on the same <img>). That matters because a srcset without sizes makes browsers assume 100vw and fetch the full-size original on desktop. On a link or emphasis target a sizes token is ignored, and a block that matches nothing recognized (like {foo="x"}) still renders as literal text.
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.
Consecutive raw-HTML lines collapse into one paragraph. On an html: true page, a run of consecutive lines that each start with an inline-level or unknown tag (<a class="card">, <picture>, a custom element) parses as ONE paragraph: the elements render inline inside a single <p> instead of as layout siblings, which is how a card grid collapses to one column. Lines starting with a block-level tag (<div>, <section>, <article>, …) never hit this, because CommonMark hoists them into an HTML block even mid-paragraph, so sixteen consecutive <div class="card"> lines render fine. Since 2.8 the compiler warns once per run when 2+ consecutive lines of one paragraph each open with a component-looking tag, meaning one that carries a class or id (classless <a href> prose links are meant to share a paragraph and 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>, which is one HTML block and renders as real siblings. The hint stays silent for runs already inside a raw container, inside fenced code, separated by blank lines, led by a { state } binding, and on .md pages; a run inside a ::: directive container DOES warn, because directive containers are not raw HTML and the collapse is real there too.
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. Three more attributes shape the frame (an embed with none of them emits byte-identical HTML to before):aspect=4:3swaps the ratio box for any<w>:<h>ratio,height=400pxpins the frame height and drops the ratio box (unitspx,rem,em,vh,vw,%;heightwins when both are given), andallow="payment"replaces the default iframe permission set with your own tokens from a fixed whitelist:payment,autoplay,encrypted-media,fullscreen,picture-in-picture,clipboard-write,camera,microphone,geolocation(allow=""strips all permissions). An unknown attribute is[WD705], an unknown allow token[WD706], and a malformed ratio or length[WD707].
Darkmown's shipped CSP pre-authorizes the two built-in embed origins (youtube-nocookie.com, player.vimeo.com) on every page, and unions any other :embed host into frame-src for exactly the pages that embed it (a same-origin embed records 'self'): a NexHealth-style booking iframe works under the shipped headers with no hand-edited policy. The per-route union is emitted in Cloudflare's dist/_headers and honored by darkmown serve; a page with no extra embed hosts keeps a byte-identical CSP. media-src 'self' https: is unchanged, so remote media works out of the box. Vercel caveat: Vercel reads vercel.json before the build runs, so the generated default carries only the base policy; a project embedding a non-YouTube/Vimeo host on Vercel widens frame-src in its own vercel.json, the same pattern as connect-src for remote :fetch targets.
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.
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.
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.)
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 |
@font-face / @keyframes |
Global, never stamped with the scope attribute, same contract as tokens |
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.
A .skin already wraps nested rules in @media and @supports. Two more CSS at-rules compile as self-contained blocks, because their indented children are not page selectors: @font-face takes font descriptors, and @keyframes takes keyframe selectors.
@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-facetakes a bare line with plain.skindeclarations indented beneath it, and several blocks per skin are allowed. A name on the@font-faceline, a block with no declarations, or a nested rule inside one is a compile error.@keyframes <name>requires a plain CSS identifier. Each indented child is a keyframe selector,from,to, a percentage (50%,12.5%), or a comma list (50%, 75%), with its declarations one level deeper. A child that matches none of those is a compile error, so a keyframe selector is never misread as a declaration or an element selector.- Both run the normal declaration pipeline: property aliases (
bg,radius,font) and$tokenrefs work inside them. Indented under an@medialine, the emitted block is wrapped in that query, which is the right home for@media (prefers-reduced-motion: no-preference)animations. - Both stay global in a
scopedskin, the same contract astokensblocks: font and keyframe names are page-global by nature, so the blocks are never stamped with thedata-wd-scopeattribute. Ordinary selectors in the same file are still scoped.
Since 2.8 the body parser also tracks raw <style> and <script> blocks on html: true pages, so @font-face { or @media … { lines inside a raw <style> no longer trip the "looks like a directive" hint on every line; the hint still fires for directive-shaped lines in ordinary prose and other raw HTML.
Declaring @font-face against a project-local .ttf/.otf/.woff gets you the font; with the wasm toolchain installed, the build also shrinks it. After every page and stylesheet is emitted, a post-pass scans dist/**/*.css for @font-face blocks, subsets each project-local font src url(...) to the site's character set, encodes it as woff2, emits <name>-subset.woff2 beside the original, and rewrites the dist CSS so src: lists the subset first with the original as fallback. The source .skin is never touched, and every other descriptor (font-display, font-weight ranges, …) passes through byte-identically. Layout features (kern, liga, calt) and variable-font axes survive subsetting.
npm i -D harfbuzzjs wawoff2Both are optional dependencies, lazily loaded in a child process behind the same seam as the image-variant sharp integration (the build is synchronous; the wasm toolchain is not, so one spawnSync worker per build does the work, and a fully cached build spawns nothing).
- The charset is the union of every code point in the emitted HTML (numeric character references like
★decoded first) plus a base set: printable ASCII (U+0020–U+007E), the Latin-1 supplement (U+00A0–U+00FF), punctuation U+2010–U+2027 (hyphens, dashes, curly quotes, ellipsis, bullets), primes and per-mille (U+2030–U+2033), single guillemets (U+2039–U+203A), the arrows ← ↑ → ↓ and diagonals (U+2190–U+2199), and ★ ☆ ✓ ✕ ✗. The base set exists so text a colocated.jsinjects at runtime is still covered; non-latin scripts flow in through the page-text union. Known boundary: glyphs used only in CSScontentare not collected, and the original font stays listed as the fallback, so nothing breaks visually. - Without the toolchain the build is unchanged plus one hint per build:
hint: 2 fonts could be subsetted to woff2 (site/_/fonts/a.ttf, site/_/fonts/b.ttf). Run npm i -D harfbuzzjs wawoff2 to subset fonts at build time.A font that fails to parse or subset gets its own hint and ships as-is; the build never fails over a font. - Skips, all silent: remote, protocol-relative,
data:, and relativesrcURLs; already-.woff2sources; paths that escape the project; missing files. - Cache:
node_modules/.cache/darkmown-fonts/<fontHash>-<charsetHash>.woff2, so a second build with unchanged fonts and content does zero wasm work and needs no toolchain for cached entries. The CSS rewrite is idempotent, which is what makes incremental dev builds safe. - The build summary gains a
fonts (3 subsetted, 0 cached)clause when the post-pass did work. Measured on a 67-route marketing rebuild: three variable TTFs totaling 1.07 MB became 288 KB of subset woff2 (a 3.7x reduction), first build about 9.6 s, cached rebuilds about 1.0 s.
Font preloads are injected automatically. The same post-pass adds one <link rel="preload" as="font" crossorigin> per project font to every emitted page, right after the last stylesheet link, mapped per page from the fonts the page's own stylesheets declare (a page whose CSS declares no project fonts is byte-identical). The preloaded URL is exactly the one the rewritten CSS leads with: the -subset.woff2 when subsetting ran, else the original project font (preloading the unsubsetted original still beats no preload), with type matching the extension. crossorigin is mandatory because font fetches are always CORS-mode; without it the browser downloads the font twice. Remote http(s) srcs are never preloaded, already-.woff2 project fonts are preloaded as-is, and the injection is idempotent, so incremental dev rebuilds never duplicate a link. The payoff: no fallback-font swap and reflow on first paint.
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; // "[WD201] Malformed :state in …/index.wd:1: :state x. Use: :state name = value [persist|ephemeral] — e.g. :state count = 0"
err.wd; // { code: "WD201", file: "…/index.wd", line: 1, hint: ":state name = value [persist|ephemeral] — 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.
Every author-facing compile error opens with a stable WDxxx code, so it is searchable, linkable, 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 full list lives in docs/errors.md: code, cause, fix, and a compilable example each. (Since 2.11 it also ships in the published tarball, so the darkmown://docs/errors MCP resource and offline readers get the same file; darkmown catalog --llms-full prints the same content offline.) Codes are grouped by subsystem:
| Range | Subsystem |
|---|---|
WD0xx |
Source, frontmatter & block structure |
WD1xx |
Loops & collections |
WD2xx |
State & expressions |
WD3xx |
Button, effect & timer actions |
WD4xx |
Forms & form fields |
WD5xx |
Data fetching & URL safety |
WD6xx |
Includes & page structure |
WD7xx |
Media & embeds |
WD8xx |
Skins & styling |
WD9xx |
Project, routing & CLI |
A shipped code is a public contract: it is never renumbered and a retired number is never reused, so an error found in an old log or issue always resolves to a meaningful entry. docs/errors.md is generated from the compiler's own registry, and drift guards fail the build if an author-facing error is added without a code, if two errors share one, or if a code goes undocumented.
Genuine internal invariants — the expression-AST parser reading the compiler's own validated output, the reader contract — deliberately stay plain Errors and carry no code, because they signal a framework bug rather than something you can fix in a page.
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 markdown cheatsheet generated from the same data. It is the artifact you stuff into a model's system prompt. Every build also writes it todist/llms.txt, followed by a one-line index of every page on your site (title, absolute URL, description) and a pointer tollms-full.txt. - Corpus (
llms-full.txt):darkmown catalog --llms-fullprints the complete reference the index points at: every directive with its full syntax template and example, every clause, action, pipe and operator, every frontmatter key, and every compile-error code with its cause and fix. An AI edit loop that hits[WD201]can resolve it from the same fetch it learned the syntax from. Every build writes it todist/llms-full.txt, with the full source text of every page appended. - 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 catalog --llms-full # the complete reference + error codes
darkmown build # also emits dist/llms.txt + dist/llms-full.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).
@zvndev/darkmown/tools is the same language description turned into a tool surface: six functions a model calls instead of rewriting a whole file. Everything runs in memory against a plain { path: contents } object, so a full compile is milliseconds and "check after every edit" is affordable rather than aspirational.
| Tool | Answers |
|---|---|
outline(files, entry) |
What is declared in this page, with line numbers and block spans. |
refs(files, entry, name) |
Every place a symbol is declared, written, or read. |
deps(files, entry) |
What this page pulls in, and which pages pull it in. |
grammar(categories) |
Only the cheatsheet rows this edit needs. |
apply(files, entry, edits) |
A targeted edit, addressed by line, symbol, or anchor. |
validate(files, entry) |
Compiles it for real, or says exactly what broke. |
Every tool answers the same shape, { ok, text, data }, and a refusal always carries a sentence the model can act on rather than throwing. Session holds the files so apply and outline are guaranteed to be looking at one snapshot, and records a history of calls:
import { Session, parseToolCall } from "@zvndev/darkmown/tools";
const session = new Session({ "site/pages/index.wd": source }, "site/pages/index.wd");
session.call("outline", {});
// :7 state count = 0
// :12-14 loop @loop /products.json into p
session.call("apply", {
edits: [{ op: "replace", symbol: "state:count", text: ":state count = 5" }]
});
session.call("validate", {});
// compiles. reactive (ships the runtime), 0 skins.toolPrompt() renders the tool list for a system prompt, and parseToolCall(reply) reads a call back out of a model's answer (brace-counting and string-aware, because the one thing a Darkmown edit reliably contains is { count }).
darkmown mcp runs a Model Context Protocol server on stdio, so an MCP-capable agent (Claude Code, Codex, Cursor, …) gets the language description and the compiler as first-class tools instead of shelling out. It is hand-rolled and zero-dependency: MCP over stdio is newline-delimited JSON-RPC 2.0 (revision 2024-11-05), so there is no SDK in dependencies and nothing to drift. Every tool is a thin adapter over the same drift-guarded surfaces the CLI uses, so an MCP client and the darkmown CLI can never disagree about what compiles or what a WD code means. stdout carries protocol frames only; logs go to stderr.
| Tool | Answers |
|---|---|
catalog |
The directive catalog: mode "json" (default), "llms" (the cheatsheet), or "llms-full" (the complete reference). |
grammar |
The GBNF directive-line grammar (gbnf: true), or a category slice of the cheatsheet (categories: ["state", "actions"]). |
validate |
Compile .wd/.md source in memory: static/reactive status and warnings on success, the WD code, file:line, Use: hint, and example on failure. |
error |
One WD code's cause, fix, and example; an unknown code answers the valid list. |
doc |
Fuzzy-find a section of the shipped README/AGENTS.md by topic (topic: "includes"; no topic lists every section). |
Five resources expose the shipped docs at darkmown://docs/readme, darkmown://docs/agents, darkmown://docs/changelog, darkmown://docs/errors, and darkmown://docs/cli, read from the installed package at request time.
Client config (Claude Code, Cursor, and friends share this shape):
{
"mcpServers": {
"darkmown": {
"command": "npx",
"args": ["-y", "@zvndev/darkmown", "mcp"]
}
}
}Deliberately out of scope: prompts, logging, completion, sampling, subscriptions, and batched requests (a JSON array gets a single Invalid Request error, which the spec permits).
Any page as plain markdown. With md_source: true on the home page, every route also serves its byte-exact source at <route>.md (/docs/ answers /docs.md), advertised with a <link rel="alternate" type="text/markdown"> head tag, so an agent can read any page without parsing HTML. llms-full.txt already carries the full source text of every page; the .md sources are the per-page, always-current form of the same idea.
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. role,aria-*, andtitleon containers and buttons.::: card role="region" aria-label="Notes"and:button "Menu" aria-expanded="false" -> open togglecompile without raw HTML. Values are static, double-quoted, and escaped on emit. See Accessibility attributes for the whole rule.
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.
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:
.mdnever gets directives. The extension is the feature gate, and it holds transitively: an.mdpage that@includes a.wdstill shipsruntime: falseand 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. (That is also why reactive pages need no'unsafe-eval'.) - 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.
Structural limits, true today:
- No per-request rendering. Everything is build time. A
:state-seeded@loopdoes bake its rows into the initial HTML, so that content is indexable, but anything behind:fetchis invisible to crawlers and to your ownsitemap.xml. No personalization, no draft preview URLs. - Auth is a public shell with
api/-gated data. A:if sessiongate ships both branches into the HTML, and a:state/:storeseed is inlined into public HTML, so a token must never be written into a page (see Authenticated requests). The workable architecture is a public page whose data is gated behind your own endpoint. - No layout or shell inheritance. Every page includes its own nav and footer. There is no template a page extends and no slot to fill.
- 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 Nis the one route multiplier, and only over an existing collection listing. - Only accessibility attributes on containers and buttons.
:::and:buttonacceptrole="…",aria-…="…", andtitle="…", and nothing else:class=,id=,style=,data-*, and event handlers are compile errors by design. Style with.classtokens, act with->actions, and reach for raw HTML on anhtml: truepage when you genuinely need another attribute. - Aria values are static text. There is no
{ state }interpolation insiderole/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), but
<a href="{ url }">on anhtml: truepage 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:. Nohreflang, 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-typeddefaults:fallbacks, and no props interface beyondwith; 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. UsecompileFromMemoryagainst your own pages, 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. There is no server to port, no proprietary component format, no framework runtime baked through the output, and nothing to un-eject. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working. The lock-in is a directory of Markdown files you already own.
See docs/spec-alignment.md for the deep alignment audit against the original vision.