@zvndev/darkmown 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +61 -6
  4. package/SECURITY.md +4 -4
  5. package/docs/cli.md +8 -0
  6. package/docs/spec-alignment.md +26 -6
  7. package/grammar/wd-directives.gbnf +58 -0
  8. package/package.json +5 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/_/nav.wd +1 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/blog/zero-js-by-default.md +1 -1
  13. package/site/pages/docs/index.wd +19 -2
  14. package/site/pages/index.wd +5 -3
  15. package/site/pages/playground/index.js +158 -0
  16. package/site/pages/playground/index.skin +128 -0
  17. package/site/pages/playground/index.wd +51 -0
  18. package/site/pages/showcase/index.wd +1 -1
  19. package/src/builder.js +15 -8
  20. package/src/catalog.js +586 -0
  21. package/src/cli.js +8 -0
  22. package/src/compiler/actions.js +45 -12
  23. package/src/compiler/collections.js +16 -11
  24. package/src/compiler/context.js +52 -3
  25. package/src/compiler/expr-ast.js +296 -0
  26. package/src/compiler/fetch.js +12 -4
  27. package/src/compiler/forms.js +78 -24
  28. package/src/compiler/fs-reader.js +25 -0
  29. package/src/compiler/includes.js +20 -15
  30. package/src/compiler/index.js +1 -1
  31. package/src/compiler/loops.js +26 -8
  32. package/src/compiler/media.js +21 -7
  33. package/src/compiler/page.js +68 -15
  34. package/src/compiler/predicates.js +29 -18
  35. package/src/compiler/reader.js +60 -0
  36. package/src/compiler/state.js +81 -32
  37. package/src/compiler/structure.js +48 -17
  38. package/src/compiler.js +57 -6
  39. package/src/grammar.js +131 -0
  40. package/src/headers.js +15 -13
  41. package/src/index.js +2 -0
  42. package/src/runtime.js +77 -45
  43. package/src/statics.js +6 -7
  44. package/types/compiler.d.ts +13 -0
  45. package/types/index.d.ts +1 -0
package/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
7
7
  - `.md` files are plain CommonMark. Directives stay inert text.
8
8
  - `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
9
9
  - To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
10
- - Static pages ship **zero** JavaScript; a page loads the ~7.5 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~7.7 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
11
11
 
12
12
  ### ⚠️ Raw HTML is escaped by default (since 2.0.0)
13
13
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 2.2.0 — 2026-07-12
6
+
7
+ AI authoring becomes a first-class framework surface: a machine-readable directive catalog, a generated prompt cheatsheet, a shipped GBNF grammar for constrained decoding, and compile errors that teach by example — all generated from the compiler's own whitelist tables, so none of it can drift from what actually compiles. Zero runtime impact (7654 B, unchanged).
8
+
9
+ - **`darkmown catalog` — the language, as data.** `directiveCatalog()` (also `import '@zvndev/darkmown/catalog'`) returns every directive, loop clause, action op, format pipe, and predicate operator with syntax, description, a concrete example, and whether it makes a page reactive — 25 directives, 13 action ops, 20 pipes, sourced from the compiler's real tables with drift-guard tests in both directions. `darkmown catalog --llms` renders the same data as a ~92-line cheatsheet, and every build emits it as `dist/llms.txt` — the exact artifact to hand an LLM (or stuff into a small on-device model's system prompt).
10
+ - **A shipped GBNF grammar (`grammar/wd-directives.gbnf`).** Generated from the same tables: directive-line shape, clause order, and the closed op/pipe/action vocabularies as literal alternations, with free-form values left permissive. Point llama.cpp's `grammar` field at it and a local model becomes structurally unable to emit JS/HTML muscle-memory syntax inside `.wd` directives. Kept in sync by test; regenerate with `scripts/gen-grammar.mjs`.
11
+ - **Compile errors now teach by example.** All 24 corrective `Use:` hints with bracket-placeholders gained a trailing concrete line — `… Use: @loop src into item [where …] — e.g. @loop /products.json into p where p.price < 50` — and a test extracts every example from the real thrown error strings and compiles it, so the examples can never rot. Empirically motivated: small local models copy error-message examples verbatim, brackets and all.
12
+ - **Structured error metadata.** Compile errors carry `err.wd = { file, line, hint, example }` alongside the unchanged message string — no more parsing error text in tooling (the playground, editors, agent harnesses).
13
+
14
+ ## 2.1.0 — 2026-07-11
15
+
16
+ Two headlines. Reactive pages now run under the **same strict, eval-free CSP as static pages** — the runtime interprets validated expressions instead of building functions, so `'unsafe-eval'` is gone from every page the framework ships. And darkmown.com gained a **browser playground**: the real compiler, bundled, compiling `.wd` live on every keystroke — built on a new fs-free `compileFromMemory` API.
17
+
18
+ - **No more `'unsafe-eval'` — anywhere.** Through 2.0.x, validated `:computed` / `@loop … where` / `.class when` / `:if` expressions compiled to JS fragments evaluated via `new Function`, which forced `script-src 'unsafe-eval'` onto reactive pages. The compiler now serializes each already-validated expression into a compact AST emitted in the `data-wd-*` attributes, and the runtime walks it with a tiny closed evaluator — no `new Function`, no `eval`. Compile-time validation (the security layer) is unchanged; the evaluator applies the actual JS operators, so semantics are identical to the old path — guarded by a parity battery comparing old-vs-new results across a wide input matrix (`tests/expr-ast.test.js`), plus the fuzz suite proving hostile text survives only as inert literal data. `REACTIVE_CSP` is now byte-identical to `STATIC_CSP` across `dist/_headers`, `vercel.json`, and local `serve`. Cost: the runtime grows 7518 → **7654 B gzipped**, still well under the 8 KB budget. Compiled-page attribute payloads changed shape, but the runtime always ships with its build, so nothing to migrate.
19
+ - **Browser playground at [darkmown.com/playground/](https://darkmown.com/playground/).** Editor + live preview: the actual compiler (markdown-it and a common-languages highlight.js subset bundled to one ~128 KB gzipped asset that loads only on that page) compiles your `.wd`/`.md` in the browser on every keystroke, renders into an iframe, and shows the real `file:line` compile errors — the error DX is part of the demo. Reactive previews run the real `/__wd/runtime.js` under the shipped strict CSP. Linked from the hero ("or try it in your browser") and the docs.
20
+ - **`compileFromMemory(files, entryPath, options)` — compile with no filesystem.** A public API taking a map of project-relative paths → source strings; includes, colocated `.skin`/`.js` detection, and `@loop` JSON data all resolve against the map, with identical `file:line` errors. Under the hood the compiler's ~11 `node:fs` call sites now go through an injectable reader (`src/compiler/fs-reader.js` is the only compiler module importing `node:fs`), so the whole compile path runs in any JS engine — the playground uses it today; an on-device mobile app can use it tomorrow. The on-disk build path is byte-identical (golden-verified against a full demo-site build).
21
+ - **Internal:** `esbuild` added as a devDependency solely to build the playground bundle (`npm run build:playground`); the bundle is committed so `npm run build` needs no bundler step.
22
+
5
23
  ## 2.0.1 — 2026-07-11
6
24
 
7
25
  A tightening pass from a post-2.0 product review: a silent reactive-loop failure becomes a loud compile error, scaffolds stop shipping the raw-HTML footgun, and every scaffolded page gets real landmarks back. All compile-time — the runtime is byte-identical (7518 B gzipped).
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **[darkmown.com](https://darkmown.com)** · Markdown that runs.
9
9
 
10
- 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 ~7.5 KB gzipped, CI-enforced under 8 KB.
10
+ 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 ~7.7 KB gzipped, CI-enforced under 8 KB.
11
11
 
12
12
  ## Showcase
13
13
 
@@ -39,6 +39,10 @@ npx darkmown dev
39
39
 
40
40
  The package is `@zvndev/darkmown`; the command it installs is plain `darkmown`.
41
41
 
42
+ Prefer to see it run before you install? The [browser playground](https://darkmown.com/playground/)
43
+ compiles `.wd`/`.md` live in your browser — the same compiler the CLI ships — with
44
+ no install and no build step.
45
+
42
46
  ## Working from this repo
43
47
 
44
48
  ```sh
@@ -55,6 +59,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
55
59
  - `darkmown build [--target cloudflare] [--drafts]` writes static output to `dist` (plus `sitemap.xml`/`rss.xml`/`robots.txt`); `--drafts` includes `draft: true` pages for staging.
56
60
  - `darkmown deploy <vercel|cloudflare> [--prod]` builds and deploys via the platform CLI.
57
61
  - `darkmown serve` previews the built `dist` locally.
62
+ - `darkmown catalog [--llms]` prints the machine-readable directive catalog (JSON), or with `--llms` a compact cheatsheet — see [AI authoring](#ai-authoring).
58
63
  - `darkmown version` prints the installed package version.
59
64
  - `darkmown help` prints CLI usage.
60
65
 
@@ -65,7 +70,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
65
70
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
66
71
  - `site/_` is the include shelf for `@include /name.wd`.
67
72
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
68
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.5 KB gzipped, CI-enforced under 8 KB).
73
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.7 KB gzipped, CI-enforced under 8 KB).
69
74
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
70
75
 
71
76
  ## Interpolation
@@ -220,7 +225,7 @@ Add `where <predicate>` to filter a loop. Conditions compare a loop-item field a
220
225
  @endloop
221
226
  ```
222
227
 
223
- Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive substring match. The predicate is a compile-time-validated whitelist — only item paths, declared `:state`, numbers, and `"strings"` are allowed (no arbitrary expressions). Raw user content is never evaluated; the validated predicate compiles to a whitelisted grammar that runs via `new Function`.
228
+ 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`.
224
229
 
225
230
  **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:
226
231
 
@@ -962,6 +967,56 @@ That is the whole contract: the framework reconciles state, text, classes, and l
962
967
 
963
968
  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.
964
969
 
970
+ ## Programmatic compile — `compileFromMemory`
971
+
972
+ `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):
973
+
974
+ ```js
975
+ import { compileFromMemory } from "@zvndev/darkmown";
976
+
977
+ const { html, assets } = compileFromMemory(
978
+ {
979
+ "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",
980
+ "site/_/nav.wd": "..." // @include targets resolve from the same map
981
+ },
982
+ "site/pages/index.wd"
983
+ );
984
+ ```
985
+
986
+ 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](https://darkmown.com/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.
987
+
988
+ ### Structured compile errors — `err.wd`
989
+
990
+ 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:
991
+
992
+ ```js
993
+ import { compileFromMemory } from "@zvndev/darkmown";
994
+
995
+ try {
996
+ compileFromMemory({ "site/pages/index.wd": ":state x" }, "site/pages/index.wd");
997
+ } catch (err) {
998
+ err.message; // "Malformed :state in …/index.wd:1: :state x. Use: :state name = value [persist] — e.g. :state count = 0"
999
+ err.wd; // { file: "…/index.wd", line: 1, hint: ":state name = value [persist] — e.g. …", example: ":state count = 0" }
1000
+ }
1001
+ ```
1002
+
1003
+ `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.
1004
+
1005
+ ## AI authoring
1006
+
1007
+ 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.
1008
+
1009
+ - **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"`.
1010
+ - **Cheatsheet (`llms.txt`)** — `darkmown catalog --llms` prints a compact (~90-line) markdown cheatsheet generated from the same data — the artifact you stuff into a model's system prompt. Every build also writes it to `dist/llms.txt`.
1011
+ - **GBNF grammar** — [`grammar/wd-directives.gbnf`](grammar/wd-directives.gbnf) is a generated [GBNF](https://github.com/ggerganov/llama.cpp/blob/master/grammars/README.md) 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`.
1012
+
1013
+ ```sh
1014
+ darkmown catalog --llms > system-prompt.md # paste into a model's context
1015
+ darkmown build # also emits dist/llms.txt
1016
+ ```
1017
+
1018
+ Because the catalog, cheatsheet, grammar, and error `example`s are all generated from the compiler's own tables, they cannot drift from what actually compiles (enforced by tests).
1019
+
965
1020
  ## Editor support
966
1021
 
967
1022
  A VS Code extension in [`editors/vscode`](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.
@@ -992,17 +1047,17 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
992
1047
 
993
1048
  - **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.
994
1049
  - **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.
995
- - **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** 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. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
1050
+ - **`: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.)*
996
1051
 
997
1052
  > **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](SECURITY.md)).
998
1053
 
999
- Directive actions and `:computed`/`@loop … where` expressions are never `eval`'d as raw user content — they compile to a whitelisted grammar (item paths, declared `:state`, numbers, strings) that runs via `new Function`. See [SECURITY.md](SECURITY.md) for the full security model.
1054
+ 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](SECURITY.md) for the full security model.
1000
1055
 
1001
1056
  ### Shipped security headers
1002
1057
 
1003
1058
  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:
1004
1059
 
1005
- - **`Content-Security-Policy`** — no `'unsafe-inline'` 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. Static pages get a tight, eval-free policy; **reactive pages** (those that ship `/__wd/runtime.js`) add `script-src 'unsafe-eval'`, which the runtime needs to compile validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. 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.
1060
+ - **`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.
1006
1061
  - **`X-Content-Type-Options: nosniff`**, **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
1007
1062
 
1008
1063
  **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](SECURITY.md) for tightening guidance.
package/SECURITY.md CHANGED
@@ -16,7 +16,7 @@ Darkmown is a **trusted-author** static site generator. The threat model assumes
16
16
 
17
17
  1. **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, or form input — without applying your own sanitization first.
18
18
  2. **Raw HTML is escaped by default; `html: true` re-opens the boundary per page.** `markdown-it` runs with `html: false`, so raw HTML in content renders as inert escaped text — a `<script>` or `onerror=` attribute in a contributed markdown file cannot execute. A page opts back into verbatim raw HTML with `html: true` frontmatter; on such a page there is no built-in sanitizer and untrusted content would execute in the visitor's browser, so reserve the opt-in for pages whose HTML you wrote yourself.
19
- 3. **`:fetch` and `:form action=` have no host allowlist, and reactive pages require `unsafe-eval`.** A `:fetch`, `refresh=`, or `:form action=` URL is read directly from the page source; the compiler rejects non-http(s) schemes (`file:`, `data:`, `javascript:`, protocol-relative `//host`) but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
19
+ 3. **`:fetch` and `:form action=` have no host allowlist.** A `:fetch`, `refresh=`, or `:form action=` URL is read directly from the page source; the compiler rejects non-http(s) schemes (`file:`, `data:`, `javascript:`, protocol-relative `//host`) 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.)*
20
20
 
21
21
  The default is deliberately stricter than most Markdown SSGs: with content collections making multi-author markdown a first-class input, escaped-by-default is the only default that is safe when one of those authors isn't you. Unsafe content on an `html: true` page remains the author's responsibility to sanitize before it reaches the compiler.
22
22
 
@@ -24,7 +24,7 @@ The default is deliberately stricter than most Markdown SSGs: with content colle
24
24
 
25
25
  Things Darkmown deliberately guards at compile time and runtime:
26
26
 
27
- - **No arbitrary JavaScript in content files.** Directive actions (`:button -> …`), `:computed` expressions, and `@loop … where` predicates compile through strict whitelisted grammars — only item paths, declared `:state`, numbers, and strings are allowed. Assignment, function calls, and unknown syntax are compile errors. Raw user content is never `eval`'d; what runs at runtime is the *validated* expression, compiled to the whitelisted grammar and executed via `new Function` (see `src/runtime.js`).
27
+ - **No arbitrary JavaScript in content files.** Directive actions (`:button -> …`), `:computed` expressions, and `@loop … where` predicates compile through strict whitelisted grammars — only item paths, declared `:state`, numbers, and strings are allowed. Assignment, function calls, and unknown syntax are compile errors. Raw user content is never `eval`'d — and since 2.1, **nothing is `eval`'d at all**: the *validated* expression is compiled to a compact serialized AST and **interpreted by a closed evaluator** in `src/runtime.js` (no `new Function`). The interpreter only ever reads a fixed op vocabulary; an unknown op is a hard error. This is what lets reactive pages run under a strict CSP with no `'unsafe-eval'`.
28
28
  - **Prototype-walk protection.** Path lookups (`{ a.b.c }`, computed expressions) reject `constructor`, `prototype`, and `__proto__` segments in both the compiler and the runtime.
29
29
  - **Include sandboxing.** `@include` and `@loop` data sources must resolve inside `site/pages` or `site/_`; traversal outside the site tree is a compile error, and include cycles are detected.
30
30
  - **Private page assets stay private.** Page-colocated assets under `site/pages` are copied only when their relative path has no hidden segment (`.`, `-`, or `_`) and is not a symlink. This keeps `.env`, `_private/*`, `-draft/*`, and symlink-to-outside files out of `dist`.
@@ -60,7 +60,7 @@ On an `html: true` page the pre-2.0.0 rules apply in full:
60
60
 
61
61
  Builds emit security response headers so a deployed site is hardened by default rather than relying on hand-written config. The build writes a `dist/_headers` file (Cloudflare Pages format) and the Vercel and local `serve` paths apply the equivalent. Every page gets:
62
62
 
63
- - **`Content-Security-Policy`** — **no `'unsafe-inline'` on `script-src`, for any page.** The inline state seed is a `<script type="application/json">` data block, which is non-executable and therefore not gated by `script-src` at all; the one inline script CSP does gate — the fixed `<script type="speculationrules">` block a `transitions: true` page emits — is authorized by a build-time `'sha256-…'` hash source (plus the `'inline-speculation-rules'` keyword for browsers that check that instead). **Static pages** (no runtime) get a tight, eval-free policy. **Reactive pages** (those that ship `/__wd/runtime.js`) additionally need `script-src 'unsafe-eval'`, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — `'unsafe-eval'` gates the eval-family APIs, not inline script elements, so it cannot be replaced by a hash. A strict CSP without `unsafe-eval` breaks reactive pages; static pages are unaffected and stay strict.
63
+ - **`Content-Security-Policy`** — **no `'unsafe-inline'` and no `'unsafe-eval'` on `script-src`, for any page.** The inline state seed is a `<script type="application/json">` data block, which is non-executable and therefore not gated by `script-src` at all; the one inline script CSP does gate — the fixed `<script type="speculationrules">` block a `transitions: true` page emits — is authorized by a build-time `'sha256-…'` hash source (plus the `'inline-speculation-rules'` keyword for browsers that check that instead). Since 2.1, **static and reactive pages share the same strict, eval-free policy**: the reactive runtime interprets a validated `:computed` / `@loop … where` / `.class when` expression AST instead of building a `new Function`, so it needs no `'unsafe-eval'`. The only `script-src` a reactive page adds over a static one is `'self'` for the same-origin `/__wd/runtime.js` — already granted.
64
64
  - **`X-Content-Type-Options: nosniff`** — stops MIME-type sniffing.
65
65
  - **`Referrer-Policy`** — limits referrer leakage.
66
66
  - **`frame-ancestors`** — clickjacking protection (controls who may frame the page).
@@ -74,5 +74,5 @@ The shipped CSP is a sensible default, not a finished policy for every site. Tig
74
74
  - **`:fetch` / `:form action=` to another host needs a wider `connect-src`.** The default CSP permits same-origin connections; a call to a third-party API is otherwise blocked. Add each external host to `connect-src` explicitly — it is **not** auto-derived from your page sources.
75
75
  - **`img-src` / `media-src` default to any `https:` host** — remote images and media in markdown are legitimate on most sites, so the default keeps them working. If your site only serves its own assets, tighten both in your deploy config (`vercel.json`, `dist/_headers`, or your server): `img-src 'self' data:` (keep `data:` — the default favicon is a `data:` SVG) and `media-src 'self'`. If you hotlink from known hosts, list them instead: `img-src 'self' data: https://images.example.com`.
76
76
  - **Remote fonts or extra embed hosts** need their hosts added to `font-src` / `frame-src` (the default `frame-src` pre-authorizes exactly the YouTube no-cookie and Vimeo player origins `:embed` rewrites to).
77
- - **Reactive pages** require `unsafe-eval` in `script-src`; do not remove it on a page that ships the runtime. If you can keep a page static, it can run under a stricter policy.
77
+ - **Reactive pages need no `'unsafe-eval'`** (since 2.1) — they run under the same strict, eval-free `script-src` as static pages, because the runtime interprets a validated expression AST rather than calling `new Function`. If you deployed an older build's CSP with `'unsafe-eval'`, you can drop it.
78
78
  - The CSP is **defense-in-depth** — it limits the blast radius of a mistake but does **not** replace the trust-boundary rules above. It is not a substitute for sanitizing untrusted content, and it does not add a host allowlist to `:fetch`/`:form` (SSRF/exfiltration protection remains the author's responsibility).
package/docs/cli.md CHANGED
@@ -82,6 +82,14 @@ The deploy URL is printed when the CLI reports it. If the platform CLI isn't sig
82
82
 
83
83
  Serves the already-built `dist` directory for local preview (static only — use `darkmown dev` to exercise `api/*` locally). Run `darkmown build` first.
84
84
 
85
+ ### `darkmown catalog [--llms]`
86
+
87
+ Prints the machine-readable `.wd` directive catalog as JSON — every directive, `@loop` clause, loop variable, button action, format pipe, and predicate operator, each with a syntax template, description, and concrete example. With `--llms` it prints a compact (~90-line) markdown cheatsheet instead — the artifact to paste into an AI model's system prompt. Every `darkmown build` also writes the cheatsheet to `dist/llms.txt`, and Darkmown ships a generated GBNF grammar (`grammar/wd-directives.gbnf`) for constrained decoding.
88
+
89
+ ```sh
90
+ darkmown catalog --llms > system-prompt.md
91
+ ```
92
+
85
93
  ## Smoke checks
86
94
 
87
95
  From this repository, run:
@@ -2,11 +2,18 @@
2
2
 
3
3
  This audit compares the current Darkmown implementation with the original MARROW/WhateverDown vision and the follow-up decisions made on 2026-06-11.
4
4
 
5
+ ## 2.1.0 additions — eval-free expression interpreter (2026-07-11)
6
+
7
+ - **Reactive pages run under a strict CSP with no `'unsafe-eval'`.** This is the headline. Through 2.0.x, the runtime executed validated `:computed` / `@loop … where` / `.class when` / `:if` expressions by building a `new Function` — which forced `script-src 'unsafe-eval'` on every reactive page, the #1 objection from security-minded adopters. The grammar was already a **closed** whitelist (comparisons, `and`/`or`/`not`, arithmetic in `:computed`, the `sum`/`avg`/`min`/`max`/`count` aggregates, literals, and dotted paths with proto-pollution guards), so it is small enough to interpret directly. The compiler now serializes each already-validated expression into a compact **AST** (nested arrays, tags `L`/`S`/`I`/`C`/`A` + the fixed operators — see `src/compiler/expr-ast.js`) emitted into the `data-wd-*` attributes, and the runtime **walks** it with a tiny closed evaluator (`src/runtime.js`). No `new Function`, no `eval`, anywhere on the page. The evaluator applies the *actual* JavaScript operators, so semantics (loose `==`/`!=`, `NaN`/`null` handling, `&&`/`||` short-circuit, string coercion, division) are byte-identical to the old path — guarded by a parity battery (`tests/expr-ast.test.js`) that compares old-`new Function` vs new-AST results across a wide input matrix, plus the fuzz suite (`tests/fuzz.test.js`) which now proves accepted expressions carry only closed op tags (dangerous text can survive only as inert `["L", …]` data) and walk to a pure value.
8
+ - **CSP consequence:** `REACTIVE_CSP` in `src/headers.js` drops `'unsafe-eval'` and is now **byte-identical to `STATIC_CSP`** — static and reactive pages ship the same strict, eval-free policy across all delivery surfaces (`dist/_headers`, `vercel.json`, the local `serve` path). An unknown op tag in the evaluator is a hard error, never a fallthrough; the proto-pollution guards in `getPath` are unchanged in both compiler and runtime.
9
+ - **Validation is unchanged — it is the security layer.** `src/compiler/predicates.js` and `compileComputedExpr` still perform every whitelist/rejection check exactly as before (the CVE-class string-injection and function-call guards are untouched); the AST is produced by re-parsing their *already-validated* output. A `:computed` RHS that passes the char-level whitelist but is not a real expression (e.g. `|| <`) is now a **file-named compile error** rather than a silently-broken binding.
10
+ - **Back-compat:** this changes the compiled-page contract (the `data-wd-*` payloads are now serialized ASTs, not JS-fragment strings). No migration is needed in practice — the runtime ships with the build, so an old `dist/` and a new runtime never mix. The runtime stays under the 8 KB gzip budget.
11
+
5
12
  ## Fundamentally aligned
6
13
 
7
14
  - **Markdown-first authoring:** `.md` is strict CommonMark rendered by a real parser (markdown-it); `.wd` is the same Markdown plus first-party directives. Renaming the file is the upgrade path — extensions genuinely gate functionality.
8
15
  - **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
9
- - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.5 KB gzipped, CI-enforced below the 8 KB target).
16
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~7.7 KB gzipped, CI-enforced below the 8 KB target).
10
17
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
11
18
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
12
19
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
@@ -50,7 +57,7 @@ This audit compares the current Darkmown implementation with the original MARROW
50
57
  ## Stage 6 additions (2026-06-13)
51
58
 
52
59
  - **Nested `:if` over loop items (v0.5.0):** conditionals nest inside reactive `@loop`, per row. Both fill paths fixed — build-time balanced `matchElement` recursion and runtime `fillItem` recursion.
53
- - **State-driven list filtering — `@loop … where <predicate>`:** the #1 need surfaced by the agent-eval benchmark. Conditions (`field <op> value`, plus `contains`) join with `and` / `or` over a compile-time-validated whitelist — item paths, declared `:state`, numbers, strings; `constructor`/`prototype`/`__proto__` rejected. Raw user content is never evaluated; the validated predicate compiles to the whitelisted grammar and runs via `new Function` at runtime. The source decides reactivity, matching the loop itself: an item-only predicate filters at build time and stays zero-JS; a predicate that reads `:state` compiles to a reactive filtered loop (rows baked in for static sources). This replaces the fragile DOM-toggle escape hatch with the keyed reconciler.
60
+ - **State-driven list filtering — `@loop … where <predicate>`:** the #1 need surfaced by the agent-eval benchmark. Conditions (`field <op> value`, plus `contains`) join with `and` / `or` over a compile-time-validated whitelist — item paths, declared `:state`, numbers, strings; `constructor`/`prototype`/`__proto__` rejected. Raw user content is never evaluated; the validated predicate compiles to the whitelisted grammar and runs via `new Function` at runtime. *(Superseded in 2.1.0: the validated predicate is now serialized to a compact AST that the runtime interprets — no `new Function` — see the 2.1.0 entry.)* The source decides reactivity, matching the loop itself: an item-only predicate filters at build time and stays zero-JS; a predicate that reads `:state` compiles to a reactive filtered loop (rows baked in for static sources). This replaces the fragile DOM-toggle escape hatch with the keyed reconciler.
54
61
  - **`:bind <state>`:** two-way `<input>` bound to a `:state` value — the input primitive that powers live search. Accepts `type=`, `placeholder=`, `autocomplete=`, `required`, `autofocus`.
55
62
  - **Per-row actions in reactive loops:** a `:button` inside `@loop … into item` can act on its own row — `cart += item` appends a copy of the row to another `:state` list (the only way to carry a loop item into another list), and `list remove item` removes the row from the looped `:state` source (both names compile-checked against the enclosing loop). The runtime resolves the clicked row by stamping each reconciled node with its item, so removal is exact even under a `where` filter. This closes the last "store demo" gap noted in the design memory (per-row `:button` couldn't carry row data). (The `:if` string-equality gap noted here was fully closed in v0.14.0 — see Stage 10 — when `:if` gained the comparison grammar.)
56
63
  - **`.skin` robustness:** `@media`/`@supports` at-rules wrap nested rules; the `font` shorthand passes through (size-led or containing `/`) while `font <stack>` still aliases to `font-family`; `/* … */` block comments and decorative divider lines are skipped.
@@ -72,7 +79,7 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
72
79
 
73
80
  ## Stage 10 additions (2026-06-18, v0.14.0)
74
81
 
75
- - **Richer `:if` / `:else if` conditions:** a conditional now accepts the same compile-time-validated predicate grammar as `.class when` (its sibling through the shared `compileWhen`) — instead of a bare truthy path only. Operands are item paths, declared `:state`/`:store`, numbers, `"strings"`, `true`/`false`/`null`; comparisons `==` `!=` `<` `<=` `>` `>=` and `contains`; joined with `and` / `or` and negated with `not`. A bare path still reads as truthy (the fast path is unchanged). (`@loop … where` stays a comparison-only subset — each condition needs an operator; it has no `not` or bare-truthy form. Unifying it is deferred, not done here.) Reactivity is decided exactly as before: a predicate over only static values folds the branch at build time (`runtime: false` preserved); one over `:state` emits a global `data-wd-if` region carrying `data-wd-if-expr`; one over a loop item emits a per-row `data-wd-each-if` with `data-wd-if-expr`. The expression compiles to the **same** whitelisted grammar over `I()`/`S()`/`C()` and runs via `new Function` through the shared `evalPredicate` runtime helper — no new eval surface, and `constructor`/`prototype`/`__proto__` segments stay rejected in compiler and runtime. `:else if` chains inherit this automatically (each desugared condition re-enters the same path). **Honest caveat:** there is no parenthesis grouping — `and`/`or` associate left-to-right with no precedence between them, so model intent explicitly (split into `:else if` branches or a `:computed` boolean when grouping matters).
82
+ - **Richer `:if` / `:else if` conditions:** a conditional now accepts the same compile-time-validated predicate grammar as `.class when` (its sibling through the shared `compileWhen`) — instead of a bare truthy path only. Operands are item paths, declared `:state`/`:store`, numbers, `"strings"`, `true`/`false`/`null`; comparisons `==` `!=` `<` `<=` `>` `>=` and `contains`; joined with `and` / `or` and negated with `not`. A bare path still reads as truthy (the fast path is unchanged). (`@loop … where` stays a comparison-only subset — each condition needs an operator; it has no `not` or bare-truthy form. Unifying it is deferred, not done here.) Reactivity is decided exactly as before: a predicate over only static values folds the branch at build time (`runtime: false` preserved); one over `:state` emits a global `data-wd-if` region carrying `data-wd-if-expr`; one over a loop item emits a per-row `data-wd-each-if` with `data-wd-if-expr`. The expression compiles to the **same** whitelisted grammar over `I()`/`S()`/`C()` and runs via `new Function` through the shared `evalPredicate` runtime helper — no new eval surface, and `constructor`/`prototype`/`__proto__` segments stay rejected in compiler and runtime. *(Superseded in 2.1.0: `evalPredicate` now walks a serialized AST with a closed evaluator instead of `new Function` — see the 2.1.0 entry.)* `:else if` chains inherit this automatically (each desugared condition re-enters the same path). **Honest caveat:** there is no parenthesis grouping — `and`/`or` associate left-to-right with no precedence between them, so model intent explicitly (split into `:else if` branches or a `:computed` boolean when grouping matters).
76
83
 
77
84
  ## Stage 12 additions (2026-06-23, v0.15.0)
78
85
 
@@ -90,7 +97,7 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
90
97
  The post-8/10-review sprint: one reactive-loop capability, a deploy-hardening layer, and a theming primitive — plus a test backfill and clearer compile errors. Tracks landed in parallel; behavior below reflects the intended surface, and any item gated on the runtime-size budget is flagged.
91
98
 
92
99
  - **Nested reactive loops (one level):** a reactive `@loop` may contain an inner reactive `@loop <outerItem>.<field> into x` — the inner source is a dotted path rooted at the outer row, so each reconciled outer row renders its own keyed inner list and both stay live. Interpolation inside the inner body resolves the inner item first, then the outer (`{ line.qty }` and `{ order.ref }` both in scope). Build-time loop nesting over JSON/frontmatter was already supported; 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 may not contain a third reactive loop. It shipped within the runtime-size budget (5910 B gzipped, no minification). A second caveat: per-row `cart += member` (append-row) works from an inner loop, but a per-row **`remove`** needs a top-level `:state`/`:store` list as its source — an inner loop's source is a path off the outer row, so deleting an inner row in place is unsupported and the compiler rejects it with a corrective hint (carry the row into a top-level list, remove it there).
93
- - **Security headers / CSP on deploy:** the build emits security response headers so a deployed site is hardened by default — `dist/_headers` (Cloudflare Pages format), with the Vercel and local `serve` paths applying the equivalent. Every page carries `X-Content-Type-Options: nosniff`, a `Referrer-Policy`, and a `frame-ancestors` directive. **Content-Security-Policy is tiered like the runtime:** static pages get a tight policy; reactive pages (those shipping `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'`, which the runtime needs to run validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. *(Since 2.0.0 `'unsafe-inline'` is dropped from `script-src` entirely — see the 2.0.0 entry; `'unsafe-eval'` remains on reactive pages.)* **Honest caveat:** the shipped CSP allows only same-origin connections — a `:fetch`/`:form action=` to another host must widen `connect-src` by hand (it is not derived from page sources), and the CSP remains defense-in-depth, **not** a host allowlist for `:fetch` (SSRF stays the author's responsibility per the trust boundary). The `html: false` per-page strict-render opt-out is unchanged; a global strict-by-default `html` setting remains a deliberate future option (no config loader exists yet). *(Superseded in 2.0.0: strict rendering is now the default and `html: true` is the per-page opt-in — see the 2.0.0 entry.)*
100
+ - **Security headers / CSP on deploy:** the build emits security response headers so a deployed site is hardened by default — `dist/_headers` (Cloudflare Pages format), with the Vercel and local `serve` paths applying the equivalent. Every page carries `X-Content-Type-Options: nosniff`, a `Referrer-Policy`, and a `frame-ancestors` directive. **Content-Security-Policy is tiered like the runtime:** static pages get a tight policy; reactive pages (those shipping `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'`, which the runtime needs to run validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. *(Since 2.0.0 `'unsafe-inline'` is dropped from `script-src` entirely — see the 2.0.0 entry. Since 2.1.0 `'unsafe-eval'` is dropped too: the runtime interprets an expression AST instead of building a `new Function`, so reactive pages share the static pages' strict, eval-free CSP — see the 2.1.0 entry.)* **Honest caveat:** the shipped CSP allows only same-origin connections — a `:fetch`/`:form action=` to another host must widen `connect-src` by hand (it is not derived from page sources), and the CSP remains defense-in-depth, **not** a host allowlist for `:fetch` (SSRF stays the author's responsibility per the trust boundary). The `html: false` per-page strict-render opt-out is unchanged; a global strict-by-default `html` setting remains a deliberate future option (no config loader exists yet). *(Superseded in 2.0.0: strict rendering is now the default and `html: true` is the per-page opt-in — see the 2.0.0 entry.)*
94
101
  - **Dark-mode theming — `tokens dark`:** a `.skin` file may add a `tokens dark` block alongside its base `tokens`; the dark tokens compile to `@media (prefers-color-scheme: dark) :root { … }`, overriding the matching base tokens under the visitor's OS dark preference. Rules keep referencing the same `$name` tokens, so dark mode is a palette swap with **zero JavaScript** — consistent with the existing zero-JS `.skin` output. A skin with no `tokens dark` block is unchanged.
95
102
  - **Test backfill + compile-error line numbers:** the sprint backfilled runtime/compiler tests for the new surface, and compile errors now carry the offending **line number** alongside the file path and corrective suggestion. **Honest caveat:** this entry documents the intended sprint scope; the exact assertion count and the precise error-message format are owned by the test/compiler tracks and were not verified from this docs track.
96
103
 
@@ -129,7 +136,7 @@ The 1.0 release: a value layer, a time primitive, an explicit theme toggle, reac
129
136
  Four progressive-disclosure capabilities plus an assets and a security change. The reactive runtime grew to ~5.7 KB gzipped; the CI budget moved 5 KB → 6 KB (still un-minified).
130
137
 
131
138
  - **`:else if` chains:** `:if … :else if … :else … :endif`, any number of `:else if`, optional trailing bare `:else` (a `:else if` after the bare `:else` is a compile error). Implemented by **desugaring** the chain tail into a nested `:if` inside the falsy branch, which re-enters the existing compile path — so static, reactive, and per-row (loop) conditionals all behave identically with no runtime change. (v0.13.0 accepted only a bare truthy path per condition; Stage 10 / v0.14.0 lifted that to the full predicate grammar.)
132
- - **Reactive classes — `::: … .class when <predicate>`:** a container class toggled by a predicate using the `@loop … where` / `:if` whitelist (item fields, `:state`/`:store`, numbers, strings, `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains`, `and`/`or`, plus a bare truthy path). A fully-static predicate folds to a plain class at build time (`runtime: false` preserved); a predicate over `:state` emits a global `data-wd-class`; a loop-item predicate emits `data-wd-each-class` evaluated per row. The predicate compiles to the same whitelisted grammar over `I()`/`S()`/`C()` and runs via `new Function`.
139
+ - **Reactive classes — `::: … .class when <predicate>`:** a container class toggled by a predicate using the `@loop … where` / `:if` whitelist (item fields, `:state`/`:store`, numbers, strings, `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains`, `and`/`or`, plus a bare truthy path). A fully-static predicate folds to a plain class at build time (`runtime: false` preserved); a predicate over `:state` emits a global `data-wd-class`; a loop-item predicate emits `data-wd-each-class` evaluated per row. The predicate compiles to the same whitelisted grammar over `I()`/`S()`/`C()` and runs via `new Function`. *(Superseded in 2.1.0: interpreted from a serialized AST, no `new Function` — see the 2.1.0 entry.)*
133
140
  - **Authenticated fetch + token refresh — `:fetch … headers= refresh=`:** `headers=<stateKey>` (existing) spreads a state object into request headers. New `refresh="<url>"`: on HTTP 401 the runtime POSTs the current headers to the refresh URL, writes the returned headers object back into the `headers` state (persisting if it is a `:store`), and retries the original request once; concurrent 401s sharing a refresh URL collapse to a single in-flight refresh. **Honest caveat:** this is bearer-token-style renewal only — no OAuth authorization-code flow, no refresh-token rotation beyond what the endpoint returns; `refresh=` requires `headers=`.
134
141
  - **Effects — `:effect <watched> -> <actions>`:** run the `:button` action vocabulary (`;`-chained) whenever a watched state path changes. Emits a zero-output `data-wd-effect` marker; the runtime snapshots each watched value and, after a render against settled state, runs the actions on change, re-rendering and re-checking up to a 10-pass settle cap (a non-settling effect warns and stops). **Honest caveat:** effects do not fire on initial load (only on change), and row-scoped actions (`append-row`/`remove`) have no clicked row in an effect, so they no-op.
135
142
  - **Page-colocated static assets:** any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and colocated `.skin`/`.js`) is copied to `dist/` at its path (`site/pages/logo.svg` → `dist/logo.svg`). The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; a name clash with an emitted route is skipped with a warning. As of v0.14.1, hidden path segments (`.`, `-`, `_`) and symlinked page assets are skipped. `.avif` was added to the served content-types.
@@ -152,7 +159,7 @@ The 1.5.0 cycle ships the headline **content collections** feature: turning a fo
152
159
  ## 2.0.0 fixes — review follow-ups (2026-07-11)
153
160
 
154
161
  - **BREAKING: raw HTML in markdown is escaped by default; `html: true` opts a page back in.** The markdown-it default flipped from `html: true` to `html: false` — a raw `<script>` or `onerror=` attribute in a `.md`/`.wd` body now renders as inert escaped text unless the page's frontmatter says `html: true`. Rationale: 1.5.0 content collections made multi-author markdown a first-class input, and passthrough-by-default is a stored-XSS default in that world. The opt-in is per file (pages *and* includes each carry their own frontmatter). `{ name }` interpolation is unaffected (values were and are always escaped; the binding plugin injects tokens programmatically, so it never depended on the `html` option), and fenced/inline code was always escaped. This supersedes the "global strict-by-default `html` setting remains a future option" caveat under Stage 15 — the *default* is now strict; what remains future work is only a site-wide config toggle (still no config loader).
155
- - **CSP: `'unsafe-inline'` dropped from `script-src` on both variants.** The inline state seed is a non-executable `application/json` data block (not gated by `script-src`); the one CSP-gated inline script — the fixed `transitions: true` speculationrules block — is now authorized by a build-time `'sha256-…'` hash source plus the `'inline-speculation-rules'` keyword, both derived in `src/headers.js` and drift-guarded by a test that hashes the block out of a real build. `'unsafe-eval'` stays on reactive pages (it gates `new Function`, not inline elements — a hash cannot replace it). **Honest caveat:** an author's raw inline `<script>` on an `html: true` page is now blocked by the shipped CSP — colocated `.js` files (allowed by `'self'`) are the supported path, or widen `script-src` deliberately. `img-src`/`media-src` stay `https:`-wide by default (remote images are legitimate); SECURITY.md documents the tightening path for self-hosted-assets sites.
162
+ - **CSP: `'unsafe-inline'` dropped from `script-src` on both variants.** The inline state seed is a non-executable `application/json` data block (not gated by `script-src`); the one CSP-gated inline script — the fixed `transitions: true` speculationrules block — is now authorized by a build-time `'sha256-…'` hash source plus the `'inline-speculation-rules'` keyword, both derived in `src/headers.js` and drift-guarded by a test that hashes the block out of a real build. `'unsafe-eval'` stays on reactive pages (it gates `new Function`, not inline elements — a hash cannot replace it). *(Superseded in 2.1.0: `'unsafe-eval'` is dropped from reactive pages too — the runtime interprets an expression AST rather than calling `new Function` — see the 2.1.0 entry.)* **Honest caveat:** an author's raw inline `<script>` on an `html: true` page is now blocked by the shipped CSP — colocated `.js` files (allowed by `'self'`) are the supported path, or widen `script-src` deliberately. `img-src`/`media-src` stay `https:`-wide by default (remote images are legitimate); SECURITY.md documents the tightening path for self-hosted-assets sites.
156
163
  - **Route output containment guard.** `outputPathForRoute` asserts the resolved `index.html` path stays inside `distRoot` and throws otherwise — defense in depth for any future caller passing an unvetted route (routes from `routeFromFile` can't traverse today).
157
164
  - **Accessibility & i18n triplet (compile-time only).** (1) `lang:` frontmatter sets `<html lang>` per page (escaped, default `en`). (2) Every page shell emits a visually-hidden-until-focused **skip-to-content link** as the first focusable element and guarantees its target: an author `<main>` is reused (an existing `id` wins as the skip target — a `data-id`-style `-id`-suffixed attribute is never mistaken for it; an id-less `<main>` gets `id="main"` stamped on, mirroring the `enhanceImages` never-override pattern), and a main-less body is wrapped in `<main id="main">`. The link's hide/reveal style is inline in the head (works with zero skins); `.wd-skip-link` is restylable and the site base skin ships a themed focus reveal. Static pages stay `runtime: false`. (3) `:fetch` lifecycle regions self-announce: a bare `:if <key>_loading` over a fetch-declared key compiles its if-region with `role="status" aria-live="polite"`, `:if <key>_error` with `role="alert"` — the runtime's existing show/hide flip becomes an announcement with **zero added runtime bytes** (this item ships no client JS at all). Author-supplied `role`/`aria-live` inside a region suppresses the defaults; `_loading`/`_error` state keys the author declared herself (not via `:fetch`) are never decorated.
158
165
  - **Loop over a missing field is an empty list.** An in-scope `@loop` source that resolves to `undefined`/`null` (e.g. an optional frontmatter field a page omits — `tags: string[]?` in a `_schema.wd`) now loops zero rows and renders the `@empty` branch, matching the runtime's existing behavior for a missing `:state` list. A source that is **present but not a list** (a string, a number, an object) is still a compile error — now with the true `file:line` and a corrective hint. Previously a draft post omitting an optional `tags` field made `build --drafts` fatal.
@@ -192,6 +199,19 @@ The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Trac
192
199
  - **Templates + deploy CLI:** `darkmown init --template <starter|blog|store|dashboard|landing>` scaffolds from in-package templates (each a real, deployable site; `store`/`dashboard` ship an example `api/` function). `darkmown deploy <vercel|cloudflare> [--prod]` writes platform config, runs a target-aware build, and wraps the platform CLI (`npx vercel` / `npx wrangler pages deploy`), printing the deploy URL; an unauthenticated CLI surfaces a login hint.
193
200
  - **Interaction behaviors (pay-for-what-you-use):** `:slider name = v min max step` is compile-time only — a range input bound through `:bind` (range values coerce to Number), ships **no** JS module. `:sortable` (a `@loop` clause) and `:carousel` compile to tiny `/__wd/behaviors/<name>.js` modules injected **only** on pages that use them, built on the public `window.wd` API. They are budgeted **separately** from the 8 KB core runtime (each in `.size-snapshot.json`), so core stays untaxed. `:sortable` requires a plain reactive `:state`/`:store` loop (no where/sort/reverse/offset/limit) and reorders via Pointer Events + keyboard; `:carousel` uses native CSS scroll-snap plus prev/next/dots/autoplay/mouse-drag.
194
201
 
202
+ ## Browser playground + fs-free compile — `compileFromMemory` (2026-07-11)
203
+
204
+ - **`compileFromMemory(files, entryPath, options)`** — a new public API (exported from `src/compiler.js` and the package root) that compiles a page from an in-memory map of project-relative path → source, with **zero `node:fs`**. It is the minimal-clean vfs refactor of the compiler: a `Reader` abstraction (`src/compiler/reader.js`) is threaded through the `Compilation`/`Ctx` and the page-shell helpers, replacing every direct `node:fs` call in `src/compiler/{page,includes,loops,collections}.js`. The default **`fsReader()`** (`src/compiler/fs-reader.js`, the ONLY compiler module importing `node:fs`) is injected by the Node-facing barrel, so `compilePage`/`compileDocument`/`enhanceImages` keep their exact signatures and the on-disk build stays **byte-identical** (golden: the demo `dist/` is unchanged across the refactor). `memoryReader(files, cwd)` backs the fs-free path: includes, colocated `.skin`/`.js` detection, and `@loop` JSON-data reads all resolve against the map; missing files are simply "absent"; image measuring degrades to no-dimensions (no image bytes in memory). It throws the **same `file:line` compile errors** as the CLI (a compile↔memory parity test asserts identical HTML and error text). This is strategic surface — a planned mobile host needs exactly a filesystem-free compile.
205
+ - **Browser playground (`/playground/`)** — a live editor + preview page on the demo site. `esbuild` (a new **devDependency only**) bundles `compileFromMemory` + markdown-it + a common-languages highlight.js subset into one self-contained ESM asset (`scripts/build-playground.mjs` → the committed colocated `site/pages/playground/index.js`, rebuilt with `npm run build:playground`); `node:path`/`node:crypto`/`image-size` are aliased to tiny browser shims (`scripts/playground/shims/`). The bundle (~128 KB gzipped) loads **only** on `/playground/` and does **not** count against the 8 KB runtime budget (`src/runtime.js` is untouched by the playground). The preview is an `iframe srcdoc` of the compiled HTML; reactive previews load `/__wd/runtime.js` by same-origin URL and inherit the page's CSP — which since 2.1 is the same strict, **eval-free** policy on every page (the runtime interprets expression ASTs; see the 2.1 entry above), so the preview runs under the shipped CSP with no special allowance (`frame-src` does not block `about:srcdoc`; no `'unsafe-inline'` anywhere). The playground page itself dogfoods a real reactive island (a `:state`/`:button` example picker bridged to the bundle via `window.wd.subscribe`), so it is `runtime: true`.
206
+
207
+ ## AI authoring — catalog, llms.txt, grammar, structured errors (2026-07-12)
208
+
209
+ - **Machine-readable directive catalog** — `src/catalog.js` (`directiveCatalog()`, exported from the package root and via the `@zvndev/darkmown/catalog` exports subpath) returns structured JSON describing the whole `.wd` surface: every directive, `@loop` clause, loop variable, button-action op, format pipe, and predicate operator, each with a syntax template, one-line description, one **concrete compilable example**, and a reactivity flag. It is sourced from the compiler's own tables — `FORMATTER_NAMES` (format.js), `PREDICATE_OPS` (predicates.js), `LOOP_META` (context.js), the action vocabulary (`ACTION_USE`, actions.js) — and from the shared `*_EXAMPLE` constants the error hints use, so it cannot drift from what compiles (drift-guarded in `tests/catalog.test.js`, which also compiles every example).
210
+ - **`llms.txt` cheatsheet** — `llmsText()` renders a compact (~90-line) markdown cheatsheet from the same catalog; `darkmown catalog` prints the JSON and `darkmown catalog --llms` the cheatsheet, and every build writes `dist/llms.txt`. This is the artifact an app pastes into a small model's system prompt.
211
+ - **Generated GBNF grammar** — `src/grammar.js` (`generateGrammar()`, wrapped by `scripts/gen-grammar.mjs`) emits `grammar/wd-directives.gbnf`, a llama.cpp grammar for `.wd` **directive lines** (plus the `{ path | pipe }` interpolation token). It constrains a line's *shape* — the leading keyword must be a real directive, `@loop` must be `@loop <src> into <item> …clauses`, a condition must be `operand <op> operand`, a `:button` must be `"…" -> <action-chain>` — which deletes the on-device failure class (JS/HTML muscle-memory openers become unrepresentable) while staying small; free-form values remain `text`. Well-formedness + catalog coverage are tested in `tests/grammar.test.js`, and the committed file is kept in sync by the same test.
212
+ - **Structured compile errors** — errors carry a machine-readable mirror on `err.wd = { file, line?, hint?, example? }` (helper `wdError` in context.js) alongside the unchanged string message. Applied across loops, includes, predicates, forms, state, actions, media, fetch, and structure. `example` is always a compilable line.
213
+ - **Concrete examples in error hints** — every corrective `Use:` hint that contained bracket-placeholders now ends with ` — e.g. <concrete valid line>`, drawn from the same `*_EXAMPLE` constants. `tests/error-examples.test.js` extracts each `e.g.` example from the *real* thrown error string and compiles it (enforcement, not convention). Motivation: the on-device model bench (`bench/on-device/RESULTS.md`) found small models echo bracket-placeholders into source verbatim.
214
+
195
215
  ## Demo-only directives (not part of the public surface)
196
216
 
197
217
  The compiler recognizes three directives — `:note`, `:try`, and `:sprint` — that exist **only to power the demo site** (darkmown.com). They are **not** part of the supported public directive set, are not documented for authors, and may change or be removed without notice. Do not build pages that depend on them. The supported public directives are the ones catalogued above: `:state`, `:store`, `:computed`, `:effect`, `:every`, `:theme`, `:button`, `:bind`, `:if`/`:else if`/`:else`/`:endif`, `:fetch` (with `headers`/`refresh`), `:form`/`:input`/`:submit`, `:slider`, `:carousel`, `:video`/`:audio`/`:embed`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by` — including reactive `{ state }` keys —, `reverse`, `offset`, `limit`, `sortable`), `@include`, and `::: section` (with reactive `.class when`). Interpolation supports **format pipes** (`{ value | name:arg }`) and `:computed`/list-pipe **aggregates** (`sum`/`avg`/`min`/`max`/`count`). The runtime escape hatch is `window.wd` (`get`/`set`/`subscribe`/`state`/`render`).
@@ -0,0 +1,58 @@
1
+ # Darkmown .wd directive-line grammar — generated by src/grammar.js
2
+ # Constrains a directive line (or a { path | pipe } interpolation). Do not edit by hand.
3
+
4
+ root ::= directive | binding
5
+ directive ::= dir-include | dir-loop | dir-container | dir-if | dir-state | dir-store | dir-computed | dir-fetch | dir-effect | dir-every | dir-theme | dir-button | dir-form | dir-input | dir-textarea | dir-select | dir-checkbox | dir-radio | dir-submit | dir-bind | dir-slider | dir-video | dir-audio | dir-embed | dir-carousel
6
+
7
+ dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses
8
+ dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )
9
+ dir-button ::= ":button" sp dqstring sp "->" sp action-chain
10
+ dir-container ::= ":::" restofline
11
+
12
+ dir-include ::= "@include" restofline
13
+ dir-state ::= ":state" restofline
14
+ dir-store ::= ":store" restofline
15
+ dir-computed ::= ":computed" restofline
16
+ dir-fetch ::= ":fetch" restofline
17
+ dir-effect ::= ":effect" restofline
18
+ dir-every ::= ":every" restofline
19
+ dir-theme ::= ":theme" restofline
20
+ dir-form ::= ":form" restofline
21
+ dir-input ::= ":input" restofline
22
+ dir-textarea ::= ":textarea" restofline
23
+ dir-select ::= ":select" restofline
24
+ dir-checkbox ::= ":checkbox" restofline
25
+ dir-radio ::= ":radio" restofline
26
+ dir-submit ::= ":submit" restofline
27
+ dir-bind ::= ":bind" restofline
28
+ dir-slider ::= ":slider" restofline
29
+ dir-video ::= ":video" restofline
30
+ dir-audio ::= ":audio" restofline
31
+ dir-embed ::= ":embed" restofline
32
+ dir-carousel ::= ":carousel" restofline
33
+
34
+ loop-clauses ::= ( sp loop-clause )*
35
+ loop-clause ::= where-clause | "sort by" sp path ( sp ( "asc" | "desc" ) )? | "reverse" | "offset" sp number | "limit" sp number | "paginate" sp number | "sortable"
36
+ where-clause ::= "where" sp condition ( sp joiner sp condition )*
37
+ condition ::= operand sp op sp operand
38
+ op ::= "contains" | "==" | "!=" | ">=" | "<=" | ">" | "<"
39
+ joiner ::= "and" | "or"
40
+
41
+ action-chain ::= action ( wsopt ";" wsopt action )*
42
+ action ::= ident action-op
43
+ action-op ::= "++" | "--" | sp "+=" sp value | sp "-=" sp value | sp "=" sp value | sp "toggle" ( sp value )? | sp "append" sp value | sp "prepend" sp value | sp "remove" sp value | sp "clear" | sp "merge" sp ident | sp "delete" sp value | sp "reset" | sp "refetch"
44
+
45
+ binding ::= "{" wsopt path pipe-chain wsopt "}"
46
+ pipe-chain ::= ( wsopt "|" wsopt pipe ( ":" value )* )*
47
+ pipe ::= "money" | "number" | "percent" | "round" | "date" | "time" | "datetime" | "upper" | "lower" | "capitalize" | "truncate" | "trim" | "pluralize" | "default" | "sum" | "avg" | "min" | "max" | "count" | "join"
48
+
49
+ operand ::= path | dqstring | number
50
+ value ::= dqstring | number | path
51
+ ident ::= [a-zA-Z_$] [a-zA-Z0-9_$]*
52
+ path ::= [a-zA-Z0-9_$/.~-]+
53
+ number ::= [0-9]+
54
+ dqstring ::= "\"" [^"]* "\""
55
+ restofline ::= [^\n]*
56
+ sp ::= " "+
57
+ wsopt ::= " "*
58
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,11 +13,13 @@
13
13
  "main": "./src/index.js",
14
14
  "exports": {
15
15
  ".": "./src/index.js",
16
+ "./catalog": "./src/catalog.js",
16
17
  "./package.json": "./package.json"
17
18
  },
18
19
  "types": "./types/index.d.ts",
19
20
  "scripts": {
20
21
  "build": "node src/cli.js build",
22
+ "build:playground": "node scripts/build-playground.mjs",
21
23
  "runtime:size": "node scripts/size-check.mjs",
22
24
  "dev": "node src/cli.js dev",
23
25
  "test": "node --test tests/*.test.js",
@@ -44,6 +46,7 @@
44
46
  "files": [
45
47
  "src",
46
48
  "types",
49
+ "grammar",
47
50
  "site",
48
51
  "!site/_/og*.png",
49
52
  "docs/cli.md",
@@ -67,6 +70,7 @@
67
70
  "@playwright/test": "^1.49.0",
68
71
  "@types/markdown-it": "^14.1.2",
69
72
  "@types/node": "^26.0.0",
73
+ "esbuild": "^0.24.2",
70
74
  "husky": "^9.1.7",
71
75
  "lint-staged": "^16.4.0",
72
76
  "typescript": "^6.0.3"
package/site/_/footer.wd CHANGED
@@ -6,7 +6,7 @@ html: true
6
6
  <div class="foot-inner">
7
7
  <div>
8
8
  <strong>Darkmown</strong> — Markdown that runs.
9
- <div class="small">Zero JS on static pages · one ~7.5&nbsp;KB runtime when reactive.</div>
9
+ <div class="small">Zero JS on static pages · one ~7.7&nbsp;KB runtime when reactive.</div>
10
10
  </div>
11
11
  <nav class="foot-links">
12
12
  <a href="/docs/">Docs</a>
package/site/_/nav.wd CHANGED
@@ -5,6 +5,7 @@ html: true
5
5
  <nav class="topnav">
6
6
  <a class="brand" href="/">Darkmown</a>
7
7
  <span class="nav-links">
8
+ <a href="/playground/">Playground</a>
8
9
  <a href="/docs/">Docs</a>
9
10
  <a href="/blog/">Blog</a>
10
11
  <a href="/showcase/">Showcase</a>
package/site/pages/app.wd CHANGED
@@ -20,7 +20,7 @@ This whole section is plain Markdown. The list below is unrolled **at build time
20
20
  - **{ feature.title }** — { feature.body }
21
21
  @endloop
22
22
 
23
- <aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~7.5&nbsp;KB gzipped runtime loads (CI-enforced under 8 KB) — and only because the document asks for it.</aside>
23
+ <aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~7.7&nbsp;KB gzipped runtime loads (CI-enforced under 8 KB) — and only because the document asks for it.</aside>
24
24
 
25
25
  ## Fetched data, looped, with nested conditions
26
26
 
@@ -15,7 +15,7 @@ none of them pay for it.
15
15
 
16
16
  That is the whole bargain. A `.md` file is plain CommonMark. Rename it to `.wd`
17
17
  and you unlock directives — state, loops, conditionals — but you only ship the
18
- ~7.5 KB runtime on the pages that actually use them. A listing built from a
18
+ ~7.7 KB runtime on the pages that actually use them. A listing built from a
19
19
  collection loop resolves entirely at build time, so it stays static HTML.
20
20
 
21
21
  ## The extension is the feature gate
@@ -66,6 +66,8 @@ npm run dev
66
66
 
67
67
  Or add Darkmown to an existing project with `npm install -D @zvndev/darkmown`. The package is scoped; the command it installs is plain `darkmown`.
68
68
 
69
+ Want to try the syntax before installing anything? The [playground](/playground/) compiles `.wd` and `.md` live in your browser — the same compiler the CLI ships — and shows the real `file:line` error when you break something.
70
+
69
71
  For editing, a [VS Code extension](https://github.com/ZVN-DEV/darkmown/tree/master/editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — install it from a `.vsix` built out of the framework repo (see its `editors/vscode` README; a Marketplace listing is pending).
70
72
 
71
73
  `darkmown dev` rebuilds incrementally: it tracks which routes depend on each file (includes, colocated assets, loop data, collections), so editing one page or include recompiles only the routes it affects — and falls back to a full rebuild whenever a file is added, removed, or renamed. `routes.json`, the sitemap, and the RSS feed are regenerated on every rebuild, so they always describe the whole site.
@@ -313,7 +315,7 @@ A container named `nav` or `main` emits the real landmark element (`<nav class="
313
315
 
314
316
  ## Reactive directives
315
317
 
316
- Reactive pages opt into `/__wd/runtime.js` (~7.5 KB gzipped, under a CI-enforced 8 KB budget). Static pages do not.
318
+ Reactive pages opt into `/__wd/runtime.js` (~7.7 KB gzipped, under a CI-enforced 8 KB budget). Static pages do not.
317
319
 
318
320
  ```wd
319
321
  :state count = 0
@@ -591,7 +593,7 @@ Darkmown is a trusted-author site generator: you compile content you wrote, the
591
593
 
592
594
  - **Compile only content you authored.** Don't run `.md`/`.wd` you didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first.
593
595
  - **Raw HTML is escaped by default.** Markdown renders with `html: false`, so a stray `<script>` in content becomes inert text — safe for multi-author collections out of the box. Set `html: true` in a page's frontmatter to pass your own raw HTML through verbatim; there is no built-in sanitizer, so keep it off pages with content you didn't write.
594
- - **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. The runtime compiles `:computed` / `@loop … where` / `.class when` expressions via `new Function`, so a reactive page needs `script-src 'unsafe-eval'` in your CSP. Static pages need neither.
596
+ - **`:fetch` and `:form action=` have no host allowlist.** Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Since 2.1 the runtime **interprets** validated `:computed` / `@loop … where` / `.class when` expressions from a compact AST instead of building a `new Function`, so **reactive pages need no `'unsafe-eval'`** — they run under the same strict, eval-free CSP as static pages.
595
597
 
596
598
  See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) in the package for the full security model.
597
599
 
@@ -602,6 +604,20 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) i
602
604
  - Both work for included fragments too, by basename.
603
605
  - Any other file under `site/pages/` (an image, font, PDF — anything that isn't a `.md`/`.wd` route or a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type. So `site/pages/logo.svg` is served at `/logo.svg`. Assets below hidden path segments (`.`, `-`, or `_`) and symlinked page assets are skipped, matching the routing privacy rule. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
604
606
 
607
+ ## AI authoring
608
+
609
+ Darkmown ships a machine-readable description of its own language, so an AI tool can learn `.wd` and be constrained to output that compiles. It is all generated from the compiler's own tables, so it never drifts from the real grammar.
610
+
611
+ - **`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. Import it too: `import { directiveCatalog } from "@zvndev/darkmown/catalog"`.
612
+ - **`darkmown catalog --llms`** prints a compact (~90-line) cheatsheet — the artifact you paste into a model's system prompt. Every build also writes it to `dist/llms.txt`.
613
+ - **`grammar/wd-directives.gbnf`** is a generated GBNF grammar for `.wd` directive lines. Point a grammar-constrained decoder (llama.cpp and friends) at it to make invalid directive lines — JS idioms, HTML muscle-memory — impossible to generate.
614
+
615
+ ```sh
616
+ darkmown catalog --llms > system-prompt.md
617
+ ```
618
+
619
+ Compile errors help here too: every corrective `Use:` hint ends with a concrete `— e.g. <valid line>`, and each thrown error carries a structured `err.wd = { file, line, hint, example }` mirror alongside its message — so an edit-and-retry loop gets the fix without parsing prose.
620
+
605
621
  ## CLI reference
606
622
 
607
623
  The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool — every command is one word:
@@ -610,6 +626,7 @@ The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool
610
626
  - `darkmown dev` — the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner for `api/*` functions.
611
627
  - `darkmown build [--target cloudflare] [--drafts]` — write 100% static output to `dist/` (plus `sitemap.xml` / `rss.xml` / `robots.txt`); `--drafts` includes `draft: true` pages for a staging deploy.
612
628
  - `darkmown serve` — preview the built `dist/` locally (static only; use `dev` to exercise `api/*`).
629
+ - `darkmown catalog [--llms]` — print the `.wd` directive catalog as JSON, or with `--llms` a compact cheatsheet for priming an AI model (see [AI authoring](#ai-authoring)).
613
630
  - `darkmown deploy <vercel|cloudflare> [--prod]` — build (target-aware) and deploy through the platform CLI, printing your URL; an unauthenticated CLI surfaces the login to run first.
614
631
  - `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.
615
632