@zvndev/darkmown 0.13.0 → 0.14.1
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 +15 -4
- package/CHANGELOG.md +17 -0
- package/README.md +14 -10
- package/SECURITY.md +2 -1
- package/docs/spec-alignment.md +14 -4
- package/package.json +1 -1
- package/site/pages/docs/index.wd +9 -8
- package/site/pages/reactive.wd +10 -0
- package/src/builder.js +12 -0
- package/src/compiler.js +75 -44
- package/src/runtime.js +42 -17
- package/src/scaffold.js +10 -1
package/AGENTS.md
CHANGED
|
@@ -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,15 +58,25 @@ Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list +
|
|
|
57
58
|
|
|
58
59
|
### Conditionals
|
|
59
60
|
```wd
|
|
60
|
-
:if
|
|
61
|
+
:if plan == "pro" or seats >= 5
|
|
61
62
|
Pro plan.
|
|
62
|
-
:else if
|
|
63
|
+
:else if trialDays > 0 and not expired
|
|
63
64
|
Trial.
|
|
64
65
|
:else
|
|
65
66
|
Free plan.
|
|
66
67
|
:endif
|
|
67
68
|
```
|
|
68
|
-
`:if
|
|
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.
|
|
69
80
|
|
|
70
81
|
### Loops — the only loop
|
|
71
82
|
```wd
|
|
@@ -151,7 +162,7 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
|
|
|
151
162
|
| reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
|
|
152
163
|
| `{ name }` | a text node |
|
|
153
164
|
|
|
154
|
-
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
|
|
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`).
|
|
155
166
|
|
|
156
167
|
## Styling with `.skin` — ship this on every page
|
|
157
168
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.1 — 2026-06-19
|
|
6
|
+
|
|
7
|
+
Patch-release hardening for page assets, form actions, and release metadata.
|
|
8
|
+
|
|
9
|
+
- **Security — private page assets stay private.** Page-colocated assets now follow the same hidden-path convention as routes: files or folders under `site/pages/` whose path segment starts with `.`, `-`, or `_` are not copied to `dist`. Symlinked page assets are also skipped, closing the accidental-publication path for `.env`, `_private/*`, `-draft/*`, and symlink-to-outside files. Normal colocated assets such as `site/pages/logo.svg` and `site/pages/blog/cover.png` still emit unchanged.
|
|
10
|
+
- **Security — `:form action=` scheme validation.** Native and round-trip forms now validate `action=` with the same compile-time URL scheme guard as `:fetch`: relative URLs, explicit `http(s)://`, and leading `{ state }` interpolation are allowed; protocol-relative URLs and non-http(s) schemes (`javascript:`, `data:`, `file:`, …) are compile errors.
|
|
11
|
+
- **Release hygiene.** `package-lock.json` package metadata is synced to the release version, so package review no longer reports stale root version drift.
|
|
12
|
+
|
|
13
|
+
## 0.14.0 — 2026-06-18
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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).
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
5
22
|
## 0.13.0 — 2026-06-18
|
|
6
23
|
|
|
7
24
|
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).
|
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ Reference them with a normal URL: ``. The preview/`
|
|
|
95
95
|
- `site/pages/logo.svg` → `dist/logo.svg` → ``
|
|
96
96
|
- `site/pages/blog/cover.png` → `dist/blog/cover.png` → ``
|
|
97
97
|
|
|
98
|
+
Hidden/private page paths stay private: any `site/pages` asset below a path segment starting with `.`, `-`, or `_` is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: `.env`, `_private/secret.txt`, or `-draft/notes.txt`.
|
|
99
|
+
|
|
98
100
|
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.
|
|
99
101
|
|
|
100
102
|
## Loops
|
|
@@ -259,7 +261,7 @@ A container class can be toggled by a predicate. Static `.class` tokens are unch
|
|
|
259
261
|
@endloop
|
|
260
262
|
```
|
|
261
263
|
|
|
262
|
-
The predicate uses the same whitelist as
|
|
264
|
+
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
265
|
|
|
264
266
|
## Reactive directives
|
|
265
267
|
|
|
@@ -270,26 +272,28 @@ Count: { count }
|
|
|
270
272
|
|
|
271
273
|
:button "Increment" -> count++
|
|
272
274
|
|
|
273
|
-
:if count
|
|
275
|
+
:if count >= 10
|
|
276
|
+
Count is high.
|
|
277
|
+
:else if count > 0
|
|
274
278
|
Count has changed.
|
|
275
279
|
:else
|
|
276
280
|
Count is still zero.
|
|
277
281
|
:endif
|
|
278
282
|
```
|
|
279
283
|
|
|
280
|
-
|
|
284
|
+
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):
|
|
281
285
|
|
|
282
286
|
```wd
|
|
283
|
-
:if
|
|
287
|
+
:if plan == "pro" or seats >= 5
|
|
284
288
|
Pro plan
|
|
285
|
-
:else if
|
|
286
|
-
Trial — {
|
|
289
|
+
:else if trialDays > 0 and not expired
|
|
290
|
+
Trial — { trialDays } days left
|
|
287
291
|
:else
|
|
288
292
|
Free plan
|
|
289
293
|
:endif
|
|
290
294
|
```
|
|
291
295
|
|
|
292
|
-
A whole chain compiles to nested conditional regions, so it stays reactive (or folds at build time when
|
|
296
|
+
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`.
|
|
293
297
|
|
|
294
298
|
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
|
|
295
299
|
|
|
@@ -350,7 +354,7 @@ Effects run after a render, against settled state, and an effect that mutates st
|
|
|
350
354
|
`:fetch name from "url"` declares state and fills it from JSON over the network:
|
|
351
355
|
|
|
352
356
|
```
|
|
353
|
-
:fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>]
|
|
357
|
+
:fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>] [refresh=<url>]
|
|
354
358
|
```
|
|
355
359
|
|
|
356
360
|
Each fetch automatically declares four state keys you can branch on:
|
|
@@ -491,7 +495,7 @@ Fetched data and a form live happily on the same page:
|
|
|
491
495
|
:state cart = [] persist
|
|
492
496
|
```
|
|
493
497
|
|
|
494
|
-
- `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement.
|
|
498
|
+
- `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement. Form actions use the same URL scheme guard as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
|
|
495
499
|
- `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.
|
|
496
500
|
- `:state x = [] persist` keeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for [`:store`](#global-state--store) instead.)
|
|
497
501
|
- `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
|
|
@@ -515,7 +519,7 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
|
|
|
515
519
|
|
|
516
520
|
- **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.
|
|
517
521
|
- **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.
|
|
518
|
-
- **`:fetch`
|
|
522
|
+
- **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** A fetch/form URL is taken straight from the page source; the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
|
|
519
523
|
|
|
520
524
|
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
|
|
521
525
|
|
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`
|
|
19
|
+
3. **`:fetch` and `:form action=` have no host allowlist, and reactive pages require `unsafe-eval`.** A `:fetch`, `refresh=`, or `:form action=` URL is read directly from the page source; the compiler rejects non-http(s) schemes (`file:`, `data:`, `javascript:`, protocol-relative `//host`) but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
|
|
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
|
|
|
@@ -27,6 +27,7 @@ Things Darkmown deliberately guards at compile time and runtime:
|
|
|
27
27
|
- **No arbitrary JavaScript in content files.** Directive actions (`:button -> …`), `:computed` expressions, and `@loop … where` predicates compile through strict whitelisted grammars — only item paths, declared `:state`, numbers, and strings are allowed. Assignment, function calls, and unknown syntax are compile errors. Raw user content is never `eval`'d; what runs at runtime is the *validated* expression, compiled to the whitelisted grammar and executed via `new Function` (see `src/runtime.js`).
|
|
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
|
+
- **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`.
|
|
30
31
|
- **Output escaping.** Interpolated values are HTML-escaped; state scripts escape `<` to prevent script-tag breakout.
|
|
31
32
|
- **Static server path containment.** The dev/preview servers resolve requests strictly inside `dist`.
|
|
32
33
|
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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)
|
|
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,22 +62,32 @@ 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]
|
|
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 11 additions (2026-06-19, v0.14.1)
|
|
77
|
+
|
|
78
|
+
- **Release hardening — page asset privacy:** page-colocated assets now honor the same hidden-path convention as routes. A file under `site/pages` is emitted only when no relative path segment starts with `.`, `-`, or `_`; symlinked page assets are skipped too. This preserves normal colocated media (`logo.svg`, `blog/cover.png`) while preventing accidental publication of `.env`, `_private/*`, `-draft/*`, or symlink-to-outside files.
|
|
79
|
+
- **Release hardening — form action URLs:** `:form action=` now uses the same scheme validation as `:fetch` and `refresh=`. Relative URLs, explicit `http(s)://`, and leading `{ state }` interpolation are allowed; protocol-relative URLs and non-http(s) schemes are compile errors. There is still no host allowlist — backend trust remains the author's deployment concern.
|
|
80
|
+
- **Release hygiene:** root `package-lock.json` package metadata is synced to the release version so tarball review and reproducible install checks do not see stale package identity.
|
|
81
|
+
|
|
72
82
|
## Stage 9 additions (2026-06-18, v0.13.0)
|
|
73
83
|
|
|
74
84
|
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).
|
|
75
85
|
|
|
76
|
-
- **`: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.
|
|
86
|
+
- **`: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.)
|
|
77
87
|
- **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`.
|
|
78
88
|
- **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=`.
|
|
79
89
|
- **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.
|
|
80
|
-
- **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.
|
|
90
|
+
- **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.
|
|
81
91
|
- **`: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).
|
|
82
92
|
|
|
83
93
|
## Demo-only directives (not part of the public surface)
|
package/package.json
CHANGED
package/site/pages/docs/index.wd
CHANGED
|
@@ -198,19 +198,19 @@ Directive actions are intentionally narrow and compile-time checked. Arbitrary J
|
|
|
198
198
|
|
|
199
199
|
### Multi-branch conditionals
|
|
200
200
|
|
|
201
|
-
|
|
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
202
|
|
|
203
203
|
```wd
|
|
204
|
-
:if
|
|
204
|
+
:if plan == "pro" or seats >= 5
|
|
205
205
|
Pro plan
|
|
206
|
-
:else if
|
|
207
|
-
Trial — {
|
|
206
|
+
:else if trialDays > 0 and not expired
|
|
207
|
+
Trial — { trialDays } days left
|
|
208
208
|
:else
|
|
209
209
|
Free plan
|
|
210
210
|
:endif
|
|
211
211
|
```
|
|
212
212
|
|
|
213
|
-
A whole chain compiles to nested conditional regions, so it folds at build time when
|
|
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
214
|
|
|
215
215
|
### Reactive classes — `.class when …`
|
|
216
216
|
|
|
@@ -224,7 +224,7 @@ A container class can react to state or the loop item. Static `.class` tokens st
|
|
|
224
224
|
@endloop
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
The predicate uses the
|
|
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
228
|
|
|
229
229
|
### Effects — `:effect`
|
|
230
230
|
|
|
@@ -307,7 +307,7 @@ You have { cart } items.
|
|
|
307
307
|
|
|
308
308
|
## Forms and persistence
|
|
309
309
|
|
|
310
|
-
- `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead.
|
|
310
|
+
- `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead. Form actions follow the same scheme rules as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
|
|
311
311
|
- `:input field placeholder="…" required` and `:submit "Label"` build the form body.
|
|
312
312
|
- `:state cart = [] persist` survives reloads via localStorage on a single page; use `:store` for state shared across pages and tabs.
|
|
313
313
|
- `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
|
|
@@ -321,7 +321,7 @@ Darkmown is a trusted-author site generator: you compile content you wrote, the
|
|
|
321
321
|
|
|
322
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.
|
|
323
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.
|
|
324
|
-
- **`:fetch`
|
|
324
|
+
- **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** Fetch/form URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF/exfiltration protection is the author's responsibility. The runtime compiles `:computed` / `@loop … where` / `.class when` expressions via `new Function`, so a reactive page needs `script-src 'unsafe-eval'` in your CSP. Static pages need neither.
|
|
325
325
|
|
|
326
326
|
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in the package for the full security model.
|
|
327
327
|
|
|
@@ -330,6 +330,7 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in
|
|
|
330
330
|
- A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
|
|
331
331
|
- A matching `.js` file attaches page behavior.
|
|
332
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`. 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.
|
|
333
334
|
|
|
334
335
|
## Spec status
|
|
335
336
|
|
package/site/pages/reactive.wd
CHANGED
|
@@ -16,6 +16,16 @@ The count is { count }.
|
|
|
16
16
|
|
|
17
17
|
:button "Increment" -> count++
|
|
18
18
|
|
|
19
|
+
`:if` reads JavaScript-style comparisons — `==` `!=` `<` `<=` `>` `>=` `contains` — joined with `and`, `or`, and `not`. This chain reacts to the counter above, no handler wired by hand:
|
|
20
|
+
|
|
21
|
+
:if count >= 3 and count <= 5
|
|
22
|
+
Counter is in the sweet spot (3–5).
|
|
23
|
+
:else if count > 5
|
|
24
|
+
Counter is over the top.
|
|
25
|
+
:else
|
|
26
|
+
Counter is warming up.
|
|
27
|
+
:endif
|
|
28
|
+
|
|
19
29
|
:state open = false
|
|
20
30
|
|
|
21
31
|
:button "Show details" -> open = true
|
package/src/builder.js
CHANGED
|
@@ -190,6 +190,7 @@ function emitPageAssets(paths) {
|
|
|
190
190
|
const ext = path.extname(file).toLowerCase();
|
|
191
191
|
if ([".md", ".wd", ".skin", ".js"].includes(ext)) continue;
|
|
192
192
|
const rel = path.relative(paths.routesRoot, file);
|
|
193
|
+
if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
|
|
193
194
|
const out = path.join(paths.distRoot, rel);
|
|
194
195
|
if (fs.existsSync(out)) {
|
|
195
196
|
console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
|
|
@@ -200,6 +201,17 @@ function emitPageAssets(paths) {
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Match the router's hidden-path convention for page-colocated assets too:
|
|
206
|
+
* anything below a `.`, `-`, or `_` segment is private/draft framework input,
|
|
207
|
+
* not public output.
|
|
208
|
+
* @param {string} rel
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
function hasHiddenPathSegment(rel) {
|
|
212
|
+
return rel.split(path.sep).some((segment) => /^[._-]/.test(segment));
|
|
213
|
+
}
|
|
214
|
+
|
|
203
215
|
/**
|
|
204
216
|
* @param {string} dir
|
|
205
217
|
* @returns {string[]}
|
package/src/compiler.js
CHANGED
|
@@ -987,7 +987,7 @@ function compileComputedExpr(raw, ctx) {
|
|
|
987
987
|
*/
|
|
988
988
|
function handleForm(line, bodyLines, ctx) {
|
|
989
989
|
let rest = line.slice(":form".length).trim();
|
|
990
|
-
const
|
|
990
|
+
const rawAction = rest.match(/action="([^"]+)"/)?.[1];
|
|
991
991
|
const method = rest.match(/method="([^"]+)"/)?.[1] || "post";
|
|
992
992
|
const into = rest.match(/(?:^|\s)into\s+([A-Za-z_$][\w$]*)/)?.[1];
|
|
993
993
|
const leftover = rest
|
|
@@ -995,11 +995,12 @@ function handleForm(line, bodyLines, ctx) {
|
|
|
995
995
|
.replace(/method="[^"]+"/, "")
|
|
996
996
|
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
997
997
|
.trim();
|
|
998
|
-
if ((!
|
|
998
|
+
if ((!rawAction && !into) || leftover) {
|
|
999
999
|
throw new Error(
|
|
1000
1000
|
`Malformed :form in ${ctx.file}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
|
|
1001
1001
|
);
|
|
1002
1002
|
}
|
|
1003
|
+
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
1003
1004
|
const inner = compileBody(bodyLines, ctx).trim();
|
|
1004
1005
|
if (!into) {
|
|
1005
1006
|
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
|
|
@@ -1159,48 +1160,71 @@ function handleEffect(line, ctx) {
|
|
|
1159
1160
|
* @returns {string}
|
|
1160
1161
|
*/
|
|
1161
1162
|
function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1163
|
+
const condition = line.replace(/^:if\s+/, "").trim();
|
|
1164
|
+
// Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
|
|
1165
|
+
// existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
|
|
1166
|
+
// `not`, …) falls through to the predicate path below.
|
|
1167
|
+
const match = condition.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/);
|
|
1168
|
+
if (match) {
|
|
1169
|
+
const segs = match[1].split(".");
|
|
1170
|
+
const head = segs[0];
|
|
1171
|
+
|
|
1172
|
+
// Per-row meta vars in :if — only valid inside a loop.
|
|
1173
|
+
if (LOOP_META[head]) {
|
|
1174
|
+
if (!ctx.loopMeta) throw new Error(`":if ${match[1]}" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Use it inside a loop body.`);
|
|
1175
|
+
if (ctx.loopItem) {
|
|
1176
|
+
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1177
|
+
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1178
|
+
return `<span data-wd-each-if data-wd-meta="${LOOP_META[head]}"><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1179
|
+
}
|
|
1180
|
+
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1181
|
+
}
|
|
1166
1182
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1183
|
+
const staticValue = lookupVar(ctx.scope, head);
|
|
1184
|
+
if (staticValue.found) {
|
|
1185
|
+
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
1186
|
+
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (ctx.loopItem && head === ctx.loopItem) {
|
|
1171
1190
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1172
1191
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1173
|
-
|
|
1192
|
+
const rest = segs.slice(1).join(".");
|
|
1193
|
+
const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
|
|
1194
|
+
return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1174
1195
|
}
|
|
1175
|
-
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
const staticValue = lookupVar(ctx.scope, head);
|
|
1179
|
-
if (staticValue.found) {
|
|
1180
|
-
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
1181
|
-
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
1182
|
-
}
|
|
1183
1196
|
|
|
1184
|
-
|
|
1197
|
+
const key = resolveStateKey(head, ctx);
|
|
1198
|
+
if (!key) {
|
|
1199
|
+
throw new Error(`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`);
|
|
1200
|
+
}
|
|
1201
|
+
ctx.comp.assets.runtime = true;
|
|
1185
1202
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1186
1203
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1187
|
-
const
|
|
1188
|
-
const pathAttr = ` data-wd-path="${escapeHtml(
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1204
|
+
const restPath = segs.slice(1).join(".");
|
|
1205
|
+
const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
|
|
1206
|
+
const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
|
|
1207
|
+
const active = initialTruthy ? truthy : falsy;
|
|
1208
|
+
return `<div data-wd-if="${key}"${pathAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Predicate path: a comparison / logical condition. Compiles through the same
|
|
1212
|
+
// whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
|
|
1213
|
+
// build when static, drives a per-row each-if when it reads the loop item, and
|
|
1214
|
+
// a global if-region (evaluated each render) when it reads state. No raw eval.
|
|
1215
|
+
if (!condition) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`);
|
|
1216
|
+
const compiled = compileWhen(condition, ctx, '":if"');
|
|
1217
|
+
if (compiled.static) return compileBody(compiled.value ? truthyLines : falsyLines, ctx);
|
|
1196
1218
|
ctx.comp.assets.runtime = true;
|
|
1197
1219
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1198
1220
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
1221
|
+
const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
|
|
1222
|
+
if (compiled.item) {
|
|
1223
|
+
return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1224
|
+
}
|
|
1225
|
+
const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
|
|
1202
1226
|
const active = initialTruthy ? truthy : falsy;
|
|
1203
|
-
return `<div data-wd-if="
|
|
1227
|
+
return `<div data-wd-if=""${exprAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1204
1228
|
}
|
|
1205
1229
|
|
|
1206
1230
|
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
@@ -1503,24 +1527,25 @@ function evalPredicate(body, item, ctx) {
|
|
|
1503
1527
|
* resolution order.
|
|
1504
1528
|
* @param {string} tok
|
|
1505
1529
|
* @param {Ctx} ctx
|
|
1530
|
+
* @param {string} what Directive label for error messages.
|
|
1506
1531
|
* @returns {{ code: string, state: boolean, item: boolean }}
|
|
1507
1532
|
*/
|
|
1508
|
-
function compileWhenOperand(tok, ctx) {
|
|
1533
|
+
function compileWhenOperand(tok, ctx, what) {
|
|
1509
1534
|
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
|
|
1510
1535
|
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
|
|
1511
1536
|
if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
|
|
1512
1537
|
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
1513
|
-
throw new Error(`Unsupported operand "${tok}" in
|
|
1538
|
+
throw new Error(`Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
|
|
1514
1539
|
}
|
|
1515
1540
|
const segs = tok.split(".");
|
|
1516
1541
|
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1517
|
-
throw new Error(`Path "${tok}" is not allowed in
|
|
1542
|
+
throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
|
|
1518
1543
|
}
|
|
1519
1544
|
const scoped = lookupVar(ctx.scope, segs[0]);
|
|
1520
1545
|
if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
|
|
1521
1546
|
if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
|
|
1522
1547
|
const key = resolveStateKey(segs[0], ctx);
|
|
1523
|
-
if (!key) throw new Error(
|
|
1548
|
+
if (!key) throw new Error(`${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
|
|
1524
1549
|
const rest = segs.slice(1).join(".");
|
|
1525
1550
|
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
|
|
1526
1551
|
}
|
|
@@ -1533,9 +1558,10 @@ function compileWhenOperand(tok, ctx) {
|
|
|
1533
1558
|
* (which decides data-wd-each-class vs the global data-wd-class).
|
|
1534
1559
|
* @param {string} raw
|
|
1535
1560
|
* @param {Ctx} ctx
|
|
1561
|
+
* @param {string} [what] Directive label for error messages.
|
|
1536
1562
|
* @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
|
|
1537
1563
|
*/
|
|
1538
|
-
function compileWhen(raw, ctx) {
|
|
1564
|
+
function compileWhen(raw, ctx, what = '"::: … when"') {
|
|
1539
1565
|
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
1540
1566
|
/** @type {string[]} */
|
|
1541
1567
|
const pieces = [];
|
|
@@ -1543,21 +1569,26 @@ function compileWhen(raw, ctx) {
|
|
|
1543
1569
|
let usesItem = false;
|
|
1544
1570
|
for (let i = 0; i < parts.length; i++) {
|
|
1545
1571
|
if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
|
|
1546
|
-
|
|
1572
|
+
let seg = parts[i].trim();
|
|
1573
|
+
// Optional leading `not` negates the whole sub-condition.
|
|
1574
|
+
let negate = false;
|
|
1575
|
+
if (/^not\s+/i.test(seg)) { negate = true; seg = seg.replace(/^not\s+/i, "").trim(); }
|
|
1547
1576
|
const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
1577
|
+
let expr;
|
|
1548
1578
|
if (opMatch) {
|
|
1549
|
-
const left = compileWhenOperand(opMatch[1].trim(), ctx);
|
|
1550
|
-
const right = compileWhenOperand(opMatch[3].trim(), ctx);
|
|
1579
|
+
const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
|
|
1580
|
+
const right = compileWhenOperand(opMatch[3].trim(), ctx, what);
|
|
1551
1581
|
usesState = usesState || left.state || right.state;
|
|
1552
1582
|
usesItem = usesItem || left.item || right.item;
|
|
1553
1583
|
const op = opMatch[2].toLowerCase();
|
|
1554
|
-
|
|
1584
|
+
expr = op === "contains" ? `C(${left.code}, ${right.code})` : `${left.code} ${op} ${right.code}`;
|
|
1555
1585
|
} else {
|
|
1556
|
-
const only = compileWhenOperand(seg, ctx);
|
|
1586
|
+
const only = compileWhenOperand(seg, ctx, what);
|
|
1557
1587
|
usesState = usesState || only.state;
|
|
1558
1588
|
usesItem = usesItem || only.item;
|
|
1559
|
-
|
|
1589
|
+
expr = only.code;
|
|
1560
1590
|
}
|
|
1591
|
+
pieces.push(negate ? `(!(${expr}))` : `(${expr})`);
|
|
1561
1592
|
}
|
|
1562
1593
|
const body = pieces.join(" ");
|
|
1563
1594
|
if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
|
package/src/runtime.js
CHANGED
|
@@ -205,12 +205,20 @@ function classToggle(el, raw, item) {
|
|
|
205
205
|
/** @type {[string, string][]} */
|
|
206
206
|
let pairs;
|
|
207
207
|
try { pairs = JSON.parse(raw); } catch { return; }
|
|
208
|
-
for (const [name, body] of pairs)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
for (const [name, body] of pairs) el.classList.toggle(name, evalPredicate(body, item));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Evaluate a compiled whitelist predicate body (over I()/S()/C()) to a boolean.
|
|
213
|
+
* Shared by reactive classes (`.class when`) and expression conditionals
|
|
214
|
+
* (`:if a > b`). `item` is the loop row for per-row predicates, else undefined.
|
|
215
|
+
* @param {string} body
|
|
216
|
+
* @param {any} item
|
|
217
|
+
* @returns {boolean}
|
|
218
|
+
*/
|
|
219
|
+
function evalPredicate(body, item) {
|
|
220
|
+
try { return Boolean(loopPredicate(body)((/** @type {string} */ p) => getPath(item, p), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn)); }
|
|
221
|
+
catch (error) { warn(body, error); return false; }
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
/**
|
|
@@ -241,8 +249,9 @@ function fillItem(node, item, meta = {}) {
|
|
|
241
249
|
// querySelectorAll skips <template> content, so this sees only the outermost
|
|
242
250
|
// regions; recursing into each injected branch fills the rest.
|
|
243
251
|
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
252
|
+
const expr = region.getAttribute("data-wd-if-expr");
|
|
244
253
|
const m = region.getAttribute("data-wd-meta");
|
|
245
|
-
const value = m ? meta[m] : getPath(item, region.getAttribute("data-wd-path"));
|
|
254
|
+
const value = expr ? evalPredicate(expr, item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
|
|
246
255
|
const output = region.querySelector("[data-wd-each-if-out]");
|
|
247
256
|
if (!output) continue;
|
|
248
257
|
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
|
|
@@ -301,6 +310,31 @@ function pipeline(list, region) {
|
|
|
301
310
|
return list;
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Evaluate one global `data-wd-if` region against current state and swap its
|
|
315
|
+
* branch when the truthiness flips. `data-wd-if-expr` carries a compiled
|
|
316
|
+
* predicate (richer `:if`); otherwise it is a bare-path truthiness test. On a
|
|
317
|
+
* swap, the freshly injected branch can carry nested if-regions (a desugared
|
|
318
|
+
* `:else if`) that were never in the live tree this pass — querySelectorAll
|
|
319
|
+
* skips <template> content — so recurse to fill them. Unchanged regions are
|
|
320
|
+
* left alone; their already-live nested regions are reached by renderNow's
|
|
321
|
+
* document-wide query.
|
|
322
|
+
* @param {Element} node
|
|
323
|
+
* @returns {void}
|
|
324
|
+
*/
|
|
325
|
+
function renderIf(node) {
|
|
326
|
+
const expr = node.getAttribute("data-wd-if-expr");
|
|
327
|
+
const value = expr ? evalPredicate(expr, undefined) : getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
|
|
328
|
+
const active = String(Boolean(value));
|
|
329
|
+
if (node.getAttribute("data-wd-if-active") === active) return;
|
|
330
|
+
node.setAttribute("data-wd-if-active", active);
|
|
331
|
+
const output = node.querySelector("[data-wd-if-out]");
|
|
332
|
+
if (!output) return;
|
|
333
|
+
const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
|
|
334
|
+
output.innerHTML = template?.innerHTML || "";
|
|
335
|
+
for (const nested of output.querySelectorAll("[data-wd-if]")) renderIf(nested);
|
|
336
|
+
}
|
|
337
|
+
|
|
304
338
|
/**
|
|
305
339
|
* Synchronously render the whole document from current `state`:
|
|
306
340
|
* computed → if-regions → keyed loop reconcile → text/input binds.
|
|
@@ -308,16 +342,7 @@ function pipeline(list, region) {
|
|
|
308
342
|
*/
|
|
309
343
|
function renderNow() {
|
|
310
344
|
recompute();
|
|
311
|
-
for (const node of document.querySelectorAll("[data-wd-if]"))
|
|
312
|
-
const value = getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
|
|
313
|
-
const active = String(Boolean(value));
|
|
314
|
-
if (node.getAttribute("data-wd-if-active") === active) continue;
|
|
315
|
-
node.setAttribute("data-wd-if-active", active);
|
|
316
|
-
const output = node.querySelector("[data-wd-if-out]");
|
|
317
|
-
if (!output) continue;
|
|
318
|
-
const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
|
|
319
|
-
output.innerHTML = template?.innerHTML || "";
|
|
320
|
-
}
|
|
345
|
+
for (const node of document.querySelectorAll("[data-wd-if]")) renderIf(node);
|
|
321
346
|
|
|
322
347
|
// Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
|
|
323
348
|
// bindings (data-wd-each-class) are handled per item inside fillItem.
|
package/src/scaffold.js
CHANGED
|
@@ -128,10 +128,19 @@ export function initProject(root) {
|
|
|
128
128
|
"",
|
|
129
129
|
"Run `npm install` and `npm run dev` to start the live compiler.",
|
|
130
130
|
"",
|
|
131
|
+
"## Commands",
|
|
132
|
+
"",
|
|
133
|
+
"- `npm run dev` — live compiler on http://localhost:5173 with hot reload.",
|
|
134
|
+
"- `npm run build` — compile the site to `dist/`.",
|
|
135
|
+
"- `npm run preview` — serve the built `dist/` locally.",
|
|
136
|
+
"",
|
|
137
|
+
"## Layout",
|
|
138
|
+
"",
|
|
131
139
|
"- Pages live in `site/pages`.",
|
|
132
140
|
"- Shared includes live in `site/_`.",
|
|
133
141
|
"- Hidden route files start with `.` or `-`.",
|
|
134
|
-
"- Colocated `index.skin` and `index.js` attach automatically."
|
|
142
|
+
"- Colocated `index.skin` and `index.js` attach automatically.",
|
|
143
|
+
"- Any other file under `site/pages` (images, fonts, …) copies to `dist/` at its own path."
|
|
135
144
|
].join("\n"));
|
|
136
145
|
return { root };
|
|
137
146
|
}
|