Skip to content
Documentation

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

Routing rules

Includes

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:

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

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 }.

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:

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.

Forms and persistence

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.

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

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.

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.

See SECURITY.md in the package for the full security model.

Colocation

AI authoring

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

darkmown catalog --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:

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.