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.
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.
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 (~2 KB gzipped, under a CI-enforced 5 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.
Data, forms, and persistence
:fetch name from "url"declares state and fills it from JSON over the network. Shelf.jsonfiles are served at/__wd/data/.:form into namecaptures submits straight into state — no backend.:form action="/url"emits a plain native form with zero JS instead.:input field placeholder="…" requiredand:submit "Label"build the form body.:state cart = [] persistsurvives reloads via localStorage.: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.
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.
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. :fetch, :form (including server round-trips), :computed, and persist are all shipped and live — try them on the Data & Forms page. Still on the roadmap: a first-party server runtime (site/api/), HTML-fragment swaps, and nested :if over loop items. See docs/spec-alignment.md in the package for the full audit.