Darkmown vs. Markdoc
Markdown that runs. Not Markdown that needs React.
The left file is Markdown. It is also a running app — a live search filter, add-to-cart, and a running total. The right file is the closest Markdoc can get on its own: a static list. To make it do what the left one already does, you add a React component, hydration, and a build step.
:state products = [{"id":1,"name":"Aurora Lamp","price":49},{"id":2,"name":"Briza Fan","price":39},{"id":3,"name":"Cove Speaker","price":89},{"id":4,"name":"Dune Mug","price":12}]
:state cart = []
:state q = ""
:computed count = cart.length
:bind q placeholder="Search products…"
@loop products into p where p.name contains q
::: card .product
**{ p.name }** — ${ p.price }
:button "Add" -> cart += p
:::
@endloop
Cart: { count } item(s)
↓ and here is that exact file, running
— $
Aurora Lamp — $49
Briza Fan — $39
Cove Speaker — $89
Dune Mug — $12
Cart: 0 item(s)
This is the whole file. It runs.
{% if $products %}
- **Aurora Lamp** — $49
- **Briza Fan** — $39
- **Cove Speaker** — $89
- **Dune Mug** — $12
{% /if %}
Markdoc has {% if %}, {% partial %}, and $variables — but no loop tag, so each row is written by hand and the list is static. To filter, add to a cart, or total it, you reach for code:
+ store-widget.jsx — a React component, registered as a custom tag:
const tags = { store: { render: 'Store' } };
Markdoc.renderers.react(content, React, {
components: { Store } // your filter + cart + total live here
});
…plus a React + hydration runtime shipped to the browser, and the build step that wires the tag to the component.
Same outcome needs a React component.
Three rules, no escape hatch
- Zero-JS static. A page with no state ships no framework JavaScript.
/markdown/and/docs/prove it — open them and check the network tab. - One runtime. Every reactive page shares the same ~5.7 KB gzipped runtime, CI-enforced under 6 KB. No per-component bundles, no hydration tax that scales with your UI.
- One loop, one binding.
@loop … into …is the only loop;{ name }is the only interpolation. Nothing else to learn, nothing else to misuse.
A whole framework, in text
Everything a site needs, expressed as Markdown directives. No components, no config, no JSX, no special files:
- Routing — folders are routes. Drop a file in, it's a page. Prefix with
.or-to hide it. - State & reactivity —
:state,:button,:if, and keyed@loops patch the DOM directly. No virtual DOM. - Server calls —
:fetchpulls JSON into state;:form action=round-trips to any backend and degrades to a native POST without JS. - Composition —
@includeembeds one Markdown file into another, passing values down Liquid-style. - Styling, optional — colocate a
.skinfile for design tokens, or ship none and let the browser default.
What to try
TryA whole live app in one .wd file: fetch, loops, nested ifs, a form TryThe docs route — a .wd page built from includes TryA plain .md page where directives stay inert text TryReactive page: state, keyed loops, scoped sections TryData page: fetch, forms, server round-trips, persistence TryA hidden route, to confirm it 404sLoops that read like prose
@loop is the only loop. Point it at a JSON file, an in-scope value, or a :state list, and the body repeats once per row. Includes inside the loop inherit the loop value:
@loop /features.json into card
@include /feature-card.wd
@endloop
Folder routing without config
Pages come from site/pages, and hidden work is just dot or minus prefixed.
One loop, @loop
Loop a JSON file, an in-scope value, or a :state list — includes inside inherit the loop value.
Colocated behavior
A matching .skin or .js file beside the page is picked up automatically.
Colocated behavior
This button is powered by site/pages/index.js, discovered by matching the page basename:
Deploy in one click
npm run build emits a plain static dist/ — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build. The demo form posts to Darkmown's dev/Vercel echo endpoint; on static-only hosts, point action= at your own backend or remove that demo.