@zvndev/darkmown 0.8.1 → 0.8.2

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 ~2 KB runtime only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~3.1 KB runtime only if it declares reactive behavior.
11
11
 
12
12
  ## Project layout & routing
13
13
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
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.8.2 — 2026-06-14
6
+
7
+ Honesty, hardening, and polish pass — every claim in the docs is now backed by shipped code.
8
+
9
+ - **Security:** new per-page `html: false` frontmatter opt-out routes that page's Markdown through a strict renderer that escapes raw HTML — the mitigation for compiling untrusted/user-submitted content (default stays `html: true` for trusted authors). README and SECURITY.md now warn prominently that untrusted Markdown must be sanitized. `escapeHtml` also escapes single quotes.
10
+ - **Runtime:** state changes now batch into a single render per tick (`requestAnimationFrame`, falling back to a microtask), so rapid `:bind` input no longer re-runs the reconciler on every keystroke; the final state always renders and the public `wd.render()` stays synchronous. Opt-in `window.wd.debug` surfaces warnings for failed `:computed` / `where` expressions that were previously silent.
11
+ - **Tests:** first runtime/DOM test suite — keyed loop reconcile (node reuse on add/remove/reorder), `getPath` prototype-pollution rejection, loop-key collisions, and render batching — loading the real runtime via `node:vm`, no new dependencies. Suite grew 83 → 98.
12
+ - **Compiler:** unterminated frontmatter now throws an actionable error with the file path instead of silently swallowing the page; empty `---`/`---` blocks parse cleanly.
13
+ - **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size corrected to the true ~3.1 KB gzipped across all surfaces; `:note`/`:try`/`:sprint` documented as demo-only; `docs/cli.md` scaffold output synced; VS Code install instructions corrected (build from source; Marketplace listing pending).
14
+ - **Site & tooling:** landing-page whitespace fixed (removed the forced `60vh` hero band); mobile nav no longer overflows; tagline locked to "Markdown that runs."; regenerated `package-lock.json` identity (`@zvndev/darkmown`).
15
+
5
16
  ## 0.8.1 — 2026-06-14
6
17
 
7
18
  - Fix: a quoted array item that follows `, ` no longer keeps a leading space — `tags: [a, "b, c"]` parses to `["a", "b, c"]`, not `["a", " b, c"]`.
@@ -33,7 +44,7 @@ All notable changes to Darkmown are documented here. Versions follow [semver](ht
33
44
  - **Nested `:if` over loop items.** Conditionals inside a reactive `@loop` now nest — an inner `:if` resolves after the outer branch, per row, both at build time (balanced-region pre-render) and at runtime (recursive fill). Previously the compiler threw "not supported yet"; the loop-template fill no longer relies on a non-greedy regex that broke on nesting.
34
45
  - **VS Code extension** (`editors/vscode`): syntax highlighting, snippets, and folding for `.wd` and `.skin` files. Grammars are tested through the real VS Code tokenizer (vscode-textmate) in CI.
35
46
  - New `/app/` demo — a whole live app in one `.wd` file: static build-time loop, fetched data looped with nested conditionals, a persistent counter with a computed milestone, and a form that round-trips to a real server. It is the framework's elevator pitch as a single page.
36
- - Site: landing page repositioned around the core wedge ("Markdown that runs" — full reactive websites in plain Markdown). `description`/Open Graph/Twitter meta tags now emit from frontmatter. Runtime-size claims corrected to the true ~2 KB; mobile nav wraps instead of overflowing.
47
+ - Site: landing page repositioned around the core wedge ("Markdown that runs" — full reactive websites in plain Markdown). `description`/Open Graph/Twitter meta tags now emit from frontmatter.
37
48
 
38
49
  ## 0.4.0 — 2026-06-11
39
50
 
@@ -57,7 +68,7 @@ All notable changes to Darkmown are documented here. Versions follow [semver](ht
57
68
  - `:if item.path` inside reactive loops (per-row branches).
58
69
  - `window.wd` escape hatch for colocated `.js`.
59
70
  - `::: section` containers with scoped state; keyed loop reconciliation.
60
- - View transitions via `transitions: true` frontmatter.
71
+ - View transitions via `transitions: true` frontmatter. **(Disabled since: cross-document `@view-transition` render-blocked deployed pages, hanging navigation. `transitions` is currently hardcoded off in `src/compiler.js`; reintroduction is tracked pending proper activation fallbacks.)**
61
72
  - Real CommonMark parsing (markdown-it); strict `.md` (directives stay plain text).
62
73
  - `@loop … into … @endloop` — the one loop, replacing `@repeat` and `:for`.
63
74
  - Dev server rebuilds in a child process (always-fresh modules).
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Darkmown
2
2
 
3
- **[darkmown.com](https://darkmown.com)** · markdown, rearranged.
3
+ **[darkmown.com](https://darkmown.com)** · Markdown that runs.
4
4
 
5
- Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around 2 KB gzipped (CI-enforced under 5 KB).
5
+ Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~3.1 KB gzipped (CI-enforced under 5 KB).
6
6
 
7
7
  ## Quick start
8
8
 
@@ -45,7 +45,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
45
45
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
46
46
  - `site/_` is the include shelf for `@include /name.wd`.
47
47
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
48
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~2 KB gzipped, CI-enforced under 5 KB).
48
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~3.1 KB gzipped, CI-enforced under 5 KB).
49
49
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
50
50
 
51
51
  ## Interpolation
@@ -100,7 +100,7 @@ Add `where <predicate>` to filter a loop. Conditions compare a loop-item field a
100
100
  @endloop
101
101
  ```
102
102
 
103
- Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive substring match. The predicate is a compile-time-validated whitelist — only item paths, declared `:state`, numbers, and `"strings"` are allowed (no arbitrary expressions, no `eval`).
103
+ Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive substring match. The predicate is a compile-time-validated whitelist — only item paths, declared `:state`, numbers, and `"strings"` are allowed (no arbitrary expressions). Raw user content is never evaluated; the validated predicate compiles to a whitelisted grammar that runs via `new Function`.
104
104
 
105
105
  **The source decides reactivity, just like the loop itself.** If the predicate only reads the row, the filter runs at build time and the page stays **zero-JS**. If the predicate reads a `:state` value, the loop becomes reactive and re-filters live as that state changes — a live search in pure Markdown:
106
106
 
@@ -211,7 +211,15 @@ Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.s
211
211
 
212
212
  ## Editor support
213
213
 
214
- A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. Build it with `cd editors/vscode && npx @vscode/vsce package`, or install the published extension from the Marketplace (search "Darkmown").
214
+ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. Install it from source by building a `.vsix`: `cd editors/vscode && npx @vscode/vsce package`, then `code --install-extension darkmown-*.vsix`. A Marketplace listing is coming soon.
215
+
216
+ ## Security
217
+
218
+ Darkmown renders Markdown with `html: true`, so **raw HTML in your content passes through verbatim** — by design, like every Markdown site generator. Treat content files as trusted input.
219
+
220
+ > **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
221
+
222
+ Directive actions and `:computed`/`@loop … where` expressions are never `eval`'d as raw user content — they compile to a whitelisted grammar (item paths, declared `:state`, numbers, strings) that runs via `new Function`. See [SECURITY.md](SECURITY.md) for the full security model.
215
223
 
216
224
  ## Spec status
217
225
 
package/docs/cli.md CHANGED
@@ -16,8 +16,10 @@ darkmown help
16
16
 
17
17
  Creates a minimal Darkmown site with:
18
18
 
19
+ - `package.json` (pins the current `@zvndev/darkmown` version)
19
20
  - `site/pages/index.wd`
20
21
  - `site/pages/index.skin`
22
+ - `site/pages/about.md`
21
23
  - `site/_/nav.wd`
22
24
  - `README.md`
23
25
 
@@ -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` (~1.1 KB gzipped, below the original 5 KB target).
9
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~3.1 KB gzipped, CI-enforced below the 5 KB target).
10
10
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
11
11
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
12
12
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
@@ -33,7 +33,7 @@ This audit compares the current Darkmown implementation with the original MARROW
33
33
 
34
34
  ## Stage 4 additions (2026-06-11, same day)
35
35
 
36
- - **View transitions:** `transitions: true` in frontmatter emits `@view-transition { navigation: auto; }` — the original doc's zero-JS MPA navigation polish.
36
+ - **View transitions (currently disabled):** `transitions: true` in frontmatter was wired to emit `@view-transition { navigation: auto; }` — the original doc's zero-JS MPA navigation polish. **This is turned off as of now:** cross-document `@view-transition` render-blocked deployed pages (stalled rAF, hanging navigation), so `transitions` is hardcoded to `""` in `src/compiler.js`. The frontmatter key is accepted but has no effect today. Reintroduction is tracked pending proper activation fallbacks (see "Still missing from the full vision").
37
37
  - **Lazy loading:** `:fetch … when=visible` defers the request until the marker scrolls into view (IntersectionObserver) — the adapted form of `:lazy`.
38
38
  - **Computed state:** `:computed name = expr` with a compile-time-validated grammar (state refs, numbers, strings, arithmetic, comparisons; assignment, calls, and prototype walks rejected). Initial values are evaluated at build time.
39
39
  - **Dev self-reload:** `darkmown dev` rebuilds in a child process, so changes to the framework's own `src/` always compile with fresh modules.
@@ -49,7 +49,7 @@ This audit compares the current Darkmown implementation with the original MARROW
49
49
  ## Stage 6 additions (2026-06-13)
50
50
 
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
- - **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, no `eval`. 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.
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
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`.
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.
@@ -60,11 +60,15 @@ This audit compares the current Darkmown implementation with the original MARROW
60
60
 
61
61
  - **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).
62
62
 
63
+ ## Demo-only directives (not part of the public surface)
64
+
65
+ 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`, `:computed`, `:button`, `:bind`, `:if`/`:else`/`:endif`, `:fetch`, `:form`/`:input`/`:submit`, `@loop`/`@endloop` (with `where`), `@include`, and `::: section`.
66
+
63
67
  ## Still missing from the full vision
64
68
 
65
69
  - First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
66
70
  - `@loop` over fetch sub-paths (looping a path *inside* fetched state rather than top-level state).
67
- - `@starting-style` emission and browser fallbacks for older engines.
71
+ - **View transitions:** re-enabling `transitions: true` with proper activation fallbacks (disabled today — see Stage 4), plus `@starting-style` emission and browser fallbacks for older engines.
68
72
  - Editor tooling, richer diagnostics, and an actual npm publish (needs a license decision).
69
73
 
70
74
  ## Current claim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/site/_/nav.skin CHANGED
@@ -15,6 +15,8 @@ tokens
15
15
  gap .5rem 1rem
16
16
  align-items center
17
17
  padding 1rem 2rem
18
+ max-width 100%
19
+ box-sizing border-box
18
20
  border-bottom 1px solid $line
19
21
  bg rgba(251,250,247,.85)
20
22
  backdrop-filter blur(8px)
@@ -22,6 +24,11 @@ tokens
22
24
  top 0
23
25
  z-index 10
24
26
 
27
+ @media (max-width: 600px)
28
+ .topnav
29
+ padding .85rem 1rem
30
+ gap .4rem .85rem
31
+
25
32
  .topnav strong
26
33
  color $warm
27
34
  font-size 1.05rem
@@ -131,7 +131,7 @@ tags: [sales, revenue, "q1, q2"]
131
131
 
132
132
  ## Reactive directives
133
133
 
134
- Reactive pages opt into `/__wd/runtime.js` (~2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
134
+ Reactive pages opt into `/__wd/runtime.js` (~3.1 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
135
135
 
136
136
  ```wd
137
137
  :state count = 0
@@ -18,16 +18,17 @@ page
18
18
  padding 0 0 4rem
19
19
 
20
20
  .hero
21
- min-height 60vh
22
21
  display flex
23
22
  flex-direction column
24
23
  align-items flex-start
25
24
  justify-content center
25
+ padding 2.5rem 0 2.25rem
26
26
  border-bottom 1px solid $line
27
- margin-bottom 2rem
27
+ margin-bottom 2.5rem
28
28
 
29
29
  .hero > p:not(.eyebrow)
30
30
  max-width 70ch
31
+ margin .75rem 0 1.5rem
31
32
 
32
33
  .eyebrow
33
34
  color $accent
@@ -39,16 +40,16 @@ page
39
40
  main
40
41
  max-width 920px
41
42
  margin 0 auto
42
- padding 1rem 2rem 2rem
43
+ padding 0 2rem 2rem
43
44
 
44
45
  h1
45
46
  font-size clamp(2.4rem, 6vw, 5rem)
46
47
  line-height .95
47
- margin 2rem 0 1rem
48
- max-width 10ch
48
+ margin .75rem 0 1rem
49
+ max-width 14ch
49
50
 
50
51
  h2
51
- margin-top 2rem
52
+ margin-top 2.75rem
52
53
  border-bottom 1px solid $line
53
54
  padding-bottom .35rem
54
55
 
@@ -10,7 +10,7 @@ description: Build full websites in plain Markdown. Routing, state, server calls
10
10
  <section class="hero">
11
11
  <p class="eyebrow">The Markdown framework</p>
12
12
 
13
- # Markdown that runs
13
+ # Markdown that runs.
14
14
 
15
15
  Not just pages — **full websites**. Routing, state, server calls, embeds, and reactive islands, all from plain Markdown files. No JavaScript or CSS required. Your docs and your code are all just text.
16
16
 
@@ -18,7 +18,7 @@ Not just pages — **full websites**. Routing, state, server calls, embeds, and
18
18
  <a class="btn" href="/docs/">Read the docs</a>
19
19
  </section>
20
20
 
21
- :note "Static pages ship zero JavaScript. The moment a page asks for reactivity, one ~2 KB runtime loads — never before. JS optional, CSS optional, always."
21
+ :note "Static pages ship zero JavaScript. The moment a page asks for reactivity, one ~3.1 KB runtime loads — never before. JS optional, CSS optional, always."
22
22
 
23
23
  ## A whole framework, in text
24
24
 
package/src/compiler.js CHANGED
@@ -5,6 +5,20 @@ import MarkdownIt from "markdown-it";
5
5
  const md = new MarkdownIt({ html: true });
6
6
  md.use(bindingPlugin);
7
7
 
8
+ // Raw HTML in markdown passes through by default (a documented design choice).
9
+ // Pages can opt out with frontmatter `html: false` for untrusted content; the
10
+ // stricter instance is built lazily so the default path stays a single instance.
11
+ let mdNoHtml = null;
12
+ function selectMd(meta) {
13
+ // Frontmatter scalars stay strings (no coercion), so accept both forms.
14
+ if (meta?.html !== false && meta?.html !== "false") return md;
15
+ if (!mdNoHtml) {
16
+ mdNoHtml = new MarkdownIt({ html: false });
17
+ mdNoHtml.use(bindingPlugin);
18
+ }
19
+ return mdNoHtml;
20
+ }
21
+
8
22
  const pageIncludeExtensions = [".md", ".wd"];
9
23
 
10
24
  export function compilePage(file, context) {
@@ -74,24 +88,27 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
74
88
  }
75
89
 
76
90
  const raw = fs.readFileSync(file, "utf8");
77
- const { meta, body } = parseFrontmatter(raw);
91
+ const { meta, body } = parseFrontmatter(raw, file);
78
92
  collectColocatedAssets(file, context, comp.assets);
79
93
 
80
94
  if (path.extname(file) === ".md") {
81
95
  scanMarkdownHints(body, file, comp);
82
- return { meta, html: md.render(body, {}) };
96
+ return { meta, html: selectMd(meta).render(body, {}) };
83
97
  }
84
98
 
85
99
  // Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
86
100
  // `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
87
- const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem };
101
+ const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem, md: selectMd(meta) };
88
102
  return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
89
103
  }
90
104
 
91
- export function parseFrontmatter(raw) {
105
+ export function parseFrontmatter(raw, file) {
92
106
  if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
93
- const end = raw.indexOf("\n---", 4);
94
- if (end === -1) return { meta: {}, body: raw };
107
+ const end = raw.indexOf("\n---", 3);
108
+ if (end === -1) {
109
+ const where = file ? ` in ${file}` : "";
110
+ throw new Error(`Unterminated frontmatter${where}: opening "---" has no closing "---". Use: --- on its own line to open and another --- to close, then the page body.`);
111
+ }
95
112
  const front = raw.slice(4, end).trim();
96
113
  const body = raw.slice(end + 4).replace(/^\n/, "");
97
114
  const meta = {};
@@ -462,6 +479,7 @@ function handleComputed(line, ctx) {
462
479
  const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
463
480
  initial = new Function("S", `return (${expr});`)(read);
464
481
  } catch {
482
+ console.warn(`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`);
465
483
  initial = null;
466
484
  }
467
485
  const key = declareState(match[1], initial ?? null, ctx);
@@ -731,6 +749,7 @@ function evalPredicate(body, item, ctx) {
731
749
  const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
732
750
  return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
733
751
  } catch {
752
+ console.warn(`@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`);
734
753
  return false;
735
754
  }
736
755
  }
@@ -878,7 +897,7 @@ function renderDemoDirective(line) {
878
897
  // ---------------------------------------------------------------------------
879
898
 
880
899
  function renderProse(text, ctx) {
881
- return md.render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
900
+ return (ctx.md ?? md).render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
882
901
  }
883
902
 
884
903
  function bindingPlugin(mdInstance) {
@@ -1141,5 +1160,6 @@ export function escapeHtml(value) {
1141
1160
  .replaceAll("&", "&amp;")
1142
1161
  .replaceAll("<", "&lt;")
1143
1162
  .replaceAll(">", "&gt;")
1144
- .replaceAll('"', "&quot;");
1163
+ .replaceAll('"', "&quot;")
1164
+ .replaceAll("'", "&#39;");
1145
1165
  }
package/src/runtime.js CHANGED
@@ -36,16 +36,22 @@ function getPath(value, path) {
36
36
 
37
37
  const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
38
38
  key: node.getAttribute("data-wd-computed-key"),
39
+ expr: node.getAttribute("data-wd-computed-expr"),
39
40
  evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
40
41
  }));
41
42
 
43
+ function warn(expr, error) {
44
+ if (window.wd && window.wd.debug) console.warn(`[wd] expression failed: ${expr}`, error);
45
+ }
46
+
42
47
  function recompute() {
43
48
  const read = (key, path) => getPath(state[key], path);
44
49
  for (const def of computedDefs) {
45
50
  try {
46
51
  state[def.key] = def.evaluate(read);
47
- } catch {
52
+ } catch (error) {
48
53
  state[def.key] = undefined;
54
+ warn(def.expr, error);
49
55
  }
50
56
  }
51
57
  }
@@ -89,7 +95,7 @@ function fillItem(node, item) {
89
95
  }
90
96
  }
91
97
 
92
- function render() {
98
+ function renderNow() {
93
99
  recompute();
94
100
  for (const node of document.querySelectorAll("[data-wd-if]")) {
95
101
  const value = getPath(state[node.getAttribute("data-wd-if")], node.getAttribute("data-wd-path"));
@@ -112,9 +118,14 @@ function render() {
112
118
  const where = region.getAttribute("data-wd-loop-where");
113
119
  if (where) {
114
120
  const predicate = loopPredicate(where);
115
- list = list.filter((item) =>
116
- predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn)
117
- );
121
+ list = list.filter((item) => {
122
+ try {
123
+ return predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn);
124
+ } catch (error) {
125
+ warn(where, error);
126
+ return false;
127
+ }
128
+ });
118
129
  }
119
130
 
120
131
  const existing = new Map();
@@ -149,6 +160,19 @@ function render() {
149
160
  }
150
161
  }
151
162
 
163
+ // Coalesce rapid state mutations into one render on the next tick. Each scheduled
164
+ // pass always reads the latest state, so the final update is never dropped.
165
+ const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask;
166
+ let scheduled = false;
167
+ function render() {
168
+ if (scheduled) return;
169
+ scheduled = true;
170
+ schedule(() => {
171
+ scheduled = false;
172
+ renderNow();
173
+ });
174
+ }
175
+
152
176
  document.addEventListener("input", (event) => {
153
177
  const input = event.target.closest("[data-wd-bind-input]");
154
178
  if (!input) return;
@@ -274,7 +298,7 @@ window.wd = {
274
298
  savePersisted();
275
299
  render();
276
300
  },
277
- render
301
+ render: renderNow
278
302
  };
279
303
 
280
- render();
304
+ renderNow();