darkmown.com · Markdown that runs.
Darkmown is a Markdown-native web framework. Two formats, one rule: .md stays plain CommonMark forever, and renaming a file to .wd ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship zero framework JavaScript; reactive pages share one runtime around ~4.7 KB gzipped, CI-enforced under 5 KB.
npx @zvndev/darkmown init my-site
cd my-site
npm install
npm run devOr add it to an existing project:
npm install -D @zvndev/darkmown
npx darkmown devThe package is @zvndev/darkmown; the command it installs is plain darkmown.
npm install
npm test
npm run smoke # pack, install, scaffold, and build a temporary consumer app
npm run dev # live demo site — the same site that runs darkmown.comdarkmown init [dir]scaffolds a new site.darkmown devstarts the live compiler with browser reload and an in-browser error overlay when a build fails.darkmown buildwrites static output todist.darkmown servepreviews the builtdistlocally.darkmown versionprints the installed package version.darkmown helpprints CLI usage.
site/pagesis the route tree..mdand.wdfiles become pages..mdis strict CommonMark (real parser: ordered lists, tables, blockquotes, images, the lot). Directives stay plain text, and the build hints when it spots.wdsyntax in a.mdfile.- Files or folders starting with
.,-, or_are hidden from routing. site/_is the include shelf for@include /name.wd.- Matching
page.skinandpage.jscolocate styling and behavior by basename. - Static pages ship zero Darkmown runtime. Reactive pages share
/__wd/runtime.js(currently ~4.7 KB gzipped, CI-enforced under 5 KB). - Shelf
.jsonfiles are published at/__wd/data/so:fetchworks on any static host.
One syntax everywhere: { name } or { name.path }.
- In-scope static values (include arguments, loop values) resolve at build time.
- Declared
:statebecomes a live binding. - The page's own frontmatter is in scope as
meta—{ meta.title }prints a field. - Anything else stays literal text — braces in prose never break a page or pull in the runtime.
YAML-style key: value frontmatter between --- fences. Values are strings, plus inline arrays:
---
title: Customers
tags: [sales, revenue, "q1, q2"]
---
{ meta.title }prints a scalar;{ meta.tags }prints an array joined with,.@loop meta.tags into tagiterates an array field at build time (stays static, zero-JS).- Arrays are inline flow only (
[a, b]); quoted items keep internal commas ("q1, q2"). A value without a leading[stays a plain string.
@loop <things> into <thing> is the only loop. The source decides the behavior:
A JSON-file loop is unrolled at build time; includes inside inherit the loop value. A :state list loop is reactive and patched by key.
@loop /features.json into card
@include /feature-card.wd
@endloop
:state todos = [{"id": 1, "title": "Route pages"}]
@loop todos into todo
- { todo.title }
@endloop
Loops nest, dotted paths reach into rows, and @include ... with x={ row.field } reassigns values Liquid-style.
Add where <predicate> to filter a loop. Conditions compare a loop-item field against a number, a string, or another value, and join with and / or:
@loop /products.json into p where p.featured == true and p.price < 80
- { p.name }
@endloop
Operators: == != < <= > >=, plus contains for case-insensitive substring match. The predicate is a compile-time-validated whitelist — only item paths, declared :state, numbers, and "strings" are allowed (no arbitrary expressions). Raw user content is never evaluated; the validated predicate compiles to a whitelisted grammar that runs via new Function.
The source decides reactivity, just like the loop itself. If the predicate only reads the row, the filter runs at build time and the page stays zero-JS. If the predicate reads a :state value, the loop becomes reactive and re-filters live as that state changes — a live search 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 — typing updates the state, and the state reflects back into the field. It accepts type= (default text), placeholder=, autocomplete=, and the required / autofocus flags.
Shape a loop without writing JavaScript. Clauses come in a fixed order after into:
@loop <src> into <item> [where …] [sort by <key> [asc|desc]] [reverse] [offset <N>] [limit <N>]
@loop /posts.json into post sort by post.date desc limit 5
{ $number }. { post.title }
@endloop
sort by <key> [asc|desc]—<key>must start with the loop item (post.date, notdate). Numbers sort numerically; everything else sorts as text.ascis the default.reverse— reverse the (already sorted) order.offset <N>/limit <N>—<N>is a non-negative integer or a:state/:storekey, which makes pagination reactive:
:state pageSize = 10
@loop products into product limit pageSize
- { product.name }
@endloop
Each row exposes five meta variables, relative to the rendered slice:
| Variable | Value |
|---|---|
{ $index } |
0-based position |
{ $number } |
1-based position ($index + 1) |
{ $first } |
true on the first row |
{ $last } |
true on the last row |
{ $count } |
number of rendered rows |
They work in interpolation and in :if:
@loop products into product
:if $first
**Top pick:**
:endif
{ $number } of { $count } — { product.name }
@endloop
Add an @empty branch to show a fallback when the loop renders no rows (after where, limit, and the rest):
@loop todos into todo
- { todo.title }
@empty
Nothing left to do.
@endloop
Note: All of these clauses stay build-time when the source and every clause argument are static — a sorted, limited loop over a JSON file ships zero JavaScript. The loop becomes reactive only when the source is
:state/:store/:fetchdata, or a clause reads reactive state (likelimit pageSize).
A :button inside a reactive @loop can act on its own row. cart += product carries the current row into another list; cart remove line drops the current row from the looped list:
: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 += <item>appends a copy of the current row to another:statelist, so adding the same product twice gives two independent lines.<list> remove <item>removes the current row from the list being looped. The<list>must be that loop's own:statesource and<item>must be the loop variable — both checked at compile time. Removal targets the exact row, so it stays correct even when the loop is filtered withwhere.
That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
::: section #cart .dark
:state count = 0
Cart has { count } items.
:button "Add" -> count++
:::
State declared inside a section is scoped to it — two sections can both own a count. Bindings and actions resolve to the nearest scope.
:state count = 0
Count: { count }
:button "Increment" -> count++
:if count
Count has changed.
:else
Count is still zero.
:endif
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated .js files.
A :button "Label" -> action mutates one :state or :store value. The same vocabulary works on both:
| Action | Syntax | Effect |
|---|---|---|
| Increment | n++ |
add 1 |
| Decrement | n-- |
subtract 1 |
| Add | n += 5 |
add a number |
| Subtract | n -= 2 |
subtract a number |
| Set | name = value |
assign a literal |
| Toggle | flag toggle |
flip a boolean |
| Append | list append v or list += v |
add to the end of an array |
| Prepend | list prepend v |
add to the front |
| Member toggle | list toggle v |
add v if absent, else remove it |
| Remove value | list remove v |
remove a value from an array |
| Clear | name clear |
empty an array or object |
| Merge | obj merge other |
shallow-merge an object (key or inline {…}) |
| Delete | obj delete "key" |
remove a key |
| Reset | name reset |
restore the declared starting value |
Values are literals: a "string", number, true/false/null, or inline JSON ({…} / […]).
Targets can be dotted paths, so a button can reach into nested state:
:state cart = {"count": 0, "total": 0}
:button "Add item" -> cart.count++
One button can run several actions with ;. They apply in order, then the page renders once:
:button "Add to cart" -> cart.count++ ; cart.total += 9
Pitfall:
list toggle vandlist remove vmatch members by value (===). That is exact for strings, numbers, and booleans, but not reliable for object members — two equal-looking objects are different values. To remove a row object, loop the list and use the per-rowremoveaction below.
:fetch name from "url" declares state and fills it from JSON over the network:
:fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>]
Each fetch automatically declares four state keys you can branch on:
| State | Type | Meaning |
|---|---|---|
name |
the data | null until the response arrives |
name_loading |
boolean | true while the request is in flight |
name_error |
string | the error message, or null |
name_empty |
boolean | true when the data is null, [], or {} |
Cover loading, errors, empty, and data — @empty (from the loops above) absorbs the empty case:
:fetch roster from "/__wd/data/team.json" timeout=8000 retry=2
:if roster_loading
Loading…
:else
:if roster_error
Couldn't load the team: { roster_error }
:else
@loop roster into member
- { member.name }
@empty
No team members yet.
@endloop
:endif
:endif
method=—GET(default),POST,PUT,PATCH, orDELETE.when=—load(default) fires on page load;visiblewaits until the spot scrolls into view.timeout=<ms>— abort and setname_errorif the response is too slow.retry=<N>— retry on network failure or a 5xx response before surfacing the error.headers=<key>— a:state/:storekey holding an object, sent as request headers.body=<key>— a:state/:storekey, JSON-serialized as the request body (for non-GET).
A URL can interpolate state with { }. The fetch re-runs automatically when that state changes (and skips while the value is still empty):
:state userId = ""
:fetch profile from "/api/users/{ userId }"
Trigger a reload by hand with the refetch action:
:button "Reload" -> roster refetch
Loop a sub-path of fetched (or any) state with a dotted source:
:fetch org from "/__wd/data/org.json"
@loop org.members into member
- { member.name }
@endloop
Note: Shelf
.jsonfiles are published at/__wd/data/, so:fetchworks on any static host. Thedarkmown devserver also ships a/__wd/echoendpoint for demos.
:state is local to its page (and section). :store is global, durable, and shared across tabs — the right home for a cart, a theme, or a signed-in user.
:store cart = []
:button "Add" -> cart += {"id": 1, "name": "Aurora"}
You have { cart } items.
- Durable by default. A store is saved to
localStorageunderwd:store:<name>and reloaded on the next visit. - Shared across tabs. Change a store in one tab and every other tab on the same site updates live.
- Global by name. A bare
{ cart }reads the same store everywhere — stores are never section-scoped. - Same value grammar as
:state— string, number, boolean,null, array, or object — and the same button actions (cart += …,count++,theme = "dark", and so on).
The declared value is a seed: it is used only the first time, when the store is absent from storage. After that the persisted value wins, so visitors keep their data.
Add ephemeral for an in-memory store that resets on reload and does not sync across tabs:
:store sidebarOpen = false ephemeral
Pitfall: A store name must be unique. Declaring the same name as both a
:storeand a:stateon one page is a compile error.
Fetched data and a form live happily on the same page:
:fetch team from "/__wd/data/team.json"
@loop team into member
- { member.name }
@endloop
:form into profile
:input name placeholder="Your name" required
:submit "Save"
:endform
:state cart = [] persist
:form into namecaptures submits straight into state (no backend).:form action="/url"emits a plain native form instead — zero JS, full progressive enhancement.:form action="/url" into replydoes both: with JS the submit posts urlencoded via fetch and the JSON reply lands in statereply(reply_erroron failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.:state x = [] persistkeeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for:storeinstead.):computed total = items.length * 4derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).:if item.pathworks inside reactive loops for per-row branches, and nests — an inner:ifresolves after the outer branch and stays reactive.
Reactive pages expose window.wd — wd.get(key), wd.set(key, value), wd.state, wd.render() — so colocated .js can do anything the directives can't. Section-scoped keys are addressed as sectionId:name.
Set window.wd.debug = true (it defaults to false) to log any :computed or @loop … where expression that fails to evaluate to the console — useful while authoring reactive pages.
A VS Code extension in editors/vscode gives .wd and .skin files syntax highlighting, snippets, and folding — so a .wd file reads as Markdown-plus-directives, never as broken Markdown. For launch, it is source-installable: build a .vsix with npm run pack:extension, then install editors/vscode/darkmown-*.vsix with VS Code. Marketplace/Open VSX publishing is tracked as post-launch distribution work, so the public package does not imply store availability yet.
Darkmown renders Markdown with html: true, so raw HTML in your content passes through verbatim — by design, like every Markdown site generator. Treat content files as trusted input.
The single biggest footgun: do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
Directive actions and :computed/@loop … where expressions are never eval'd as raw user content — they compile to a whitelisted grammar (item paths, declared :state, numbers, strings) that runs via new Function. See SECURITY.md for the full security model.
See docs/spec-alignment.md for the deep alignment audit against the original vision.