@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.
- package/AGENTS.md +297 -0
- package/CHANGELOG.md +15 -0
- package/README.md +34 -3
- package/docs/spec-alignment.md +11 -2
- package/package.json +3 -2
- package/site/_/nav.skin +2 -1
- package/site/_/nav.wd +1 -0
- package/site/_/roadmap.json +7 -0
- package/site/pages/app.skin +76 -0
- package/site/pages/app.wd +92 -0
- package/site/pages/docs/index.wd +30 -3
- package/site/pages/index.wd +30 -14
- package/site/pages/reactive.wd +40 -0
- package/src/compiler.js +191 -20
- package/src/runtime.js +37 -2
- package/src/scaffold.js +42 -1
- package/src/skin.js +28 -5
package/site/pages/docs/index.wd
CHANGED
|
@@ -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
|
|
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
|
|
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>
|
package/site/pages/index.wd
CHANGED
|
@@ -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
|
|
11
|
+
<p class="eyebrow">The Markdown framework</p>
|
|
11
12
|
|
|
12
|
-
#
|
|
13
|
+
# Markdown that runs
|
|
13
14
|
|
|
14
|
-
|
|
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="/
|
|
17
|
-
<a class="btn" href="/
|
|
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 "
|
|
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 "
|
|
25
|
-
:try "
|
|
26
|
-
:try "
|
|
27
|
-
:try "
|
|
28
|
-
:try "
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
package/site/pages/reactive.wd
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
" <
|
|
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
|
"",
|