@zvndev/darkmown 2.0.0 → 2.1.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 +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +31 -7
- package/SECURITY.md +4 -4
- package/docs/spec-alignment.md +19 -6
- package/package.json +5 -2
- package/site/_/footer.wd +1 -1
- package/site/_/nav.wd +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/docs/index.wd +18 -2
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +7 -3
- package/site/pages/playground/index.js +158 -0
- package/site/pages/playground/index.skin +128 -0
- package/site/pages/playground/index.wd +51 -0
- package/site/pages/showcase/index.wd +1 -1
- package/src/builder.js +10 -8
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +22 -4
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +25 -14
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +44 -7
- package/src/compiler/page.js +97 -17
- package/src/compiler/predicates.js +4 -12
- package/src/compiler/reader.js +60 -0
- package/src/compiler/state.js +16 -18
- package/src/compiler/structure.js +40 -10
- package/src/compiler.js +57 -6
- package/src/headers.js +15 -13
- package/src/index.js +1 -0
- package/src/runtime.js +77 -45
- package/src/statics.js +6 -7
- package/src/templates/blog/site/_/nav.wd +6 -9
- package/src/templates/blog/site/pages/index.skin +7 -4
- package/src/templates/blog/site/pages/index.wd +2 -3
- package/src/templates/dashboard/site/_/nav.wd +6 -9
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -3
- package/src/templates/landing/site/_/nav.wd +6 -9
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -9
- package/src/templates/starter/site/_/nav.wd +7 -10
- package/src/templates/starter/site/pages/about.md +7 -18
- package/src/templates/starter/site/pages/about.skin +0 -25
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -3
- package/src/templates/store/site/_/nav.wd +6 -9
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -3
- package/types/compiler.d.ts +13 -0
- 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.
|
|
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,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
|
+
## 2.1.0 — 2026-07-11
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **`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).
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+
## 2.0.1 — 2026-07-11
|
|
15
|
+
|
|
16
|
+
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).
|
|
17
|
+
|
|
18
|
+
- **A third reactive `@loop` level is now a compile error.** The runtime supports one level of reactive nesting; a third reactive level previously compiled clean and painted the inner region empty at runtime. It now fails the build with `file:line`, the offending opener quoted, and a corrective suggestion — including when the third level arrives through an `@include` (nesting depth threads across the include boundary). Static (build-unrolled) nesting is unaffected at any depth.
|
|
19
|
+
- **`::: nav` and `::: main` containers emit real landmark elements.** A container named `nav` or `main` now compiles to `<nav class="nav">` / `<main class="main">` instead of a `<div>` (the name stays as a class, so existing `.nav`/`.main` skin selectors match unchanged; every other name still emits a `<div>`). With that, the shell reuses the container's `<main>` for its `id="main"` skip-link target instead of wrapping the whole body — nav chrome sits outside `<main>` and "Skip to content" actually skips it.
|
|
20
|
+
- **Scaffold templates no longer set `html: true`.** 2.0.0 made escaped markdown the default and called raw-HTML mode the #1 footgun — while every template scaffolded with the flag on. All five templates now express their nav/layout/CTAs with container directives and `{.class}` inline attributes instead of raw HTML (zero `html: true` anywhere in a fresh scaffold), and with the landmark containers above they keep proper `<nav>`/`<main>` structure. The starter's `about.md` becomes pure CommonMark with a "Back home" link — a `.md` page can't include the shared nav, and the page now says so instead of duplicating raw markup.
|
|
21
|
+
- **`file:line` on the remaining compile errors.** Malformed `@loop` headers and include-resolution failures (unresolvable path, sandbox escape) reported only the file; they now report `file:line` like every other directive error, and the unresolvable-include message gained a hint ("Looked in site/pages and site/_ — check the path and leading slash").
|
|
22
|
+
- **Size prose is drift-guarded.** Docs and site copy quoting the runtime size (now "~7.5 KB" everywhere current-tense) are asserted against `.size-snapshot.json` by a new test, so the number in the prose can never silently diverge from the shipped bytes again.
|
|
23
|
+
- **darkmown.com hero: install one-liner with a copy button.** The landing page now puts `npx @zvndev/darkmown init my-site` (click-to-copy) directly under the CTAs, and the site docs gained a CLI reference section covering all six commands.
|
|
24
|
+
- **Internal: a build-throughput benchmark.** `npm run bench:build` generates a synthetic site (collections, includes, static loops, highlighted code) and reports pages/sec; it refuses to touch a `site/` directory it didn't generate itself.
|
|
25
|
+
|
|
5
26
|
## 2.0.0 — 2026-07-11
|
|
6
27
|
|
|
7
28
|
The raw-HTML markdown default flips to escaped (`html: false` — the breaking change that makes this a major), alongside security hardening and a DX pass from a product review: incremental dev rebuilds, true error line numbers, a docs table of contents, and styled templates. Nearly all build-time and dev-server; the one runtime change — deterministic UTC date pipes — nudges the runtime to 7518 B gzipped, still well under the 8 KB budget.
|
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.
|
|
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
|
|
@@ -65,7 +69,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
65
69
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
66
70
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
67
71
|
- 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.
|
|
72
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~7.7 KB gzipped, CI-enforced under 8 KB).
|
|
69
73
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
70
74
|
|
|
71
75
|
## Interpolation
|
|
@@ -220,7 +224,7 @@ Add `where <predicate>` to filter a loop. Conditions compare a loop-item field a
|
|
|
220
224
|
@endloop
|
|
221
225
|
```
|
|
222
226
|
|
|
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
|
|
227
|
+
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
228
|
|
|
225
229
|
**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
230
|
|
|
@@ -353,7 +357,7 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
|
|
|
353
357
|
- Interpolation inside the inner body resolves the inner item first, then the outer item — `{ line.qty }` and `{ order.ref }` are both in scope.
|
|
354
358
|
- Build-time loops over JSON/frontmatter already nest freely (the "loops nest" behavior above); what's new is **reactive-inside-reactive** over a `:state`/`:store`/`:fetch` row field.
|
|
355
359
|
|
|
356
|
-
> **Honest caveat:** nesting is **one level only** — an inner reactive loop
|
|
360
|
+
> **Honest caveat:** nesting is **one level only** — an inner reactive loop that itself contains a third reactive loop is a **compile error** with a corrective message, not a silent runtime failure. Build-time loop nesting is unaffected by this limit.
|
|
357
361
|
>
|
|
358
362
|
> **Per-row actions inside an inner loop:** `cart += member` (append-row) works from an inner loop — it carries the inner item into a top-level `:state`/`:store` list. But a per-row **`remove`** needs a top-level list as its source, and an inner loop's source is a path off the outer row (`team.members`), so deleting an inner row in place is **not** supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
|
|
359
363
|
|
|
@@ -440,6 +444,8 @@ Cart has { count } items.
|
|
|
440
444
|
|
|
441
445
|
State declared inside a section is scoped to it — two sections can both own a `count`. Bindings and actions resolve to the nearest scope.
|
|
442
446
|
|
|
447
|
+
A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) instead of a `<div>`, so scaffolded pages keep proper landmarks — a skip link skips a `::: nav`, and `::: main` becomes the page's `<main id="main">`. Any other name stays a `<div>` with that class.
|
|
448
|
+
|
|
443
449
|
### Reactive classes — `.class when <predicate>`
|
|
444
450
|
|
|
445
451
|
A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
|
|
@@ -960,6 +966,24 @@ That is the whole contract: the framework reconciles state, text, classes, and l
|
|
|
960
966
|
|
|
961
967
|
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.
|
|
962
968
|
|
|
969
|
+
## Programmatic compile — `compileFromMemory`
|
|
970
|
+
|
|
971
|
+
`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):
|
|
972
|
+
|
|
973
|
+
```js
|
|
974
|
+
import { compileFromMemory } from "@zvndev/darkmown";
|
|
975
|
+
|
|
976
|
+
const { html, assets } = compileFromMemory(
|
|
977
|
+
{
|
|
978
|
+
"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",
|
|
979
|
+
"site/_/nav.wd": "..." // @include targets resolve from the same map
|
|
980
|
+
},
|
|
981
|
+
"site/pages/index.wd"
|
|
982
|
+
);
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
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.
|
|
986
|
+
|
|
963
987
|
## Editor support
|
|
964
988
|
|
|
965
989
|
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.
|
|
@@ -990,17 +1014,17 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
|
|
|
990
1014
|
|
|
991
1015
|
- **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.
|
|
992
1016
|
- **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.
|
|
993
|
-
- **`:fetch` and `:form action=` have no host allowlist
|
|
1017
|
+
- **`: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.)*
|
|
994
1018
|
|
|
995
1019
|
> **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)).
|
|
996
1020
|
|
|
997
|
-
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
|
|
1021
|
+
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.
|
|
998
1022
|
|
|
999
1023
|
### Shipped security headers
|
|
1000
1024
|
|
|
1001
1025
|
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:
|
|
1002
1026
|
|
|
1003
|
-
- **`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.
|
|
1027
|
+
- **`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.
|
|
1004
1028
|
- **`X-Content-Type-Options: nosniff`**, **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
|
|
1005
1029
|
|
|
1006
1030
|
**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
|
|
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
|
|
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). **
|
|
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
|
|
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/spec-alignment.md
CHANGED
|
@@ -2,14 +2,22 @@
|
|
|
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.
|
|
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.
|
|
20
|
+
- **Semantic landmark containers:** `::: nav` / `::: main` emit the real `<nav>`/`<main>` element (keeping the name as a class for styling); other container names stay `<div>`s. Keeps skip links and landmarks intact without raw HTML.
|
|
13
21
|
- **`.skin` language:** indentation-based token styling compiles to CSS.
|
|
14
22
|
- **Folder router:** `site/pages` maps to routes, and `.`, `-`, `_` prefixes hide pages.
|
|
15
23
|
- **Colocation:** matching `.skin` and `.js` files attach to the page by basename, for includes too.
|
|
@@ -49,7 +57,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
49
57
|
## Stage 6 additions (2026-06-13)
|
|
50
58
|
|
|
51
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.
|
|
52
|
-
- **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.
|
|
53
61
|
- **`:bind <state>`:** two-way `<input>` bound to a `:state` value — the input primitive that powers live search. Accepts `type=`, `placeholder=`, `autocomplete=`, `required`, `autofocus`.
|
|
54
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.)
|
|
55
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.
|
|
@@ -71,7 +79,7 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
71
79
|
|
|
72
80
|
## Stage 10 additions (2026-06-18, v0.14.0)
|
|
73
81
|
|
|
74
|
-
- **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).
|
|
75
83
|
|
|
76
84
|
## Stage 12 additions (2026-06-23, v0.15.0)
|
|
77
85
|
|
|
@@ -89,7 +97,7 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
89
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.
|
|
90
98
|
|
|
91
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).
|
|
92
|
-
- **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
|
|
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.)*
|
|
93
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.
|
|
94
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.
|
|
95
103
|
|
|
@@ -128,7 +136,7 @@ The 1.0 release: a value layer, a time primitive, an explicit theme toggle, reac
|
|
|
128
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).
|
|
129
137
|
|
|
130
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.)
|
|
131
|
-
- **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.)*
|
|
132
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=`.
|
|
133
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.
|
|
134
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.
|
|
@@ -151,7 +159,7 @@ The 1.5.0 cycle ships the headline **content collections** feature: turning a fo
|
|
|
151
159
|
## 2.0.0 fixes — review follow-ups (2026-07-11)
|
|
152
160
|
|
|
153
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).
|
|
154
|
-
- **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.
|
|
155
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).
|
|
156
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.
|
|
157
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.
|
|
@@ -191,6 +199,11 @@ The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Trac
|
|
|
191
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.
|
|
192
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.
|
|
193
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
|
+
|
|
194
207
|
## Demo-only directives (not part of the public surface)
|
|
195
208
|
|
|
196
209
|
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`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"types": "./types/index.d.ts",
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "node src/cli.js build",
|
|
21
|
+
"build:playground": "node scripts/build-playground.mjs",
|
|
21
22
|
"runtime:size": "node scripts/size-check.mjs",
|
|
22
23
|
"dev": "node src/cli.js dev",
|
|
23
24
|
"test": "node --test tests/*.test.js",
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
"prepare": "husky || true",
|
|
36
37
|
"prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
|
|
37
38
|
"prepublishOnly": "npm run prepublish:check",
|
|
38
|
-
"og": "node scripts/og-image.mjs"
|
|
39
|
+
"og": "node scripts/og-image.mjs",
|
|
40
|
+
"bench:build": "node scripts/bench-build.mjs"
|
|
39
41
|
},
|
|
40
42
|
"lint-staged": {
|
|
41
43
|
"*.{js,json}": "biome check --no-errors-on-unmatched --files-ignore-unknown=true"
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
"@playwright/test": "^1.49.0",
|
|
67
69
|
"@types/markdown-it": "^14.1.2",
|
|
68
70
|
"@types/node": "^26.0.0",
|
|
71
|
+
"esbuild": "^0.24.2",
|
|
69
72
|
"husky": "^9.1.7",
|
|
70
73
|
"lint-staged": "^16.4.0",
|
|
71
74
|
"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.
|
|
9
|
+
<div class="small">Zero JS on static pages · one ~7.7 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
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.
|
|
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 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.
|
|
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
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -47,6 +47,7 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
|
|
|
47
47
|
- [Syntax highlighting](#syntax-highlighting)
|
|
48
48
|
- [Trust boundary](#trust-boundary)
|
|
49
49
|
- [Colocation](#colocation)
|
|
50
|
+
- [CLI reference](#cli-reference)
|
|
50
51
|
- [Spec status](#spec-status)
|
|
51
52
|
</nav>
|
|
52
53
|
|
|
@@ -65,6 +66,8 @@ npm run dev
|
|
|
65
66
|
|
|
66
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`.
|
|
67
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
|
+
|
|
68
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).
|
|
69
72
|
|
|
70
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.
|
|
@@ -308,9 +311,11 @@ You don't size images by hand. Every `<img>` the compiler emits gets its intrins
|
|
|
308
311
|
|
|
309
312
|
`::: section #id .class` opens a container and `:::` closes it. Sections scope state: a `:state` declared inside a section belongs to that section, so two sections can both declare `count` without colliding. Bindings and buttons resolve to the nearest scope.
|
|
310
313
|
|
|
314
|
+
A container named `nav` or `main` emits the real landmark element (`<nav class="nav">`, `<main class="main">`) so pages keep proper landmarks without raw HTML. Any other name stays a `<div>` with that class.
|
|
315
|
+
|
|
311
316
|
## Reactive directives
|
|
312
317
|
|
|
313
|
-
Reactive pages opt into `/__wd/runtime.js` (~7.
|
|
318
|
+
Reactive pages opt into `/__wd/runtime.js` (~7.7 KB gzipped, under a CI-enforced 8 KB budget). Static pages do not.
|
|
314
319
|
|
|
315
320
|
```wd
|
|
316
321
|
:state count = 0
|
|
@@ -588,7 +593,7 @@ Darkmown is a trusted-author site generator: you compile content you wrote, the
|
|
|
588
593
|
|
|
589
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.
|
|
590
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.
|
|
591
|
-
- **`:fetch` and `:form action=` have no host allowlist
|
|
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.
|
|
592
597
|
|
|
593
598
|
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) in the package for the full security model.
|
|
594
599
|
|
|
@@ -599,6 +604,17 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/master/SECURITY.md) i
|
|
|
599
604
|
- Both work for included fragments too, by basename.
|
|
600
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.
|
|
601
606
|
|
|
607
|
+
## CLI reference
|
|
608
|
+
|
|
609
|
+
The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool — every command is one word:
|
|
610
|
+
|
|
611
|
+
- `darkmown init [dir] [--template <name>]` — scaffold a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`). Existing files are never overwritten.
|
|
612
|
+
- `darkmown dev` — the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner for `api/*` functions.
|
|
613
|
+
- `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.
|
|
614
|
+
- `darkmown serve` — preview the built `dist/` locally (static only; use `dev` to exercise `api/*`).
|
|
615
|
+
- `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.
|
|
616
|
+
- `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.
|
|
617
|
+
|
|
602
618
|
## Spec status
|
|
603
619
|
|
|
604
620
|
The implementation is faithful to the original core thesis: Markdown-first authoring, no component ceremony, zero runtime on static pages, and tiny direct-DOM reactivity only when declared. `:store`, the full `:fetch` lifecycle (loading/error/empty states, dynamic URLs, `refetch`, dotted loop sources, authenticated `headers=` + `refresh=` token renewal), `:form` (including server round-trips), loop ergonomics (`sort by`/`limit`/`offset`/`reverse`/`@empty` and `$index`/`$number`/`$first`/`$last`/`$count`), `:if … :else if … :else`, reactive `.class when …`, `:effect`, `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/) and the [Reactive page](/reactive/). Still on the roadmap: HTML-fragment `swap` semantics and cart server sync. (The serverless `api/` model already shipped in 1.2.0 — see [Backends & deploy](#backends--deploy) above; a `.wd` backend DSL remains a deliberate non-goal.) See `docs/spec-alignment.md` in the package for the full audit.
|
package/site/pages/index.js
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
const copyBtn = document.querySelector("[data-copy-cmd]");
|
|
2
|
+
|
|
3
|
+
if (copyBtn) {
|
|
4
|
+
copyBtn.addEventListener("click", async () => {
|
|
5
|
+
const cmd = copyBtn.previousElementSibling?.textContent ?? "";
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(cmd);
|
|
8
|
+
copyBtn.textContent = "Copied";
|
|
9
|
+
copyBtn.classList.add("is-copied");
|
|
10
|
+
setTimeout(() => {
|
|
11
|
+
copyBtn.textContent = "Copy";
|
|
12
|
+
copyBtn.classList.remove("is-copied");
|
|
13
|
+
}, 1600);
|
|
14
|
+
} catch {
|
|
15
|
+
copyBtn.textContent = "Select + ⌘C";
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
const button = document.querySelector("[data-wd-demo]");
|
|
2
21
|
|
|
3
22
|
if (button) {
|
package/site/pages/index.skin
CHANGED
|
@@ -29,7 +29,52 @@
|
|
|
29
29
|
.hero-cta
|
|
30
30
|
display flex
|
|
31
31
|
flex-wrap wrap
|
|
32
|
-
margin-bottom
|
|
32
|
+
margin-bottom 1.5rem
|
|
33
|
+
|
|
34
|
+
.hero-install
|
|
35
|
+
display inline-flex
|
|
36
|
+
align-items center
|
|
37
|
+
gap .55rem
|
|
38
|
+
max-width 100%
|
|
39
|
+
margin 0 0 2rem
|
|
40
|
+
padding .7rem 1rem
|
|
41
|
+
border 1px solid $line
|
|
42
|
+
radius 10px
|
|
43
|
+
bg $sunk
|
|
44
|
+
font-family ui-monospace, SFMono-Regular, Menlo, monospace
|
|
45
|
+
overflow-x auto
|
|
46
|
+
|
|
47
|
+
.hero-install::before
|
|
48
|
+
content "$"
|
|
49
|
+
color $accent
|
|
50
|
+
font-weight 700
|
|
51
|
+
flex-shrink 0
|
|
52
|
+
|
|
53
|
+
.hero-install code
|
|
54
|
+
font-family inherit
|
|
55
|
+
font-size .95rem
|
|
56
|
+
color $ink
|
|
57
|
+
white-space nowrap
|
|
58
|
+
|
|
59
|
+
.hero-install .copy-cmd
|
|
60
|
+
flex-shrink 0
|
|
61
|
+
margin-left .35rem
|
|
62
|
+
padding .25rem .65rem
|
|
63
|
+
border 1px solid $line
|
|
64
|
+
radius 7px
|
|
65
|
+
bg transparent
|
|
66
|
+
color $ink-soft
|
|
67
|
+
font-family inherit
|
|
68
|
+
font-size .8rem
|
|
69
|
+
cursor pointer
|
|
70
|
+
|
|
71
|
+
.hero-install .copy-cmd:hover
|
|
72
|
+
color $ink
|
|
73
|
+
border-color $accent
|
|
74
|
+
|
|
75
|
+
.hero-install .copy-cmd.is-copied
|
|
76
|
+
color $accent
|
|
77
|
+
border-color $accent
|
|
33
78
|
|
|
34
79
|
.hero-stats
|
|
35
80
|
display grid
|
package/site/pages/index.wd
CHANGED
|
@@ -24,9 +24,13 @@ The left file is Markdown. It is also a running app — a live search filter, ad
|
|
|
24
24
|
<a class="btn" href="/docs/">Read the docs</a>
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
|
+
<div class="hero-install"><code>npx @zvndev/darkmown init my-site</code><button type="button" class="copy-cmd" data-copy-cmd aria-label="Copy install command">Copy</button></div>
|
|
28
|
+
|
|
29
|
+
<p class="hero-try"><a href="/playground/">or try it in your browser →</a></p>
|
|
30
|
+
|
|
27
31
|
<ul class="hero-stats">
|
|
28
32
|
<li><strong>0 KB</strong><span>framework JS on static pages</span></li>
|
|
29
|
-
<li><strong>~7.
|
|
33
|
+
<li><strong>~7.7 KB</strong><span>one runtime, CI-enforced < 8 KB</span></li>
|
|
30
34
|
<li><strong>1</strong><span>loop and one binding to learn</span></li>
|
|
31
35
|
<li><strong>MIT</strong><span>zero runtime dependencies</span></li>
|
|
32
36
|
</ul>
|
|
@@ -110,14 +114,14 @@ Markdoc.renderers.react(content, React, {
|
|
|
110
114
|
|
|
111
115
|
</section>
|
|
112
116
|
|
|
113
|
-
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~7.
|
|
117
|
+
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~7.7 KB gzipped runtime loads (CI-enforced under 8 KB), never before. JS optional, CSS optional, always."
|
|
114
118
|
|
|
115
119
|
<section class="props">
|
|
116
120
|
|
|
117
121
|
## Three rules, no escape hatch
|
|
118
122
|
|
|
119
123
|
- **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
|
|
120
|
-
- **One runtime.** Every reactive page shares the same ~7.
|
|
124
|
+
- **One runtime.** Every reactive page shares the same ~7.7 KB gzipped runtime, CI-enforced under 8 KB. No per-component bundles, no hydration tax that scales with your UI.
|
|
121
125
|
- **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
|
|
122
126
|
|
|
123
127
|
</section>
|