The Darkmown docs
Darkmown is a Markdown-native framework for mostly-static sites with tiny reactive islands. Pages are plain files: .md stays pure CommonMark, and .wd ("whateverdown") is the same Markdown plus first-party directives.
Every heading on every page gets a stable, GitHub-style id at build time (lowercased, punctuation stripped, duplicates deduped), so the links above — and deep links into any Darkmown page — are plain # anchors with zero JavaScript.
Relative include: this hidden block lives beside the docs page as -relative-note.wd, so it can be included without becoming a route.
Install
npx @zvndev/darkmown init my-site
cd my-site
npm install
npm run dev
Or add Darkmown to an existing project with npm install -D @zvndev/darkmown. The package is scoped; the command it installs is plain darkmown.
Want to try the syntax before installing anything? The playground compiles .wd and .md live in your browser — the same compiler the CLI ships — and shows the real file:line error when you break something.
For editing, there is a VS Code extension for .wd and .skin files: see Editor support.
darkmown dev rebuilds incrementally: it tracks which routes depend on each file (includes, colocated assets, loop data, collections), so editing one page or include recompiles only the routes it affects — and falls back to a full rebuild whenever a file is added, removed, or renamed. routes.json, the sitemap, and the RSS feed are regenerated on every rebuild, so they always describe the whole site.
The two formats
.mdis strict CommonMark. Directives, includes, and{ bindings }stay plain text, and the build prints a hint if it spots.wdsyntax in a.mdfile..wdis Markdown plus directives. Renaming a file from.mdto.wdis the upgrade path — nothing else changes.
Routing rules
site/pages/index.wdbecomes/.site/pages/docs/index.wdbecomes/docs/..secret.wd,-draft.wd, and hidden folders do not become pages.site/_/is the include shelf, never a route.
Directive lines
A directive is a whole line, written flush against the left margin. Four rules cover what the compiler will and will not accept there.
- One directive per line, unindented. An indented
:state(inside a list item, say) is prose, and a directive inside a fenced code block is code. - A bare keyword is an error. A line that is exactly
:state,:button,@loop, or any other directive that takes arguments used to render as literal text with no warning at all. It now reaches that directive's own handler and fails with that directive's coded message andUse:hint, so a lone:stategives[WD201]and a lone:buttongives[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. Writing
\:fetchrenders the literal text:fetch. It is the ordinary CommonMark backslash escape, so it works anywhere Markdown does. - There is no comment syntax. A trailing
#or arrow note on a directive line is parsed as part of the value, not as a comment:html: true (required)seeds the stringtrue (required), which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.
Includes
@include /nav.wdresolves fromsite/_.@include ./-relative-note.wdresolves beside the current page.@include /card.wd with title="Hello" count=3passes values into the include.with title={ feature.title }passes a value already in scope — including whole objects.- Includes inside a loop inherit the loop value automatically.
Loops
@loop <things> into <thing> is the only loop. The body is normal Markdown (or more directives) and repeats once per row:
@loop /features.json into card
@include /feature-card.wd
@endloop
The source decides how the loop behaves:
- A JSON file path (
/features.json,./data.json) unrolls at build time — pure static HTML. - An in-scope value (an include argument or an outer loop value) also unrolls at build time.
- A
:statelist compiles to a reactive region the runtime patches by key.
Loops nest, and { card.title } style dotted paths reach into each row.
Filtering with where
Add where <predicate> to filter a loop. Conditions join with and / or:
@loop /products.json into p where p.featured == true and p.price < 80
- { p.name }
@endloop
Operators are == != < <= > >= and contains (case-insensitive substring). Operands are item fields, declared :state, numbers, or "strings" — a validated whitelist, never arbitrary code.
The predicate decides reactivity exactly like the source does: read only the row and the filter runs at build time (zero JS); read a :state value and the loop re-filters live. Pair it with :bind for a search box in pure Markdown:
:state products = [{"id":1,"name":"Aurora Lamp"},{"id":2,"name":"Briza Fan"}]
:state q = ""
:bind q placeholder="Search"
@loop products into p where p.name contains q
- { p.name }
@endloop
:bind <state> renders an <input> wired two-way to a :state value — type and the list filters as you go.
Editable lists with per-row actions
A :button inside a reactive loop can act on its own row — the basis for carts and to-do lists:
:state products = [{"id": 1, "name": "Aurora", "price": 49}]
:state cart = []
@loop products into product
::: card
**{ product.name }** — ${ product.price }
:button "Add to cart" -> cart += product
:::
@endloop
@loop cart into line
::: card
{ line.name }
:button "Remove" -> cart remove line
:::
@endloop
cart += product appends a copy of the current row to another :state list; cart remove line removes the current row from the list being looped. Both are checked against the enclosing loop at compile time — no JavaScript.
Sorting, paging, and meta variables
Shape a loop with clauses in a fixed order after into: where, then sort by, reverse, offset, limit.
@loop /posts.json into post sort by post.date desc limit 5
{ $number }. { post.title }
@endloop
sort by <key> [asc|desc]— the key must start with the loop item (post.date). Numbers sort numerically; anything else sorts as text.ascis the default.reverseflips the order;offset N/limit Nslice it.Ncan be an integer or a:state/:storekey, which makes paging reactive (limit pageSize).
Every row exposes meta variables — { $index } (0-based), { $number } (1-based), { $first }, { $last }, { $count } — usable in interpolation and :if:
@loop products into product
:if $first
**Top pick:**
:endif
{ $number } of { $count } — { product.name }
@endloop
Empty lists with @empty
Add an @empty branch to render a fallback when the loop produces no rows (after filtering and slicing):
@loop todos into todo
- { todo.title }
@empty
Nothing left to do.
@endloop
A missing in-scope source is an empty list, not an error — @loop meta.tags into tag on a page whose frontmatter omits tags (an optional field) loops zero rows and renders the @empty branch. A value that is present but not a list is still a compile error with the file and line.
These clauses stay build-time when the source and every argument are static — a sorted, limited loop over a JSON file ships zero JS. The loop turns reactive only when the source or a clause reads :state/:store/:fetch data.
Table rows from a loop
A static loop whose body is bare | … | cells fills a Markdown table. Write the header in prose and let the loop supply the rows:
| Item | Price |
| --- | --- |
@loop /products.json into row
| **{ row.name }** | { row.price } |
@endloop
That renders one <table> with one <tr> per row, and each cell holds ordinary inline Markdown, links included. A format pipe works too, but its | has to be escaped inside a cell: { row.price \| money }. Put the whole table inside the loop instead and you get a headerless table of the same shape; give the loop body its own | --- | separator and each row is a complete table, collapsed into one when the headers match.
Two limits to know before building on it:
- A pipe row written in prose after
@endloop(a totals row) does not join the table. It stays a paragraph. - A reactive loop over pipe rows is
[WD191], not silent breakage. A reactive row is cloned into a<div>, which is not a legal child of<table>, so there is no correct HTML to emit. Loop a static source for a Markdown table, or build reactive rows out of containers (::: trow/::: td) and style them withdisplay: table-row/table-cell.
Content collections
Any folder under site/pages/ is a collection — loop it by its bare name. No content/ root, no marker file: a site/pages/blog/ folder of posts is the blog collection. The whole blog on this site is one such loop.
@loop blog into post sort by post.date desc
- [{ post.title }]({ post.url }) — { post.date }
@endloop
Each entry's frontmatter is a row, plus three derived fields: { post.url } (the route), { post.slug } (the filename), and { post.excerpt } (the frontmatter excerpt:, else the first paragraph of a .md body). It's the same loop with the same clauses, resolved at build time — a pure listing ships zero JS. Drafts never appear in a default-build listing (only under darkmown build --drafts).
Typed schema — _schema.wd
Drop a _schema.wd at a collection's root to validate every entry at build time. It's frontmatter-shaped, one field: type per line:
---
title: string
date: date
excerpt: string?
tags: string[]?
---
Closed types: string, number, boolean, date, string[], each with ? for optional. A missing required field, a wrong type, an unknown extra field (typo guard), or an unknown type token fails the build with a file:line. No _schema.wd means no validation — it's opt-in.
Pagination — paginate N
Add paginate N (collections only) to split a listing into static pages. Page 1 keeps the listing route; pages 2+ are at /<route>/page/2/, /page/3/, … all static HTML.
@loop blog into post sort by post.date desc paginate 5
- [{ post.title }]({ post.url })
@endloop
Page { page.current } of { page.total }
:if page.prev
[← Newer]({ page.prev })
:endif
:if page.next
[Older →]({ page.next })
:endif
The pager — { page.current }, { page.total }, { page.prev }, { page.next } (URLs, empty at the ends) — is exposed to the whole page as plain links. Zero JS. paginate can't combine with offset/limit (it owns the slice).
Interpolation
One syntax everywhere: { name } or { name.path }.
- If the name is a static value in scope (include argument, loop value), it is resolved at build time.
- If the name is declared
:state, it becomes a live binding. - The page's frontmatter is in scope as
meta—{ meta.title }prints a field. - Otherwise the text is left exactly as written — braces in prose never break a page or pull in the runtime.
- Values resolve inside a link or image destination too, and every brace in the destination resolves, not just one filling the whole value:
[Docs](/docs/{ region }/)works. - Values resolve in raw HTML as well (attributes and html blocks, HTML-escaped) on an
html: truepage, but those are painted once at build time rather than bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show`[x](/p/{ p.slug }/)`as syntax.
Destinations that bind
A destination that reads :state, :store, :computed, a reactive loop row, or a loop meta variable is a live binding. The compiler emits the destination as a small template and the runtime rebuilds the value on every render:
:state region = "eu"
:button "EU" -> region = "eu"
:button "US" -> region = "us"
[Open the docs for this region](/docs/{ region }/)
Clicking a button rewrites the href. The build-time paint is still the seed (/docs/eu/), so the link works before the runtime loads and a crawler sees a real URL. Per-row destinations work the same way inside a reactive @loop: - [{ p.name }](/products/{ p.slug }/).
Three rules worth knowing:
- Values in URL position are percent-encoded, so a
), a space, or an angle bracket coming from your data cannot end the link and hand the remainder back to the Markdown parser. - Dangerous schemes are refused twice. The compiler vets what you wrote; the runtime re-checks what it assembled (control characters stripped first) and applies an empty attribute rather than a half-applied one when the value resolves to
javascript:,data:, 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.
See it live on the reactive links demo.
Format pipes — { value | name:arg }
Shape a value for display with a pipe: { price | money }, { joinedAt | date:"medium" }, { ratio | percent }. Pipes chain ({ name | trim | capitalize }) and take literal arguments ({ total | money:"EUR" }, { bio | truncate:80 }). The same syntax works on static values (folded at build time, zero-JS) and live bindings (re-applied on render).
The value being piped must be in scope — a loop variable, :state/:store, an include argument, or meta. A bare literal like { 89 | money } is not a name in scope, so it stays literal text (braces and all), exactly like any unresolved { … }. A date-only value ("2026-06-22") formats in UTC, so date prints the written calendar date on any build machine.
The whitelist: money, number, percent, round, date / time / datetime (Intl-backed; short / medium / long), upper / lower / capitalize, truncate:n / trim, pluralize:"item", and default:"—". The five aggregates double as list pipes — { cart | count }, { cart | sum:"price" | money }, plus avg / min / max. No custom functions, nothing eval'd; an unknown pipe is a compile error. There is deliberately no relative "time ago" formatter, so builds stay reproducible.
Frontmatter
Frontmatter sits between --- fences. Values are strings, plus inline arrays:
---
title: Customers
tags: [sales, revenue, "q1, q2"]
---
{ meta.title } prints a scalar and { meta.tags } joins an array with , . You can loop a frontmatter array at build time — @loop meta.tags into tag — and the page stays static. Arrays are inline flow only ([a, b]); quoted items keep their commas.
lang: sets the document language on <html lang> per page (lang: fr, lang: pt-BR, …); it defaults to en. Every page also ships a visually-hidden-until-focused skip-to-content link targeting the main landmark — your own <main> is reused (an existing id wins as the target; an id-less one gets id="main"), and a page without one is wrapped in <main id="main">. Restyle it via .wd-skip-link in a skin. All compile-time — static pages stay zero-JS.
SEO & feeds
darkmown build writes sitemap.xml, rss.xml, and robots.txt — all build-time, zero client JS. There's no config file: the home page frontmatter carries the site's identity. Add site_url (absolute origin, no trailing slash) and the sitemap and feed switch on, prefixed with that origin and titled with the home title/description.
---
title: My Blog
description: Notes
site_url: https://example.com
---
A page becomes an RSS post by carrying a date:; excerpt: is its feed summary (else description:, else — for a plain .md — its first paragraph). The sitemap's <lastmod> is the date: if set, else the file's git/last-modified date. robots.txt is always written; the Sitemap: line only appears with a site_url. Without site_url, robots still emits and the build prints a hint — it never breaks.
The feed carries the 20 newest posts by default. Set rss_limit: 50 on the home page to change that. It takes digits only (a positive whole number of items), so 1e3, 0x10, and 7.0 are refused with [WD950] rather than silently coerced.
A sitemap over 50,000 URLs shards automatically into sitemap-N.xml files behind a <sitemapindex>, and a date: that is not a real yyyy-mm-dd is omitted from <lastmod> with a build hint instead of writing a broken value.
Canonical URLs
With site_url set, every page states its own absolute URL twice, as <link rel="canonical"> and as og:url, both built from the same route string the sitemap and your internal links use, so nothing can drift out of agreement:
<link rel="canonical" href="https://example.com/docs/">
<meta property="og:url" content="https://example.com/docs/">
Darkmown routes are trailing-slashed (/docs/), matching the dist/docs/index.html the build writes. darkmown deploy vercel writes trailingSlash: true so the host serves exactly that form. Host it elsewhere and configure the same: a host that redirects /docs/ to /docs puts a redirect hop on every internal navigation and makes your own sitemap advertise URLs that redirect.
A paginated listing canonicalises each page to itself (/blog/page/2/ points at /blog/page/2/), never back to page one. Pages 2+ hold different content, and pointing them at page one asks a crawler to drop them.
og:type is article for a page with a date: (or an article schema:), website otherwise. There is no twitter:title/twitter:description: X's card parser falls back to the Open Graph tags, so they would be duplicate bytes on every page.
Structured data
One frontmatter key emits a JSON-LD block into the head. This is ordinary indexing hygiene for Google's conventional rich results; Google's own generative-search guidance says there is no special AI schema and that structured data is not required for generative results, so Darkmown does not sell it as an AI-citation lever.
---
title: Zero JavaScript, by default
date: 2026-06-25
author: Ada Lovelace
schema: BlogPosting
---
The types are a compile-time whitelist, like every other Darkmown vocabulary: Article, BlogPosting, TechArticle, WebSite, Organization. Anything else is a compile error that names every valid type. Pass a list for a page that is honestly both: schema: [WebSite, Organization]. Supporting keys are author: (a name or a list) and updated: for articles, organization: and logo: for Organization; everything else is reused from title, description, image, date, lang, and the canonical URL.
Only what the page has. A key you did not write produces no property at all: never a blank one, never a guess. There is deliberately no way to emit aggregateRating, review, or offers: fabricated ratings are a manual-action risk and Darkmown cannot know them. FAQPage is not supported either, because it would mean inferring a Q&A structure out of prose.
BreadcrumbList is automatic for nested routes (two or more path segments) once site_url is set, built from routes that actually exist with their real titles, so a crumb never links a page you never wrote. JSON-LD is an inert data block, so a static page stays static and ships no JavaScript.
AI crawlers
robots.txt names every major AI crawler and answer engine explicitly, grouped by operator, each group linking the documentation its tokens were verified against and noting what that operator's crawlers actually do:
# OpenAI: https://developers.openai.com/api/docs/bots
# OAI-SearchBot = ChatGPT Search crawling and citations. GPTBot = potential model training. …
User-agent: OAI-SearchBot
User-agent: GPTBot
User-agent: ChatGPT-User
Allow: /
Covered: OpenAI, Anthropic, Google (Google-Extended), Apple (Applebot-Extended), Perplexity, Meta, Mistral, Amazon, and Common Crawl. Search crawling, model training, and live user-triggered fetches are different permissions from the same operator, which is why each group says which is which.
Flip the whole set to Disallow: / from the home page with ai_crawlers: deny. allow is the default. A value that is neither fails the build rather than defaulting. The intent behind a typo like ai_crawlers: block is almost always to opt out, and silently allowing would be the one unrecoverable outcome.
Drafts
Mark a page draft: true and it stays out of production: darkmown build leaves it out of dist, routes.json, the sitemap, and the feed — even if it has a date:. darkmown dev still builds and serves it (with a "DRAFT" banner you only see in dev), and darkmown build --drafts includes drafts for a staging deploy. This is separate from the permanent ./-/_ filename hiding — a hidden name is private forever; draft: is a switch you flip when the page is ready.
Instant navigation
Add transitions: true to a page's frontmatter for instant, flash-free navigation — zero JavaScript. It emits a directional fade+slide view transition for the page swap and a <script type="speculationrules"> prerender hint, so the browser renders the next same-origin page on hover/pointerdown and the click activates an already-painted page (no white render-gap). It honors prefers-reduced-motion, and only same-origin pages that both opt in transition. Off by default; opt out with transitions: false, or exclude a single link from prerendering with {.no-prefetch}. (Chrome turns prerendering off while DevTools is open — test the built site with DevTools closed.)
Images
You don't size images by hand. Every <img> the compiler emits gets its intrinsic width/height read from the source file (so the page doesn't reflow as images decode), decoding="async", and a priority split — the first image stays eager with fetchpriority="high", the rest loading="lazy". Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
Static assets
Non-page files live on the include shelf at site/_/ and are copied into the build untouched:
- Any non-
.md/.wdshelf file is served at/__wd/media/<path>, sosite/_/logo.svgbecomes/__wd/media/logo.svg. Reference it with a normal URL:. - Shelf
.jsonfiles are served at/__wd/data/<name>, which is what:fetchreads. That is why:fetchworks on any plain static host, with no server. - A file colocated under
site/pages/copies todist/at its own path instead (see Colocation), sosite/pages/logo.svgis served at/logo.svg.
Use the shelf for assets shared across many pages, and colocate the ones a single page owns. Both ship with the right content-type.
Sections
::: section #id .class opens a container and ::: closes it. Sections scope state: a :state declared inside a section belongs to that section, so two sections can both declare count without colliding. Bindings and buttons resolve to the nearest scope.
A container named nav or main emits the real landmark element (<nav class="nav">, <main class="main">) so pages keep proper landmarks without raw HTML. Any other name stays a <div> with that class.
Accessibility attributes
Darkmown has no general attribute syntax on purpose: styling is .class tokens and behavior is -> actions, which is what keeps output predictable and static pages script-free. That left one thing genuinely unreachable, the ARIA vocabulary a screen reader needs. Exactly three attribute names compile, on exactly two directives (::: and :button), always with a double-quoted static value:
:state open = false
::: card .note role="region" aria-label="Release notes" title="What changed"
Notes go here.
:::
::: nav .menu role="navigation" aria-label="Main"
[Docs](/docs/)
:::
:button "Menu" aria-expanded="false" aria-controls="m" -> open toggle
- On a container they interleave freely with
.classand#idtokens and coexist with.class when <predicate>. - On a
:buttonthey 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=, anddata-*are refused, and so isARIA-LABEL(the match is case-sensitive) or a single-quoted value. A whitelisted name with no double-quoted value is[WD651]. - Values are static text. There is no
{ state }interpolation inside an aria value in this release:aria-label="{ who }"emits the literal braces. For a live accessible name, write the element in raw HTML on anhtml: truepage, or set it from a colocated.js.
Compile-time only: zero runtime bytes, and a static page carrying attributes stays runtime: false.
Reactive directives
Reactive pages opt into /__wd/runtime.js, a minified build of the readable src/runtime.js (~6.6 KB gzipped, under a CI-enforced 8 KB budget), with an external sourcemap beside it at /__wd/runtime.js.map carrying the original source, so DevTools shows real names and comments. Development serves the same bytes as production. Static pages ship none of it.
:state count = 0
The count is { count }.
:button "Increment" -> count++
:if count
Count has changed.
:else
Count is still zero.
:endif
:state todos = [{"id": 1, "title": "Route pages"}]
@loop todos into todo
- { todo.title }
@endloop
:button "Add" -> todos += {"id": 2, "title": "Live compile"}
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in a colocated .js file.
Computed values and aggregates
:computed name = expr derives state from state — arithmetic, comparisons, and the five list aggregates sum / avg / min / max / count. It recomputes when an input changes and reads like any binding, so it pairs with format pipes:
:store cart = [{"price": 89}, {"price": 12}]
:computed subtotal = sum(cart, price)
:computed total = subtotal * 1.08
Total: { total | money }
The aggregate's field is a bare row key (price, not item.price).
Timers — :every
:every <duration> -> <actions> runs a :button-style action on an interval (ms / s / m). Intervals pause while the tab is hidden and resume on return.
:fetch board from "/status.json"
:every 10s -> board refetch
:state secs = 0
:every 1s -> secs++
:every is a page-level registration. A reactive @loop compiles its body once into a template and the runtime clones it per row, so a timer inside that body would be registered once per row (three rows, three intervals) and a removed row's interval would keep firing. That placement is [WD315]. Declare the timer once outside the loop, at page level or inside the ::: section, and act on the whole list (:every 5s -> rows refetch). It stays legal at page level, inside a :::, inside a static loop (N literal copies that never churn), and in a reactive loop's @empty branch. A :button inside a reactive loop is unaffected.
Multi-branch conditionals
A condition reads the same predicate grammar as .class when — a bare path (truthy), or the comparisons == != < <= > >= contains, joined with and, or, and not. (@loop … where is the comparison-only subset.) Chain with :else if (any number; a bare :else must come last):
:if plan == "pro" or seats >= 5
Pro plan
:else if trialDays > 0 and not expired
Trial — { trialDays } days left
:else
Free plan
:endif
A whole chain compiles to nested conditional regions, so it folds at build time when every value is static and stays reactive otherwise — exactly like a single :if.
Declarations inside a closed branch are live. A :state, :store, or :theme written inside an :if branch that starts closed is hydrated the moment the branch opens, persist included. The seed is claimed once per key, not per node, so closing and re-opening the branch does not reset the value the reader set, and the claimed seed is that key's reset baseline. A :computed inside a closed branch stays dormant until the branch opens, by design. See the closed-branch demo.
Reactive classes — .class when …
A container class can react to state or the loop item. Static .class tokens stay as-is; add when <predicate> for a reactive one:
@loop products into p
::: card .product .on-sale when p.price < 50
**{ p.name }** — ${ p.price }
:::
@endloop
The predicate uses the :if whitelist (item fields, :state, numbers, strings, comparisons, and/or/not, contains). A static predicate folds to a plain class at build; a state or loop-item predicate stays reactive.
Effects — :effect
:effect <watched> -> <actions> runs actions (the :button vocabulary, ;-chained) whenever the watched state changes — for side effects beyond :computed and fetch deps:
:state q = ""
:state searches = 0
:effect q -> searches++
Effects run after a render against settled state, never on the initial load, with a 10-pass settle cap guarding against loops. Like :every, an :effect is page-level: written inside a reactive @loop body it is [WD315], because an effect watches a top-level state key and its actions target one, so there is no per-row meaning to give it.
Button actions
A :button "Label" -> action mutates one :state or :store value. The vocabulary is the same for both:
- Numbers:
n++,n--,n += 5,n -= 2 - Set / toggle:
name = value,flag toggle - Arrays:
list append v(orlist += v),list prepend v,list toggle v,list remove v,list clear - Objects:
obj merge other,obj delete "key" - Universal:
name resetrestores the declared starting value
Values are literals — a "string", number, true/false/null, or inline JSON. Targets may be dotted paths (cart.count++), and one button can run several actions separated by ; (applied in order, rendered once):
: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.
list toggle v and list remove v match by value, which is exact for strings and numbers but not for object members — remove row objects with the per-row remove action instead.
Fetching data
:fetch name from "url" declares state and fills it from JSON over the network. Shelf .json files are served at /__wd/data/. Each fetch auto-declares five states you can branch on: name, name_loading, name_error, name_error_body, and name_empty.
:fetch team from "/__wd/data/team.json" timeout=8000 retry=2
:if team_loading
Loading…
:else if team_error
Couldn't load the team: { team_error }
:else
@loop team into member
- { member.name }
@empty
No team members yet.
@endloop
:endif
The lifecycle regions announce themselves to assistive tech: a bare :if name_loading compiles with role="status" aria-live="polite" and :if name_error with role="alert" — compile-time attributes, no extra runtime JS. Write your own role/aria-live inside a region and nothing is added.
Options after the URL: method= (default GET), when=load|visible, timeout=<ms>, retry=<N>, headers=<key>, and body=<key> (the last two name a :state/:store key). A URL can interpolate state ("/api/users/{ userId }") and re-fetches automatically when that state changes; trigger a reload by hand with :button "Reload" -> team refetch. Loop a sub-path of fetched data with a dotted source — @loop team.members into member. URLs must be relative, http(s)://, or a leading { state } interpolation — other schemes are rejected at compile time.
What the server said
A real API explains why it refused, and Darkmown surfaces that instead of a status line. When the failing response has a JSON body:
name_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. See the server errors demo.
Authenticated requests
headers=<key> sends a state object as request headers; pair it with :store to persist a bearer token. Add refresh="<url>" and a 401 triggers a token refresh: Darkmown POSTs the current headers to the refresh URL, writes the renewed token back into the headers state, and retries the request once (concurrent 401s share one refresh).
:store session = { "Authorization": "Bearer …" }
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
Global state — :store
:store is global, durable, and shared across browser tabs — the right home for a cart, a theme, or a signed-in user. Unlike :state, it is never section-scoped and persists by default.
:store cart = []
:button "Add" -> cart += {"id": 1, "name": "Aurora"}
You have { cart } items.
- Saved to localStorage under
wd:store:<name>and reloaded on the next visit; changes sync live to other open tabs. - The declared value is a seed, used only the first time — afterward the persisted value wins.
- Same value grammar and the same button actions as
:state. - An array/object seed may span multiple lines for readability — open the
[/{on the directive line and let it run until it closes (no blank line inside; an unterminated literal is a compile error). Quote literal bracket text::state tag = "[draft]". - On a multi-line seed, a
persist/ephemeraltoken goes after the closing bracket on the last line, not on the declaration line. persistmeans "survives a reload",ephemeralmeans "does not", and both are accepted on:state,:store, and:theme. The keyword only picks the default.:store sidebarOpen = false ephemeralis an in-memory store;:state cart = [] persistis a persisted one. Writing the token that matches the default is redundant but never wrong.:computedtakes neither: computed values are derived rather than stored, so persist the state they derive from instead (WD211).- There is a third word,
from-url, which says the value also lives in the query string. It composes withpersistand belongs to:stateonly. Writingpersistandephemeralon one line (or the same word twice) is[WD261]rather than a token silently folded into the value.
URL as state: from-url
A filter nobody can link to is half a feature. Add from-url to a :state and the value lives in the query string as well as in memory: a reload keeps it, a shared link arrives with it applied, and the back button walks through it.
: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: /url-state/.
Forms and persistence
:form into namecaptures submits straight into state — no backend.:form action="/url"emits a plain native form with zero JS instead. Form actions follow the same scheme rules as:fetch: relative paths, explicithttp(s)://, or leading{ state }interpolation; protocol-relative and non-http(s) schemes are compile errors.:input field placeholder="…" requiredand:submit "Label"build the form body.:state cart = [] persistsurvives reloads via localStorage on a single page; use:storefor state shared across pages and tabs.:if item.pathworks inside reactive loops for per-row branches, and conditionals nest — an inner:ifresolves after the outer branch, staying reactive.- Reactive pages expose
window.wd(get,set,subscribe,state,render) so colocated.jscan do anything directives can't. Section state is addressed assectionId:name.
Bound controls outside a form
:select, :radio, and :checkbox mean two different things, and where they sit decides which. Inside a :form they are form fields, submitted by name (unchanged). Outside one they bind to a declared :state or :store of that name, exactly like :bind and :slider: move the control and the state changes, change the state and the control moves.
: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.
See the settings demo.
File upload
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. See the upload demo.
See it all live on the Data & Forms page.
Backends & deploy
Darkmown builds 100% static HTML — never per-request rendering. When you need a server, you write a plain serverless function; there is no Darkmown backend syntax to learn.
- A backend endpoint is a Web-standard handler in a top-level
api/directory:export default (request, context) => Response.api/echo.js→/api/echo;api/users/[id].js→/api/users/:id. - One shape runs everywhere:
darkmown dev(local runner), Vercel (native Edge Functions), and Cloudflare Pages (the build emits adist/_worker.js).:fetch/:formpoint at/api/*with no extra config. - If your host rewrites trailing slashes, point at the form it will settle on. Vercel's
trailingSlash: trueredirects/api/echoto/api/echo/, and the local runner answers both, so a missing slash costs a redirect that only appears once deployed. The redirect is a308, so a form POST still arrives intact; you are paying a round trip, not losing data. darkmown deploy vercelordarkmown deploy cloudflarebuilds and ships in one command, printing your URL.- For a remote/custom backend, point
:fetch/:format an absolute URL and widen the CSPconnect-src/form-action. Darkmown owns no server — it adapts to yours.
darkmown init shop --template store scaffolds a cart and its api/checkout.js; --template dashboard ships a :fetch view and api/metrics.js.
Interactions — :slider, sortable, :carousel
Pay-for-what-you-use: sortable/:carousel compile to a tiny /__wd/behaviors/*.js module loaded only where used, budgeted separately from the ≤8 KB core runtime. :slider is compile-time only.
:slider name = v min=0 max=100 step=5— a range input bound through:bind; range values coerce to Number.sortable(a bare@loopclause, written without a leading colon) — drag-to-reorder a:state/:storelist via Pointer Events, with keyboard (Arrow Up/Down) + screen-reader announcements; valid on a plain reactive loop only.:carousel [autoplay=N]— each direct child block is one slide (wrap each in its own block, e.g.::: slide); native scroll-snap plus prev/next, dots, and mouse drag. Autoplay respectsprefers-reduced-motion.
See all three live on the Interactions page.
Theme toggle — :theme
For an explicit light/dark switch alongside the OS preference, declare :theme once and drive it with buttons. It registers a durable theme store and reflects onto <html data-theme>:
:theme
:button "Auto" -> theme = "auto"
:button "Light" -> theme = "light"
:button "Dark" -> theme = "dark"
A single tokens dark block in your .skin already powers both the OS prefers-color-scheme query and this toggle — no extra block needed. "auto" clears the attribute and follows the OS again. See it on the Ledger demo.
Scoped styles — scoped
A colocated .skin is global by default. Make its first line scoped and its selectors only ever match the component it ships with — so two components can both use .card without colliding. It's pure compile time: a path-derived id (e.g. wd-7c21) is stamped onto the HTML and appended to each selector.
scoped
.card
padding 1.5rem
bg $panel
compiles to .card[data-wd-scope="wd-7c21"] { … }, and the component's markup is stamped data-wd-scope="wd-7c21". You still write class="card" — no renaming, no runtime, zero JS on a static page. A page skin scopes the page body; an include skin scopes just that include's subtree wherever it's used.
In a scoped skin |
Result |
|---|---|
| A selector rule | Scoped — attribute on the subject, before any :hover/::before |
tokens / tokens dark |
Global on :root — $vars and dark mode keep working site-wide |
:global(.toast) (whole selector) |
Opts back out — plain unscoped .toast { … } |
page / * / html / body / ::selection |
Compile error — page-level styles belong in a global skin |
Honest limits this release: only whole-selector :global() (no .card :global(.x) yet), and an unused scoped selector warns but is not removed (a colocated .js may add the class). See the side-by-side Scoped demo.
Inline attributes
A trailing {.class .class #id} attaches classes or an id to the inline element directly before it, most often to style a link as a button without a wrapper:
[Get started](/start/){.btn .lg}
The block must follow the element with no space. It works on links, images, and emphasis, and it does not attach to inline code. It never collides with { name } interpolation, because an interpolation always starts with a name, never a . or #. This page uses it: a link marked {.no-prefetch} is excluded from the transitions: true prerender hint.
Media — :video, :audio, :embed
Three compile-time-only directives (they emit no data-wd-*, so a media page stays zero-JS):
:video /clip.mp4 poster=/clip.jpg controls
:audio /track.mp3 controls
:embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
:video/:audio emit a hardened HTML5 player — preload="metadata" and controls by default, whitelisted flags (autoplay implies muted), and the :fetch URL scheme guard. :embed rewrites a YouTube/Vimeo link to its no-cookie/player form in a lazy, responsive 16/9 iframe. See the zero-JS Media demo.
Syntax highlighting
Fenced code blocks with a language are highlighted at build time — HTML and CSS only, no client JavaScript. Tag the fence with a language:
```js
const greeting = "Darkmown"; // highlighted at build time
```
The highlighter is highlight.js and isn't configurable (one closed default, like everything else). It maps onto your skin's $code-* tokens, so highlighted code dark-modes for free through the same tokens dark / :theme system — no extra wiring. Define the palette once:
tokens
code-bg #1b2420
code-fg #e9efe7
code-keyword #d9a8e0
code-string #97d892
code-comment #859289
code-function #88c4ee
code-number #ecae78
code-punctuation #c1ccc6
tokens dark
code-bg #100d0a
code-keyword #e2b9e8
The framework ships a small default $code-* set, so highlighting looks right out of the box. The stylesheet is pay-for-what-you-use: it's emitted and linked only on pages that actually contain a highlighted block — a page with no code ships nothing extra. A fence with an unknown or absent language renders as plain escaped <code> (no highlighting, no error); inline `code` is never highlighted, and there are deliberately no line numbers (they break copy-paste). See the live Syntax highlighting demo.
Colocation
- A matching
.skinfile attaches CSS to the page (indentation-based, compiles to real CSS). - A matching
.jsfile attaches page behavior. It loads after the runtime, sowindow.wdis ready:wd.get(key),wd.set(key, value), andwd.subscribe(key, cb)(runs now and on every settled change) bridge declarative state to imperative widgets — the gestures the framework leaves out (keyboard, drag/touch, canvas). The Swiper demo is a draggable carousel built this way. - Both work for included fragments too, by basename.
- Any other file under
site/pages/(an image, font, PDF — anything that isn't a.md/.wdroute or a basename-matched.skin/.js) copies todist/at its own path with the right content-type. Sosite/pages/logo.svgis served at/logo.svg. Assets below hidden path segments (.,-, or_) and symlinked page assets are skipped, matching the routing privacy rule. Use thesite/_/shelf for assets shared across pages; colocate the ones a single page owns.
Programmatic compile: compileFromMemory
darkmown build reads from disk, but the compiler itself is filesystem-free. compileFromMemory(files, entryPath, options) compiles a page from an in-memory map of project-relative path to source, so the whole compile path bundles for the browser or any other non-Node host:
import { compileFromMemory } from "@zvndev/darkmown";
const { html, assets } = compileFromMemory(
{ "site/pages/index.wd": "---\ntitle: Hi\n---\n\n:state n = 0\n" },
"site/pages/index.wd"
);
Includes, colocated .skin/.js detection, and @loop JSON reads all resolve against the map (anything not in it is simply absent), and it throws the same file:line compile errors as the CLI. The playground is built on exactly this entry point: markdown-it plus the compiler bundled into one asset, recompiling on every keystroke into an iframe. On disk, compilePage(file, paths) is the same compile with a filesystem reader injected for you.
AI authoring
Darkmown ships a machine-readable description of its own language, so an AI tool can learn .wd and be constrained to output that compiles. It is all generated from the compiler's own tables, so it never drifts from the real grammar.
darkmown 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. Import it too:import { directiveCatalog } from "@zvndev/darkmown/catalog".darkmown catalog --llmsprints a compact cheatsheet, the artifact you paste into a model's system prompt. Every build writes it todist/llms.txttoo, followed by a one-line index of every page on your site and a pointer to the full corpus.darkmown catalog --llms-fullprints the complete reference that index points at: every directive with its full syntax and example, every clause, action, pipe, operator, and frontmatter key, and every compile-error code with its cause and fix. Every build writes it todist/llms-full.txt, with the full source text of every page appended.grammar/wd-directives.gbnfis a generated GBNF grammar for.wddirective lines. Point a grammar-constrained decoder (llama.cpp and friends) at it to make invalid directive lines — JS idioms, HTML muscle-memory — impossible to generate.
darkmown catalog --llms > system-prompt.md
darkmown catalog --llms-full # the complete reference + every error code
Compile errors help here too: every corrective Use: hint ends with a concrete — e.g. <valid line>, and each thrown error carries a structured err.wd = { file, line, hint, example } mirror alongside its message — so an edit-and-retry loop gets the fix without parsing prose.
Agent tools
@zvndev/darkmown/tools turns that same description into six functions a model calls instead of rewriting a whole file. Everything runs in memory over a plain { path: contents } object, so a full compile is milliseconds:
outline(files, entry)lists what the page declares, with line numbers and block spans.refs(files, entry, name)finds every declare, write, and read of one symbol.deps(files, entry)reports what the page pulls in, and what pulls it in.grammar(categories)returns only the cheatsheet rows this edit needs.apply(files, entry, edits)makes a targeted edit, addressed byline,symbol, oranchor.validate(files, entry)compiles it for real, or says exactly what broke.
Every tool answers { ok, text, data }, and a refusal carries a sentence the model can act on rather than throwing. Session holds the files so apply and outline always see one snapshot:
import { Session } from "@zvndev/darkmown/tools";
const session = new Session({ "site/pages/index.wd": source }, "site/pages/index.wd");
session.call("outline", {});
session.call("apply", { edits: [{ op: "replace", symbol: "state:count", text: ":state count = 5" }] });
session.call("validate", {});
Compile error codes
Every author-facing compile error opens with a stable WDxxx code, so it is searchable and matchable by tooling without parsing prose:
[WD201] Malformed :state in site/pages/index.wd:1: :state x.
Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
The code is mirrored on the thrown error as err.wd.code, alongside file, line, hint, and a compilable example. Codes are grouped by subsystem — WD0xx source and frontmatter, WD1xx loops, WD2xx state and expressions, WD3xx actions, WD4xx forms, WD5xx fetching, WD6xx includes and structure, WD7xx media, WD8xx skins, WD9xx project and CLI.
A shipped code is a public contract: never renumbered, never reused. The full reference — cause, fix, and an example per code — is generated from the compiler's own registry, so it cannot drift from what actually throws.
CLI reference
The darkmown command (installed by npx @zvndev/darkmown) is the whole tool — every command is one word:
darkmown init [dir] [--template <name>]scaffolds a new site from a template (starter,blog,store,dashboard,landing). It also writes agent context at the project root:AGENTS.md(the full directive reference, copied from the installed package so it can never teach syntax the compiler rejects), aCLAUDE.mdpointing at it, and a.gitignore. Existing files are never overwritten.darkmown dev— the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner forapi/*functions.darkmown build [--target cloudflare] [--drafts]— write 100% static output todist/(plussitemap.xml/rss.xml/robots.txt);--draftsincludesdraft: truepages for a staging deploy.darkmown serve— preview the builtdist/locally (static only; usedevto exerciseapi/*).darkmown catalog [--llms|--llms-full]: print the.wddirective catalog as JSON, a compact cheatsheet for priming an AI model, or the complete reference including every error code (see AI authoring).darkmown deploy <vercel|cloudflare> [--prod]— build (target-aware) and deploy through the platform CLI, printing your URL; an unauthenticated CLI surfaces the login to run first.darkmown version/darkmown help— print the installed version, or the full command summary and directive syntax.
Editor support
A VS Code extension gives .wd and .skin files syntax highlighting, snippets, and folding, so a .wd file reads as Markdown-plus-directives instead of broken Markdown. Build a .vsix from the framework repo and install it with code --install-extension; a Marketplace listing is pending.
Accessibility
Landmark and announcement basics are baked in at build time, so they cost zero JavaScript and a static page stays static:
- Skip link. The first focusable element on every page is a visually-hidden-until-focused "Skip to content" link. The shell guarantees it has a target: your own
<main>is reused (an existingidwins, an id-less<main>getsid="main"stamped on), and a page without one is wrapped in<main id="main">. Restyle it through.wd-skip-linkin a skin. - Document language.
lang:frontmatter sets<html lang>per page, defaulting toen. - Live
:fetchregions. A bare:if name_loadingregion compiles withrole="status" aria-live="polite"and:if name_errorwithrole="alert", so screen readers announce the flips for free. Arole/aria-liveyou write inside the region always wins. - Accessible names on controls.
:input,:bind,:textarea,:select,:slider, and the choice groups derive anaria-labelfrom the placeholder, else a humanized field name, unless you supply one. 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.
There is still a ceiling, and it is stated in Limits: those three names are the whole attribute vocabulary, and their values are static text, so a live accessible name needs raw HTML or a colocated .js.
Security
Trust boundary
Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.
- Compile only content you authored. Don't run
.md/.wdyou didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first. - Raw HTML is escaped by default. Markdown renders with
html: false, so a stray<script>in content becomes inert text — safe for multi-author collections out of the box. Sethtml: truein a page's frontmatter to pass your own raw HTML through verbatim; there is no built-in sanitizer, so keep it off pages with content you didn't write. :fetchand:form action=have no host allowlist. Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Since 2.1 the runtime interprets validated:computed/@loop … where/.class whenexpressions from a compact AST instead of building anew Function, so reactive pages need no'unsafe-eval'— they run under the same strict, eval-free CSP as static pages.
See SECURITY.md in the package for the full security model.
Shipped security headers
Builds emit security response headers, so a deployed site gets sane defaults without a hand-written config. The build writes dist/_headers (Cloudflare Pages format), and the Vercel and local serve paths apply the equivalent:
Content-Security-Policywith no'unsafe-inline'and no'unsafe-eval'onscript-src, on every page. The one 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. Static and reactive pages get the same strict policy.X-Content-Type-Options: nosniff, aReferrer-Policy, and aframe-ancestorsdirective (clickjacking protection) on every page.- A raw inline
<script>you write into anhtml: truepage is blocked by that policy. Put it in a colocated.jsfile (same-origin, allowed by'self') or widenscript-srcdeliberately. - Calling another host with
:fetchor:form action=? Widenconnect-src(andform-actionfor a native form POST) in your deploy config. It is not derived from your page sources. - The two embed origins (
youtube-nocookie.com,player.vimeo.com) andmedia-src 'self' https:are pre-authorized, so:embedand remote media work out of the box.
Limits
Darkmown is small on purpose. Here is what it deliberately refuses and what it genuinely cannot do today, collected in one place so you can decide before you build rather than after.
Refusals that will not change:
.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. - 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 it is indexable, but anything behind:fetchis invisible to crawlers and to the sitemap. 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. The workable architecture is a public page whose data is gated behind yourapi/endpoint. - No layout or shell inheritance. Every page includes its own nav and footer. There is no template a page extends.
- No arbitrary
<head>content. The document head is what frontmatter drives; you cannot inject your own tags. - No route generation from data. Collections are folders of files. A JSON array cannot become routes.
paginate 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. No children, no slots, no default arguments, and a missing argument renders
{ title }literally rather than erroring. - No
darkmown test. There is no built-in way to assert your own site's behavior. UsecompileFromMemoryor a browser test runner.
Migrating out is easy, and that is on purpose. dist/ is portable static HTML plus one runtime file of ~6.6 KB gzipped. No server to port, no proprietary component format, no framework baked into the output. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working.
Spec status
The implementation is faithful to the original core thesis: Markdown-first authoring, no component ceremony, zero runtime on static pages, and tiny direct-DOM reactivity only when declared. :store, the full :fetch lifecycle (loading/error/empty states, dynamic URLs, refetch, dotted loop sources, authenticated headers= + refresh= token renewal), :form (including server round-trips), loop ergonomics (sort by/limit/offset/reverse/@empty and $index/$number/$first/$last/$count), :if … :else if … :else, reactive .class when …, :effect, :computed, and persist are all shipped and live — try them on the Data & Forms page and the Reactive page. Still on the roadmap: HTML-fragment swap semantics and cart server sync. (The serverless api/ model already shipped in 1.2.0 — see Backends & deploy above; a .wd backend DSL remains a deliberate non-goal.) See docs/spec-alignment.md in the package for the full audit.
See these directives composed into real apps on the showcase.