@zvndev/darkmown 0.12.1 → 0.14.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 ~4.7 KB gzipped runtime (CI-enforced under 5 KB) only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~5.7 KB gzipped runtime (CI-enforced under 6 KB) only if it declares reactive behavior.
11
11
 
12
12
  ## Project layout & routing
13
13
 
@@ -20,6 +20,7 @@ package.json
20
20
  - **Folders are routes.** `site/pages/blog/post.wd` serves at `/blog/post/`.
21
21
  - Files/folders starting with `.`, `-`, or `_` are **hidden** from routing (drafts, partials).
22
22
  - A `page.skin` next to `page.wd` attaches styling; a `page.js` attaches behavior. Matched by basename.
23
+ - **Colocated assets:** any other file under `site/pages/` (not a `.md`/`.wd` route, not 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`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
23
24
 
24
25
  Commands: `darkmown dev`, `darkmown build`, `darkmown serve`.
25
26
 
@@ -57,13 +58,25 @@ Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list +
57
58
 
58
59
  ### Conditionals
59
60
  ```wd
60
- :if count
61
- Shown when truthy.
61
+ :if plan == "pro" or seats >= 5
62
+ Pro plan.
63
+ :else if trialDays > 0 and not expired
64
+ Trial.
62
65
  :else
63
- Shown when falsy.
66
+ Free plan.
64
67
  :endif
65
68
  ```
66
- `:if name.path` tests a dotted path. Conditionals nest, including per-row inside a loop.
69
+ `:if <predicate>` branches on a compile-time-validated predicate — a bare path (truthy), comparisons (`==` `!=` `<` `<=` `>` `>=` `contains`), joined with `and`/`or`/`not`. Operands are item paths, declared `:state`/`:store`, numbers, or `"strings"` — no expressions. (Same grammar as `.class when`; `@loop … where` is the comparison-only subset — operators with `and`/`or`.) Chain with `:else if` (any number; a bare `:else` must be last). Conditionals nest, including per-row inside a loop.
70
+
71
+ ### Reactive classes — `.class when <predicate>`
72
+ ```wd
73
+ @loop tasks into task
74
+ ::: card .task .done when task.done .urgent when task.priority > 3 ← toggles each class by predicate
75
+ **{ task.title }**
76
+ :::
77
+ @endloop
78
+ ```
79
+ `.class when <predicate>` on a `:::` container toggles that class from the same predicate grammar as `:if`. A predicate over only static values folds to a plain class at build time; one reading `:state` or the loop item stays reactive.
67
80
 
68
81
  ### Loops — the only loop
69
82
  ```wd
@@ -120,8 +133,13 @@ Thanks, **{ profile.name }**!
120
133
  :endif
121
134
 
122
135
  :computed total = items.length * 4 ← derived state; arithmetic & comparisons only
136
+
137
+ :store session = { "Authorization": "Bearer …" } ← persisted token
138
+ :fetch feed from "/api/feed" headers=session refresh="/auth/refresh" ← auth header; 401 → renew token, retry once
139
+
140
+ :effect q -> searches++ ← run actions when watched state changes
123
141
  ```
124
- Shelf `.json` files (in `site/_/`) publish to `/__wd/data/`. `:form action="/api/x" into reply` posts urlencoded and lands the JSON reply in state (without JS it degrades to a native POST). Darkmown owns no backend — point forms at your own API.
142
+ Shelf `.json` files (in `site/_/`) publish to `/__wd/data/`. `:form action="/api/x" into reply` posts urlencoded and lands the JSON reply in state (without JS it degrades to a native POST). Darkmown owns no backend — point forms at your own API. `:fetch`/`refresh=` URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation. `:effect` actions are the `:button` vocabulary (`;`-chained); effects run on change, not on load, capped at 10 settle passes.
125
143
 
126
144
  **Form-state rules (these cause most form failures):**
127
145
  - `:form into x` declares `x` only at `:endform`. A `:if x` (or `{ x.field }`) that reads it must come **after `:endform`**, never inside the form body.
@@ -144,7 +162,7 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
144
162
  | reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
145
163
  | `{ name }` | a text node |
146
164
 
147
- So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`. For any other custom element, write raw HTML `<div class="…">`. There is NO `{.class}` attribute syntax.**
165
+ So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`. For any other custom element, write raw HTML `<div class="…">`. There is NO `{.class}` attribute syntax.** A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
148
166
 
149
167
  ## Styling with `.skin` — ship this on every page
150
168
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
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.14.0 — 2026-06-18
6
+
7
+ Richer conditionals — `:if` / `:else if` now read the full predicate grammar instead of a bare truthy path only. The reactive runtime is ~5.7 KB gzipped (5762 B), still under the 6 KB CI budget and shipped un-minified.
8
+
9
+ - **Conditionals — comparison & logical expressions.** A `:if` / `:else if` condition now accepts the same compile-time-validated grammar as `.class when`: a bare path (truthy) **or** the comparisons `==` `!=` `<` `<=` `>` `>=` `contains`, joined with `and`, `or`, and negated with `not`. Operands are loop-item paths, declared `:state`/`:store`, numbers, `"strings"`, or `true`/`false`/`null`. `:if plan == "pro" or seats >= 5` and `:else if trialDays > 0 and not expired` are now valid where only `:if plan` worked before. A bare path keeps the original zero-overhead fast path.
10
+ - **Same reactivity rules, same security.** A condition over only static values still folds the branch at build time (`runtime: false` preserved); one over `:state` emits a reactive region (`data-wd-if` + `data-wd-if-expr`); one over a loop item evaluates per row (`data-wd-each-if`). The expression compiles to the same whitelisted form over `I()`/`S()`/`C()` and runs via the shared `evalPredicate` runtime helper — no new eval surface, and `constructor`/`prototype`/`__proto__` path segments stay rejected in compiler and runtime. `:else if` chains inherit this automatically.
11
+ - **No grouping (caveat).** There is no parenthesis grouping; `and`/`or` associate left-to-right with no precedence between them. Split into `:else if` branches or a `:computed` boolean when grouping matters. `@loop … where` stays the comparison-only subset of this grammar (no `not`, no bare-truthy form).
12
+ - **Fix — nested global conditionals re-evaluate after an outer flip.** When a page-level (non-loop) `:if` flips to its falsy branch, the runtime now recurses into the freshly injected branch and evaluates any nested conditional (the desugared `:else if` tail) in the same pass. Previously the injected nested region kept its build-time branch until the next unrelated render. This also corrects bare-path nested `:else if` chains over page state, not just the new comparison form.
13
+
14
+ ## 0.13.0 — 2026-06-18
15
+
16
+ Layered capabilities — four reactive features shaped as progressive disclosure: a simple default, with more power one layer down. The reactive runtime grew to ~5.7 KB gzipped; the CI budget moves from 5 KB to **6 KB** (still shipped un-minified, source fully commented).
17
+
18
+ - **Conditionals — `:else if` chains.** `:if A` / `:else if B` / … / `:else` / `:endif`. Any number of `:else if`; an optional trailing bare `:else` must come last (a later `:else if` is a compile error). Desugars to nested `:if` regions the runtime already drives — identical behavior for static, reactive, and loop-row conditionals, and no runtime change.
19
+ - **Styling — reactive classes (`::: … .class when <predicate>`).** A container class can be toggled by a predicate using the same whitelisted grammar as `@loop … where` / `:if` (`item.field`, `:state`, `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains`, `and`/`or`). A fully-static predicate folds at build time; a state predicate becomes a global `data-wd-class` binding; a loop-item predicate reacts per row. Static `.class` tokens are unchanged.
20
+ - **Fetch — authenticated requests + token refresh (`:fetch … headers= refresh=`).** `headers=<stateKey>` (already shipped) spreads a state object into request headers; pair with `:store` to persist a bearer token. New `refresh="<url>"`: on an HTTP 401 the runtime POSTs the current headers to the refresh URL, writes the renewed token back to state (persisting if it is a `:store`), and retries the original request once. Concurrent 401s sharing a refresh URL are de-duplicated into a single in-flight refresh.
21
+ - **Effects — `:effect <state> -> <actions>`.** Run actions (the full `:button` vocabulary, `;`-chained) whenever a watched state path changes — for side effects beyond `:computed` (derive state) and fetch deps (auto-refetch). Effects run after a render against settled state, with a 10-pass settle cap guarding against effect→effect loops.
22
+ - **Assets — page-colocated static files.** Any non-page file under `site/pages/` (images, SVG, fonts — anything but `.md`/`.wd`/`.skin`/`.js`) is copied to `dist/` at its path: `site/pages/logo.svg` → `dist/logo.svg`, `site/pages/blog/cover.png` → `dist/blog/cover.png`. The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; `.avif` joins the served content-types.
23
+ - **Security — `:fetch` URL scheme hardening.** `:fetch` (and `refresh=`) URLs are validated at compile time: relative paths, `http(s)://`, and a leading `{ state }` interpolation are allowed; a protocol-relative `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is now a compile error with a corrective hint.
24
+
5
25
  ## 0.12.1 — 2026-06-18
6
26
 
7
27
  Release-hygiene and DX polish (post-0.12.0 smoke audit).
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 ~4.7 KB gzipped, CI-enforced under 5 KB.
10
+ Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~5.7 KB gzipped, CI-enforced under 6 KB.
11
11
 
12
12
  ## Quick start
13
13
 
@@ -52,7 +52,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
52
52
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
53
53
  - `site/_` is the include shelf for `@include /name.wd`.
54
54
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
55
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~4.7 KB gzipped, CI-enforced under 5 KB).
55
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.7 KB gzipped, CI-enforced under 6 KB).
56
56
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
57
57
 
58
58
  ## Interpolation
@@ -88,7 +88,14 @@ Images, fonts, icons, and other non-page files live on the include shelf at `sit
88
88
  - Any non-`.md`/`.wd` file in `site/_/` → served at `/__wd/media/<path>` (e.g. `site/_/logo.svg` → `/__wd/media/logo.svg`).
89
89
  - `.json` files → served at `/__wd/data/<name>` (this is what `:fetch` reads).
90
90
 
91
- Reference them with a normal URL: `![logo](/__wd/media/logo.svg)`. The preview/`serve` layer returns the correct `Content-Type` per extension. (Files dropped in `site/pages/` are treated as routes, not assets — put shareable assets on the shelf.)
91
+ Reference them with a normal URL: `![logo](/__wd/media/logo.svg)`. The preview/`serve` layer returns the correct `Content-Type` per extension.
92
+
93
+ **Or colocate an asset next to the page that uses it.** Any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and the colocated `.skin`/`.js`) is copied to `dist/` at its own path, so a clean relative URL just works:
94
+
95
+ - `site/pages/logo.svg` → `dist/logo.svg` → `![logo](/logo.svg)`
96
+ - `site/pages/blog/cover.png` → `dist/blog/cover.png` → `![cover](/blog/cover.png)`
97
+
98
+ Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.
92
99
 
93
100
  ## Loops
94
101
 
@@ -240,6 +247,20 @@ Cart has { count } items.
240
247
 
241
248
  State declared inside a section is scoped to it — two sections can both own a `count`. Bindings and actions resolve to the nearest scope.
242
249
 
250
+ ### Reactive classes — `.class when <predicate>`
251
+
252
+ A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
253
+
254
+ ```wd
255
+ @loop products into p
256
+ ::: card .product .on-sale when p.price < 50 .featured when p.featured
257
+ **{ p.name }** — ${ p.price }
258
+ :::
259
+ @endloop
260
+ ```
261
+
262
+ The predicate uses the same whitelist as `:if` — item fields, declared `:state`/`:store`, numbers, strings, the `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains` operators, and `and`/`or`/`not`. A bare path (`.featured when p.featured`) reads as truthy. (`@loop … where` is the comparison-only subset of this grammar.) A predicate over only static values folds at build time into a plain class; one that reads state or the loop item stays reactive and ships with the runtime.
263
+
243
264
  ## Reactive directives
244
265
 
245
266
  ```wd
@@ -249,13 +270,29 @@ Count: { count }
249
270
 
250
271
  :button "Increment" -> count++
251
272
 
252
- :if count
273
+ :if count >= 10
274
+ Count is high.
275
+ :else if count > 0
253
276
  Count has changed.
254
277
  :else
255
278
  Count is still zero.
256
279
  :endif
257
280
  ```
258
281
 
282
+ A condition reads the same predicate grammar as `.class when`: a bare path (truthy), or the comparisons `==` `!=` `<` `<=` `>` `>=` `contains`, joined with `and`, `or`, and `not`. (`@loop … where` is the comparison-only subset — operators with `and`/`or`.) Chain with `:else if` (any number; an optional bare `:else` must be the last branch):
283
+
284
+ ```wd
285
+ :if plan == "pro" or seats >= 5
286
+ Pro plan
287
+ :else if trialDays > 0 and not expired
288
+ Trial — { trialDays } days left
289
+ :else
290
+ Free plan
291
+ :endif
292
+ ```
293
+
294
+ A whole chain compiles to nested conditional regions, so it stays reactive (or folds at build time when every value is static) exactly like a single `:if`.
295
+
259
296
  Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
260
297
 
261
298
  ### Button actions
@@ -297,12 +334,25 @@ Values are literals: a `"string"`, number, `true`/`false`/`null`, or inline JSON
297
334
 
298
335
  > **Pitfall:** `list toggle v` and `list remove v` match members by value (`===`). That is exact for strings, numbers, and booleans, but not reliable for object members — two equal-looking objects are different values. To remove a row object, loop the list and use the per-row `remove` action below.
299
336
 
337
+ ### Effects — `:effect`
338
+
339
+ `:effect <watched> -> <actions>` runs actions whenever a watched state path changes. The actions are the same `:button` vocabulary (`;`-chained) — this is the escape hatch for side effects beyond `:computed` (which derives state) and `:fetch` deps (which auto-refetch):
340
+
341
+ ```wd
342
+ :state q = ""
343
+ :state searches = 0
344
+
345
+ :effect q -> searches++
346
+ ```
347
+
348
+ Effects run after a render, against settled state, and an effect that mutates state triggers another pass — bounded by a 10-pass settle cap that warns (and stops) if an effect never settles. They do not fire on the initial load, only on a real change.
349
+
300
350
  ## Fetching data
301
351
 
302
352
  `:fetch name from "url"` declares state and fills it from JSON over the network:
303
353
 
304
354
  ```
305
- :fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>]
355
+ :fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>] [refresh=<url>]
306
356
  ```
307
357
 
308
358
  Each fetch automatically declares four state keys you can branch on:
@@ -323,8 +373,7 @@ Cover loading, errors, empty, and data — `@empty` (from the loops above) absor
323
373
 
324
374
  :if roster_loading
325
375
  Loading…
326
- :else
327
- :if roster_error
376
+ :else if roster_error
328
377
  Couldn't load the team: { roster_error }
329
378
  :else
330
379
  @loop roster into member
@@ -333,7 +382,6 @@ Couldn't load the team: { roster_error }
333
382
  No team members yet.
334
383
  @endloop
335
384
  :endif
336
- :endif
337
385
  ```
338
386
 
339
387
  ### Options
@@ -344,6 +392,28 @@ No team members yet.
344
392
  - `retry=<N>` — retry on network failure or a 5xx response before surfacing the error.
345
393
  - `headers=<key>` — a `:state`/`:store` key holding an object, sent as request headers.
346
394
  - `body=<key>` — a `:state`/`:store` key, JSON-serialized as the request body (for non-`GET`).
395
+ - `refresh=<url>` — a token-refresh endpoint; on a `401`, renew the `headers=` token and retry once (see below).
396
+
397
+ > **URL safety.** A `:fetch`/`refresh` URL must be a relative path, an `http(s)://` URL, or a leading `{ state }` interpolation. A protocol-relative `//host` or a non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is a compile error.
398
+
399
+ ### Authenticated requests and token refresh
400
+
401
+ `headers=<key>` sends a state object as request headers — pair it with `:store` to persist a bearer token across reloads:
402
+
403
+ ```wd
404
+ :store session = { "Authorization": "Bearer …" }
405
+ :fetch feed from "/api/feed" headers=session
406
+ ```
407
+
408
+ Add `refresh="<url>"` and Darkmown manages the token lifecycle: when a request comes back `401`, it POSTs the current `headers` object to the refresh URL, writes the returned headers (the new token) back into the `session` state — persisting it, since `session` is a `:store` — and retries the original request once. Concurrent `401`s sharing a refresh URL are de-duplicated into a single in-flight refresh.
409
+
410
+ ```wd
411
+ :store session = { "Authorization": "Bearer …" }
412
+
413
+ :fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
414
+ ```
415
+
416
+ `refresh=` requires `headers=` (it needs a state key to renew). The refresh endpoint should accept the current headers as a JSON body and reply with the new headers object.
347
417
 
348
418
  ### Dynamic URLs and refetching
349
419
 
@@ -447,7 +517,7 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
447
517
 
448
518
  - **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.
449
519
  - **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.
450
- - **`: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).
520
+ - **`:fetch` has no host allowlist, and reactive pages need `unsafe-eval`.** A `:fetch` (or `refresh=`) 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 protection is the author's responsibility. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
451
521
 
452
522
  > **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
453
523
 
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 passes through by default.** `markdown-it` runs with `html: true`, so raw HTML in content is emitted verbatim. There is no built-in sanitizer, so 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 strips raw HTML.
19
- 3. **`:fetch` has no URL allowlist, and reactive pages require `unsafe-eval`.** A `:fetch` URL is read directly from the page source, so requests go wherever the author wrote them. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
19
+ 3. **`:fetch` has no host allowlist, and reactive pages require `unsafe-eval`.** A `:fetch` (or `refresh=`) 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 protection is the author's responsibility. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
20
20
 
21
21
  This is the same trust posture as every Markdown SSG: the framework is safe for the trusted-author use case it targets, and unsafe content is the author's responsibility to sanitize before it reaches the compiler.
22
22
 
@@ -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` (~4.7 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` (~5.7 KB gzipped, CI-enforced below the 6 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.
@@ -51,7 +51,7 @@ This audit compares the current Darkmown implementation with the original MARROW
51
51
  - **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
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.
53
53
  - **`:bind <state>`:** two-way `<input>` bound to a `:state` value — the input primitive that powers live search. Accepts `type=`, `placeholder=`, `autocomplete=`, `required`, `autofocus`.
54
- - **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); `:if` string-equality remains the only listed gap, now partly covered by `@loop … where`.
54
+ - **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
55
  - **`.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.
56
56
  - **Editor tooling:** VS Code extension (`editors/vscode`) ships `.wd` + `.skin` TextMate grammars, snippets, and folding, grammar-tested in CI.
57
57
  - **`AGENTS.md`:** a model-facing build guide (shipped in the npm files array), tuned over a 5-round agent-eval benchmark that lifted build quality from 2.78→4.51/5.
@@ -62,16 +62,31 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
62
62
 
63
63
  - **Global state — `:store`:** `:store name = value [ephemeral]` declares page-global, durable-by-default state. Non-ephemeral stores persist to `localStorage["wd:store:<name>"]`, hydrate the declared value as a seed only when absent (first write wins thereafter), and sync across tabs via a single `storage` listener that re-renders on change. `ephemeral` opts out of persistence and cross-tab sync (in-memory for the session). Stores are never section-scoped; they share the runtime `state` object with `:state`, and a compile-time collision check keeps the two namespaces disjoint (declaring a `:store` and `:state` with one name, or a store twice, is a path-and-suggestion compile error). Reads (`{ cart }`, `:if`, `@loop`, `:computed`) and all actions resolve stores identically to state.
64
64
  - **Full action vocabulary:** `:button … ->` now covers `inc`/`dec` (`n++`, `n--`), `add`/`sub` (`n += k`, `n -= k`), `set` (`name = v`), `toggle` (`flag toggle`), `append` (`list append v` / `list += v`), `prepend`, `member-toggle` (`list toggle v`), `remove-value` (`list remove v`), `clear`, `merge` (`obj merge <key|{…}>`), `delete` (`obj delete "key"`), and `reset` (restore the declared seed) — alongside the existing `append-row` (`cart += item`) and per-row `remove`. Targets are dotted-path aware (`cart.count++`) via a safe `setPath` mirroring `getPath` (proto-pollution guards in compiler and runtime). Buttons may carry a `;`-separated action sequence, applied in order with a single render. Operands are the compile-validated literal grammar (string / number / boolean / null / inline JSON; merge also accepts a bare state/store key). **Honest caveat:** `member-toggle` and `remove-value` compare by value with `===`/`includes`, so they are reliable for primitive members but not for object members (two equal-looking objects are distinct values) — remove row objects with the per-row `remove` action instead.
65
- - **Fetch lifecycle:** `:fetch name from "url" [method=] [when=load|visible] [timeout=ms] [retry=N] [headers=key] [body=key]`. Each fetch auto-declares four states — `name` (null), `name_loading` (false), `name_error` (null), `name_empty` (false, true for `null`/`[]`/`{}`) — enabling the recommended loading → error → empty → data pattern (with `@empty` absorbing the empty case for list fetches). URLs may interpolate `{ state }` and auto-refetch (≈150 ms debounce) when a dependency key changes, skipping while a required value is empty. A `refetch` action (`name refetch`) re-triggers a load on demand. Full HTTP via `method`/`headers`/`body`; `timeout` arms an AbortController; `retry` backs off on network error or 5xx before surfacing the error.
65
+ - **Fetch lifecycle:** `:fetch name from "url" [method=] [when=load|visible] [timeout=ms] [retry=N] [headers=key] [body=key] [refresh=url]` (`headers=`/`refresh=` detailed under Stage 9). Each fetch auto-declares four states — `name` (null), `name_loading` (false), `name_error` (null), `name_empty` (false, true for `null`/`[]`/`{}`) — enabling the recommended loading → error → empty → data pattern (with `@empty` absorbing the empty case for list fetches). URLs may interpolate `{ state }` and auto-refetch (≈150 ms debounce) when a dependency key changes, skipping while a required value is empty. A `refetch` action (`name refetch`) re-triggers a load on demand. Full HTTP via `method`/`headers`/`body`; `timeout` arms an AbortController; `retry` backs off on network error or 5xx before surfacing the error.
66
66
  - **Loop ergonomics:** `@loop src into item [where …] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`, clauses in that fixed order. `sort by` keys must start with the loop item (`item.path`); the comparator is numeric when both values are numbers, else `String(...).localeCompare`. `offset`/`limit` accept an integer **or** a `:state`/`:store` key for reactive pagination. Per-row meta vars — `$index` (0-based), `$number` (1-based), `$first`, `$last`, `$count` — are usable in interpolation and `:if`. An `@empty` branch renders when the post-pipeline row count is 0. **Honest caveat:** `sort by` over null/missing keys is best-effort — those values stringify (`"null"`/`"undefined"`) and fall into the lexicographic `localeCompare` path rather than getting special handling. Every clause stays build-time when the source and all arguments are static, preserving `runtime: false`.
67
67
 
68
68
  ## Stage 7 additions (2026-06-14)
69
69
 
70
70
  - **Array frontmatter + readable `meta`:** `parseFrontmatter` now parses inline flow arrays (`tags: [a, b, "x, y"]` → real array; quoted items keep internal commas; a value without a leading `[` stays a scalar — block sequences are intentionally out of scope to keep the parser single-pass). A page's frontmatter is exposed to its body under `meta`, so `{ meta.title }` prints a field, `{ meta.tags }` renders an array joined with `, `, and `@loop meta.tags into tag` iterates a frontmatter array at build time (stays `runtime: false`). Genuine correctness fix on its own, and the groundwork for reading/emitting OKF-style bundles (Google Cloud's Open Knowledge Format — markdown + `type`-bearing frontmatter + cross-links; tracked but deliberately not coupled to its v0.1 Draft).
71
71
 
72
+ ## Stage 10 additions (2026-06-18, v0.14.0)
73
+
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).
75
+
76
+ ## Stage 9 additions (2026-06-18, v0.13.0)
77
+
78
+ 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).
79
+
80
+ - **`: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.)
81
+ - **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`.
82
+ - **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=`.
83
+ - **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.
84
+ - **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. `.avif` was added to the served content-types.
85
+ - **`:fetch` URL scheme hardening (security):** `:fetch`/`refresh=` URLs are validated at compile time — relative, `http(s)://`, or a leading `{ state }` interpolation are allowed; protocol-relative `//host` and non-http(s) schemes (`file:`, `data:`, `javascript:`, …) are rejected with a corrective hint. **This is scheme validation, not a host allowlist** — SSRF to arbitrary http(s) hosts remains the author's responsibility (see the trust boundary).
86
+
72
87
  ## Demo-only directives (not part of the public surface)
73
88
 
74
- 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`, `:button`, `:bind`, `:if`/`:else`/`:endif`, `:fetch`, `:form`/`:input`/`:submit`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by`, `reverse`, `offset`, `limit`), `@include`, and `::: section`.
89
+ 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`, `:button`, `:bind`, `:if`/`:else if`/`:else`/`:endif`, `:fetch` (with `headers`/`refresh`), `:form`/`:input`/`:submit`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by`, `reverse`, `offset`, `limit`), `@include`, and `::: section` (with reactive `.class when`).
75
90
 
76
91
  ## Still missing from the full vision
77
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,7 +13,7 @@
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
+ "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: 6144)'); if (size >= 6144) process.exit(1)\"",
17
17
  "dev": "node src/cli.js dev",
18
18
  "test": "node --test tests/*.test.js",
19
19
  "test:cov": "node scripts/test-coverage-gate.mjs 90",
@@ -0,0 +1 @@
1
+ []
package/site/pages/app.wd CHANGED
@@ -19,7 +19,7 @@ This whole section is plain Markdown. The list below is unrolled **at build time
19
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 ~4.7&nbsp;KB gzipped runtime loads (CI-enforced under 5 KB) — 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 ~5.7&nbsp;KB gzipped runtime loads (CI-enforced under 6 KB) — and only because the document asks for it.</aside>
23
23
 
24
24
  ## Fetched data, looped, with nested conditions
25
25
 
@@ -33,6 +33,47 @@ Loading the team…
33
33
 
34
34
  That loop also proves `:if` over loop items: the lead badge is decided per row.
35
35
 
36
+ ## The four states of a fetch
37
+
38
+ Every `:fetch` tracks the whole lifecycle — `loading`, `error`, `empty`, and data — so a page can answer each one. Here the same primitive shows an **error** (a missing URL 404s) and an **empty** result (a file that is just `[]`):
39
+
40
+ :fetch broken from "/__wd/data/intentionally-missing.json"
41
+
42
+ :if broken_error
43
+ The request failed on purpose — `broken_error` holds: { broken_error }
44
+ :else if broken_loading
45
+ Trying the missing endpoint…
46
+ :else
47
+ Waiting…
48
+ :endif
49
+
50
+ :fetch roster from "/__wd/data/empty.json"
51
+
52
+ @loop roster into person
53
+ - { person.name }
54
+ @empty
55
+ The roster came back empty — `@empty` renders instead of the rows.
56
+ @endloop
57
+
58
+ The error branch uses an `:else if` chain; the empty branch uses the loop's `@empty`. No state machine to wire by hand.
59
+
60
+ ## Authenticated requests
61
+
62
+ A real API usually wants a token. `:fetch … headers=<key>` spreads a state object into the request headers, so pair it with `:store` to persist that token across reloads:
63
+
64
+ :store session = { "Authorization": "Bearer demo-token" }
65
+ :fetch feed from "/__wd/data/team.json" headers=session
66
+
67
+ :if feed_error
68
+ The API rejected the token: { feed_error }
69
+ :else if feed
70
+ Authenticated fetch returned { feed.length } row(s). Open the network tab — the `Authorization` header rides along on the request.
71
+ :else
72
+ Loading the authenticated feed…
73
+ :endif
74
+
75
+ This demo points `from=` at a static file (which ignores the header), so it stays self-contained — point it at your real API and the bearer token travels with every request. When that token expires, add `refresh="/auth/refresh"` and Darkmown renews it on a `401` and retries once, automatically.
76
+
36
77
  ## Forms write state, no backend required
37
78
 
38
79
  `:form into profile` captures the submit straight into state. With `action="/url"` instead, Darkmown emits a plain native form and stays out of the way entirely:
@@ -170,7 +170,7 @@ tags: [sales, revenue, "q1, q2"]
170
170
 
171
171
  ## Reactive directives
172
172
 
173
- Reactive pages opt into `/__wd/runtime.js` (~4.7 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
173
+ Reactive pages opt into `/__wd/runtime.js` (~5.7 KB gzipped, under a CI-enforced 6 KB budget). Static pages do not.
174
174
 
175
175
  ```wd
176
176
  :state count = 0
@@ -196,6 +196,49 @@ Count is still zero.
196
196
 
197
197
  Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in a colocated `.js` file.
198
198
 
199
+ ### Multi-branch conditionals
200
+
201
+ A condition reads the same predicate grammar as `.class when` — a bare path (truthy), or the comparisons `==` `!=` `<` `<=` `>` `>=` `contains`, joined with `and`, `or`, and `not`. (`@loop … where` is the comparison-only subset.) Chain with `:else if` (any number; a bare `:else` must come last):
202
+
203
+ ```wd
204
+ :if plan == "pro" or seats >= 5
205
+ Pro plan
206
+ :else if trialDays > 0 and not expired
207
+ Trial — { trialDays } days left
208
+ :else
209
+ Free plan
210
+ :endif
211
+ ```
212
+
213
+ A whole chain compiles to nested conditional regions, so it folds at build time when every value is static and stays reactive otherwise — exactly like a single `:if`.
214
+
215
+ ### Reactive classes — `.class when …`
216
+
217
+ A container class can react to state or the loop item. Static `.class` tokens stay as-is; add `when <predicate>` for a reactive one:
218
+
219
+ ```wd
220
+ @loop products into p
221
+ ::: card .product .on-sale when p.price < 50
222
+ **{ p.name }** — ${ p.price }
223
+ :::
224
+ @endloop
225
+ ```
226
+
227
+ The predicate uses the `:if` whitelist (item fields, `:state`, numbers, strings, comparisons, `and`/`or`/`not`, `contains`). A static predicate folds to a plain class at build; a state or loop-item predicate stays reactive.
228
+
229
+ ### Effects — `:effect`
230
+
231
+ `:effect <watched> -> <actions>` runs actions (the `:button` vocabulary, `;`-chained) whenever the watched state changes — for side effects beyond `:computed` and fetch deps:
232
+
233
+ ```wd
234
+ :state q = ""
235
+ :state searches = 0
236
+
237
+ :effect q -> searches++
238
+ ```
239
+
240
+ Effects run after a render against settled state, never on the initial load, with a 10-pass settle cap guarding against loops.
241
+
199
242
  ### Button actions
200
243
 
201
244
  A `:button "Label" -> action` mutates one `:state` or `:store` value. The vocabulary is the same for both:
@@ -223,8 +266,7 @@ Values are literals — a `"string"`, number, `true`/`false`/`null`, or inline J
223
266
 
224
267
  :if team_loading
225
268
  Loading…
226
- :else
227
- :if team_error
269
+ :else if team_error
228
270
  Couldn't load the team: { team_error }
229
271
  :else
230
272
  @loop team into member
@@ -233,10 +275,18 @@ Couldn't load the team: { team_error }
233
275
  No team members yet.
234
276
  @endloop
235
277
  :endif
236
- :endif
237
278
  ```
238
279
 
239
- Options after the URL: `method=` (default `GET`), `when=load|visible`, `timeout=<ms>`, `retry=<N>`, `headers=<key>`, and `body=<key>` (the last two name a `:state`/`:store` key). A URL can interpolate state (`"/api/users/{ userId }"`) and re-fetches automatically when that state changes; trigger a reload by hand with `:button "Reload" -> team refetch`. Loop a sub-path of fetched data with a dotted source — `@loop team.members into member`.
280
+ Options after the URL: `method=` (default `GET`), `when=load|visible`, `timeout=<ms>`, `retry=<N>`, `headers=<key>`, and `body=<key>` (the last two name a `:state`/`:store` key). A URL can interpolate state (`"/api/users/{ userId }"`) and re-fetches automatically when that state changes; trigger a reload by hand with `:button "Reload" -> team refetch`. Loop a sub-path of fetched data with a dotted source — `@loop team.members into member`. URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation — other schemes are rejected at compile time.
281
+
282
+ ### Authenticated requests
283
+
284
+ `headers=<key>` sends a state object as request headers; pair it with `:store` to persist a bearer token. Add `refresh="<url>"` and a `401` triggers a token refresh: Darkmown POSTs the current headers to the refresh URL, writes the renewed token back into the `headers` state, and retries the request once (concurrent `401`s share one refresh).
285
+
286
+ ```wd
287
+ :store session = { "Authorization": "Bearer …" }
288
+ :fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
289
+ ```
240
290
 
241
291
  ## Global state — `:store`
242
292
 
@@ -271,7 +321,7 @@ Darkmown is a trusted-author site generator: you compile content you wrote, the
271
321
 
272
322
  - **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
323
  - **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.
324
+ - **`:fetch` has no host allowlist, and reactive pages need `unsafe-eval`.** Fetch URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF is the author's responsibility. The runtime compiles `:computed` / `@loop … where` / `.class when` expressions via `new Function`, so a reactive page needs `script-src 'unsafe-eval'` in your CSP. Static pages need neither.
275
325
 
276
326
  See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in the package for the full security model.
277
327
 
@@ -280,9 +330,10 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in
280
330
  - A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
281
331
  - A matching `.js` file attaches page behavior.
282
332
  - Both work for included fragments too, by basename.
333
+ - 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`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
283
334
 
284
335
  ## Spec status
285
336
 
286
- 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`, and dotted loop sources), `:form` (including server round-trips), loop ergonomics (`sort by`/`limit`/`offset`/`reverse`/`@empty` and `$index`/`$number`/`$first`/`$last`/`$count`), `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/). Still on the roadmap: a first-party server runtime (`site/api/`) and HTML-fragment swaps. See `docs/spec-alignment.md` in the package for the full audit.
337
+ 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: a first-party server runtime (`site/api/`) and HTML-fragment swaps. See `docs/spec-alignment.md` in the package for the full audit.
287
338
 
288
339
  </main>
@@ -97,14 +97,14 @@ Markdoc.renderers.react(content, React, {
97
97
 
98
98
  </section>
99
99
 
100
- :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
+ :note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~5.7 KB gzipped runtime loads (CI-enforced under 6 KB), never before. JS optional, CSS optional, always."
101
101
 
102
102
  <section class="props">
103
103
 
104
104
  ## Three rules, no escape hatch
105
105
 
106
106
  - **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
107
- - **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
+ - **One runtime.** Every reactive page shares the same ~5.7 KB gzipped runtime, CI-enforced under 6 KB. No per-component bundles, no hydration tax that scales with your UI.
108
108
  - **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
109
109
 
110
110
  </section>