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
.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:
@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
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
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 }.
- 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.
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:
- 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
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. - 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,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.
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 passes through by default. Markdown renders with
html: true, so raw HTML is emitted verbatim and there is no built-in sanitizer. Sethtml: falsein a page's frontmatter to drop raw HTML for untrusted content. :fetchand:form action=have no host allowlist, and reactive pages needunsafe-eval. 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. The runtime compiles:computed/@loop … where/.class whenexpressions vianew Function, so a reactive page needsscript-src 'unsafe-eval'in your CSP. Static pages need neither.
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. - 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.
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.