Skip to content

Latest commit

 

History

288 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Darkmown

npm CI provenance license

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 ~6.6 KB gzipped, CI-enforced under 8 KB.

Who it is for. Content sites that need a few interactive islands: documentation, blogs, marketing sites, changelogs, small storefronts and dashboards. You get real Markdown files, real static HTML, and a handful of directives when a page needs to react. It is not a general-purpose application framework, and the Limits section says exactly where the line is before you build against it.

Quick start

npx @zvndev/darkmown init my-site
cd my-site
npm install
npm run dev

Or add it to an existing project:

npm install -D @zvndev/darkmown
npx darkmown dev

The package is @zvndev/darkmown; the command it installs is plain darkmown.

Every merge to master also publishes a prerelease to the next tag, so you can try a fix before it ships stable: npm install -D @zvndev/darkmown@next. Use @latest (the default) for anything you care about.

Prefer to see it run before you install? The browser playground compiles .wd/.md live in your browser — the same compiler the CLI ships — with no install and no build step.

On this page

Showcase

Five complete apps, each a readable .wd file — see them at darkmown.com/showcase:

  • Folio — a boutique storefront whose cart persists across pages, reloads, and browser tabs (:store), with live search and a checkout form.
  • Pulse — a service dashboard driven by :fetch: loading / error / empty states, live refresh (:every), and status badges, with no hand-written JavaScript.
  • Forge — a plan configurator where :computed recomputes the price as you toggle features and seats, with conditional upsell hints.
  • Compass — a product-finder quiz: a branching state machine expressed entirely as :if steps over scored answers.
  • Ledger — a spreadsheet-grade expense table: clickable-header reactive sort, a live filter, running totals via sum/avg/max, | money/| date format pipes, and a :theme toggle.

Plus focused feature demos: a draggable, keyboard-navigable Swiper (the wd.subscribe escape hatch) and a zero-JS Media page (:video / :audio / :embed).

Working from this repo

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.com

Commands

  • darkmown init [dir] [--template <name>] scaffolds a new site from a template (starter, blog, store, dashboard, landing). It also drops AGENTS.md (the full directive reference), a CLAUDE.md pointing at it, and a .gitignore into the project root, so a coding agent working in your new project has the directive vocabulary in context from the first prompt.
  • darkmown dev starts the live compiler with browser reload, an in-browser error overlay, and a local runner for api/* functions. Rebuilds are incremental: each dev build records a per-route dependency map (page file, includes, colocated assets, loop data, collections), so editing one file recompiles only the routes that depend on it — with a full rebuild on any uncertainty (a new/deleted/renamed file, or a framework src/ change). Rebuilds run one at a time; edits landing mid-build batch into the next one.
  • darkmown build [--target cloudflare] [--drafts] writes static output to dist (plus sitemap.xml/rss.xml/robots.txt); --drafts includes draft: true pages for staging.
  • darkmown deploy <vercel|cloudflare> [--prod] builds and deploys via the platform CLI.
  • darkmown serve previews the built dist locally.
  • darkmown catalog [--llms|--llms-full] prints the machine-readable directive catalog (JSON), a compact cheatsheet with --llms, or the complete reference including every error code with --llms-full. See AI authoring.
  • darkmown version prints the installed package version.
  • darkmown help prints CLI usage.

Authoring model

  • site/pages is the route tree. .md and .wd files become pages.
  • .md is strict CommonMark (real parser: ordered lists, tables, blockquotes, images, the lot). Directives stay plain text, and the build hints when it spots .wd syntax in a .md file.
  • Files or folders starting with ., -, or _ are hidden from routing.
  • site/_ is the include shelf for @include /name.wd.
  • Matching page.skin and page.js colocate styling and behavior by basename.
  • Static pages ship zero Darkmown runtime. Reactive pages share /__wd/runtime.js, a minified build of the readable src/runtime.js (currently ~6.6 KB gzipped, CI-enforced under 8 KB). An external sourcemap ships beside it at /__wd/runtime.js.map with the original source inlined, so DevTools shows real names and real comments. Development serves the same bytes as production.
  • Shelf .json files are published at /__wd/data/ so :fetch works on any static host.

Directive lines

A directive is a whole line, written flush against the left margin. Four rules cover everything the compiler will and will not accept there.

  • One directive per line, unindented. An indented :state (inside a list item, say) is prose, and a directive inside a fenced code block is code.
  • A bare keyword is an error. A line that is exactly :state, :button, @loop, or any other directive that needs arguments used to render as literal text with no warning at all. It now reaches that directive's own handler and fails with that directive's coded message and Use: hint, so :state alone gives [WD201] and :button alone gives [WD301]. Three keywords stay valid bare, because they mean something bare: ::: opens or closes a container, :theme declares the theme store, and :carousel opens a carousel with no autoplay.
  • To show a directive name as text, escape it. \:fetch renders the literal text :fetch. That is the ordinary CommonMark backslash escape, so it works anywhere Markdown does, including inside a container.
  • There is no comment syntax. A trailing # or arrow note on a directive line is parsed as part of the value, not as a comment. html: true ← required seeds the string true ← required, which compiles clean while doing the opposite of what it looks like. Put explanations in the prose around the line.

Interpolation

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

  • In-scope static values (include arguments, loop values) resolve at build time.
  • Declared :state becomes 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.
  • Values resolve inside a link or image destination too ([{ item.label }]({ item.url }), ![{ p.alt }]({ p.src })), so an @loop can drive an href/src and the page stays static. Every brace in the destination resolves, not just one filling the whole value: [Docs](/docs/{ region }/) works. A reactive value there becomes a live binding; see Destinations that bind below.
  • Values resolve in raw HTML too (attributes and html blocks, HTML-escaped) on an html: true page. Those are painted once at build time, not bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `[x](/p/{ p.slug }/)` as syntax.

Destinations that bind

A destination that reads :state, :store, :computed, a reactive loop row, or a loop meta variable is a live binding: the compiler emits the destination as a small template and the runtime rebuilds the value on every render.

:state region = "eu"

:button "EU" -> region = "eu"
:button "US" -> region = "us"

[Open the docs for this region](/docs/{ region }/)

Clicking a button rewrites the href. The build-time paint is still the seed (/docs/eu/), so the link works before the runtime loads and a crawler sees a real URL. Per-row destinations work the same way inside a reactive @loop:

:state products = [{"slug": "aurora", "name": "Aurora Lamp"}]

@loop products into p
- [{ p.name }](/products/{ p.slug }/)
@endloop

Three rules worth knowing:

  • Values in URL position are percent-encoded. A ), a space, or an angle bracket coming from your data cannot end the link and hand the remainder back to the Markdown parser.
  • Dangerous schemes are refused twice. The compiler vets what you wrote; the runtime re-checks what it assembled (control characters stripped first) and applies an empty attribute rather than a half-applied one if the value resolves to javascript:, data:, or vbscript:.
  • Raw HTML attributes do not bind. <a href="{ url }"> is painted once with a build warning that names the position that does bind. Move the value into a Markdown destination, or drive the attribute from a colocated .js with wd.subscribe.

Format pipes — { value | name:arg }

Shape a value for display with a pipe, Liquid/Angular style: { price | money }, { joinedAt | date:"medium" }, { ratio | percent }. Pipes chain left to right ({ name | trim | capitalize }) and take literal arguments ({ total | money:"EUR" }, { bio | truncate:80 }). They work in static prose (folded at build time, zero-JS) and on live :state/:store bindings (re-applied on every render) — identical syntax either way. The pipe list is a fixed whitelist compiled to safe calls; there is no custom-function hook and nothing is eval'd.

A pipe shapes a value that is in scope — a loop variable, :state/:store, an include argument, or meta. The examples below read a field off a loop row p (e.g. inside @loop products into p, with p.price == 89, p.joined == "2026-06-22", …). A bare literal like { 89 | money } is not a name in scope, so — like any unresolved { … } — it is left as literal text, braces and all; pipes only transform a value the page can resolve.

Pipe Example Result
money[:currency[:locale]] { p.price | money } $89.00
number[:decimals] { p.units | number } 1,234.5
percent[:decimals] { p.ratio | percent } 8%
round[:decimals] { p.pi | round:2 } 3.14
date / time / datetime [:style] { p.joined | date:"medium" } Jun 22, 2026
upper / lower / capitalize { p.status | capitalize } Draft
truncate:n / trim { p.bio | truncate:80 } …the first 80 chars…
pluralize:"item"[:"plural"] { p.qty | pluralize:"item" } 3 items
default:"—" { p.nickname | default:"—" } —

(A date-only value like p.joined == "2026-06-22" formats in UTC, so date:"medium" renders the written calendar date on any build machine.)

The five aggregates double as pipes over a list — { cart \| count }, { cart \| sum:"price" \| money }, plus avg / min / max — and join:", ":"name" flattens a list of rows to a string. date/time/datetime are Intl-backed (short / medium / long); there is deliberately no relative "time ago" formatter, so builds stay byte-for-byte reproducible. An unknown pipe name is a compile error with the valid list.

Frontmatter

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 tag iterates 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.

Three keys also drive the document <head>: title sets <title>, description adds the meta description plus Open Graph / Twitter tags, and image (an absolute URL) sets the social-share preview (og:image / twitter:image and a summary_large_image card). lang: sets the document language on <html lang> (lang: fr, lang: pt-BR, …) — it defaults to en.

A few reserved keys drive drafts and feeds (see SEO & feeds below): draft: true excludes a page from production builds; site_url on the home page turns on sitemap.xml + rss.xml; rss_limit: on the home page caps how many posts the feed carries; date: marks a page as a blog post (it lands in rss.xml and sets the page's <lastmod>); excerpt: is the RSS summary. The same frontmatter is queryable when the page is an entry in a content collection: { post.date }, { post.excerpt }, { post.tags }, and any custom key resolve in an @loop over the folder.

transitions: true opts a page into instant, flash-free navigation — zero JavaScript, all declarative. It emits a directional fade+slide view transition for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a <script type="speculationrules"> prerender hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors prefers-reduced-motion. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with transitions: false. Mark a link {.no-prefetch} to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)

Static assets

Images, fonts, icons, and other non-page files live on the include shelf at site/_/ and are copied into the build untouched:

  • Any non-.md/.wd file in site/_/ → served at /__wd/media/<path> (e.g. site/_/logo.svg → /__wd/media/logo.svg).
  • .json files → served at /__wd/data/<name> (this is what :fetch reads).

Reference them with a normal URL: ![logo](/__wd/media/logo.svg). The preview/serve layer returns the correct Content-Type per extension.

Images are hardened at compile time. Every <img> the compiler emits is stamped with its intrinsic width/height (read from the source file, so the browser reserves space and the page doesn't reflow as images decode), decoding="async", and a load-priority split — the first image on the page stays eager with fetchpriority="high" (the LCP candidate), the rest get loading="lazy". Author-set attributes always win, and remote or unreadable sources just skip the dimensions. The compiler measures, it doesn't resize — keep source images web-sized (modern formats, sensible dimensions).

Or colocate an asset next to the page that uses it. Any non-page file under site/pages/ (anything but .md/.wd routes and the colocated .skin/.js) is copied to dist/ at its own path, so a clean relative URL just works:

  • site/pages/logo.svg → dist/logo.svg → ![logo](/logo.svg)
  • site/pages/blog/cover.png → dist/blog/cover.png → ![cover](/blog/cover.png)

Hidden/private page paths stay private: any site/pages asset below a path segment starting with ., -, or _ is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: .env, _private/secret.txt, or -draft/notes.txt.

Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.

SEO & feeds (sitemap, RSS, robots)

darkmown build emits the crawler files a site needs — all build-time, zero client JS — driven by a little frontmatter. There is no config file: the home page (site/pages/index.md or .wd) carries the site's identity.

---
title: My Blog
description: Notes on shipping Markdown that runs.
site_url: https://example.com
---
  • site_url (absolute origin, no trailing slash) turns on sitemap.xml, rss.xml, and every page's canonical URL, and is the absolute prefix for every URL in them. The home title/description become the RSS channel title/description.
  • robots.txt is always emitted; the Sitemap: line is added only when site_url is set. See AI crawlers below for the explicit per-crawler groups.
  • sitemap.xml lists every built page (reactive pages included — they're indexable HTML). Each <lastmod> is the page's frontmatter date: if set, else its git last-commit date, else the file's mtime. No <priority>/<changefreq>.
  • rss.xml syndicates your posts — any page with a date: in its frontmatter. Newest first, capped at the 20 most recent. Each item's <description> is the page's excerpt:, else its description:, else (for a plain .md post) its first paragraph. Every page links the feed with <link rel="alternate" type="application/rss+xml"> so readers can autodiscover it.
  • rss_limit: on the home page changes that cap: rss_limit: 50 carries the 50 newest posts. It takes digits only (a positive whole number of items), so 1e3, 0x10, and 7.0 are refused with [WD950] rather than silently coerced. Leave it out for the default of 20.

A post is just a page that carries a date:. Add excerpt: to control the summary the feed shows for it:

---
title: Hello, Darkmown
date: 2026-01-15
excerpt: Why I rewrote my blog as plain Markdown files.
---

# Hello, Darkmown
…

Canonical URLs

With site_url set, every page's <head> states its own absolute URL twice, as <link rel="canonical"> and og:url:

<link rel="canonical" href="https://example.com/docs/">
<meta property="og:url" content="https://example.com/docs/">

Both are built from the same route string the sitemap and your internal links use, so the three can never disagree about which URL form is canonical. Darkmown routes are trailing-slashed (/docs/, matching the dist/docs/index.html the build writes), and darkmown deploy vercel writes trailingSlash: true so the host serves that same form directly. If you host it yourself, configure the host to match: a host that redirects /docs/ to /docs puts a redirect hop on every internal navigation and makes your own sitemap advertise URLs that redirect.

A paginated listing canonicalises each page to itself (/blog/page/2/ points at /blog/page/2/), never back to page one, because pages 2..N hold different content and pointing them at page one asks a crawler to drop them.

og:type is article for a page with a date: (or an article schema:), and website otherwise. There is no twitter:title/twitter:description: X's card parser falls back to the Open Graph tags, so they would be duplicate bytes on every page. twitter:card has no Open Graph equivalent, so it is stated.

Structured data (schema:)

Opt a page into JSON-LD with one frontmatter key. This is ordinary indexing hygiene for Google's conventional rich results. Google's own generative-search guidance is explicit that "there's no special schema.org markup you need to add" and that structured data "isn't required for generative AI search", so this is not an AI-citation lever and is not sold as one.

---
title: Zero JavaScript, by default
description: A Darkmown page ships no framework JavaScript unless it declares reactive behavior.
date: 2026-06-25
author: Ada Lovelace
schema: BlogPosting
---
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting",
"headline":"Zero JavaScript, by default","datePublished":"2026-06-25",
"author":{"@type":"Person","name":"Ada Lovelace"},"url":"https://example.com/blog/zero-js/"}</script>
  • Types (a compile-time whitelist, like every other Darkmown vocabulary): Article, BlogPosting, TechArticle, WebSite, Organization. Anything else is a compile error naming every valid type. Pass a list for a page that is honestly both: schema: [WebSite, Organization].
  • Supporting keys: author: (a name or a list) and updated: for articles; organization: and logo: for Organization. Everything else is reused from title, description, image, date, lang, and the canonical URL.
  • Only what the page has. A key you did not write produces no property at all: never a blank one and never a guess. There is deliberately no way to emit aggregateRating, review, or offers: fabricated ratings are a manual-action risk, and Darkmown cannot know them. FAQPage is likewise not supported, because it would mean inferring a Q&A structure out of prose.
  • BreadcrumbList is automatic for nested routes (two or more path segments) once site_url is set. The trail is built from routes that actually exist, with their real titles, so a crumb never links a page you never wrote: /vs/markdoc/ on a site with no /vs/ page yields Home > vs Markdoc.
  • Zero runtime cost. JSON-LD is an inert data block, so a static page stays static (runtime: false) and ships no JavaScript. It is verified in a real browser to survive Darkmown's strict, unsafe-inline-free script-src.

AI crawlers

robots.txt names every major AI crawler and answer engine explicitly, grouped by operator, with a link to the documentation each token was verified against:

# OpenAI: https://developers.openai.com/api/docs/bots
# OAI-SearchBot = ChatGPT Search crawling and citations. GPTBot = potential model training. …
User-agent: OAI-SearchBot
User-agent: GPTBot
User-agent: ChatGPT-User
Allow: /

Covered: OpenAI (OAI-SearchBot, GPTBot, ChatGPT-User), Anthropic (Claude-SearchBot, ClaudeBot, Claude-User), Google (Google-Extended), Apple (Applebot-Extended), Perplexity (PerplexityBot, Perplexity-User), Meta (meta-externalagent), Mistral (MistralAI-Index, MistralAI-Training, MistralAI-User), Amazon (Amazonbot), and Common Crawl (CCBot). Search crawling, model training, and live user-triggered fetches are different permissions from the same operator, so each group carries a comment saying which is which.

Flip the whole set to Disallow: / from the home page:

---
site_url: https://example.com
ai_crawlers: deny
---

allow is the default (and matches what User-agent: * already grants). A value that is neither allow nor deny fails the build rather than defaulting: the intent behind a typo like ai_crawlers: block is almost always to opt out, and silently allowing would be the one unrecoverable outcome.

Without site_url, robots.txt still emits and the build prints a one-line hint telling you which field to set; it never crashes.

Drafts

Mark any page draft: true to keep it out of production:

---
title: Work in progress
draft: true
---
  • darkmown build excludes drafts everywhere — no HTML in dist, no entry in routes.json, sitemap.xml, or rss.xml (even a draft that also has a date: never reaches a feed).
  • darkmown dev builds and serves drafts so you can preview them, with a visible "DRAFT" banner that exists only in the dev server — never in any shipped HTML.
  • darkmown build --drafts includes drafts everywhere — for a staging deploy.

Drafts are filtered at one point — route discovery — so nothing downstream ever sees them. This is separate from the permanent ./-/_ filename hiding: a hidden name (-notes.wd) is private forever; draft: is a toggle you flip when the page is ready to ship.

The build summary reports what it emitted: Built 14 routes, sitemap (14 urls), rss (6 posts) into dist.

Loops

@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.

Filtering — @loop … where

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 compact AST that the runtime interprets — no eval, no 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.

Sorting, paging, and meta — sort by, limit, offset, reverse

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, not date). Numbers sort numerically; everything else sorts as text. asc is the default.
  • Reactive sort — the field and the direction can each be a { state } reference, so a clickable column header re-sorts the table live without any JavaScript: sort by { sortKey } { sortDir }. Drive them with :state sortKey = "amount" / :state sortDir = "desc" and :button "Amount" -> sortKey = "amount" ; sortDir = "desc". The bare { sortKey } resolves to a field on the loop item at render time.
  • reverse — reverse the (already sorted) order.
  • offset <N> / limit <N> — <N> is a non-negative integer or a :state/:store key, which makes pagination reactive:
:state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
: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:

:state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]

@loop products into product
:if $first
**Top pick:**
:endif
{ $number } of { $count } — { product.name }
@endloop

Empty lists — @empty

Add an @empty branch to show a fallback when the loop renders no rows (after where, limit, and the rest):

:state todos = []

@loop todos into todo
- { todo.title }
@empty
Nothing left to do.
@endloop

A missing in-scope source is an empty list, not an error: @loop meta.tags into tag over a page whose frontmatter omits tags (say, an optional tags: string[]? schema field) loops zero rows and renders the @empty branch. A field that is present but not a list (a string, a number) is still a compile error with the file:line.

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/:fetch data, or a clause reads reactive state (like limit pageSize).

Table rows from a loop

A static loop whose body is bare | … | cells fills a Markdown table. Write the header in prose and let the loop supply the rows:

| Item | Price |
| --- | --- |
@loop /products.json into row
| **{ row.name }** | { row.price } |
@endloop

That renders one <table> with one <tr> per row, and each cell holds ordinary inline Markdown, links included (| [buy](/buy/{ row.id }/) |). A format pipe works too, but its | has to be escaped inside a table cell: { row.price \| money }. Put the whole table inside the loop instead and you get a headerless <table> of the same shape; give the loop body its own | --- | separator and each row is a complete table, collapsed into one when the headers match.

Two limits to know before you build on it:

  • A pipe row written in prose after @endloop (a totals row) does not join the table. It stays a paragraph.
  • A reactive loop over pipe rows is [WD191], not silent breakage. A reactive row is cloned into a <div>, which is not a legal child of <table>, so there is no correct HTML to emit. Loop a static source for a Markdown table, or build reactive rows out of containers (::: trow / ::: td) and style them with display: table-row / table-cell.

Editable lists — per-row actions

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 :state list, 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 :state source 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 with where.

That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.

Nested reactive loops

A reactive @loop may contain an inner reactive @loop over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (order.lines), so each row renders its own list and both stay live as state changes:

:state orders = [
  {"id": 1, "ref": "A-100", "lines": [{"sku": "x1", "qty": 2}, {"sku": "x2", "qty": 1}]},
  {"id": 2, "ref": "A-101", "lines": [{"sku": "y9", "qty": 5}]}
]

@loop orders into order
::: card
**{ order.ref }**
@loop order.lines into line
- { line.sku } × { line.qty }
@endloop
:::
@endloop
  • The inner loop is keyed and reconciled per outer row, like any reactive loop.
  • Interpolation inside the inner body resolves the inner item first, then the outer item — { line.qty } and { order.ref } are both in scope.
  • Build-time loops over JSON/frontmatter already nest freely (the "loops nest" behavior above); what's new is reactive-inside-reactive over a :state/:store/:fetch row field.

Honest caveat: nesting is one level only — an inner reactive loop that itself contains a third reactive loop is a compile error with a corrective message, not a silent runtime failure. Build-time loop nesting is unaffected by this limit.

Per-row actions inside an inner loop: cart += member (append-row) works from an inner loop — it carries the inner item into a top-level :state/:store list. But a per-row remove needs a top-level list as its source, and an inner loop's source is a path off the outer row (team.members), so deleting an inner row in place is not supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.

Content collections

Any folder under site/pages/ is a queryable collection — referenced in @loop by its bare name. There is no content/ root to opt into and no marker file to add: a site/pages/blog/ directory of posts is the blog collection.

@loop blog into post sort by post.date desc
- [{ post.title }]({ post.url }) — { post.date }
@endloop

It's the same one loop, with the same clauses (where, sort by, reverse, offset, limit, format pipes). The collection resolves at build time, so a pure listing ships zero JavaScript (runtime: false).

Each entry's frontmatter becomes a row, plus three fields the framework derives:

Field Value
{ post.url } the entry's route, e.g. /blog/hello/
{ post.slug } the filename without extension (an index.md uses its folder name)
{ post.excerpt } the frontmatter excerpt:, else the first paragraph of a .md body

Scalar frontmatter is coerced for querying, so where post.featured == true and numeric sorts behave like they do over a JSON file. Drafts never leak: a draft: true entry is excluded from a default build's listing (included only under darkmown build --drafts), exactly like routing and feeds.

Typed schema — _schema.wd

Drop a _schema.wd at a collection's root to validate every entry's frontmatter at build time. It's frontmatter-shaped — one field: type rule per line:

---
title: string
date: date
description: string
excerpt: string?
tags: string[]?
---

The vocabulary is small and closed: string, number, boolean, date, string[], each with a trailing ? to mark it optional. A missing required field, a wrong type, an unknown extra field (a typo guard), or an unknown type token in the schema itself each fail the build with a file:line and the offending field. Validation is opt-in — no _schema.wd, no validation.

Pagination — paginate N

Add paginate N (collections only) to split a listing into static pages: page 1 keeps the listing's own route, and pages 2+ live at /<route>/page/2/, /<route>/page/3/, … Every generated page is static HTML and appears in routes.json and sitemap.xml.

@loop blog into post sort by post.date desc paginate 5
- [{ post.title }]({ post.url })
@endloop

Page { page.current } of { page.total }
:if page.prev
[← Newer]({ page.prev })
:endif
:if page.next
[Older →]({ page.next })
:endif

A page pager is exposed to the whole page:

Variable Value
{ page.current } 1-based current page number
{ page.total } total number of pages
{ page.prev } URL of the previous page, or "" on page 1
{ page.next } URL of the next page, or "" on the last page

The pager is plain <a href> links to the generated routes — zero JavaScript. paginate is for collections only (route-multiplication only makes sense for a folder of entries) and can't combine with offset/limit (it owns the slice).

See the live blog demo — the whole index, paginated and sorted, is one @loop over site/pages/blog/, validated by a _schema.wd, shipping no framework JS.

Sections

::: 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.

A container named nav or main emits the real landmark element (<nav class="nav">, <main class="main">) instead of a <div>, so scaffolded pages keep proper landmarks — a skip link skips a ::: nav, and ::: main becomes the page's <main id="main">. Any other name stays a <div> with that class.

Accessibility attributes

Darkmown has no general attribute syntax on purpose: styling is .class tokens and behavior is -> actions, which is what keeps output predictable and static pages script-free. That left one thing genuinely unreachable, the ARIA vocabulary a screen reader needs. Exactly three attribute names now compile, on exactly two directives (::: and :button), always with a double-quoted static value:

:state open = false

::: card .note role="region" aria-label="Release notes" title="What changed"
Notes go here.
:::

::: nav .menu role="navigation" aria-label="Main"
[Docs](/docs/)
:::

:button "Menu" aria-expanded="false" aria-controls="m" -> open toggle
  • On a container, attributes interleave freely with .class and #id tokens and coexist with .class when <predicate>.
  • On a :button, they sit between the label and the ->. An arrow inside a quoted value is safe: attributes are peeled before the action arrow is looked for.
  • Values are HTML-escaped on emit, so nothing you write can close the attribute or open another.
  • Anything outside the whitelist is [WD650]: onclick=, style=, href=, class=, id=, data-* are all refused, and so is ARIA-LABEL (the match is case-sensitive) or a single-quoted value. A name on the whitelist with no double-quoted value is [WD651].
  • Values are static text. There is no { state } interpolation inside an aria value in this release: aria-label="{ who }" emits the literal braces. For a live accessible name, write the element in raw HTML on an html: true page, or set it from a colocated .js.

This is compile-time only. It costs zero runtime bytes, and a static page carrying attributes stays runtime: false.

Reactive classes — .class when <predicate>

A container class can be toggled by a predicate. Static .class tokens are unchanged; add when <predicate> to make one reactive:

:state products = [{"id": 1, "name": "Aurora", "price": 49, "featured": true}]

@loop products into p
::: card .product .on-sale when p.price < 50 .featured when p.featured
**{ p.name }** — ${ p.price }
:::
@endloop

The predicate uses the same whitelist as :if — item fields, declared :state/:store, numbers, strings, the ==/!=/>/</>=/<=/contains operators, and and/or/not. A bare path (.featured when p.featured) reads as truthy. (@loop … where is the comparison-only subset of this grammar.) A predicate over only static values folds at build time into a plain class; one that reads state or the loop item stays reactive and ships with the runtime.

Reactive directives

:state count = 0

Count: { count }

:button "Increment" -> count++

:if count >= 10
Count is high.
:else if count > 0
Count has changed.
:else
Count is still zero.
:endif

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 — operators with and/or.) Chain with :else if (any number; an optional bare :else must be the last branch):

: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 stays reactive (or folds at build time when every value is static) exactly like a single :if.

Declarations inside a closed branch are live. A :state, :store, or :theme written inside an :if branch that starts closed is hydrated the moment the branch opens, persist included. The seed is claimed once per key, not per node, so closing and re-opening the branch does not reset the value the reader set, and the claimed seed is that key's reset baseline. (A :computed inside a closed branch stays dormant until the branch opens, by design.)

Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated .js files.

Button actions

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

A :button also accepts the three accessibility attributes, written between the label and the ->: :button "Menu" aria-expanded="false" -> open toggle.

Pitfall: list toggle v and list remove v match 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-row remove action below.

Computed values — :computed

:computed name = <expression> derives state from other state with a compile-time-checked expression — names, numbers, arithmetic (+ - * /), comparisons, and the five aggregates over a list: sum(list, field), avg(list, field), min(list, field), max(list, field), and count(list). It recomputes whenever an input changes and reads like any other binding (so it pairs naturally with format pipes):

:store cart = [{"price": 89}, {"price": 12}]
:computed subtotal = sum(cart, price)
:computed tax      = subtotal * 0.08
:computed total    = subtotal + tax

Subtotal: { subtotal | money } · Tax: { tax | money } · **{ total | money }**

The aggregate's field argument is a bare key on each row (price, not item.price). There are no function calls beyond the five aggregates and no property access beyond dotted state paths — anything richer belongs in a colocated .js behavior.

Timers — :every

:every <duration> -> <actions> runs a :button-style action on an interval — the one piece of time the framework owns. Durations are <n>ms / <n>s / <n>m, and the actions are the same ;-chained vocabulary as :button/:effect (including name refetch to re-run a :fetch):

:fetch board from "/status.json"
:every 10s -> board refetch

:state secs = 0
:every 1s -> secs++

The first line live-refreshes a dashboard; the second is a ticking counter. There is no comment syntax on a directive line, so a trailing # note would be parsed as part of the action.

Intervals pause while the tab is hidden (via visibilitychange) and resume on return, so a backgrounded dashboard stops firing requests and draining battery.

:every is a page-level registration, not a per-row one. A reactive @loop compiles its body once into a template and the runtime clones it per row, so a timer written inside that body would be registered once per row (three rows, three intervals), and a removed row's interval would keep firing. That placement is [WD315]. Declare the timer once outside the loop, at page level or inside the ::: section, and act on the whole list (:every 5s -> rows refetch). It stays legal at page level, inside a :::, inside a static loop (which produces N literal copies that never churn), and in a reactive loop's @empty branch. :button inside a reactive loop is unaffected.

Effects — :effect

:effect <watched> -> <actions> runs actions whenever a watched state path changes. The actions are the same :button vocabulary (;-chained) — this is the escape hatch for side effects beyond :computed (which derives state) and :fetch deps (which auto-refetch):

:state q = ""
:state searches = 0

:effect q -> searches++

Effects run after a render, against settled state, and an effect that mutates state triggers another pass — bounded by a 10-pass settle cap that warns (and stops) if an effect never settles. They do not fire on the initial load, only on a real change.

Like :every, :effect is page-level: written inside a reactive @loop body it is [WD315], because an effect watches a top-level state key and its actions target one, so there is no per-row meaning to give it. Declare it once outside the loop.

Fetching data

: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>] [refresh=<url>]

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_error_body object the parsed JSON error body, or null
name_empty boolean true when the data is null, [], or {}

The four-state pattern

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

The lifecycle regions announce themselves: a bare :if name_loading compiles with role="status" aria-live="polite" and :if name_error with role="alert", so assistive tech hears the flips with no extra markup. Author-supplied role/aria-live inside a region always wins.

What the server said: name_error and name_error_body

A real API explains why it refused, and Darkmown surfaces that instead of a status line. When the failing response has a JSON body:

  • name_error is the body's own error field, then its message field, falling back to HTTP <status> when the body carries neither or is not JSON at all.
  • name_error_body is the whole parsed body (null when the response was not JSON), so per-field messages render without a line of your own JavaScript.

Given a 422 whose body is {"error": "Pick a file first.", "fields": {"photo": "No file was attached."}}:

:fetch signup from "/api/signup" method=POST

:if signup_error
**{ signup_error }**
:if signup_error_body
Photo: { signup_error_body.fields.photo }
:endif
:endif

The first line renders Pick a file first., not Error: HTTP 422. Both keys are declared automatically by :fetch and by a round-trip :form (one with both into and action=), and both are cleared at the start of the next request, so a stale message never outlives the failure that produced it.

Options

  • method= — GET (default), POST, PUT, PATCH, or DELETE.
  • when= — load (default) fires on page load; visible waits until the spot scrolls into view.
  • timeout=<ms> — abort and set name_error if the response is too slow.
  • retry=<N> — retry on network failure or a 5xx response before surfacing the error.
  • headers=<key> — a :state/:store key holding an object, sent as request headers.
  • body=<key> — a :state/:store key, JSON-serialized as the request body (for non-GET).
  • refresh=<url> — a token-refresh endpoint; on a 401, renew the headers= token and retry once (see below).

URL safety. A :fetch/refresh URL must be a relative path, an http(s):// URL, or a leading { state } interpolation. A protocol-relative //host or a non-http(s) scheme (file:, data:, javascript:, …) is a compile error.

Authenticated requests and token refresh

headers=<key> sends a state object as request headers; pair it with :store to keep the token across reloads.

Seed the store empty. A :state/:store value is a build-time seed: it is inlined into the page's public HTML, so a literal token written into a .wd file ships to every visitor. The token has to arrive at runtime, from your own api/ endpoint:

:store session = {}

:form action="/api/login" into login
:input email type=email required
:input password type=password required
:submit "Sign in"
:endform

:effect login -> session merge login

:fetch feed from "/api/feed" headers=session

Add refresh="<url>" and Darkmown manages the token lifecycle: when a request comes back 401, it POSTs the current headers object to the refresh URL, writes the returned headers (the new token) back into the session state — persisting it, since session is a :store — and retries the original request once. Concurrent 401s sharing a refresh URL are de-duplicated into a single in-flight refresh.

:store session = {}

:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"

refresh= requires headers= (it needs a state key to renew). The refresh endpoint should accept the current headers as a JSON body and reply with the new headers object.

Dynamic URLs and refetching

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

Looping into fetched data

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 .json files are published at /__wd/data/, so :fetch works on any static host. The darkmown dev server also ships a /__wd/echo endpoint for demos.

Global state — :store

: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 localStorage under wd: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).

  • Multi-line seeds. An array or object literal may span several lines for readability — open the [/{ on the :store/:state line and let it run until it closes:

    :store rows = [
      {"id": 1, "label": "One"},
      {"id": 2, "label": "Two"}
    ]
    

    The literal must balance with no blank line inside it (a blank line ends the value); an unterminated literal is a compile error. Quote genuinely literal bracket text — :state tag = "[draft]".

    A persist/ephemeral token goes after the closing bracket on the last line, not on the declaration line:

    :state products = [
      {"name": "Mug"}
    ] persist
    

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.

Choosing persistence

persist means "survives a reload" and ephemeral means "does not". Both are accepted on :state, :store, and :theme; the keyword only picks the default:

Declaration Persisted?
:state count = 0 no (the default)
:state count = 0 persist yes, in localStorage for this page
:store cart = [] yes (the default), and synced across tabs
:store sidebarOpen = false ephemeral no, in-memory for the session

Writing the token that matches the default is redundant but never wrong, so :store cart = [] persist compiles and means exactly what it says. If a value genuinely ends in one of these words, quote it: :state note = "0 persist" seeds the string.

There is a third word, from-url, which says the value also lives in the query string. It composes with persist and belongs to :state only. Writing persist and ephemeral on one line (or the same word twice) is [WD261] rather than a token silently folded into the value.

:computed takes neither: computed values are derived rather than stored, so persist the state they derive from instead (that mistake is a compile error, WD211).

Pitfall: A store name must be unique. Declaring the same name as both a :store and a :state on one page is a compile error.

URL as state: from-url

A filter nobody can link to is half a feature. Add from-url to a :state and the value lives in the query string as well as in memory: a reload keeps it, a shared link arrives with it applied, and the back button walks through it.

:state q = "" from-url
:state tier = "all" from-url

:bind q placeholder="Search products"

:radio tier
- all
- budget
- premium

:state products = [{"name": "Aurora Lamp", "tier": "budget"}]

@loop products into p where p.name contains q
- **{ p.name }** ({ p.tier })
@empty
Nothing matches that search.
@endloop

Type in the box and the address bar becomes ?q=aurora. Reload, and the search comes back. Copy the URL into a new tab and it opens on the same view.

The rules:

  • The parameter is named after the state key. A section-scoped key like cart:items becomes the parameter cart.items, so the name stays readable and stays unique.
  • A value equal to its declared seed drops its parameter, so the default page keeps a clean URL.
  • Writes go through history.replaceState, so filtering never fills the back button with one entry per keystroke. popstate re-reads on back and forward, and a parameter that is gone restores the seed.
  • It composes with persist, and the boot precedence is URL, then stored value, then seed. A link somebody sent you beats what this browser remembers. When back navigation lands on a clean URL, the stored value follows the seed too.
  • Strings stay strings. For any other seed type the parameter is JSON-parsed, falling back to the raw string.
  • from-url is :state only. A :store is shared by every page and every tab while a query parameter belongs to one page's address, so from-url on a :store or a :theme is [WD260] rather than a guess. On a :computed it is [WD211]: derive the value from a state key that does come from the URL.

Live demo: darkmown.com/url-state/.

Forms and persistence

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
:select topic
- General
- Billing
:checkbox channels
- Email
- SMS
:radio plan
- Basic
- Pro
:textarea note placeholder="Anything else?"
:submit "Save"
:endform

:state cart = [] persist
  • :form into name captures submits straight into state (no backend). :form action="/url" emits a plain native form instead — zero JS, full progressive enhancement. Form actions use the same URL scheme guard as :fetch: relative paths, explicit http(s)://, or leading { state } interpolation; protocol-relative and non-http(s) schemes are compile errors.
  • :form action="/url" into reply does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state reply (reply_error on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.
  • Field directives: :input, :textarea name [rows=N], :select name, :checkbox name, and :radio name (the last three take - Label option lines) all capture into :form into state the same way. A :checkbox group captures every checked value as an array; a :radio group captures a single value. Each derives a non-visual aria-label from its placeholder, else a humanized field name, unless you supply aria-label/aria-describedby.
  • :state x = [] persist keeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for :store instead.)
  • :computed total = items.length * 4 derives state from state with a compile-time-checked expression — names, numbers, arithmetic, comparisons, and list aggregates (sum/avg/min/max/count). See Computed values.
  • :if item.path works inside reactive loops for per-row branches, and nests — an inner :if resolves after the outer branch and stays reactive.

Bound controls outside a form

:select, :radio, and :checkbox mean two different things, and where they sit decides which. Inside a :form they are form fields, submitted by name (unchanged). Outside one they bind to a declared :state or :store of that name, exactly like :bind and :slider: move the control and the state changes, change the state and the control moves.

:state density = "Comfortable"
:state previews = true

:select density
- Compact
- Comfortable
- Spacious

:checkbox previews
- Show image previews

:if density == "Compact"
Rows sit tight together.
:endif
  • The state has to exist first. A bound field naming state that is not declared is [WD450], which spells out both readings: declare the state, or move the field inside a :form.
  • A bound :checkbox is a single boolean, so it takes exactly one - Label line (the label shown beside it). Several options is [WD451]; for a set of choices use a :radio group. The multi-value checkbox group is the in-form behavior and is unchanged.
  • A bound :radio group keeps its shared name, which is what makes the browser treat it as mutually exclusive, and carries the chosen option's text.

File upload

A :form that contains a file field posts multipart, so the file itself travels:

:form into reply action="/api/upload/"
:input photo type=file required
:input caption placeholder="A caption (optional)"
:submit "Upload"
:endform

:if reply
Uploaded **{ reply.name }**, { reply.size } bytes.
:endif

:if reply_error
**{ reply_error }**
:endif

The compiler writes enctype="multipart/form-data" for the browser's native submit, and the runtime sends real FormData with no content type of its own so the browser writes the boundary. A raw <input type="file"> on an html: true page counts as a file field too.

Bound controls inside the form carry no name, so FormData would never see them; on the multipart path they are appended by their state key instead of being silently dropped. A file field on a method="get" form is [WD452]: a GET request has no body, so only the file's name would ever travel, which is the kind of failure that looks like it worked.

Backends & deploy

Darkmown builds 100% static, CDN-cacheable HTML — never per-request server rendering. Reactive pages hydrate from data-attributes client-side; dynamic data arrives via :fetch. When you need a backend, you don't learn a new syntax — you write a plain serverless function, and it just comes with wherever you deploy.

A backend endpoint is a plain-JS Web-standard handler in a top-level api/ directory:

// api/subscribe.js  →  /api/subscribe
export const config = { runtime: "edge" }; // Vercel runs api/ as Edge Functions

export default async function (request, context) {
  const { email } = await request.json();      // context.params for /api/users/[id]
  return Response.json({ ok: true, email });
}
  • One shape, every host. export default (request) => Response is exactly what Vercel Edge, Cloudflare Pages, and Netlify Edge run. api/users/[id].js → /api/users/:id.
  • Local parity. darkmown dev runs a local runner, so :fetch /api/subscribe and :form action="/api/subscribe" behave the same as production before you ever deploy.
  • Deploy in one command. darkmown deploy vercel (functions run natively) or darkmown deploy cloudflare (the build emits a dist/_worker.js that routes /api/* and serves the rest from env.ASSETS). It prints your URL, or the login to run if the platform CLI isn't signed in.
  • Custom server / remote backend. Point :fetch/:form at an absolute https://… URL and widen the CSP connect-src (and form-action for native form POSTs). Darkmown owns no server — it adapts to yours.

Templates get you to a running, deployable app fast: darkmown init shop --template store ships a cart and an api/checkout.js; --template dashboard ships a :fetch view and an api/metrics.js; --template blog ships a typed posts collection (_schema.wd + one @loop over the folder) — adding a post is adding a .md file.

Interactions — :slider, sortable, :carousel

Rich interactions are pay-for-what-you-use: sortable/:carousel compile to a tiny /__wd/behaviors/<name>.js module injected only on pages that use them, budgeted separately from the ≤8 KB core runtime. :slider is compile-time only — zero extra JS.

:slider volume = 50 min=0 max=100 step=5
Volume: { volume }

:store tasks = ["Draft", "Review", "Ship"]
@loop tasks into t sortable
- { t }
@endloop

:carousel autoplay=4000
::: slide
First slide
:::
::: slide
Second slide
:::
:endcarousel
  • :slider name = v min max step renders a range input two-way bound through :bind; range values coerce to Number so :computed/math see a number. Ships no behavior module.
  • sortable (a bare @loop clause, written without a leading colon) drag-reorders the underlying :state/:store list via Pointer Events (mouse + touch), with full keyboard support (Arrow Up/Down on a focused row, screen-reader instructions + a live "Moved to position N of M" announcement), rewriting the list through the public window.wd API so the keyed loop repaints. Valid only on a plain reactive loop (no where/sort/reverse/offset/limit).
  • :carousel [autoplay=N] treats each direct child block as one slide (wrap each in its own block, e.g. ::: slide, and size it in your skin), using native CSS scroll-snap (touch swipe is free) plus prev/next buttons, dot navigation, and mouse drag. autoplay is suppressed under prefers-reduced-motion.

Inline attributes

A trailing {.class .class #id} attaches classes / an id to the inline element directly before it — most often to style a link as a button without a wrapper:

[Get started](/start/){.btn .lg}
![logo](/logo.svg){.brand}

The block must follow the element with no space, and works on links, images, and emphasis. It does not attach to inline code: `x`{.hl} leaves the braces in the output as literal text. It never collides with { name } interpolation, because an interpolation always starts with a name, never a . or #.

Headings get anchors for free. Every markdown heading carries a stable, GitHub-style slug id at build time (lowercased, punctuation stripped, whitespace → hyphens; duplicates dedupe with -1/-2 suffixes across the whole document), so any section of any page is deep-linkable with a plain #the-slug fragment — zero JS. The docs page's "On this page" table of contents is just markdown links to those anchors.

Media — :video, :audio, :embed

Three one-line directives replace hand-written <video> / <iframe> markup. They are compile-time only — they emit no data-wd-*, so a media-only page still ships zero framework JavaScript.

:video /clip.mp4 poster=/clip.jpg controls
:audio /track.mp3 controls
:embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
  • :video / :audio compile to a hardened HTML5 player. preload="metadata" and controls are added by default; flags (controls, autoplay, loop, muted, playsinline) and attributes (poster, width, height, preload) are validated against a per-element whitelist, and autoplay silently implies muted (browsers block sound-on autoplay). The src/poster URLs run through the same scheme guard as :fetch — relative or http(s) only.
  • :embed rewrites a YouTube or Vimeo URL to its no-cookie / player form, wraps it in a responsive 16/9 box, and marks the iframe loading="lazy" with a locked-down referrerpolicy. Any other http(s) URL becomes a generic lazy iframe. Add title="…" for the accessible name.

Darkmown's shipped CSP pre-authorizes exactly the two embed origins (youtube-nocookie.com, player.vimeo.com) and media-src 'self' https:, so embeds and remote media work out of the box on the bundled server, Cloudflare _headers, and Vercel.

Syntax highlighting

Fenced code blocks with a language are highlighted at build time — HTML and CSS only, no client JavaScript. Tag the fence with a language and it just works:

```js
const greeting = "Darkmown"; // highlighted at build time
```

The highlighter is highlight.js and is not configurable (one closed default, like the rest of the framework). Its token classes map onto your skin's $code-* tokens, so highlighted code dark-modes for free through the same tokens dark / :theme system below — no extra wiring. Tune the palette (or rely on the built-in default set) in your .skin:

tokens
  code-bg #1b2420
  code-fg #e9efe7
  code-keyword #d9a8e0
  code-string #97d892
  code-comment #859289
  code-function #88c4ee
  code-number #ecae78
  code-punctuation #c1ccc6
tokens dark
  code-bg #100d0a
  code-keyword #e2b9e8
  • Pay-for-what-you-use. The stylesheet (/__wd/highlight.css) is emitted and linked only on pages that actually contain a highlighted block — a page with no code ships nothing extra.
  • Zero runtime. Highlighting is build-time output, so a page of prose plus code stays runtime: false (no /__wd/runtime.js). It never pulls in the reactive runtime.
  • Graceful degradation. A fence with an unknown or absent language renders as plain escaped <code> (no highlighting, no error). Inline `code` is never highlighted, and there are deliberately no line numbers (they break copy-paste).

See it recolor live on the Syntax highlighting demo — flip the theme toggle and every block recolors at once.

Dark mode — tokens dark

A colocated .skin file already declares its palette in a tokens block (name value pairs referenced elsewhere as $name). Add a second tokens dark block to override any of those tokens under the visitor's OS dark preference — it compiles to a @media (prefers-color-scheme: dark) :root { … } rule, so the page follows the system theme with zero JavaScript.

tokens
  paper #ffffff
  ink #171717

tokens dark
  paper #0b0b0f
  ink #f4f4f5

page
  bg $paper
  color $ink

A single tokens dark block powers both theming paths: it compiles to :root[data-theme="dark"] { … } (the manual toggle below) and @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } } (the OS preference, unless a visitor has explicitly forced light). The base tokens block stays the light default, so a skin with no tokens dark block is unchanged.

Manual toggle — :theme

For an explicit light/dark switch alongside (or instead of) the OS preference, declare :theme once and drive it with ordinary buttons. It registers a durable theme store and reflects its value onto <html data-theme="…">:

:theme
:button "Auto"  -> theme = "auto"
:button "Light" -> theme = "light"
:button "Dark"  -> theme = "dark"

Because tokens dark already emits the [data-theme="dark"] rule, no extra skin block is needed — the same palette drives the toggle. "light" forces the light palette even under OS dark; "auto" clears the attribute and follows the OS again; theme persists across reloads and tabs like any :store. (:theme name = "light" renames the store and seeds a different default. The older tokens [data-theme=dark] block — a manual-only override — still works for bespoke setups.)

Scoped styles — scoped

By default a colocated .skin is global — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called .card, global CSS makes them fight. Opt a skin into scoping so its selectors only ever match the component it ships with: make the first line of the .skin file the word scoped.

scoped

.card
  padding 1.5rem
  bg $panel
  radius $radius

Scoping is pure compile time — a short, path-derived id (e.g. wd-7c21) is stamped onto the component's HTML (data-wd-scope="wd-7c21") and appended to each of the skin's selectors:

.card[data-wd-scope="wd-7c21"] { padding: 1.5rem; }
.card[data-wd-scope="wd-7c21"] { background: var(--panel); }

A second component's .card gets a different id, so the two never collide. There is no runtime cost and no class renaming in your markup — you still write class="card"; the framework adds the attribute during the build. A static page with a scoped skin stays zero-JS.

What scopes, what stays global:

In a scoped skin Result
A selector rule (.card, .card:hover, .card .title, h2, h3) Scoped — the attribute lands on the subject (rightmost) selector, before any :hover/::before so it stays valid CSS
A descendant selector (.card .title) Only .title (the subject) is scoped; .card matches inside the subtree
tokens / tokens dark / tokens [data-theme=…] Global — design tokens always emit on :root, so $accent and dark mode keep working site-wide
:global(.toast) (whole selector) Opts back out — emits a plain, unscoped .toast { … }
@media / @supports wrappers Untouched; only the inner rule's selector is scoped
page / * / html / body / ::selection Compile error — page-level styles belong in a global skin, not a scoped one

Where the scope applies depends on where the skin lives:

  • A skin colocated with a page (about.skin next to about.wd) scopes that page's body.
  • A skin colocated with an include (card.skin next to card.wd) scopes just that include's subtree wherever it's @included — so the same scoped component can appear many times on a page without leaking into its neighbours.

Caveats (honest limits this release):

  • Whole-selector :global() only. A descendant :global (.card :global(.x)) is not supported yet — use a whole-selector :global(.x).
  • Unused selectors warn, they aren't removed. If a scoped selector's class/element/id never appears in the stamped subtree, the build prints hint: scoped selector ".badge" in card.skin matches no element — but the rule is kept (a colocated .js may add the class at runtime). It's a typo nudge, not a dead-code remover.
  • Scoping is opt-in. Every existing .skin (no scoped marker) is byte-for-byte unchanged.

The escape hatch

Reactive pages expose window.wd so a colocated .js file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:

Method Purpose
wd.get(key) read a state value
wd.set(key, value) write it and re-render
wd.subscribe(key, cb) run cb(value) now and on every settled change; returns an unsubscribe
wd.state the live state object
wd.render() force a render

Section-scoped keys are addressed as sectionId:name. The bridge for "behaviors" is subscribe — it primes the callback immediately, then fires whenever the value settles, so an imperative widget stays in lockstep with declarative state:

// index.js, colocated beside the page. Loads after the runtime, so wd is ready.
const track = document.querySelector("[data-track]");
wd.subscribe("slide", (i) => {            // framework state → imperative view
  track.style.transform = `translateX(${-i * 100}%)`;
});
document.querySelector("[data-next]")
  .addEventListener("click", () => wd.set("slide", wd.get("slide") + 1));

That is the whole contract: the framework reconciles state, text, classes, and loops; your behavior owns the gestures; they meet at one shared key. (See the Swiper demo — a draggable, keyboard-navigable carousel built exactly this way.)

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.

Programmatic compile — compileFromMemory

darkmown build reads from disk, but the compiler itself is filesystem-free. compileFromMemory(files, entryPath, options) compiles a page from an in-memory map of project-relative path → source — no node:fs, so the whole compile path bundles for the browser (or any non-Node host):

import { compileFromMemory } from "@zvndev/darkmown";

const { html, assets } = compileFromMemory(
  {
    "site/pages/index.wd": "---\ntitle: Hi\nhtml: true\n---\n<main>\n\n:state n = 0\n\nN is { n }.\n\n:button \"+\" -> n++\n\n</main>\n",
    "site/_/nav.wd": "..."   // @include targets resolve from the same map
  },
  "site/pages/index.wd"
);

Includes, colocated .skin/.js detection, and @loop JSON-data reads all resolve against the map (anything not in it is simply "absent"), and it throws the same file:line compile errors as the CLI — the error DX is identical. This is exactly the entry point the browser playground is built on: markdown-it and the compiler bundled to one asset, compiling .wd/.md on every keystroke and rendering the result into an iframe. On disk, compilePage(file, paths) is the same compile with a filesystem reader injected for you.

Structured compile errors — err.wd

Every compile error is still a plain Error with the same file:line message, but it now also carries a machine-readable mirror on err.wd — so an AI edit loop (or an editor) gets the facts without re-parsing the prose:

import { compileFromMemory } from "@zvndev/darkmown";

try {
  compileFromMemory({ "site/pages/index.wd": ":state x" }, "site/pages/index.wd");
} catch (err) {
  err.message; // "[WD201] Malformed :state in …/index.wd:1: :state x. Use: :state name = value [persist|ephemeral] — e.g. :state count = 0"
  err.wd;      // { code: "WD201", file: "…/index.wd", line: 1, hint: ":state name = value [persist|ephemeral] — e.g. …", example: ":state count = 0" }
}

example is always a concrete, compilable line (never a [placeholder]), and every corrective Use: hint that contains bracket-placeholders ends with a matching — e.g. <valid line>. This is a deliberate affordance for small local models, which otherwise copy [optional]-style placeholders into source verbatim.

Compile error codes

Every author-facing compile error opens with a stable WDxxx code, so it is searchable, linkable, and matchable by tooling without parsing prose:

[WD201] Malformed :state in site/pages/index.wd:1: :state x.
        Use: :state name = value [persist|ephemeral] — e.g. :state count = 0

The full list lives in docs/errors.md: code, cause, fix, and a compilable example each. (It is generated into the repository, not the published tarball; darkmown catalog --llms-full prints the same content offline.) Codes are grouped by subsystem:

Range Subsystem
WD0xx Source, frontmatter & block structure
WD1xx Loops & collections
WD2xx State & expressions
WD3xx Button, effect & timer actions
WD4xx Forms & form fields
WD5xx Data fetching & URL safety
WD6xx Includes & page structure
WD7xx Media & embeds
WD8xx Skins & styling
WD9xx Project, routing & CLI

A shipped code is a public contract: it is never renumbered and a retired number is never reused, so an error found in an old log or issue always resolves to a meaningful entry. docs/errors.md is generated from the compiler's own registry, and drift guards fail the build if an author-facing error is added without a code, if two errors share one, or if a code goes undocumented.

Genuine internal invariants — the expression-AST parser reading the compiler's own validated output, the reader contract — deliberately stay plain Errors and carry no code, because they signal a framework bug rather than something you can fix in a page.

AI authoring

Darkmown ships its own description of the .wd language — the same tables the compiler validates against — so a tool can teach a model to write .wd and constrain its output to what compiles.

  • Directive catalog — darkmown catalog prints structured JSON: every directive, @loop clause, loop variable, button action, format pipe, and predicate operator, each with a syntax template, a one-line description, one concrete example, and whether it needs the reactive runtime. Importable too: import { directiveCatalog } from "@zvndev/darkmown/catalog".
  • Cheatsheet (llms.txt): darkmown catalog --llms prints a compact markdown cheatsheet generated from the same data. It is the artifact you stuff into a model's system prompt. Every build also writes it to dist/llms.txt, followed by a one-line index of every page on your site (title, absolute URL, description) and a pointer to llms-full.txt.
  • Corpus (llms-full.txt): darkmown catalog --llms-full prints the complete reference the index points at: every directive with its full syntax template and example, every clause, action, pipe and operator, every frontmatter key, and every compile-error code with its cause and fix. An AI edit loop that hits [WD201] can resolve it from the same fetch it learned the syntax from. Every build writes it to dist/llms-full.txt, with the full source text of every page appended.
  • GBNF grammar — grammar/wd-directives.gbnf is a generated GBNF grammar for .wd directive lines. Point llama.cpp (or any grammar-constrained decoder) at it to make invalid directive lines — JS idioms like cart.filter(...), HTML muscle-memory like ::: card class="…" — literally unrepresentable during generation. Regenerate with node scripts/gen-grammar.mjs.
darkmown catalog --llms > system-prompt.md   # paste into a model's context
darkmown catalog --llms-full                 # the complete reference + error codes
darkmown build                               # also emits dist/llms.txt + dist/llms-full.txt

Because the catalog, cheatsheet, grammar, and error examples are all generated from the compiler's own tables, they cannot drift from what actually compiles (enforced by tests).

Agent tools

@zvndev/darkmown/tools is the same language description turned into a tool surface: six functions a model calls instead of rewriting a whole file. Everything runs in memory against a plain { path: contents } object, so a full compile is milliseconds and "check after every edit" is affordable rather than aspirational.

Tool Answers
outline(files, entry) What is declared in this page, with line numbers and block spans.
refs(files, entry, name) Every place a symbol is declared, written, or read.
deps(files, entry) What this page pulls in, and which pages pull it in.
grammar(categories) Only the cheatsheet rows this edit needs.
apply(files, entry, edits) A targeted edit, addressed by line, symbol, or anchor.
validate(files, entry) Compiles it for real, or says exactly what broke.

Every tool answers the same shape, { ok, text, data }, and a refusal always carries a sentence the model can act on rather than throwing. Session holds the files so apply and outline are guaranteed to be looking at one snapshot, and records a history of calls:

import { Session, parseToolCall } from "@zvndev/darkmown/tools";

const session = new Session({ "site/pages/index.wd": source }, "site/pages/index.wd");

session.call("outline", {});
//   :7       state     count = 0
//   :12-14   loop      @loop /products.json into p

session.call("apply", {
  edits: [{ op: "replace", symbol: "state:count", text: ":state count = 5" }]
});
session.call("validate", {});
//   compiles. reactive (ships the runtime), 0 skins.

toolPrompt() renders the tool list for a system prompt, and parseToolCall(reply) reads a call back out of a model's answer (brace-counting and string-aware, because the one thing a Darkmown edit reliably contains is { count }).

Editor support

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.

Install from a .vsix today:

npm run pack:extension   # builds editors/vscode/darkmown-<version>.vsix
code --install-extension editors/vscode/darkmown-*.vsix

Or inside VS Code: Extensions panel → … menu → Install from VSIX… and pick the built file. A Visual Studio Marketplace listing is pending (see editors/vscode/PUBLISHING.md); until it lands, the .vsix route above is the supported install.

Accessibility

Every compiled page ships with landmark-and-announcement basics baked in at build time — zero runtime JS, so static pages stay static:

  • Skip link. The first focusable element on every page is a visually-hidden-until-focused "Skip to content" link. The shell guarantees it has a target: your own <main> is reused (an existing id wins as the skip target; an id-less <main> gets id="main" stamped on), and a page without one is wrapped in <main id="main">. Restyle it via .wd-skip-link in a skin.
  • Document language. lang: frontmatter sets <html lang> per page (default en).
  • Live :fetch regions. A bare :if name_loading region over a :fetch key compiles to role="status" aria-live="polite" and :if name_error to role="alert", so screen readers announce loading and error flips for free. Write your own role/aria-live inside the region and Darkmown adds nothing.
  • Accessible names on form controls. Generated :input/:bind/:textarea/:select controls without an author-supplied aria-label/aria-describedby get an aria-label derived from the placeholder or field name; :slider and choice groups do the same.
  • role, aria-*, and title on containers and buttons. ::: card role="region" aria-label="Notes" and :button "Menu" aria-expanded="false" -> open toggle compile without raw HTML. Values are static, double-quoted, and escaped on emit. See Accessibility attributes for the whole rule.

Security

Trust boundary

Darkmown is a trusted-author site generator: you compile content you wrote, the same way you trust your own source code. Three assumptions hold the model together — know them before you point Darkmown at content from anyone else.

  • Compile only trusted, author-written content. Do not compile .md/.wd files you did not write — user-generated content, third-party docs, form input — without sanitizing it first.
  • Raw HTML is escaped by default; html: true opts a page back in. Darkmown runs markdown-it with html: false, so a raw <script> or event-handler attribute in content renders as inert escaped text instead of executing — multi-author content (blog collections, contributed docs) is stored-XSS-safe out of the box. A page whose author writes their own HTML sets html: true in its frontmatter to pass raw HTML through verbatim. There is still no built-in sanitizer: on an html: true page, untrusted content executes in the visitor's browser.
  • :fetch and :form action= have no host allowlist. A fetch/form URL is taken 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. (Since 2.1, reactive pages no longer need 'unsafe-eval': the runtime interprets a validated expression AST instead of building a new Function, so reactive and static pages share the same strict, eval-free CSP.)

The single biggest footgun: do not put html: true on a page whose content is even partly out of your hands, and do not compile untrusted or user-submitted Markdown without sanitizing it first — there is no built-in sanitizer (see SECURITY.md).

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), serialized to a compact AST that the runtime interprets with a closed evaluator (no eval, no new Function). That is why reactive pages ship under a strict CSP with no 'unsafe-eval'. See SECURITY.md for the full security model.

Shipped security headers

Builds emit security response headers so a deployed site gets sane defaults without hand-writing a config. The build writes a dist/_headers file (Cloudflare Pages format), and the Vercel/local serve paths apply the equivalent. On every page:

  • Content-Security-Policy — no 'unsafe-inline' and no 'unsafe-eval' on script-src for any page: the only inline script the framework emits that CSP gates (the transitions: true speculationrules block) is authorized by a build-time 'sha256-…' hash, and the inline state seed is a non-executable JSON data block CSP does not gate. Since 2.1, static and reactive pages get the same strict, eval-free policy — the runtime interprets validated :computed / @loop … where / .class when expressions from a compact AST instead of building a new Function, so it needs no 'unsafe-eval'. A raw inline <script> you write into an html: true page is blocked by the shipped CSP — put it in a colocated .js file (same-origin, allowed by 'self') or widen script-src deliberately.
  • X-Content-Type-Options: nosniff, Referrer-Policy, and a frame-ancestors directive (clickjacking protection) on every page.

If you use :fetch or :form action= against another host, widen connect-src. The shipped CSP allows same-origin connections; calling a third-party API is otherwise blocked by the policy. Add the host to connect-src in your deploy config (it is not auto-derived from your page sources). The CSP is a defense-in-depth layer — it does not replace the trust-boundary rules above; see SECURITY.md for tightening guidance.

Limits

Darkmown is small on purpose. Here is what it deliberately refuses and what it genuinely cannot do today, collected in one place so you can decide before you build rather than after.

Refusals that will not change:

  • .md never gets directives. The extension is the feature gate, and it holds transitively: an .md page that @includes a .wd still ships runtime: false and inert text.
  • No eval, and a closed expression vocabulary. Predicates, :computed, and directive actions are compile-time whitelists interpreted by a closed evaluator. There are no custom format pipes and no user-supplied functions anywhere in the language. (That is also why reactive pages need no 'unsafe-eval'.)
  • No backend DSL. api/ is plain Web-standard JavaScript. Darkmown owns no server and never will.
  • No built-in sanitizer. The model is trusted-author; see Security.

Structural limits, true today:

  • No per-request rendering. Everything is build time. A :state-seeded @loop does bake its rows into the initial HTML, so that content is indexable, but anything behind :fetch is invisible to crawlers and to your own sitemap.xml. No personalization, no draft preview URLs.
  • Auth is a public shell with api/-gated data. A :if session gate ships both branches into the HTML, and a :state/:store seed is inlined into public HTML, so a token must never be written into a page (see Authenticated requests). The workable architecture is a public page whose data is gated behind your own endpoint.
  • No layout or shell inheritance. Every page includes its own nav and footer. There is no template a page extends and no slot to fill.
  • No arbitrary <head> content. The document head is what frontmatter drives; you cannot inject your own tags.
  • No route generation from data. Collections are folders of files. A JSON array cannot become routes. paginate N is the one route multiplier, and only over an existing collection listing.
  • Only accessibility attributes on containers and buttons. ::: and :button accept role="…", aria-…="…", and title="…", and nothing else: class=, id=, style=, data-*, and event handlers are compile errors by design. Style with .class tokens, act with -> actions, and reach for raw HTML on an html: true page when you genuinely need another attribute.
  • Aria values are static text. There is no { state } interpolation inside role/aria-*/title, so a live accessible name means raw HTML or a colocated .js.
  • Raw HTML attributes do not bind. A Markdown link or image destination does (Destinations that bind), but <a href="{ url }"> on an html: true page is painted once at build time and warned about. There is no element for the compiler to mark up.
  • Reactive rows cannot fill a Markdown table. A reactive row is cloned into a <div>, which is not a legal child of <table>, so [WD191] refuses it. Static loops fill tables fine; for reactive rows, build them from containers (::: trow / ::: td) and style them as a table.
  • No i18n beyond lang:. No hreflang, no message catalog, no ambient locale, no locale routing. A multilingual site means duplicating the tree per locale.
  • Every framework asset URL is absolute (/__wd/...), so a Darkmown site cannot be mounted at a subpath. It is all-or-nothing per origin.
  • Includes are macros, not components. No children, no slots, no default arguments, and a missing argument renders { title } literally rather than erroring.
  • No darkmown test. There is no built-in way to assert your own site's behavior. Use compileFromMemory against your own pages, or a browser test runner.

Migrating out is easy, and that is on purpose. dist/ is portable static HTML plus one runtime file of ~6.6 KB gzipped. There is no server to port, no proprietary component format, no framework runtime baked through the output, and nothing to un-eject. Point any host at the folder, or hand it to whatever you move to next, and the site keeps working. The lock-in is a directory of Markdown files you already own.

Spec status

See docs/spec-alignment.md for the deep alignment audit against the original vision.

About

Darkmown — the markdown framework. Plain .md stays plain; rename to .wd for loops, state, includes, and tiny reactive islands. Zero JS on static pages.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages