@zvndev/darkmown 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.5.0 — 2026-06-12
6
+
7
+ - **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.
8
+ - **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.
9
+ - 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.
10
+ - 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.
11
+
5
12
  ## 0.4.0 — 2026-06-11
6
13
 
7
14
  - **Renamed**: the framework is now **Darkmown** (formerly Markie). Package `@zvndev/darkmown` (npm's typosquat guard reserves bare `darkmown` as too similar to `markdown`), CLI `darkmown`. The `.wd` format and `/__wd/` asset paths are unchanged.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **[darkmown.com](https://darkmown.com)** · markdown, rearranged.
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 under 2 KB gzipped.
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).
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 ~1.6 KB gzipped).
48
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~2 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
@@ -132,12 +132,16 @@ Loading…
132
132
  - `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement.
133
133
  - `: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. `darkmown dev` ships a `/__wd/echo` endpoint for demos.
134
134
  - `:state x = [] persist` keeps that state in localStorage across reloads.
135
- - `:if item.path` works inside reactive loops for per-row branches.
135
+ - `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
136
136
 
137
137
  ## The escape hatch
138
138
 
139
139
  Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
140
140
 
141
+ ## Editor support
142
+
143
+ 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").
144
+
141
145
  ## Spec status
142
146
 
143
147
  See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.
@@ -20,7 +20,7 @@ This audit compares the current Darkmown implementation with the original MARROW
20
20
  - **`:fetch name from "url"`:** declarative data loading into reactive state, with `name_error` for failures. Shelf `.json` publishes to `/__wd/data/` so it works on static hosts.
21
21
  - **`:form` two ways:** `:form into name` captures submits into client state with zero backend; `:form action="/url"` emits a plain native form and ships no JS — the progressive-enhancement story from the original doc.
22
22
  - **`:state x = v persist`:** localStorage-backed state. This is the honest client-side slice of the original `:cart` primitive; server sync remains future work.
23
- - **`:if item.path` inside reactive loops:** per-row conditional branches, filled at compile time and re-filled by the keyed patcher.
23
+ - **`:if item.path` inside reactive loops:** per-row conditional branches, filled at compile time and re-filled by the keyed patcher. Conditionals **nest** — an inner `:if` is resolved after the outer branch, both at build time (balanced-region pre-render) and at runtime (recursive fill).
24
24
  - **`window.wd` escape hatch:** `get`/`set`/`state`/`render` exposed to colocated `.js` — the adapted form of the original `<script scope>` "infinite extension" idea, aligned with Darkmown's colocation model instead of inline scripts.
25
25
 
26
26
  ## Deliberate adaptations
@@ -49,7 +49,7 @@ This audit compares the current Darkmown implementation with the original MARROW
49
49
  ## Still missing from the full vision
50
50
 
51
51
  - 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.
52
- - Nested `:if` over loop items, and `@loop` over fetch sub-paths.
52
+ - `@loop` over fetch sub-paths (looping a path *inside* fetched state rather than top-level state).
53
53
  - `@starting-style` emission and browser fallbacks for older engines.
54
54
  - Editor tooling, richer diagnostics, and an actual npm publish (needs a license decision).
55
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "build": "node src/cli.js build",
15
15
  "dev": "node src/cli.js dev",
16
- "test": "node --test"
16
+ "test": "node --test tests/*.test.js"
17
17
  },
18
18
  "files": [
19
19
  "src",
package/site/_/nav.skin CHANGED
@@ -11,7 +11,8 @@ tokens
11
11
 
12
12
  .topnav
13
13
  display flex
14
- gap 1rem
14
+ flex-wrap wrap
15
+ gap .5rem 1rem
15
16
  align-items center
16
17
  padding 1rem 2rem
17
18
  border-bottom 1px solid $line
package/site/_/nav.wd CHANGED
@@ -5,4 +5,5 @@
5
5
  <a href="/markdown/">Plain Markdown</a>
6
6
  <a href="/reactive/">Reactive</a>
7
7
  <a href="/data/">Data &amp; Forms</a>
8
+ <a href="/app/">Live app</a>
8
9
  </nav>
@@ -0,0 +1,7 @@
1
+ [
2
+ { "id": "loops", "title": "Nested conditionals in loops", "shipped": true, "blocked": false },
3
+ { "id": "editor", "title": "VS Code syntax highlighting", "shipped": true, "blocked": false },
4
+ { "id": "filter", "title": "State-driven list filtering", "shipped": false, "blocked": false },
5
+ { "id": "api", "title": "First-party site/api/ runtime", "shipped": false, "blocked": true },
6
+ { "id": "swap", "title": "HTML-fragment swap responses", "shipped": false, "blocked": false }
7
+ ]
@@ -0,0 +1,76 @@
1
+ tokens
2
+ ink #171717
3
+ paper #fbfaf7
4
+ panel #ffffff
5
+ line #e4e1d8
6
+ accent #16645a
7
+ warm #a83f2a
8
+ radius 10px
9
+
10
+ main
11
+ max-width 760px
12
+ margin 0 auto
13
+ padding 2.5rem 1.5rem 4rem
14
+ font ui-sans-serif, system-ui, sans-serif
15
+ color $ink
16
+
17
+ h1
18
+ font-size 2.1rem
19
+ letter-spacing -0.02em
20
+
21
+ .boundary
22
+ margin 1.5rem 0
23
+ padding 1rem 1.2rem
24
+ border-left 3px solid $warm
25
+ background #fcf4ef
26
+ border-radius $radius
27
+ font-size .95rem
28
+
29
+ .roadmap-row
30
+ display flex
31
+ align-items center
32
+ justify-content space-between
33
+ gap 1rem
34
+ padding .75rem 1rem
35
+ margin .5rem 0
36
+ background $panel
37
+ border 1px solid $line
38
+ border-radius $radius
39
+
40
+ .badge
41
+ font-size .8rem
42
+ font-weight 600
43
+ padding .2rem .6rem
44
+ border-radius 999px
45
+ white-space nowrap
46
+
47
+ .badge.shipped
48
+ background #e3f3ec
49
+ color $accent
50
+
51
+ .badge.wip
52
+ background #fef0e6
53
+ color $warm
54
+
55
+ .badge.blocked
56
+ background #f3e3e3
57
+ color #9a2a2a
58
+
59
+ button
60
+ background $accent
61
+ color white
62
+ border 0
63
+ padding .55rem .9rem
64
+ margin .25rem .4rem .25rem 0
65
+ border-radius 8px
66
+ font-size .95rem
67
+ cursor pointer
68
+
69
+ input
70
+ font-size 1rem
71
+ padding .6rem .7rem
72
+ border 1px solid $line
73
+ border-radius 8px
74
+ width 100%
75
+ max-width 22rem
76
+ margin .3rem 0
@@ -0,0 +1,92 @@
1
+ ---
2
+ title: A live app in one Markdown file
3
+ description: One .wd file — static prose, fetched data, looped results with nested conditionals, a persistent counter, and a form that round-trips to a real server. No JavaScript written, no components, no build config.
4
+ ---
5
+
6
+ @include /nav.wd
7
+
8
+ <main>
9
+
10
+ # A live app, in one Markdown file
11
+
12
+ Everything on this page — the prose, the fetched list, the badges, the form, the counter — is a single `.wd` file. No components. No JSX. No build config. Watch where JavaScript actually enters.
13
+
14
+ ## Static until proven reactive
15
+
16
+ This whole section is plain Markdown. The list below is unrolled **at build time** from a JSON file — it renders into HTML with no runtime at all:
17
+
18
+ @loop /features.json into feature
19
+ - **{ feature.name }** — { feature.detail }
20
+ @endloop
21
+
22
+ <aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~2&nbsp;KB runtime loads — and only because the document asks for it.</aside>
23
+
24
+ ## Fetched data, looped, with nested conditions
25
+
26
+ `:fetch` pulls JSON over the network into reactive state — from a static file here, or any API. Until it lands, the page shows a fallback. Each row then decides its own badge from **nested** conditionals, in plain Markdown:
27
+
28
+ :fetch roadmap from "/__wd/data/roadmap.json"
29
+
30
+ :if roadmap
31
+ @loop roadmap into item
32
+ ::: card .roadmap-row
33
+ **{ item.title }**
34
+ :if item.blocked
35
+ <span class="badge blocked">blocked</span>
36
+ :else
37
+ :if item.shipped
38
+ <span class="badge shipped">shipped ✓</span>
39
+ :else
40
+ <span class="badge wip">in progress</span>
41
+ :endif
42
+ :endif
43
+ :::
44
+ @endloop
45
+ :else
46
+ Loading the roadmap…
47
+ :endif
48
+
49
+ No ternaries, no `.map()`, no framework — the outer `:if` checks `blocked`, the inner one checks `shipped`, per row, and both stay reactive.
50
+
51
+ ## A counter that survives reload
52
+
53
+ `:state … persist` keeps this in `localStorage`, and `:computed` derives from it. Star a few, reload the page, and it holds:
54
+
55
+ ::: section #watch
56
+ :state stars = 0 persist
57
+ :computed milestone = stars >= 3
58
+
59
+ You are watching { stars } item(s).
60
+
61
+ :button "★ Watch one" -> stars++
62
+ :button "Reset" -> stars = 0
63
+
64
+ :if milestone
65
+ Nice — you are tracking the whole near-term roadmap.
66
+ :endif
67
+ :::
68
+
69
+ ## A form that talks to a real server
70
+
71
+ This form posts to a live serverless function and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST — Darkmown adapts to your backend instead of owning one:
72
+
73
+ :form action="/__wd/echo" into reply
74
+ :input note placeholder="Tell us what you'd build with this"
75
+ :submit "Send to the server"
76
+ :endform
77
+
78
+ :if reply
79
+ The server got it and answered at **{ reply.at }**.
80
+ :else
81
+ Your reply will appear here.
82
+ :endif
83
+
84
+ :if reply_error
85
+ Something went wrong: { reply_error }
86
+ :endif
87
+
88
+ ---
89
+
90
+ That was one file. Static prose, build-time loops, fetched data, nested conditionals, persistent state, computed values, and a server round-trip — and the only JavaScript on the page is the shared runtime Darkmown loads for you. [Read the docs](/docs/) or [deploy your own](/#deploy).
91
+
92
+ </main>
@@ -75,7 +75,7 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
75
75
 
76
76
  ## Reactive directives
77
77
 
78
- Reactive pages opt into `/__wd/runtime.js` (under 1 KB gzipped). Static pages do not.
78
+ Reactive pages opt into `/__wd/runtime.js` (~2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
79
79
 
80
80
  ```wd
81
81
  :state count = 0
@@ -107,7 +107,7 @@ Directive actions are intentionally narrow and compile-time checked. Arbitrary J
107
107
  - `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead.
108
108
  - `:input field placeholder="…" required` and `:submit "Label"` build the form body.
109
109
  - `:state cart = [] persist` survives reloads via localStorage.
110
- - `:if item.path` works inside reactive loops for per-row branches.
110
+ - `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
111
111
  - Reactive pages expose `window.wd` (`get`, `set`, `state`, `render`) so colocated `.js` can do anything directives can't. Section state is addressed as `sectionId:name`.
112
112
 
113
113
  See it all live on the [Data & Forms page](/data/).
@@ -120,6 +120,6 @@ See it all live on the [Data & Forms page](/data/).
120
120
 
121
121
  ## Spec status
122
122
 
123
- 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. `:fetch`, `:form`, `:session`, `:cart`, and server-driven fragments remain roadmap work — see `docs/spec-alignment.md` in the package for the full audit.
123
+ 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. `:fetch`, `:form` (including server round-trips), `: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/`), HTML-fragment swaps, and nested `:if` over loop items. See `docs/spec-alignment.md` in the package for the full audit.
124
124
 
125
125
  </main>
@@ -1,5 +1,6 @@
1
1
  ---
2
- title: Darkmown
2
+ title: Darkmown — Markdown that runs
3
+ description: Build full websites in plain Markdown. Routing, state, server calls, includes, and reactive islands — no JavaScript or CSS required. Your docs and your code are all just text.
3
4
  ---
4
5
 
5
6
  @include /nav.wd
@@ -7,25 +8,36 @@ title: Darkmown
7
8
  <main>
8
9
 
9
10
  <section class="hero">
10
- <p class="eyebrow">Markdown-native framework</p>
11
+ <p class="eyebrow">The Markdown framework</p>
11
12
 
12
- # Darkmown
13
+ # Markdown that runs
13
14
 
14
- Markdown pages that compile into fast, mostly-static websites, with tiny reactive islands only where the document asks for them.
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.
15
16
 
16
- <a class="btn primary" href="/docs/">Read the docs</a>
17
- <a class="btn" href="/reactive/">Try reactivity</a>
17
+ <a class="btn primary" href="/app/">See a whole app in one file</a>
18
+ <a class="btn" href="/docs/">Read the docs</a>
18
19
  </section>
19
20
 
20
- :note "Two formats, one rule: .md stays plain CommonMark forever, and renaming to .wd is what unlocks directives, includes, loops, and state."
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."
22
+
23
+ ## A whole framework, in text
24
+
25
+ Everything a site needs, expressed as Markdown directives. No components, no config, no JSX, no special files:
26
+
27
+ - **Routing** — folders are routes. Drop a file in, it's a page. Prefix with `.` or `-` to hide it.
28
+ - **State & reactivity** — `:state`, `:button`, `:if`, and keyed `@loop`s patch the DOM directly. No virtual DOM.
29
+ - **Server calls** — `:fetch` pulls JSON into state; `:form action=` round-trips to any backend and degrades to a native POST without JS.
30
+ - **Composition** — `@include` embeds one Markdown file into another, passing values down Liquid-style.
31
+ - **Styling, optional** — colocate a `.skin` file for design tokens, or ship none and let the browser default.
21
32
 
22
33
  ## What to try
23
34
 
24
- :try "Open the docs route, a .wd page full of includes" href="/docs/"
25
- :try "Open a plain .md page and see directives stay inert" href="/markdown/"
26
- :try "Open the reactive page: state, keyed loops, scoped sections" href="/reactive/"
27
- :try "Open the data page: :fetch, forms into state, persistence" href="/data/"
28
- :try "Open a hidden route and confirm it 404s" href="/-draft/"
35
+ :try "A whole live app in one .wd file: fetch, loops, nested ifs, a form" href="/app/"
36
+ :try "The docs route — a .wd page built from includes" href="/docs/"
37
+ :try "A plain .md page where directives stay inert text" href="/markdown/"
38
+ :try "Reactive page: state, keyed loops, scoped sections" href="/reactive/"
39
+ :try "Data page: fetch, forms, server round-trips, persistence" href="/data/"
40
+ :try "A hidden route, to confirm it 404s" href="/-draft/"
29
41
 
30
42
  ## Loops that read like prose
31
43
 
@@ -47,15 +59,19 @@ This button is powered by `site/pages/index.js`, discovered by matching the page
47
59
 
48
60
  <button class="pulse-button" data-wd-demo>Script is waiting</button>
49
61
 
62
+ <span id="deploy"></span>
63
+
50
64
  ## Deploy in one click
51
65
 
52
- This repo doubles as a template — `npm run build` emits a plain static `dist/`, so it deploys anywhere that serves files:
66
+ `npm run build` emits a plain static `dist/` — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build:
53
67
 
54
68
  <a class="btn primary" href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Vercel</a>
55
69
  <a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Cloudflare</a>
56
70
  <a class="btn" href="https://github.com/ZVN-DEV/darkmown">Star on GitHub</a>
57
71
 
58
- ## Sprint board
72
+ ## Built in the open
73
+
74
+ Darkmown itself was built by a small agent team. The board below is a live `@loop` over real data — itself a demo of the framework rendering this page:
59
75
 
60
76
  :sprint min=4 max=6 roles="PM, Development, Research, Testing, Review"
61
77
 
@@ -39,6 +39,31 @@ The same `@loop` that unrolls static data also drives reactive lists. Items are
39
39
 
40
40
  :button "Add todo" -> todos += {"id": 3, "title": "Live compile", "owner": "Testing"}
41
41
 
42
+ ## Conditionals inside a loop, nested
43
+
44
+ `:if` works per row, and conditionals nest — the inner branch is decided after the outer one, for every item, and stays reactive. Add a "done" task and watch which badge each row shows:
45
+
46
+ :state tasks = [{"id": 1, "title": "Draft the spec", "done": true, "blocked": false}, {"id": 2, "title": "Wire the runtime", "done": false, "blocked": true}, {"id": 3, "title": "Write the demo", "done": false, "blocked": false}]
47
+
48
+ @loop tasks into task
49
+ ::: card .task-row
50
+ **{ task.title }** —
51
+ :if task.done
52
+ done ✓
53
+ :else
54
+ :if task.blocked
55
+ blocked ✗
56
+ :else
57
+ in progress …
58
+ :endif
59
+ :endif
60
+ :::
61
+ @endloop
62
+
63
+ :button "Add a finished task" -> tasks += {"id": 4, "title": "Ship it", "done": true, "blocked": false}
64
+
65
+ Each row picks one of three states from two nested conditionals, all in plain Markdown — no component, no ternary, no JavaScript.
66
+
42
67
  ## Section-scoped state
43
68
 
44
69
  Two sections can each own a state value named `tally` without colliding. Buttons resolve to the nearest scope:
package/src/compiler.js CHANGED
@@ -10,6 +10,14 @@ const pageIncludeExtensions = [".md", ".wd"];
10
10
  export function compilePage(file, context) {
11
11
  const compiled = compileDocument(file, context);
12
12
  const title = compiled.meta.title || "Darkmown";
13
+ const description = compiled.meta.description || "";
14
+ const descriptionTag = description
15
+ ? `\n <meta name="description" content="${escapeHtml(description)}">
16
+ <meta property="og:title" content="${escapeHtml(title)}">
17
+ <meta property="og:description" content="${escapeHtml(description)}">
18
+ <meta property="og:type" content="website">
19
+ <meta name="twitter:card" content="summary">`
20
+ : "";
13
21
  const favicon = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
14
22
  const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
15
23
  const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
@@ -26,7 +34,7 @@ export function compilePage(file, context) {
26
34
  <head>
27
35
  <meta charset="utf-8">
28
36
  <meta name="viewport" content="width=device-width, initial-scale=1">
29
- <title>${escapeHtml(title)}</title>
37
+ <title>${escapeHtml(title)}</title>${descriptionTag}
30
38
  <link rel="icon" href="${favicon}">
31
39
  ${cssLinks}${transitions}
32
40
  </head>
@@ -530,9 +538,6 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
530
538
  if (ctx.loopItem && head === ctx.loopItem) {
531
539
  const truthy = compileBody(truthyLines, ctx).trim();
532
540
  const falsy = compileBody(falsyLines, ctx).trim();
533
- if (truthy.includes("data-wd-each-if") || falsy.includes("data-wd-each-if")) {
534
- throw new Error(`Nested :if over loop items is not supported yet (${ctx.file})`);
535
- }
536
541
  const rest = segs.slice(1).join(".");
537
542
  const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
538
543
  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>`;
@@ -624,20 +629,75 @@ function withLoopKey(template, itemKey) {
624
629
  }
625
630
 
626
631
  function fillTemplateString(template, item) {
627
- const withConditionals = template.replace(
628
- /<span data-wd-each-if data-wd-path="([^"]*)"><template data-wd-if-true>([\s\S]*?)<\/template><template data-wd-if-false>([\s\S]*?)<\/template><span data-wd-each-if-out><\/span><\/span>/g,
629
- (region, p, truthy, falsy) => {
630
- const branch = getPath(item, p ? p.split(".") : []) ? truthy : falsy;
631
- return region.replace("<span data-wd-each-if-out></span>", `<span data-wd-each-if-out>${branch}</span>`);
632
- }
633
- );
634
- return withConditionals.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
632
+ // Resolve per-item :if regions first (recursively, so nested conditionals are
633
+ // pre-rendered for the initial paint), then fill the plain text bindings.
634
+ return fillEachText(fillEachIfRegions(template, item), item);
635
+ }
636
+
637
+ // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
638
+ // chosen branch is recursed so nested conditionals resolve too. The <template>
639
+ // markup is left pristine so the runtime can keep toggling branches.
640
+ function fillEachIfRegions(str, item) {
641
+ const marker = '<span data-wd-each-if ';
642
+ let result = "";
643
+ let i = 0;
644
+ for (;;) {
645
+ const start = str.indexOf(marker, i);
646
+ if (start === -1) return result + str.slice(i);
647
+ result += str.slice(i, start);
648
+ const end = matchElement(str, start, "span");
649
+ result += fillOneEachIf(str.slice(start, end), item);
650
+ i = end;
651
+ }
652
+ }
653
+
654
+ function fillOneEachIf(region, item) {
655
+ const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || [, ""])[1];
656
+ const trueStart = region.indexOf("<template data-wd-if-true>");
657
+ const trueEnd = matchElement(region, trueStart, "template");
658
+ const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
659
+ const falseEnd = matchElement(region, falseStart, "template");
660
+ const open = "<template data-wd-if-true>".length;
661
+ const close = "</template>".length;
662
+ const truthy = region.slice(trueStart + open, trueEnd - close);
663
+ const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
664
+ const branch = getPath(item, path ? path.split(".") : []) ? truthy : falsy;
665
+ const head = region.slice(0, falseEnd);
666
+ return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
667
+ }
668
+
669
+ function fillEachText(str, item) {
670
+ return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
635
671
  const value = p ? getPath(item, p.split(".")) : item;
636
672
  const pathAttr = p ? ` data-wd-path="${p}"` : "";
637
673
  return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
638
674
  });
639
675
  }
640
676
 
677
+ // Return the index just past the balanced close of the element of `tag` that
678
+ // begins at `start`. Counts nested same-tag opens/closes so regions that embed
679
+ // their own spans/templates (nested :if) match correctly.
680
+ function matchElement(str, start, tag) {
681
+ const openPrefix = `<${tag}`;
682
+ const closeTag = `</${tag}>`;
683
+ let depth = 0;
684
+ let i = start;
685
+ while (i < str.length) {
686
+ if (str.startsWith(openPrefix, i) && (str[i + openPrefix.length] === " " || str[i + openPrefix.length] === ">")) {
687
+ depth++;
688
+ const gt = str.indexOf(">", i);
689
+ i = gt === -1 ? str.length : gt + 1;
690
+ } else if (str.startsWith(closeTag, i)) {
691
+ depth--;
692
+ i += closeTag.length;
693
+ if (depth === 0) return i;
694
+ } else {
695
+ i++;
696
+ }
697
+ }
698
+ return str.length;
699
+ }
700
+
641
701
  export function loopKeyOf(item, counts) {
642
702
  const base =
643
703
  item && typeof item === "object"
package/src/runtime.js CHANGED
@@ -61,11 +61,14 @@ function loopKeyOf(item, counts) {
61
61
  }
62
62
 
63
63
  function fillItem(node, item) {
64
+ // querySelectorAll does not descend into <template> content, so this only sees
65
+ // the outermost regions; recursing into each injected branch fills the rest.
64
66
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
65
67
  const value = getPath(item, region.getAttribute("data-wd-path"));
66
68
  const output = region.querySelector("[data-wd-each-if-out]");
67
69
  const template = region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]");
68
70
  output.innerHTML = template?.innerHTML || "";
71
+ fillItem(output, item);
69
72
  }
70
73
  const targets = node.matches("[data-wd-each]")
71
74
  ? [node, ...node.querySelectorAll("[data-wd-each]")]