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, a VS Code extension gives .wd and .skin files syntax highlighting, snippets, and folding — install it from a .vsix built out of the framework repo (see its editors/vscode README; a Marketplace listing is pending).
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.
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.
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]()
:endif
:if page.next
[Older →]()
: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.
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.
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.
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.
Reactive directives
Reactive pages opt into /__wd/runtime.js (~7.7 KB gzipped, under a CI-enforced 8 KB budget). Static pages do not.
: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++
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.
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.
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
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 four states you can branch on: name, name_loading, name_error, 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.
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]". - Add
ephemeralfor an in-memory store that resets on reload::store sidebarOpen = false ephemeral.
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.
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. 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@loopclause) — 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.
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.
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.
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.
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 (~90-line) cheatsheet — the artifact you paste into a model's system prompt. Every build also writes it todist/llms.txt.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
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.
CLI reference
The darkmown command (installed by npx @zvndev/darkmown) is the whole tool — every command is one word:
darkmown init [dir] [--template <name>]— scaffold a new site from a template (starter,blog,store,dashboard,landing). 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]— print the.wddirective catalog as JSON, or with--llmsa compact cheatsheet for priming an AI model (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.
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.