@zvndev/darkmown 0.11.1 → 0.12.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.
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 ~3.2 KB runtime only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~4.7 KB gzipped runtime (CI-enforced under 5 KB) only if it declares reactive behavior.
11
11
 
12
12
  ## Project layout & routing
13
13
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
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
+ ## 0.12.0 — 2026-06-18
6
+
7
+ Security and deploy-hardening sprint.
8
+
9
+ - **Critical fix — `:computed` expression injection closed.** String literals inside `:computed` expressions are now escaped with `JSON.stringify` (the same path `@loop where` already used), so a crafted literal can no longer break out of the validated whitelist and execute arbitrary JavaScript at build time or in the browser. Added a regression suite that decodes and evaluates the emitted expression, plus a fuzzer invariant that runs compiled expressions inside a trapped sandbox to catch the whole class.
10
+ - **Honest coverage gate.** Coverage now measures `src/**` only — test files no longer inflate the denominator — and the gate explicitly discloses that the browser runtime is covered by the Playwright e2e job rather than the line gate.
11
+ - **Accessible form inputs by default.** `:input` and `:bind` auto-emit an `aria-label` (from the placeholder, else a humanized field name) when no `aria-label`/`aria-describedby` is supplied. The label is non-visual and never overrides an author-supplied attribute.
12
+ - **Static-host deploys hardened.** Builds emit `dist/404.html`; the preview server serves it on a miss and returns correct content-types for images, SVG, and fonts (unknown types default to `application/octet-stream`). `vercel.json`/`wrangler.toml` document clean-URL and 404 behavior.
13
+ - **Dev server survives a broken build.** A compile error during `darkmown dev` startup no longer crashes the process — the server stays up and the error appears in the overlay, clearing on the next good build.
14
+ - **Trust boundary documented.** README, SECURITY.md, and the docs site now state the trust model plainly: compile only authored content — `html: true` passes raw HTML through, `:fetch` has no URL allowlist, and reactive pages require `script-src 'unsafe-eval'`.
15
+
16
+ ## 0.11.2 — 2026-06-17
17
+
18
+ Product trust and release-hardening sprint.
19
+
20
+ - **Public demos fixed.** `/app/` now renders feature bullets from the current data shape, and deploy/form copy clearly distinguishes static hosting from server-backed demo endpoints.
21
+ - **Preview servers safer by default.** `darkmown dev` and `darkmown serve` bind to `127.0.0.1` unless `HOST` is set, and malformed preview URLs no longer crash the server.
22
+ - **Directive hardening.** Demo `:try` links reject dangerous URL schemes; `:input` and `:bind` now carry escaped accessibility attributes.
23
+ - **Release gates tightened.** Tag releases verify Node 20/22/24 before publishing, keep the runtime-size budget enforced, and prepublish checks cover typecheck, coverage, build, size, audit, and consumer smoke.
24
+ - **Runtime guidance synced.** Packaged guidance and public copy now describe the measured ~4.7 KB gzipped runtime with the under-5 KB CI budget.
25
+
5
26
  ## 0.11.1 — 2026-06-17
6
27
 
7
28
  Maintenance release. No functional changes since 0.11.0.
@@ -36,7 +57,7 @@ The "proof + full test pyramid" release: prove the moat on the homepage, test ev
36
57
  - **Full test pyramid.** Unit + integration backfill (grammar whitelists, skin compiler, interpolation, router, every compile-error path), a zero-dependency CLI/pipeline e2e (`darkmown init` → build → serve → fetch → assert), real-browser e2e via Playwright (`npm run test:e2e`), and a seeded property/fuzz suite (5,000 cases/run) for the parser and expression validators. The zero-dep `npm test` suite grew from 98 to ~200 checks; coverage is ~96% lines, gated at 80% in CI.
37
58
  - **The fuzzer earned its keep:** it found two real bugs, now fixed — two `:button` action errors that omitted the file path, and bare object interpolation (`{ meta }`) emitting `[object Object]` instead of an actionable error. Arrays now join with `, ` consistently.
38
59
  - **Type safety.** Source stays plain JavaScript but is fully type-checked via JSDoc + `checkJs` (`npm run typecheck`, gated in CI), and ships `.d.ts` declarations. `typescript` is a dev-only dependency; the runtime stays zero-dependency.
39
- - **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still downloads ~3.2 KB gzipped even though the source carries full type annotations. The size budget now measures the shipped artifact.
60
+ - **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still stays under the shared runtime budget; the current runtime is ~4.7 KB gzipped even as the source carries full type annotations. The size budget now measures the shipped artifact.
40
61
  - **Gold-standard hardening (46.5/50 audit).** Release pack-smoke test before publish, `prepublishOnly` guard, `dependabot` (npm + GitHub Actions), CI `concurrency` cancellation, `.github/` PR + issue templates, `CODEOWNERS`, a short `CODE_OF_CONDUCT.md`, an exposed/documented `window.wd.debug`, and repo-root tidy-up.
41
62
 
42
63
  ## 0.8.2 — 2026-06-14
@@ -47,7 +68,7 @@ Honesty, hardening, and polish pass — every claim in the docs is now backed by
47
68
  - **Runtime:** state changes now batch into a single render per tick (`requestAnimationFrame`, falling back to a microtask), so rapid `:bind` input no longer re-runs the reconciler on every keystroke; the final state always renders and the public `wd.render()` stays synchronous. Opt-in `window.wd.debug` surfaces warnings for failed `:computed` / `where` expressions that were previously silent.
48
69
  - **Tests:** first runtime/DOM test suite — keyed loop reconcile (node reuse on add/remove/reorder), `getPath` prototype-pollution rejection, loop-key collisions, and render batching — loading the real runtime via `node:vm`, no new dependencies. Suite grew 83 → 98.
49
70
  - **Compiler:** unterminated frontmatter now throws an actionable error with the file path instead of silently swallowing the page; empty `---`/`---` blocks parse cleanly.
50
- - **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size corrected to the true ~3.1 KB gzipped across all surfaces; `:note`/`:try`/`:sprint` documented as demo-only; `docs/cli.md` scaffold output synced; VS Code install instructions corrected (build from source; Marketplace listing pending).
71
+ - **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size copy corrected across all surfaces; the current runtime is ~4.7 KB gzipped and CI-enforced under 5 KB; `:note`/`:try`/`:sprint` documented as demo-only; `docs/cli.md` scaffold output synced; VS Code install instructions corrected (build from source; Marketplace listing pending).
51
72
  - **Site & tooling:** landing-page whitespace fixed (removed the forced `60vh` hero band); mobile nav no longer overflows; tagline locked to "Markdown that runs."; regenerated `package-lock.json` identity (`@zvndev/darkmown`).
52
73
 
53
74
  ## 0.8.1 — 2026-06-14
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **[darkmown.com](https://darkmown.com)** · Markdown that runs.
4
4
 
5
- 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 ~3.2 KB gzipped (CI-enforced under 5 KB).
5
+ 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 ~4.7 KB gzipped, CI-enforced under 5 KB.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -47,7 +47,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
47
47
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
48
48
  - `site/_` is the include shelf for `@include /name.wd`.
49
49
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
50
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~3.2 KB gzipped, CI-enforced under 5 KB).
50
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~4.7 KB gzipped, CI-enforced under 5 KB).
51
51
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
52
52
 
53
53
  ## Interpolation
@@ -425,7 +425,13 @@ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin
425
425
 
426
426
  ## Security
427
427
 
428
- Darkmown renders Markdown with `html: true`, so **raw HTML in your content passes through verbatim** — by design, like every Markdown site generator. Treat content files as trusted input.
428
+ ### Trust boundary
429
+
430
+ Darkmown is a **trusted-author** site generator: you compile content you wrote, the same way you trust your own source code. Three assumptions hold the model together — know them before you point Darkmown at content from anyone else.
431
+
432
+ - **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.
433
+ - **Raw HTML passes through by default.** Darkmown runs `markdown-it` with `html: true`, so raw HTML in your content is emitted verbatim — by design, like every Markdown site generator. There is no built-in sanitizer; a raw `<script>` or event-handler attribute in untrusted content would execute in the visitor's browser. A page can opt into strict mode with `html: false` frontmatter, which drops raw HTML entirely.
434
+ - **`:fetch` has no URL allowlist, and reactive pages need `unsafe-eval`.** A `:fetch` URL is taken straight from the page source, so the request goes wherever the author wrote. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
429
435
 
430
436
  > **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
431
437
 
package/docs/cli.md CHANGED
@@ -73,3 +73,9 @@ npm run smoke
73
73
  ```
74
74
 
75
75
  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.
76
+
77
+ ## Deployment notes
78
+
79
+ ### Cloudflare Pages
80
+
81
+ `wrangler.toml` builds and deploys the static `dist/` output for Cloudflare Pages. That configuration does **not** provide the demo `/__wd/echo` endpoint used by server-backed form examples; Vercel handles that route separately through `vercel.json` and `api/echo.js`. Add a Cloudflare Pages Function before advertising that demo endpoint on Cloudflare.
@@ -6,7 +6,7 @@ This audit compares the current Darkmown implementation with the original MARROW
6
6
 
7
7
  - **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
8
  - **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` (~3.2 KB gzipped, CI-enforced below the 5 KB target).
9
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~4.7 KB gzipped, CI-enforced below the 5 KB target).
10
10
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
11
11
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
12
12
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,16 +13,18 @@
13
13
  "types": "./types/index.d.ts",
14
14
  "scripts": {
15
15
  "build": "node src/cli.js build",
16
+ "runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 5120)'); if (size >= 5120) process.exit(1)\"",
16
17
  "dev": "node src/cli.js dev",
17
18
  "test": "node --test tests/*.test.js",
18
- "test:cov": "node --test --experimental-test-coverage tests/*.test.js",
19
+ "test:cov": "node scripts/test-coverage-gate.mjs 90",
19
20
  "test:e2e": "playwright test",
20
21
  "test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
21
22
  "pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
22
23
  "typecheck": "tsc --noEmit",
23
24
  "types": "tsc -p tsconfig.types.json",
24
- "prepublishOnly": "npm test",
25
- "smoke": "node scripts/smoke-consumer.mjs"
25
+ "smoke": "node scripts/smoke-consumer.mjs",
26
+ "prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
27
+ "prepublishOnly": "npm run prepublish:check"
26
28
  },
27
29
  "files": [
28
30
  "src",
package/site/_/team.json CHANGED
@@ -1,7 +1,7 @@
1
1
  [
2
- { "id": 1, "name": "PM", "role": "Acceptance & demo proof", "lead": true },
3
- { "id": 2, "name": "Dev", "role": "Compiler, router, runtime", "lead": false },
4
- { "id": 3, "name": "Research", "role": "Prior art & drift control", "lead": false },
5
- { "id": 4, "name": "Testing", "role": "Routes, includes, browser smoke", "lead": false },
6
- { "id": 5, "name": "Review", "role": "Blocks sloppy completion", "lead": false }
2
+ { "id": 1, "name": "Ada", "role": "Product", "lead": true },
3
+ { "id": 2, "name": "Lin", "role": "Engineering", "lead": false },
4
+ { "id": 3, "name": "Mateo", "role": "Design", "lead": false },
5
+ { "id": 4, "name": "Priya", "role": "Research", "lead": false },
6
+ { "id": 5, "name": "Noah", "role": "Support", "lead": false }
7
7
  ]
@@ -0,0 +1,21 @@
1
+ ---
2
+ title: Page not found — Darkmown
3
+ description: That route does not exist. Head back to the Darkmown homepage.
4
+ ---
5
+
6
+ @include /nav.wd
7
+
8
+ <main>
9
+
10
+ <section class="hero">
11
+ <p class="eyebrow">404</p>
12
+
13
+ # This page ran off the page.
14
+
15
+ The route you asked for is hidden, was never created, or has moved. Nothing was lost — Markdown that runs is still one click away.
16
+
17
+ <a class="btn primary" href="/">Back to home</a>
18
+ <a class="btn" href="/docs/">Read the docs</a>
19
+ </section>
20
+
21
+ </main>
package/site/pages/app.wd CHANGED
@@ -16,10 +16,10 @@ Everything on this page — the prose, the fetched list, the badges, the form, t
16
16
  This whole section is plain Markdown. The list below is unrolled **at build time** from a JSON file — it renders into HTML with no runtime at all:
17
17
 
18
18
  @loop /features.json into feature
19
- - **{ feature.name }** — { feature.detail }
19
+ - **{ feature.title }** — { feature.body }
20
20
  @endloop
21
21
 
22
- <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 ~2&nbsp;KB runtime loads — and only because the document asks for it.</aside>
22
+ <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 ~4.7&nbsp;KB gzipped runtime loads (CI-enforced under 5 KB) — and only because the document asks for it.</aside>
23
23
 
24
24
  ## Fetched data, looped, with nested conditions
25
25
 
@@ -68,7 +68,7 @@ Nice — you are tracking the whole near-term roadmap.
68
68
 
69
69
  ## A form that talks to a real server
70
70
 
71
- This form posts to a live serverless function and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST — Darkmown adapts to your backend instead of owning one:
71
+ This form posts to the demo echo endpoint and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST. On Vercel/dev the sample endpoint replies; on static-only hosts, point `action=` at your own backend — Darkmown adapts instead of owning one:
72
72
 
73
73
  :form action="/__wd/echo" into reply
74
74
  :input note placeholder="Tell us what you'd build with this"
@@ -68,7 +68,7 @@ The reply will appear here.
68
68
  The request failed: { reply_error }
69
69
  :endif
70
70
 
71
- In `darkmown dev` the echo endpoint is built into the dev server; on darkmown.com it is a real serverless function behind the same URL. Either way, Darkmown itself stays static — sessions ride on `:fetch` plus ordinary cookies against your real API.
71
+ In `darkmown dev` the echo endpoint is built into the dev server; on darkmown.com it is a real serverless function behind the same URL. On static-only hosts such as a plain Cloudflare Pages deploy, replace `action="/__wd/echo"` with your own API endpoint. Either way, Darkmown itself stays static — sessions ride on `:fetch` plus ordinary cookies against your real API.
72
72
 
73
73
  ## Persistent state survives reload
74
74
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Darkmown docs
3
+ description: Learn Darkmown routing, directives, state, loops, forms, and the static-to-reactive model.
3
4
  ---
4
5
 
5
6
  @include /nav.wd
@@ -169,7 +170,7 @@ tags: [sales, revenue, "q1, q2"]
169
170
 
170
171
  ## Reactive directives
171
172
 
172
- Reactive pages opt into `/__wd/runtime.js` (~3.2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
173
+ Reactive pages opt into `/__wd/runtime.js` (~4.7 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
173
174
 
174
175
  ```wd
175
176
  :state count = 0
@@ -264,6 +265,16 @@ You have { cart } items.
264
265
 
265
266
  See it all live on the [Data & Forms page](/data/).
266
267
 
268
+ ## Trust boundary
269
+
270
+ Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.
271
+
272
+ - **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.
273
+ - **Raw HTML passes through by default.** Markdown renders with `html: true`, so raw HTML is emitted verbatim and there is no built-in sanitizer. Set `html: false` in a page's frontmatter to drop raw HTML for untrusted content.
274
+ - **`:fetch` has no URL allowlist, and reactive pages need `unsafe-eval`.** Fetch URLs come straight from the page source, and the runtime compiles `:computed` / `@loop … where` expressions via `new Function` — so a reactive page needs `script-src 'unsafe-eval'` in your CSP. Static pages need neither.
275
+
276
+ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in the package for the full security model.
277
+
267
278
  ## Colocation
268
279
 
269
280
  - A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
@@ -96,14 +96,14 @@ Markdoc.renderers.react(content, React, {
96
96
 
97
97
  </section>
98
98
 
99
- :note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~3.2 KB runtime loads, never before. JS optional, CSS optional, always."
99
+ :note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~4.7 KB gzipped runtime loads (CI-enforced under 5 KB), never before. JS optional, CSS optional, always."
100
100
 
101
101
  <section class="props">
102
102
 
103
103
  ## Three rules, no escape hatch
104
104
 
105
105
  - **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
106
- - **One runtime.** Every reactive page shares the same ~3.2 KB runtime. No per-component bundles, no hydration tax that scales with your UI.
106
+ - **One runtime.** Every reactive page shares the same ~4.7 KB gzipped runtime, CI-enforced under 5 KB. No per-component bundles, no hydration tax that scales with your UI.
107
107
  - **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
108
108
 
109
109
  </section>
@@ -151,18 +151,10 @@ This button is powered by `site/pages/index.js`, discovered by matching the page
151
151
 
152
152
  ## Deploy in one click
153
153
 
154
- `npm run build` emits a plain static `dist/` — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build:
154
+ `npm run build` emits a plain static `dist/` — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build. The demo form posts to Darkmown's dev/Vercel echo endpoint; on static-only hosts, point `action=` at your own backend or remove that demo.
155
155
 
156
156
  <a class="btn primary" href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Vercel</a>
157
- <a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Cloudflare</a>
157
+ <a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy static to Cloudflare</a>
158
158
  <a class="btn" href="https://github.com/ZVN-DEV/darkmown">Star on GitHub</a>
159
159
 
160
- ## Built in the open
161
-
162
- Darkmown itself was built by a small agent team. The board below is a live `@loop` over real data — itself a demo of the framework rendering this page:
163
-
164
- :sprint min=4 max=6 roles="PM, Development, Research, Testing, Review"
165
-
166
- @include /sprint-loop.wd
167
-
168
160
  </main>
package/src/builder.js CHANGED
@@ -38,6 +38,9 @@ export function buildSite(cwd = process.cwd()) {
38
38
  /** @type {Set<string>} */
39
39
  const warned = new Set();
40
40
 
41
+ /** @type {string | undefined} HTML of the `/404/` route, copied to dist/404.html. */
42
+ let notFoundHtml;
43
+
41
44
  for (const route of routes) {
42
45
  const page = compilePage(route.file, paths);
43
46
  for (const warning of page.warnings || []) {
@@ -48,6 +51,7 @@ export function buildSite(cwd = process.cwd()) {
48
51
  const outFile = outputPathForRoute(paths.distRoot, route.route);
49
52
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
50
53
  fs.writeFileSync(outFile, page.html);
54
+ if (route.route === "/404/") notFoundHtml = page.html;
51
55
  emitAssets(page.assets, paths);
52
56
  if (page.assets.runtime) emitRuntime(paths);
53
57
  manifest.push({
@@ -61,6 +65,11 @@ export function buildSite(cwd = process.cwd()) {
61
65
  });
62
66
  }
63
67
 
68
+ // Hosts (Vercel, Cloudflare Pages) and the framework's own server auto-serve
69
+ // dist/404.html for misses. Prefer the compiled 404 route; fall back to a
70
+ // minimal page so a 404 is always available.
71
+ fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
72
+
64
73
  fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
65
74
  return { routes: manifest, distRoot: paths.distRoot };
66
75
  }
@@ -89,6 +98,30 @@ export function stripRuntimeComments(source) {
89
98
  .replace(/^[ \t]*\n/gm, "");
90
99
  }
91
100
 
101
+ /**
102
+ * A minimal, zero-JS fallback 404 page used only when no `404.wd` route exists.
103
+ * @returns {string}
104
+ */
105
+ function defaultNotFoundHtml() {
106
+ return [
107
+ "<!doctype html>",
108
+ '<html lang="en">',
109
+ "<head>",
110
+ '<meta charset="utf-8">',
111
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
112
+ "<title>Page not found</title>",
113
+ "</head>",
114
+ "<body>",
115
+ "<main>",
116
+ "<h1>Page not found</h1>",
117
+ "<p>That route does not exist. <a href=\"/\">Back to home</a>.</p>",
118
+ "</main>",
119
+ "</body>",
120
+ "</html>",
121
+ ""
122
+ ].join("\n");
123
+ }
124
+
92
125
  /**
93
126
  * @param {Paths} paths
94
127
  * @returns {void}
package/src/cli.js CHANGED
@@ -28,12 +28,24 @@ if (command === "help" || command === "--help" || command === "-h") {
28
28
  console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
29
29
  } else if (command === "dev") {
30
30
  const port = Number(process.env.PORT || 5173);
31
+ const host = process.env.HOST || "127.0.0.1";
31
32
  const distRoot = path.join(process.cwd(), "dist");
32
- buildSite();
33
33
  /** @type {Set<import("node:http").ServerResponse>} */
34
34
  const clients = new Set();
35
35
  /** @type {ReturnType<typeof setTimeout> | undefined} */
36
36
  let timer;
37
+ /** @type {string | undefined} Last build failure, replayed to clients on connect. */
38
+ let lastBuildError;
39
+
40
+ // A broken initial build must NOT crash the process before the HTTP server —
41
+ // and thus the error overlay — comes up. Record the error so new SSE clients
42
+ // see it on connect; the next successful rebuild clears it via `reload`.
43
+ try {
44
+ buildSite();
45
+ } catch (error) {
46
+ lastBuildError = (error instanceof Error ? error.message : String(error)).trim();
47
+ console.error(lastBuildError);
48
+ }
37
49
 
38
50
  // Rebuild in a child process so changes to Darkmown's own src/ always load
39
51
  // fresh modules — an in-process rebuild would reuse the stale import cache.
@@ -44,10 +56,12 @@ if (command === "help" || command === "--help" || command === "-h") {
44
56
  execFile(process.execPath, [cliPath, "build"], { cwd: process.cwd() }, (error, stdout, stderr) => {
45
57
  if (error) {
46
58
  const message = (stderr || stdout || String(error)).trim();
59
+ lastBuildError = message;
47
60
  console.error(message);
48
61
  broadcast(clients, `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`);
49
62
  return;
50
63
  }
64
+ lastBuildError = undefined;
51
65
  const elapsed = Math.round(performance.now() - started);
52
66
  if (stderr.trim()) console.warn(stderr.trim());
53
67
  broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
@@ -73,6 +87,11 @@ if (command === "help" || command === "--help" || command === "-h") {
73
87
  });
74
88
  res.write("\n");
75
89
  clients.add(res);
90
+ // Replay a broken build to clients that connect while it's still failing,
91
+ // so the overlay shows even after a startup or pre-connect compile error.
92
+ if (lastBuildError) {
93
+ res.write(`event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`);
94
+ }
76
95
  req.on("close", () => clients.delete(res));
77
96
  return;
78
97
  }
@@ -100,19 +119,20 @@ if (command === "help" || command === "--help" || command === "-h") {
100
119
  res.end((error instanceof Error && error.stack) || String(error));
101
120
  }
102
121
  });
103
- server.listen(port, () => {
104
- console.log(`Darkmown dev server ready at http://localhost:${port}`);
122
+ server.listen(port, host, () => {
123
+ console.log(`Darkmown dev server ready at http://${host}:${port}`);
105
124
  console.log(`Live compiler watching site/ and src/`);
106
125
  });
107
126
  } else if (command === "serve") {
108
127
  const port = Number(process.env.PORT || 4173);
128
+ const host = process.env.HOST || "127.0.0.1";
109
129
  const distRoot = path.join(process.cwd(), "dist");
110
130
  if (!fs.existsSync(distRoot)) {
111
131
  console.error("No dist directory found. Run `darkmown build` first.");
112
132
  process.exit(1);
113
133
  }
114
- http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, () => {
115
- console.log(`Darkmown preview of dist at http://localhost:${port}`);
134
+ http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, host, () => {
135
+ console.log(`Darkmown preview of dist at http://${host}:${port}`);
116
136
  });
117
137
  } else {
118
138
  console.error(`Unknown command: ${command}`);
package/src/compiler.js CHANGED
@@ -400,7 +400,7 @@ function compileBody(lines, ctx) {
400
400
  i = block.end;
401
401
  continue;
402
402
  }
403
- const demo = renderDemoDirective(line);
403
+ const demo = renderDemoDirective(line, ctx);
404
404
  if (demo) {
405
405
  flush();
406
406
  out.push(demo);
@@ -838,7 +838,10 @@ function compileComputedExpr(raw, ctx) {
838
838
  /** @type {string[]} */
839
839
  const strings = [];
840
840
  let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
841
- strings.push(`"${literal.slice(1, -1)}"`);
841
+ // JSON.stringify fully escapes embedded ", \, etc. so the re-inserted literal
842
+ // (line below) is an inert JS string and cannot terminate early to smuggle in
843
+ // live code. Mirrors the SAFE @loop where path (compileOperand).
844
+ strings.push(JSON.stringify(literal.slice(1, -1)));
842
845
  return `__WDSTR${strings.length - 1}__`;
843
846
  });
844
847
  if (/["'\\`]/.test(expr)) {
@@ -910,6 +913,8 @@ function handleInput(line, ctx) {
910
913
  if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
911
914
  const attrs = [`name="${escapeHtml(match[1])}"`];
912
915
  let type = "text";
916
+ let placeholder;
917
+ let hasAria = false;
913
918
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
914
919
  for (const token of (match[2] || "").matchAll(re)) {
915
920
  if (token[3]) {
@@ -924,11 +929,19 @@ function handleInput(line, ctx) {
924
929
  type = value;
925
930
  continue;
926
931
  }
927
- if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete"].includes(token[1])) {
932
+ if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
928
933
  throw new Error(`Unknown :input attribute "${token[1]}" in ${ctx.file}`);
929
934
  }
935
+ if (token[1] === "placeholder") placeholder = value;
936
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
930
937
  attrs.push(`${token[1]}="${escapeHtml(value)}"`);
931
938
  }
939
+ // Accessible name: if the author supplied neither aria-label nor aria-describedby,
940
+ // derive a non-visual aria-label from the placeholder, else from the field name.
941
+ // Never overrides an author-supplied aria attribute (zero layout impact).
942
+ if (!hasAria) {
943
+ attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
944
+ }
932
945
  return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
933
946
  }
934
947
 
@@ -949,6 +962,8 @@ function handleBind(line, ctx) {
949
962
  }
950
963
  ctx.comp.assets.runtime = true;
951
964
  let type = "text";
965
+ let placeholder;
966
+ let hasAria = false;
952
967
  const attrs = [];
953
968
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
954
969
  for (const token of (match[2] || "").matchAll(re)) {
@@ -959,11 +974,19 @@ function handleBind(line, ctx) {
959
974
  }
960
975
  const value = stripQuotes(token[2]);
961
976
  if (token[1] === "type") { type = value; continue; }
962
- if (!["placeholder", "autocomplete"].includes(token[1])) {
977
+ if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
963
978
  throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
964
979
  }
980
+ if (token[1] === "placeholder") placeholder = value;
981
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
965
982
  attrs.push(`${token[1]}="${escapeHtml(value)}"`);
966
983
  }
984
+ // Accessible name (see handleInput): default a non-visual aria-label from the
985
+ // placeholder, else a humanized version of the bound state key. The author's own
986
+ // aria-label/aria-describedby always wins.
987
+ if (!hasAria) {
988
+ attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
989
+ }
967
990
  const initial = ctx.comp.state.get(key);
968
991
  const valueAttr = initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
969
992
  return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
@@ -1571,11 +1594,15 @@ export function loopKeyOf(item, counts) {
1571
1594
  /**
1572
1595
  * Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
1573
1596
  * @param {string} line
1597
+ * @param {Ctx} ctx
1574
1598
  * @returns {string}
1575
1599
  */
1576
- function renderDemoDirective(line) {
1600
+ function renderDemoDirective(line, ctx) {
1577
1601
  const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
1578
- if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
1602
+ if (tryMatch) {
1603
+ const href = validateDemoHref(tryMatch[2], ctx);
1604
+ return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
1605
+ }
1579
1606
  const note = line.match(/^:note\s+"([^"]+)"$/);
1580
1607
  if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
1581
1608
  const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
@@ -1586,6 +1613,30 @@ function renderDemoDirective(line) {
1586
1613
  return "";
1587
1614
  }
1588
1615
 
1616
+ /**
1617
+ * Validate demo-card links without adding arbitrary URL passthrough.
1618
+ * @param {string} href
1619
+ * @param {Ctx} ctx
1620
+ * @returns {string}
1621
+ */
1622
+ function validateDemoHref(href, ctx) {
1623
+ const value = href.trim();
1624
+ if (value !== href || /[\u0000-\u001F\u007F]/.test(value)) {
1625
+ throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
1626
+ }
1627
+ if (value.startsWith("//")) {
1628
+ throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http: or https: explicitly.`);
1629
+ }
1630
+ if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("#")) {
1631
+ return value;
1632
+ }
1633
+ const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
1634
+ if (scheme && ["http", "https", "mailto"].includes(scheme[1].toLowerCase())) {
1635
+ return value;
1636
+ }
1637
+ throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
1638
+ }
1639
+
1589
1640
  // ---------------------------------------------------------------------------
1590
1641
  // Interpolation: one syntax, { name } / { name.path }
1591
1642
  // ---------------------------------------------------------------------------
@@ -2061,6 +2112,23 @@ function stripQuotes(value) {
2061
2112
  return value?.replace(/^["']|["']$/g, "") ?? "";
2062
2113
  }
2063
2114
 
2115
+ /**
2116
+ * Turn a field name / state key into a human-readable accessible name, e.g.
2117
+ * `quest` → "Quest", `user-name` → "User name", `firstName` → "First name".
2118
+ * Hyphens, underscores, and camelCase boundaries become spaces; the first letter
2119
+ * is capitalized. Used to auto-derive aria-label on generated form inputs.
2120
+ * @param {string} name
2121
+ * @returns {string}
2122
+ */
2123
+ function humanizeName(name) {
2124
+ const words = name
2125
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // split camelCase
2126
+ .replace(/[-_]+/g, " ") // hyphens/underscores → spaces
2127
+ .trim()
2128
+ .toLowerCase();
2129
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
2130
+ }
2131
+
2064
2132
  /**
2065
2133
  * Escape a value for safe inclusion in HTML text and attribute contexts.
2066
2134
  * @param {unknown} value
package/src/statics.js CHANGED
@@ -12,7 +12,12 @@ import path from "node:path";
12
12
  export function serve(distRoot, url, res) {
13
13
  const file = resolvePublicFile(distRoot, url || "/");
14
14
  if (!file || !fs.existsSync(file)) {
15
- res.writeHead(404, { "content-type": "text/html" });
15
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
16
+ const notFound = path.join(distRoot, "404.html");
17
+ if (fs.existsSync(notFound)) {
18
+ res.end(fs.readFileSync(notFound));
19
+ return;
20
+ }
16
21
  res.end("<h1>Not found</h1><p>This route is hidden or has not been created.</p>");
17
22
  return;
18
23
  }
@@ -28,7 +33,12 @@ export function serve(distRoot, url, res) {
28
33
  * @returns {string | null}
29
34
  */
30
35
  export function resolvePublicFile(distRoot, url) {
31
- const cleanUrl = decodeURIComponent(url.split("?")[0]);
36
+ let cleanUrl;
37
+ try {
38
+ cleanUrl = decodeURIComponent(url.split("?")[0]);
39
+ } catch {
40
+ return null;
41
+ }
32
42
  const base = cleanUrl.startsWith("/__wd/")
33
43
  ? cleanUrl
34
44
  : path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
@@ -39,13 +49,37 @@ export function resolvePublicFile(distRoot, url) {
39
49
  }
40
50
 
41
51
  /**
42
- * Map a file path to a response content-type, defaulting to `text/html`.
52
+ * Map a file extension to a response content-type. Unknown types fall back to
53
+ * `application/octet-stream` so the browser never mis-sniffs an asset as HTML.
54
+ * @type {Record<string, string>}
55
+ */
56
+ const contentTypes = {
57
+ ".html": "text/html; charset=utf-8",
58
+ ".css": "text/css; charset=utf-8",
59
+ ".js": "text/javascript; charset=utf-8",
60
+ ".json": "application/json",
61
+ ".map": "application/json",
62
+ ".svg": "image/svg+xml",
63
+ ".png": "image/png",
64
+ ".jpg": "image/jpeg",
65
+ ".jpeg": "image/jpeg",
66
+ ".gif": "image/gif",
67
+ ".webp": "image/webp",
68
+ ".ico": "image/x-icon",
69
+ ".woff": "font/woff",
70
+ ".woff2": "font/woff2",
71
+ ".ttf": "font/ttf",
72
+ ".otf": "font/otf",
73
+ ".txt": "text/plain; charset=utf-8",
74
+ ".xml": "application/xml"
75
+ };
76
+
77
+ /**
78
+ * Map a file path to a response content-type by extension, defaulting to
79
+ * `application/octet-stream` for unknown types.
43
80
  * @param {string} file File path or name.
44
81
  * @returns {string}
45
82
  */
46
83
  export function contentType(file) {
47
- if (file.endsWith(".css")) return "text/css";
48
- if (file.endsWith(".js")) return "text/javascript";
49
- if (file.endsWith(".json")) return "application/json";
50
- return "text/html";
84
+ return contentTypes[path.extname(file).toLowerCase()] || "application/octet-stream";
51
85
  }
@@ -1,11 +0,0 @@
1
- ## Long-running session sprinting
2
-
3
- Darkmown treats sprinting as a visible operating loop, not as hidden recursion inside Markdown.
4
-
5
- - PM keeps the acceptance checklist and decides whether the demo proves the promise.
6
- - Development owns compiler, router, runtime, and demo integration.
7
- - Research checks the theory against prior art and keeps naming or platform drift from leaking in.
8
- - Testing owns route, include, asset, and browser smoke evidence.
9
- - Review inspects the final diff and blocks sloppy completion.
10
-
11
- The standing team size is 4 to 6. If work expands, add a design or architecture lane; if work shrinks, keep PM, development, testing, and review active.