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.

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.

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:

@loop products into product
:button "Add to cart" -> cart += product
:::
@loop cart into line
:button "Remove" -> cart remove line
:::

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

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.

Interpolation

One syntax everywhere: { name } or { name.path }.

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.

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.

Reactive directives

Reactive pages opt into /__wd/runtime.js (~5.7 KB gzipped, under a CI-enforced 6 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.

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

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.

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

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: a first-party server runtime (site/api/) and HTML-fragment swaps. See docs/spec-alignment.md in the package for the full audit.