@zvndev/darkmown 0.4.0 → 0.6.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.
@@ -61,6 +61,33 @@ The source decides how the loop behaves:
61
61
 
62
62
  Loops nest, and `{ card.title }` style dotted paths reach into each row.
63
63
 
64
+ ### Filtering with `where`
65
+
66
+ Add `where <predicate>` to filter a loop. Conditions join with `and` / `or`:
67
+
68
+ ```wd
69
+ @loop /products.json into p where p.featured == true and p.price < 80
70
+ - { p.name }
71
+ @endloop
72
+ ```
73
+
74
+ Operators are `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item fields, declared `:state`, numbers, or `"strings"` — a validated whitelist, never arbitrary code.
75
+
76
+ The predicate decides reactivity exactly like the source does: read only the row and the filter runs at build time (zero JS); read a `:state` value and the loop re-filters live. Pair it with `:bind` for a search box in pure Markdown:
77
+
78
+ ```wd
79
+ :state products = [{"id":1,"name":"Aurora Lamp"},{"id":2,"name":"Briza Fan"}]
80
+ :state q = ""
81
+
82
+ :bind q placeholder="Search"
83
+
84
+ @loop products into p where p.name contains q
85
+ - { p.name }
86
+ @endloop
87
+ ```
88
+
89
+ `:bind <state>` renders an `<input>` wired two-way to a `:state` value — type and the list filters as you go.
90
+
64
91
  ## Interpolation
65
92
 
66
93
  One syntax everywhere: `{ name }` or `{ name.path }`.
@@ -75,7 +102,7 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
75
102
 
76
103
  ## Reactive directives
77
104
 
78
- Reactive pages opt into `/__wd/runtime.js` (under 1 KB gzipped). Static pages do not.
105
+ Reactive pages opt into `/__wd/runtime.js` (~2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
79
106
 
80
107
  ```wd
81
108
  :state count = 0
@@ -107,7 +134,7 @@ Directive actions are intentionally narrow and compile-time checked. Arbitrary J
107
134
  - `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead.
108
135
  - `:input field placeholder="…" required` and `:submit "Label"` build the form body.
109
136
  - `:state cart = [] persist` survives reloads via localStorage.
110
- - `:if item.path` works inside reactive loops for per-row branches.
137
+ - `: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
138
  - 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
139
 
113
140
  See it all live on the [Data & Forms page](/data/).
@@ -120,6 +147,6 @@ See it all live on the [Data & Forms page](/data/).
120
147
 
121
148
  ## Spec status
122
149
 
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.
150
+ 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
151
 
125
152
  </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,46 @@ 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
+
67
+ ## Live filtering — search in pure Markdown
68
+
69
+ `@loop … where` filters a list, and because the predicate reads a `:state` value, the loop re-filters live as you type. `:bind` is the input bound two-way to that state. Type below and watch the list narrow — no event handlers, no JavaScript:
70
+
71
+ :state products = [{"id": 1, "name": "Aurora Lamp", "price": 49}, {"id": 2, "name": "Briza Fan", "price": 39}, {"id": 3, "name": "Cove Speaker", "price": 89}, {"id": 4, "name": "Dune Mug", "price": 12}, {"id": 5, "name": "Ember Kettle", "price": 64}]
72
+ :state q = ""
73
+
74
+ :bind q placeholder="Search products…"
75
+
76
+ @loop products into p where p.name contains q
77
+ - **{ p.name }** — ${ p.price }
78
+ @endloop
79
+
80
+ The predicate whitelist is compile-time validated — item fields, declared state, numbers, and strings only. An item-only predicate (say `where p.price < 50`) would instead filter at build time and ship zero JavaScript.
81
+
42
82
  ## Section-scoped state
43
83
 
44
84
  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>
@@ -170,6 +178,11 @@ function compileBody(lines, ctx) {
170
178
  out.push(handleInput(line, ctx));
171
179
  continue;
172
180
  }
181
+ if (/^:bind\s/.test(line)) {
182
+ flush();
183
+ out.push(handleBind(line, ctx));
184
+ continue;
185
+ }
173
186
  if (/^:submit\s/.test(line)) {
174
187
  flush();
175
188
  out.push(handleSubmit(line, ctx));
@@ -500,6 +513,38 @@ function handleInput(line, ctx) {
500
513
  return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
501
514
  }
502
515
 
516
+ // :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
517
+ // declared :state. Updates state on every keystroke; reflects state back when
518
+ // not focused. The state must be declared first (with :state).
519
+ function handleBind(line, ctx) {
520
+ const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
521
+ if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
522
+ const key = resolveStateKey(match[1], ctx);
523
+ if (!key) {
524
+ throw new Error(`:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`);
525
+ }
526
+ ctx.comp.assets.runtime = true;
527
+ let type = "text";
528
+ const attrs = [];
529
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
530
+ for (const token of (match[2] || "").matchAll(re)) {
531
+ if (token[3]) {
532
+ if (!["required", "autofocus"].includes(token[3])) throw new Error(`Unknown :bind flag "${token[3]}" in ${ctx.file}`);
533
+ attrs.push(token[3]);
534
+ continue;
535
+ }
536
+ const value = stripQuotes(token[2]);
537
+ if (token[1] === "type") { type = value; continue; }
538
+ if (!["placeholder", "autocomplete"].includes(token[1])) {
539
+ throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
540
+ }
541
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
542
+ }
543
+ const initial = ctx.comp.state.get(key);
544
+ const valueAttr = initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
545
+ return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
546
+ }
547
+
503
548
  function handleSubmit(line, ctx) {
504
549
  const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
505
550
  if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
@@ -530,9 +575,6 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
530
575
  if (ctx.loopItem && head === ctx.loopItem) {
531
576
  const truthy = compileBody(truthyLines, ctx).trim();
532
577
  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
578
  const rest = segs.slice(1).join(".");
537
579
  const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
538
580
  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>`;
@@ -553,16 +595,17 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
553
595
  }
554
596
 
555
597
  function handleLoop(line, bodyLines, ctx) {
556
- const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)\s*$/);
557
- if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing>`);
598
+ const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
599
+ if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
558
600
  const source = stripQuotes(match[1].trim());
559
601
  const itemName = match[2];
602
+ const where = match[3] ? compilePredicate(match[3].trim(), itemName, ctx) : null;
560
603
 
561
604
  if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.endsWith(".json")) {
562
605
  const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
563
606
  const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
564
607
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
565
- return staticUnroll(rows, itemName, bodyLines, ctx);
608
+ return loopOverData(rows, itemName, bodyLines, ctx, where);
566
609
  }
567
610
 
568
611
  const resolved = lookupPath(source, ctx);
@@ -570,12 +613,12 @@ function handleLoop(line, bodyLines, ctx) {
570
613
  if (!Array.isArray(resolved.value)) {
571
614
  throw new Error(`@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`);
572
615
  }
573
- return staticUnroll(resolved.value, itemName, bodyLines, ctx);
616
+ return loopOverData(resolved.value, itemName, bodyLines, ctx, where);
574
617
  }
575
618
 
576
619
  if (/^[A-Za-z_$][\w$]*$/.test(source)) {
577
620
  const key = resolveStateKey(source, ctx);
578
- if (key) return reactiveLoop(key, itemName, bodyLines, ctx);
621
+ if (key) return reactiveLoop(key, itemName, bodyLines, ctx, where ? { where } : null);
579
622
  }
580
623
 
581
624
  throw new Error(
@@ -583,6 +626,74 @@ function handleLoop(line, bodyLines, ctx) {
583
626
  );
584
627
  }
585
628
 
629
+ // A static source (JSON file / in-scope value). No `where`, or a `where` that
630
+ // only reads the loop item → filter at build time and stay zero-JS. A `where`
631
+ // that reads :state → becomes a reactive filtered loop with the rows baked in.
632
+ function loopOverData(rows, itemName, bodyLines, ctx, where) {
633
+ if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
634
+ if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
635
+ return reactiveLoop(null, itemName, bodyLines, ctx, { where, data: rows });
636
+ }
637
+
638
+ // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
639
+ // Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
640
+ // paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
641
+ function compilePredicate(raw, itemName, ctx) {
642
+ const parts = raw.split(/\s+(and|or)\s+/i);
643
+ const pieces = [];
644
+ let refsState = false;
645
+ for (let i = 0; i < parts.length; i++) {
646
+ if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
647
+ const cond = compileCondition(parts[i].trim(), itemName, ctx);
648
+ refsState = refsState || cond.usesState;
649
+ pieces.push(`(${cond.expr})`);
650
+ }
651
+ return { body: pieces.join(" "), refsState };
652
+ }
653
+
654
+ function compileCondition(cond, itemName, ctx) {
655
+ const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
656
+ if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
657
+ const left = compileOperand(m[1].trim(), itemName, ctx);
658
+ const right = compileOperand(m[3].trim(), itemName, ctx);
659
+ const usesState = left.usesState || right.usesState;
660
+ const op = m[2].toLowerCase();
661
+ if (op === "contains") return { expr: `C(${left.code}, ${right.code})`, usesState };
662
+ return { expr: `${left.code} ${op} ${right.code}`, usesState };
663
+ }
664
+
665
+ function compileOperand(tok, itemName, ctx) {
666
+ if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
667
+ if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
668
+ if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
669
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
670
+ throw new Error(`Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`);
671
+ }
672
+ const segs = tok.split(".");
673
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
674
+ throw new Error(`Path "${tok}" is not allowed in @loop where (${ctx.file})`);
675
+ }
676
+ if (segs[0] === itemName) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
677
+ const key = resolveStateKey(segs[0], ctx);
678
+ if (!key) {
679
+ throw new Error(`@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`);
680
+ }
681
+ const rest = segs.slice(1).join(".");
682
+ return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
683
+ }
684
+
685
+ const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
686
+
687
+ function evalPredicate(body, item, ctx) {
688
+ try {
689
+ const I = (p) => getPath(item, p ? p.split(".") : []);
690
+ const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
691
+ return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
692
+ } catch {
693
+ return false;
694
+ }
695
+ }
696
+
586
697
  function staticUnroll(rows, itemName, bodyLines, ctx) {
587
698
  const out = [];
588
699
  for (const row of rows) {
@@ -592,7 +703,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
592
703
  return out.join("\n");
593
704
  }
594
705
 
595
- function reactiveLoop(key, itemName, bodyLines, ctx) {
706
+ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
596
707
  ctx.comp.assets.runtime = true;
597
708
  const templateCtx = { ...ctx, loopItem: itemName, scope: createScope(ctx.scope) };
598
709
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
@@ -607,7 +718,10 @@ function reactiveLoop(key, itemName, bodyLines, ctx) {
607
718
  itemTemplate = `<div data-wd-loop-piece>${templateHtml}</div>`;
608
719
  }
609
720
 
610
- const rows = Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : [];
721
+ const where = opts?.where || null;
722
+ const baked = opts?.data || null; // rows for a static source filtered by state
723
+ const allRows = key ? (Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : []) : (baked || []);
724
+ const rows = where ? allRows.filter((row) => evalPredicate(where.body, row, ctx)) : allRows;
611
725
  const counts = new Map();
612
726
  const initial = rows
613
727
  .map((item) => {
@@ -616,7 +730,9 @@ function reactiveLoop(key, itemName, bodyLines, ctx) {
616
730
  })
617
731
  .join("");
618
732
 
619
- return `<div data-wd-loop="${key}"><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
733
+ const whereAttr = where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "";
734
+ const dataAttr = baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "";
735
+ return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
620
736
  }
621
737
 
622
738
  function withLoopKey(template, itemKey) {
@@ -624,20 +740,75 @@ function withLoopKey(template, itemKey) {
624
740
  }
625
741
 
626
742
  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) => {
743
+ // Resolve per-item :if regions first (recursively, so nested conditionals are
744
+ // pre-rendered for the initial paint), then fill the plain text bindings.
745
+ return fillEachText(fillEachIfRegions(template, item), item);
746
+ }
747
+
748
+ // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
749
+ // chosen branch is recursed so nested conditionals resolve too. The <template>
750
+ // markup is left pristine so the runtime can keep toggling branches.
751
+ function fillEachIfRegions(str, item) {
752
+ const marker = '<span data-wd-each-if ';
753
+ let result = "";
754
+ let i = 0;
755
+ for (;;) {
756
+ const start = str.indexOf(marker, i);
757
+ if (start === -1) return result + str.slice(i);
758
+ result += str.slice(i, start);
759
+ const end = matchElement(str, start, "span");
760
+ result += fillOneEachIf(str.slice(start, end), item);
761
+ i = end;
762
+ }
763
+ }
764
+
765
+ function fillOneEachIf(region, item) {
766
+ const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || [, ""])[1];
767
+ const trueStart = region.indexOf("<template data-wd-if-true>");
768
+ const trueEnd = matchElement(region, trueStart, "template");
769
+ const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
770
+ const falseEnd = matchElement(region, falseStart, "template");
771
+ const open = "<template data-wd-if-true>".length;
772
+ const close = "</template>".length;
773
+ const truthy = region.slice(trueStart + open, trueEnd - close);
774
+ const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
775
+ const branch = getPath(item, path ? path.split(".") : []) ? truthy : falsy;
776
+ const head = region.slice(0, falseEnd);
777
+ return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
778
+ }
779
+
780
+ function fillEachText(str, item) {
781
+ return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
635
782
  const value = p ? getPath(item, p.split(".")) : item;
636
783
  const pathAttr = p ? ` data-wd-path="${p}"` : "";
637
784
  return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
638
785
  });
639
786
  }
640
787
 
788
+ // Return the index just past the balanced close of the element of `tag` that
789
+ // begins at `start`. Counts nested same-tag opens/closes so regions that embed
790
+ // their own spans/templates (nested :if) match correctly.
791
+ function matchElement(str, start, tag) {
792
+ const openPrefix = `<${tag}`;
793
+ const closeTag = `</${tag}>`;
794
+ let depth = 0;
795
+ let i = start;
796
+ while (i < str.length) {
797
+ if (str.startsWith(openPrefix, i) && (str[i + openPrefix.length] === " " || str[i + openPrefix.length] === ">")) {
798
+ depth++;
799
+ const gt = str.indexOf(">", i);
800
+ i = gt === -1 ? str.length : gt + 1;
801
+ } else if (str.startsWith(closeTag, i)) {
802
+ depth--;
803
+ i += closeTag.length;
804
+ if (depth === 0) return i;
805
+ } else {
806
+ i++;
807
+ }
808
+ }
809
+ return str.length;
810
+ }
811
+
641
812
  export function loopKeyOf(item, counts) {
642
813
  const base =
643
814
  item && typeof item === "object"
package/src/runtime.js CHANGED
@@ -50,6 +50,17 @@ function recompute() {
50
50
  }
51
51
  }
52
52
 
53
+ const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
54
+ const predicateCache = new Map();
55
+ function loopPredicate(body) {
56
+ let fn = predicateCache.get(body);
57
+ if (!fn) {
58
+ fn = new Function("I", "S", "C", `return (${body});`);
59
+ predicateCache.set(body, fn);
60
+ }
61
+ return fn;
62
+ }
63
+
53
64
  function loopKeyOf(item, counts) {
54
65
  const base =
55
66
  item && typeof item === "object"
@@ -61,11 +72,14 @@ function loopKeyOf(item, counts) {
61
72
  }
62
73
 
63
74
  function fillItem(node, item) {
75
+ // querySelectorAll does not descend into <template> content, so this only sees
76
+ // the outermost regions; recursing into each injected branch fills the rest.
64
77
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
65
78
  const value = getPath(item, region.getAttribute("data-wd-path"));
66
79
  const output = region.querySelector("[data-wd-each-if-out]");
67
80
  const template = region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]");
68
81
  output.innerHTML = template?.innerHTML || "";
82
+ fillItem(output, item);
69
83
  }
70
84
  const targets = node.matches("[data-wd-each]")
71
85
  ? [node, ...node.querySelectorAll("[data-wd-each]")]
@@ -88,11 +102,20 @@ function render() {
88
102
  }
89
103
 
90
104
  for (const region of document.querySelectorAll("[data-wd-loop]")) {
91
- const rows = state[region.getAttribute("data-wd-loop")];
105
+ const key = region.getAttribute("data-wd-loop");
106
+ const data = region.getAttribute("data-wd-loop-data");
107
+ const rows = key ? state[key] : (data ? JSON.parse(data) : []);
92
108
  const template = region.querySelector("template[data-wd-loop-template]");
93
109
  const out = region.querySelector("[data-wd-loop-out]");
94
110
  if (!template || !out) continue;
95
- const list = Array.isArray(rows) ? rows : [];
111
+ let list = Array.isArray(rows) ? rows : [];
112
+ const where = region.getAttribute("data-wd-loop-where");
113
+ if (where) {
114
+ const predicate = loopPredicate(where);
115
+ list = list.filter((item) =>
116
+ predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn)
117
+ );
118
+ }
96
119
 
97
120
  const existing = new Map();
98
121
  for (const child of [...out.children]) {
@@ -119,8 +142,20 @@ function render() {
119
142
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
120
143
  node.textContent = getPath(state[node.getAttribute("data-wd-bind")], node.getAttribute("data-wd-path")) ?? "";
121
144
  }
145
+
146
+ for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
147
+ if (document.activeElement !== input) input.value = state[input.getAttribute("data-wd-bind-input")] ?? "";
148
+ }
122
149
  }
123
150
 
151
+ document.addEventListener("input", (event) => {
152
+ const input = event.target.closest("[data-wd-bind-input]");
153
+ if (!input) return;
154
+ state[input.getAttribute("data-wd-bind-input")] = input.value;
155
+ savePersisted();
156
+ render();
157
+ });
158
+
124
159
  document.addEventListener("click", (event) => {
125
160
  const action = event.target.closest("[data-wd-action]");
126
161
  if (!action) return;
package/src/scaffold.js CHANGED
@@ -62,6 +62,31 @@ export function initProject(root) {
62
62
  " margin 0 auto",
63
63
  " padding 3rem 1.5rem",
64
64
  "",
65
+ "nav",
66
+ " display flex",
67
+ " align-items center",
68
+ " justify-content space-between",
69
+ " gap 1rem",
70
+ " max-width 760px",
71
+ " margin 0 auto",
72
+ " padding 1.25rem 1.5rem",
73
+ "",
74
+ "nav strong",
75
+ " font-size 1.1rem",
76
+ "",
77
+ "nav .links",
78
+ " display flex",
79
+ " gap 1.25rem",
80
+ "",
81
+ "nav a",
82
+ " color $ink",
83
+ " text-decoration none",
84
+ " opacity .8",
85
+ "",
86
+ "nav a:hover",
87
+ " opacity 1",
88
+ " color $accent",
89
+ "",
65
90
  "button",
66
91
  " bg $accent",
67
92
  " color white",
@@ -71,9 +96,25 @@ export function initProject(root) {
71
96
  ].join("\n"));
72
97
  writeNew(root, "site/_/nav.wd", [
73
98
  "<nav>",
74
- " <a href=\"/\">Home</a>",
99
+ " <strong>My site</strong>",
100
+ " <span class=\"links\">",
101
+ " <a href=\"/\">Home</a>",
102
+ " <a href=\"/about/\">About</a>",
103
+ " </span>",
75
104
  "</nav>"
76
105
  ].join("\n"));
106
+ writeNew(root, "site/pages/about.md", [
107
+ "---",
108
+ "title: About",
109
+ "---",
110
+ "",
111
+ "# About",
112
+ "",
113
+ "This page is plain Markdown (`.md`) — strict CommonMark, zero framework JavaScript.",
114
+ "Rename it to `.wd` when you want directives like state, loops, or forms.",
115
+ "",
116
+ "[Back home](/)"
117
+ ].join("\n"));
77
118
  writeNew(root, "README.md", [
78
119
  "# My Darkmown site",
79
120
  "",