@zvndev/darkmown 2.6.1-next.12.ge562f5d → 2.7.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 (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
package/docs/cli.md CHANGED
@@ -1,14 +1,26 @@
1
1
  # Darkmown CLI
2
2
 
3
- ## Install locally
3
+ The published package is `@zvndev/darkmown`; the executable it installs is plain `darkmown`. Nothing here needs a global install.
4
+
5
+ ## Install
6
+
7
+ Scaffold a new project in one command, with no prior install:
4
8
 
5
9
  ```sh
10
+ npx @zvndev/darkmown init my-site
11
+ cd my-site
6
12
  npm install
7
- npm link
8
- darkmown help
13
+ npm run dev
9
14
  ```
10
15
 
11
- `package.json` exposes the executable as `darkmown`.
16
+ Or add it to a project you already have, and call it through `npx`:
17
+
18
+ ```sh
19
+ npm install -D @zvndev/darkmown
20
+ npx darkmown help
21
+ ```
22
+
23
+ Working on the framework itself instead? See [Contributing](../CONTRIBUTING.md). From a clone, `npm install` then `npm link` puts a `darkmown` on your `PATH` that points at your working tree.
12
24
 
13
25
  ## Commands
14
26
 
@@ -61,11 +73,13 @@ Rebuilds are **incremental** for `site/` content changes. Every dev build writes
61
73
 
62
74
  Correctness beats speed: **any uncertainty runs a full rebuild** — a new, deleted, or renamed file; a file no dependency graph accounts for; a change to the site-wide feed link; a missing or stale map. A change under `src/` (the framework's own code) always runs a full rebuild in a child process so fresh modules load. Production `darkmown build` writes no map and is unaffected.
63
75
 
64
- ### `darkmown build [--target cloudflare]`
76
+ ### `darkmown build [--target cloudflare] [--drafts]`
65
77
 
66
78
  Compiles `site/pages` into `dist`. The output is always 100% static HTML.
67
79
 
68
- Static pages do not receive `/__wd/runtime.js`; reactive pages do. Pay-for-what-you-use behavior modules (`:sortable`, `:carousel`) emit to `dist/__wd/behaviors/` only on the pages that use them.
80
+ `--drafts` includes pages marked `draft: true` in their frontmatter, which a default `build` leaves out of `dist`, `routes.json`, `sitemap.xml`, and `rss.xml` entirely. Use it for a staging deploy. `darkmown dev` always builds drafts, with a "DRAFT" banner that exists only in the dev server.
81
+
82
+ Static pages do not receive `/__wd/runtime.js`; reactive pages do. Pay-for-what-you-use behavior modules (`sortable`, `:carousel`) emit to `dist/__wd/behaviors/` only on the pages that use them.
69
83
 
70
84
  `--target cloudflare` additionally emits `dist/_worker.js` — a Cloudflare Pages worker that routes `/api/*` to the project's `api/*.js` functions and serves everything else from `env.ASSETS`. The default target leaves `api/` for Vercel to run natively. `darkmown deploy` sets the right target automatically.
71
85
 
@@ -84,23 +98,19 @@ The deploy URL is printed when the CLI reports it. If the platform CLI isn't sig
84
98
 
85
99
  Serves the already-built `dist` directory for local preview (static only — use `darkmown dev` to exercise `api/*` locally). Run `darkmown build` first.
86
100
 
87
- ### `darkmown catalog [--llms]`
101
+ ### `darkmown catalog [--llms|--llms-full]`
88
102
 
89
- 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.
103
+ 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.
90
104
 
91
- ```sh
92
- darkmown catalog --llms > system-prompt.md
93
- ```
94
-
95
- ## Smoke checks
96
-
97
- From this repository, run:
105
+ - `--llms` prints a compact markdown cheatsheet instead: the artifact to paste into an AI model's system prompt.
106
+ - `--llms-full` prints the complete reference that cheatsheet points at: every directive with its full syntax and example, every clause, action, pipe, operator, and frontmatter key, plus **every compile-error code with its cause and fix**. An edit loop that hits `[WD201]` can resolve it from the same output it learned the syntax from.
98
107
 
99
108
  ```sh
100
- npm run smoke
109
+ darkmown catalog --llms > system-prompt.md
110
+ darkmown catalog --llms-full > reference.md
101
111
  ```
102
112
 
103
- The smoke script packs the local tarball, installs the packed CLI in a temporary driver project, scaffolds a consumer app through that installed bin, installs the same tarball into the app, builds it, verifies the reactive home route, and verifies the plain `.md` about route stays zero-JS.
113
+ Every `darkmown build` also writes both files (`dist/llms.txt` and `dist/llms-full.txt`), and Darkmown ships a generated GBNF grammar (`grammar/wd-directives.gbnf`) for constrained decoding.
104
114
 
105
115
  ## Backends (`api/`)
106
116
 
@@ -123,3 +133,27 @@ export default async function (request, context) {
123
133
  ### Cloudflare Pages note
124
134
 
125
135
  Cloudflare advanced mode (`dist/_worker.js`) does not run a bundler, so an `api/` handler deployed to Cloudflare must be dependency-free (or pre-bundled). The same source still runs on Vercel (which bundles) and in `darkmown dev`.
136
+
137
+ ## Working on the framework itself
138
+
139
+ Everything from here down is for a clone of the Darkmown repository, not for a project built with it.
140
+
141
+ ### Regenerating the runtime artifact
142
+
143
+ The runtime that ships is `src/runtime.min.js`, a committed esbuild build of the readable `src/runtime.js` (plus its sourcemap). `darkmown build` copies that artifact byte for byte, so it has to be regenerated whenever the source changes:
144
+
145
+ ```sh
146
+ npm run build:runtime
147
+ ```
148
+
149
+ Nothing regenerates it behind your back. A drift test rebuilds it in memory and fails on any byte difference, and CI runs the same check, so a stale artifact fails the build rather than shipping. `npm run dev` regenerates it first through `predev`, which is why development and production serve the same bytes.
150
+
151
+ ### Smoke checks
152
+
153
+ From this repository, run:
154
+
155
+ ```sh
156
+ npm run smoke
157
+ ```
158
+
159
+ The smoke script packs the local tarball, installs the packed CLI in a temporary driver project, scaffolds a consumer app through that installed bin, installs the same tarball into the app, builds it, verifies the reactive home route, and verifies the plain `.md` about route stays zero-JS.
@@ -2,6 +2,27 @@
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.7.0 additions: silent failures closed, and the headroom spent (2026-09-04)
6
+
7
+ An audit put one question to every path in the compiler, the builder and the runtime: when this goes wrong, does the author find out? Where the answer was no, the behavior is now either correct or a coded refusal. Separately the runtime started shipping minified, which returned roughly 2 KB of the 8 KB budget, and that headroom bought five features the original vision named but the framework could not reach.
8
+
9
+ - **Interpolation resolves everywhere, or says why not.** A `{ … }` in a link or image destination, a raw HTML attribute, or an html block used to be left as literal text with no diagnostic at all. Build-time values now resolve in every one of those positions (every brace in a destination, not only a leading one), heading ids slug the *resolved* text so an interpolated heading gets a real anchor, and the two positions that cannot bind are warned with `file:line` and the way out named. Values in URL position are percent-encoded and dangerous schemes refused, so data can no longer break out of a destination on an `html: true` page. Inline code spans are masked, so a page can still document the syntax.
10
+ - **Reactive attribute binding (`data-wd-attr` / `data-wd-each-attr`).** The syntax is unchanged (`[label]({ expr })`, `[label](/p/{ expr }/)`, `![alt]({ expr })`); what changed is that a `:state`/`:store`/`:computed`/row/meta value there is a live binding rather than a one-time paint. The compiler emits the destination as literal parts plus readers, and the runtime rebuilds the whole value each render, re-checking the scheme because it is the only layer that can see a value which only exists at runtime. The build-time paint stays the seed, so the pre-hydration page and the crawler both see a real URL. +185 B.
11
+ - **`:state … from-url`, the URL as state.** A third word in the persistence vocabulary, on `:state` only. The key mirrors into the query string via `replaceState` (`cart:items` becomes `cart.items`), a value at its seed drops the parameter, `popstate` re-reads, and boot precedence is URL then storage then seed. `WD260` refuses it on `:store`/`:theme` with the reason (both are shared by every page and tab; a query parameter belongs to one address), and `WD261` names conflicting modifiers instead of folding one into the value.
12
+ - **`:select`/`:radio`/`:checkbox` outside a `:form` bind to state.** The placement decides the meaning: a form field inside a `:form` (byte-identical output, golden-tested), a bound control outside one, exactly like `:bind` and `:slider`. A bound `:checkbox` is a single boolean. `WD450` refuses an undeclared target naming both readings; `WD451` refuses a bound checkbox with several options. +45 B.
13
+ - **Server error bodies.** `<name>_error` is the response body's own `error`/`message` field when the body is JSON, falling back to `HTTP <status>`, and `<name>_error_body` carries the parsed body for field-level messages. Auto-declared for `:fetch` and round-trip `:form`, cleared on the next success. +63 B.
14
+ - **File upload.** A `:form` containing a file field compiles with `enctype="multipart/form-data"` and posts real `FormData` (the browser writes the boundary); bound controls are appended by state key. `WD452` refuses a file field on `method="get"`. The local `api/` runner already forwarded multipart untouched, so the demo is end to end. +61 B.
15
+ - **Seeds inside a closed `:if` branch hydrate when it opens.** A closed branch lives in a `<template>`, which `querySelectorAll` does not enter, so a `:state`/`:store`/`:theme` declared in one was never hydrated and its `persist` was ignored. Claiming is now per **key** rather than per node, so re-opening does not reset the reader's value, and the claimed seed is that key's `reset` baseline.
16
+ - **Accessibility attributes on `:::` and `:button`.** Exactly `role="…"`, `aria-…="…"`, `title="…"`, double-quoted static values, escaped on emit; `WD650` for anything else and `WD651` for a missing quoted value. This is the one place the no-attribute-syntax rule genuinely blocked a correct page, and it is closed without opening an injection surface: compile-time only, zero runtime bytes, and a static page carrying attributes stays `runtime: false`. Aria values are deliberately not interpolated in this release.
17
+ - **Markdown table rows from a static `@loop`.** Rows are spliced into the table above them, or form a headerless table of their own, instead of falling out as `<p>| Ash | 3 |</p>`. A reactive loop over pipe rows is `WD191` rather than silent junk: a cloned row is a `<div>`, which cannot live in a `<table>`, so the hint names the static source and the container alternative.
18
+ - **A bare directive keyword is an error.** A line that was exactly `:state` (or any of 21 other argument-taking directives) rendered as prose with no warning. It now reaches that directive's own handler and throws that directive's coded error with its own `Use:` hint, so there is one owner per hint and no second copy to drift. `:::`, `:theme` and `:carousel` stay valid bare; `\:name` is the escape for showing one as text.
19
+ - **`:every`/`:effect` inside a reactive `@loop` is `WD315`.** A reactive loop clones its body per row, so each row registered its own timer or watcher and a removed row's kept firing. Neither directive has a per-row meaning to salvage, so the placement is refused, mirroring the `WD202` check `declareState` has always had. Static loop bodies and the `@empty` branch still accept them.
20
+ - **The runtime is a committed minified artifact.** `/__wd/runtime.js` was the comment-stripped readable source (8886 B gzipped with these features, against an 8192 B budget). It is now `src/runtime.min.js`, built by `scripts/build-runtime.mjs` with an exact-pinned esbuild, plus an external sourcemap served at `/__wd/runtime.js.map` with `sourcesContent`, so DevTools still shows the readable source. The artifact is committed because `darkmown build` runs on a consumer's machine from the published tarball where esbuild does not exist; a drift test and a `--check` step in CI fail on a stale one, and `predev` regenerates before `npm run dev`. Measured size **6584 B gzipped** against the unchanged 8192 B budget, and `RUNTIME_SIZE` in the catalog is measured from the file rather than written down.
21
+ - **Runtime correctness, from the same audit.** A render settles (a pass that clones a row or injects a branch marks itself dirty, and a render exiting dirty schedules one more); `:fetch`/`:effect`/`:every` are claimed by `scan()` on every render so a directive inside a branch works when the branch opens, and unregistered when a branch, row, or `@empty` swap removes them, so re-opening never double-fires; row placement is prefix-stable so focus and scroll survive; every `localStorage` access is guarded so blocked storage cannot crash boot; `:form` gained the generation guard `:fetch` had; `:effect`/`:every` clone their action values per fire.
22
+ - **Compiler correctness, from the same audit.** `WD250` refuses `__proto__`/`constructor`/`prototype` as a declaration name at the one choke point every declaring directive uses; the include sandbox re-checks canonical paths so a symlink out of `site/` is `WD601`; `{ toString }` is literal text; every state key emitted into a `data-wd-*` attribute is escaped; `append` is no longer read as `prepend`; a clause keyword inside a quoted `where` operand is text; loop keys cannot collide; a per-row `:if` paints the right branch at build; sort is stable and resolves a `{ state }` key for the initial paint; quoted numeric frontmatter stays a string in typed collections.
23
+ - **Feeds and rebuilds.** `rss_limit:` on the home page caps the feed (digits only; `WD950` otherwise); a sitemap over 50,000 URLs shards behind a `<sitemapindex>`; `<lastmod>` is round-trip validated; a paginated dated listing yields one RSS item. Incremental rebuilds guard `site_url` and ancestor titles as whole-site inputs (dep map v2), so an edit can no longer ship a stale canonical or breadcrumb.
24
+ - **Zero new dependencies at runtime.** esbuild is a devDependency used by the minify step, exactly as it already was for the playground bundle. `.size-snapshot.json` remains the single source for the budget, and the behavior modules (`sortable` 1541 B, `carousel` 1380 B) are unminified and budgeted separately.
25
+
5
26
  ## Agent tool surface: `@zvndev/darkmown/tools` (2026-08-16)
6
27
 
7
28
  The AI-authoring work so far shipped *description*: a catalog, a cheatsheet, a corpus, a GBNF grammar. All of it teaches a model to write a `.wd` file from scratch. None of it helps a model **edit one it did not write**, which is the operation an AI editor actually performs, and the one that fails: measurement showed the failure is not counting lines, it is recalling a string read once, and it gets worse as a file grows.
@@ -49,7 +70,7 @@ The 1.3.0 cycle shipped the crawler *files* (sitemap, rss, robots). This closes
49
70
 
50
71
  - **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.
51
72
  - **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
52
- - **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).
73
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js`, a minified build of the readable source with a sourcemap beside it (~6.6 KB gzipped, CI-enforced below the 8 KB target).
53
74
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
54
75
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
55
76
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
@@ -233,7 +254,7 @@ The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Trac
233
254
 
234
255
  - **Serverless `api/` model:** backend endpoints are plain-JS Web-standard handlers — `export default (request, context) => Response` — in a top-level `api/` directory (`api/echo.js` → `/api/echo`; `api/users/[id].js` → `/api/users/:id` with `context.params.id`). Darkmown owns no server; it *adapts* to the host. The build stays 100% static. On Vercel the `api/` functions run natively (Edge); `darkmown deploy cloudflare` (or `build --target cloudflare`) emits `dist/_worker.js` that routes `/api/*` to the same functions and serves everything else from `env.ASSETS`. `darkmown dev` runs a local runner so `/api/*` behaves identically before deploy. No `.wd` backend DSL — minimal new learning, zero lock-in. `form-action 'self'` was added to both CSP variants (and `vercel.json`) so a native `:form action="/api/…"` POST is allowed.
235
256
  - **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.
236
- - **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.
257
+ - **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 bare `@loop` clause, written without a leading colon) 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.
237
258
 
238
259
  ## Browser playground + fs-free compile — `compileFromMemory` (2026-07-11)
239
260
 
@@ -250,7 +271,7 @@ The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Trac
250
271
 
251
272
  ## Demo-only directives (not part of the public surface)
252
273
 
253
- 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`).
274
+ The compiler recognizes one directive, `:try`, that exists **only to power the demo site** (darkmown.com). It is **not** part of the supported public directive set, is not documented for authors, and may change or be removed without notice. Do not build pages that depend on it. (`:note` and `:sprint` were two more of these; both were removed in 2.7.0, and those lines now render as text with an unknown-directive warning.) 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`/`:textarea`/`:select`/`:checkbox`/`:radio`/`: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`).
254
275
 
255
276
  ## Still missing from the full vision
256
277
 
@@ -262,4 +283,4 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
262
283
 
263
284
  Darkmown is faithful to the core thesis: Markdown remains the authoring center, static pages ship zero framework JavaScript, and reactive behavior compiles into tiny direct-DOM islands only where declared. The extension is the feature gate, the loop is one concept with progressive disclosure, and state scopes to document structure.
264
285
 
265
- As of **v1.0.0** the public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the directive surface is stable. The 1.0 release closes the audit-flagged display/time/theme/sort/media/escape-hatch gaps; the remaining items above are explicitly scoped as post-launch product/distribution work rather than missing core claims. **2.0.0** made one breaking change to that stable surface — raw HTML in markdown is now escaped by default (`html: false`), with `html: true` the per-page opt-in — a security default flip, not a directive change (see the 2.0.0 entry above and CHANGELOG).
286
+ As of **v2.7.0** the public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the directive surface is stable at the 25 directives `darkmown catalog` prints. Three releases have moved that surface since 1.0, and each is recorded above: **2.0.0** made the one breaking change, escaping raw HTML in markdown by default (`html: false`), with `html: true` as the per-page opt-in, a security default flip rather than a directive change. **2.1.0** replaced the runtime's `new Function` with a closed interpreter over a validated expression AST, so reactive pages now run under the same strict, `'unsafe-eval'`-free CSP as static ones. **2.5.0** added the agent tool surface (`@zvndev/darkmown/tools`). The remaining items above stay scoped as post-launch product and distribution work rather than missing core claims.
@@ -1,12 +1,13 @@
1
1
  # Darkmown .wd directive-line grammar — generated by src/grammar.js
2
2
  # Constrains a directive line (or a { path | pipe } interpolation). Do not edit by hand.
3
3
 
4
- root ::= directive | binding
4
+ root ::= directive | closer | binding
5
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
+ closer ::= "@endloop" | "@empty" | ":endif" | ":endform" | ":endcarousel" | ":::" | ":else if" sp condition ( sp joiner sp condition )* | ":else if" sp path | ":else"
6
7
 
7
8
  dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses
8
9
  dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )
9
- dir-button ::= ":button" sp dqstring sp "->" sp action-chain
10
+ dir-button ::= ":button" sp dqstring ( sp a11y-attr )* sp "->" sp action-chain
10
11
  dir-container ::= ":::" restofline
11
12
 
12
13
  dir-include ::= "@include" restofline
@@ -32,20 +33,27 @@ dir-embed ::= ":embed" restofline
32
33
  dir-carousel ::= ":carousel" restofline
33
34
 
34
35
  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
+ loop-clause ::= where-clause | "sort by" sp sort-key ( sp sort-dir )? | "reverse" | "offset" sp count | "limit" sp count | "paginate" sp number | "sortable"
37
+ sort-key ::= path | statebinding
38
+ sort-dir ::= "asc" | "desc" | statebinding
39
+ count ::= number | ident
40
+ statebinding ::= "{" wsopt ident wsopt "}"
36
41
  where-clause ::= "where" sp condition ( sp joiner sp condition )*
37
- condition ::= operand sp op sp operand
42
+ condition ::= ( "not" sp )? operand sp op sp operand
38
43
  op ::= "contains" | "==" | "!=" | ">=" | "<=" | ">" | "<"
39
44
  joiner ::= "and" | "or"
40
45
 
41
46
  action-chain ::= action ( wsopt ";" wsopt action )*
42
47
  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"
48
+ 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 value | sp "delete" sp value | sp "reset" | sp "refetch"
44
49
 
45
50
  binding ::= "{" wsopt path pipe-chain wsopt "}"
46
51
  pipe-chain ::= ( wsopt "|" wsopt pipe ( ":" value )* )*
47
52
  pipe ::= "money" | "number" | "percent" | "round" | "date" | "time" | "datetime" | "upper" | "lower" | "capitalize" | "truncate" | "trim" | "pluralize" | "default" | "sum" | "avg" | "min" | "max" | "count" | "join"
48
53
 
54
+ a11y-attr ::= a11y-name "=" dqstring
55
+ a11y-name ::= "role" | "title" | "aria-" [a-z] [a-z0-9-]*
56
+
49
57
  operand ::= path | dqstring | number
50
58
  value ::= dqstring | number | path
51
59
  ident ::= [a-zA-Z_$] [a-zA-Z0-9_$]*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "2.6.1-next.12.ge562f5d",
3
+ "version": "2.7.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -30,7 +30,9 @@
30
30
  "scripts": {
31
31
  "build": "node src/cli.js build",
32
32
  "build:playground": "node scripts/build-playground.mjs",
33
+ "build:runtime": "node scripts/build-runtime.mjs",
33
34
  "runtime:size": "node scripts/size-check.mjs",
35
+ "predev": "node scripts/build-runtime.mjs",
34
36
  "dev": "node src/cli.js dev",
35
37
  "test": "node --test tests/*.test.js",
36
38
  "test:cov": "node scripts/test-coverage-gate.mjs 100",
@@ -45,7 +47,7 @@
45
47
  "format:check": "biome format .",
46
48
  "size:check": "node scripts/size-check.mjs",
47
49
  "prepare": "husky || true",
48
- "prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
50
+ "prepublish:check": "npm run typecheck && node scripts/build-runtime.mjs --check && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
49
51
  "prepublishOnly": "npm run prepublish:check",
50
52
  "og": "node scripts/og-image.mjs",
51
53
  "bench:build": "node scripts/bench-build.mjs"
@@ -55,6 +57,8 @@
55
57
  },
56
58
  "files": [
57
59
  "src",
60
+ "src/runtime.min.js",
61
+ "src/runtime.min.js.map",
58
62
  "types",
59
63
  "grammar",
60
64
  "site",
@@ -79,7 +83,7 @@
79
83
  "@biomejs/biome": "^2.5.7",
80
84
  "@playwright/test": "^1.61.1",
81
85
  "@types/node": "^26.1.1",
82
- "esbuild": "^0.28.2",
86
+ "esbuild": "0.28.2",
83
87
  "husky": "^9.1.7",
84
88
  "lint-staged": "^17.0.0",
85
89
  "typescript": "^6.0.3"
package/site/_/base.skin CHANGED
@@ -463,7 +463,7 @@ label
463
463
  border 1px solid $line
464
464
  color $ink-soft
465
465
 
466
- /* ── Callout (the demo :note directive renders <aside class="note">) ─ */
466
+ /* ── Callout ───────────────────────────────────────────────────── */
467
467
 
468
468
  .note, .callout
469
469
  margin 1.6rem 0
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.7&nbsp;KB runtime when reactive.</div>
9
+ <div class="small">Zero JS on static pages · one ~6.6&nbsp;KB runtime when reactive.</div>
10
10
  </div>
11
11
  <nav class="foot-links">
12
12
  <a href="/docs/">Docs</a>
@@ -0,0 +1,81 @@
1
+ ---
2
+ html: true
3
+ title: Server error bodies: what the API actually said
4
+ description: A failed :fetch or :form now surfaces the server's own error message, and the whole parsed JSON body lands in a second state key so a page can render field-level errors.
5
+ ---
6
+
7
+ @include /nav.wd
8
+ @include /base.wd
9
+
10
+ <main>
11
+
12
+ <section class="intro">
13
+ <span class="eyebrow">Darkmown 2.7</span>
14
+
15
+ # What the API actually said
16
+
17
+ A real API explains why it refused. `<name>_error` used to read `Error: HTTP 422` no matter what came back. It is now the body's own `error` or `message` string, and the whole parsed body lands in `<name>_error_body`, so a page can render per-field errors without a line of its own JavaScript.
18
+
19
+ </section>
20
+
21
+ <section>
22
+
23
+ ## A refusal, rendered
24
+
25
+ `/api/echo` only accepts POST, so a plain `:fetch` gets a `405` whose JSON body carries an `error` string.
26
+
27
+ :fetch probe from "/api/echo/"
28
+
29
+ :if probe_loading
30
+ Asking the endpoint…
31
+ :endif
32
+
33
+ :if probe_error
34
+ The endpoint said: **{ probe_error }**
35
+ :endif
36
+
37
+ :if probe_error_body
38
+ And the whole body is available too: `ok` came back as { probe_error_body.ok }.
39
+ :endif
40
+
41
+ </section>
42
+
43
+ <section>
44
+
45
+ ## Field-level errors
46
+
47
+ `/api/upload` answers a fileless POST with a `422` whose body carries both a sentence and a per-field map:
48
+
49
+ ```json
50
+ { "error": "Pick a file first.", "fields": { "photo": "No file was attached." } }
51
+ ```
52
+
53
+ A page renders each half where it belongs:
54
+
55
+ ```wd-fragment
56
+ :if signup_error
57
+ { signup_error }
58
+ :endif
59
+
60
+ { signup_error_body.fields.email }
61
+ ```
62
+
63
+ The [upload demo](/upload/) has this wired up end to end.
64
+
65
+ </section>
66
+
67
+ <section>
68
+
69
+ ## The rules
70
+
71
+ - `<name>_error` prefers the body's `error` field, then its `message` field, and falls back to `HTTP <status>` when the body has neither or is not JSON at all.
72
+ - `<name>_error_body` is the parsed JSON body, or `null` when the response was not JSON.
73
+ - Both keys are auto-declared by `:fetch` and by a round-trip `:form`, and both are cleared on the next success.
74
+
75
+ <a class="btn primary" href="/docs/#what-the-server-said">Read the docs on fetching data</a>
76
+
77
+ </section>
78
+
79
+ </main>
80
+
81
+ @include /footer.wd
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.7&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 ~6.6&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
 
@@ -0,0 +1,84 @@
1
+ ---
2
+ html: true
3
+ title: Reactive links: a destination that follows state
4
+ description: A markdown link destination that reads :state or a loop row now binds, so the href and src keep up with the page instead of being painted once at build time. Dangerous schemes are refused at paint time, in the browser.
5
+ ---
6
+
7
+ @include /nav.wd
8
+ @include /base.wd
9
+
10
+ <main>
11
+
12
+ <section class="intro">
13
+ <span class="eyebrow">Darkmown 2.7</span>
14
+
15
+ # Reactive links
16
+
17
+ A `{ … }` inside a link or image destination used to be painted once at build time and then went stale. Now it binds: the compiler emits the destination as a small template, and the runtime rebuilds the whole value on every render.
18
+
19
+ </section>
20
+
21
+ <section>
22
+
23
+ ## The destination follows the state
24
+
25
+ :state region = "eu"
26
+
27
+ Pick a region and watch the link underneath it change. The link is written as ordinary markdown: `[Open the EU docs](/docs/{ region }/)`.
28
+
29
+ :button "EU" -> region = "eu"
30
+ :button "US" -> region = "us"
31
+ :button "APAC" -> region = "apac"
32
+
33
+ Current region: **{ region }**
34
+
35
+ [Open the docs for this region](/docs/{ region }/)
36
+
37
+ The `href` is rebuilt from the literal parts (`/docs/` and `/`) plus the live value, so a value in the middle of a path works exactly like one that fills the whole destination.
38
+
39
+ </section>
40
+
41
+ <section>
42
+
43
+ ## One template, every row
44
+
45
+ :state products = [
46
+ { "slug": "aurora", "name": "Aurora Lamp" },
47
+ { "slug": "briza", "name": "Briza Fan" },
48
+ { "slug": "cove", "name": "Cove Shelf" }
49
+ ]
50
+
51
+ A reactive `@loop` shares ONE row template, so a per-row destination has no single build-time value: it paints empty and the runtime fills each row from its own item.
52
+
53
+ @loop products into p
54
+ - [{ p.name }](/products/{ p.slug }/)
55
+ @endloop
56
+
57
+ :button "Add a product" -> products append { "slug": "dune", "name": "Dune Rug" }
58
+
59
+ </section>
60
+
61
+ <section>
62
+
63
+ ## A bound URL is checked where it lands
64
+
65
+ The compiler vets URLs an author writes. A bound `href` carries a value that only exists at runtime, so the scheme is checked again in the browser: `javascript:`, `data:` and `vbscript:` are refused and the attribute is left empty, with control characters stripped first so `java&#9;script:` cannot sneak through.
66
+
67
+ :state danger = "/safe/place/"
68
+
69
+ :button "Set a safe URL" -> danger = "/safe/place/"
70
+ :button "Try a javascript: URL" -> danger = "javascript:alert(1)"
71
+
72
+ The value in state: `{ danger }`
73
+
74
+ [This link's href]({ danger })
75
+
76
+ Click the second button and inspect the anchor: the whole attribute is emptied rather than half-applied, because the guard runs on the value the runtime assembled, not on the literal parts the author wrote.
77
+
78
+ <a class="btn primary" href="/docs/#destinations-that-bind">Read the docs on destinations that bind</a>
79
+
80
+ </section>
81
+
82
+ </main>
83
+
84
+ @include /footer.wd