@zvndev/darkmown 0.12.1 → 0.14.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 +25 -7
- package/CHANGELOG.md +20 -0
- package/README.md +79 -9
- package/SECURITY.md +1 -1
- package/docs/spec-alignment.md +19 -4
- package/package.json +2 -2
- package/site/_/empty.json +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/data.wd +41 -0
- package/site/pages/docs/index.wd +58 -7
- package/site/pages/index.wd +2 -2
- package/site/pages/reactive.wd +30 -7
- package/src/builder.js +32 -0
- package/src/compiler.js +271 -52
- package/src/runtime.js +149 -16
- package/src/scaffold.js +10 -1
- package/src/statics.js +1 -0
package/site/pages/reactive.wd
CHANGED
|
@@ -16,6 +16,16 @@ The count is { count }.
|
|
|
16
16
|
|
|
17
17
|
:button "Increment" -> count++
|
|
18
18
|
|
|
19
|
+
`:if` reads JavaScript-style comparisons — `==` `!=` `<` `<=` `>` `>=` `contains` — joined with `and`, `or`, and `not`. This chain reacts to the counter above, no handler wired by hand:
|
|
20
|
+
|
|
21
|
+
:if count >= 3 and count <= 5
|
|
22
|
+
Counter is in the sweet spot (3–5).
|
|
23
|
+
:else if count > 5
|
|
24
|
+
Counter is over the top.
|
|
25
|
+
:else
|
|
26
|
+
Counter is warming up.
|
|
27
|
+
:endif
|
|
28
|
+
|
|
19
29
|
:state open = false
|
|
20
30
|
|
|
21
31
|
:button "Show details" -> open = true
|
|
@@ -39,30 +49,34 @@ The same `@loop` that unrolls static data also drives reactive lists. Items are
|
|
|
39
49
|
|
|
40
50
|
:button "Add todo" -> todos += {"id": 3, "title": "Live compile", "owner": "Testing"}
|
|
41
51
|
|
|
42
|
-
##
|
|
52
|
+
## Multi-branch conditionals + reactive classes
|
|
53
|
+
|
|
54
|
+
`:if … :else if … :else … :endif` picks one of several branches per row, and a container class can react to the row too: `.class when <predicate>` toggles a class from the same whitelist `@loop … where` uses. Each card below picks one of three badges *and* tags itself `.is-done` / `.is-blocked`, all reactive:
|
|
43
55
|
|
|
44
|
-
|
|
56
|
+
<style>
|
|
57
|
+
.task-row { border-left: 4px solid #9991; padding-left: .6rem; }
|
|
58
|
+
.task-row.is-done { border-left-color: #2e9e5b; }
|
|
59
|
+
.task-row.is-blocked { border-left-color: #d2453f; }
|
|
60
|
+
</style>
|
|
45
61
|
|
|
46
62
|
: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
63
|
|
|
48
64
|
@loop tasks into task
|
|
49
|
-
::: card .task-row
|
|
65
|
+
::: card .task-row .is-done when task.done .is-blocked when task.blocked
|
|
50
66
|
**{ task.title }** —
|
|
51
67
|
:if task.done
|
|
52
68
|
done ✓
|
|
53
|
-
:else
|
|
54
|
-
:if task.blocked
|
|
69
|
+
:else if task.blocked
|
|
55
70
|
blocked ✗
|
|
56
71
|
:else
|
|
57
72
|
in progress …
|
|
58
73
|
:endif
|
|
59
|
-
:endif
|
|
60
74
|
:::
|
|
61
75
|
@endloop
|
|
62
76
|
|
|
63
77
|
:button "Add a finished task" -> tasks += {"id": 4, "title": "Ship it", "done": true, "blocked": false}
|
|
64
78
|
|
|
65
|
-
Each row picks one of three states from
|
|
79
|
+
Each row picks one of three states from a single `:else if` chain, and its left border color comes from a reactive `.class when …` — plain Markdown, no component, no ternary, no JavaScript.
|
|
66
80
|
|
|
67
81
|
## Live filtering — search in pure Markdown
|
|
68
82
|
|
|
@@ -83,6 +97,15 @@ Each row picks one of three states from two nested conditionals, all in plain Ma
|
|
|
83
97
|
|
|
84
98
|
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.
|
|
85
99
|
|
|
100
|
+
### …and an effect that watches the search
|
|
101
|
+
|
|
102
|
+
`:effect <state> -> <actions>` runs actions whenever the watched state changes — for side effects past deriving state. This one counts how many times the search box above has changed:
|
|
103
|
+
|
|
104
|
+
:state searches = 0
|
|
105
|
+
:effect q -> searches++
|
|
106
|
+
|
|
107
|
+
The search has changed **{ searches }** time(s). Type in the box above and this updates on its own — no handler wired by hand.
|
|
108
|
+
|
|
86
109
|
### …and an editable cart
|
|
87
110
|
|
|
88
111
|
A `:button` inside a loop can act on its own row. **Add to cart** carries the row into another list; **Remove** drops a line — a whole add/remove flow with no JavaScript:
|
package/src/builder.js
CHANGED
|
@@ -71,6 +71,11 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
71
71
|
fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
|
|
72
72
|
|
|
73
73
|
fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
|
|
74
|
+
|
|
75
|
+
// Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
|
|
76
|
+
// sees every emitted route/framework file already on disk.
|
|
77
|
+
emitPageAssets(paths);
|
|
78
|
+
|
|
74
79
|
return { routes: manifest, distRoot: paths.distRoot };
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -168,6 +173,33 @@ function emitShelfAssets(paths) {
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Copy page-colocated static assets (images, SVG, fonts, …) from `site/pages`
|
|
178
|
+
* into `dist`, preserving their relative path: `site/pages/logo.svg` →
|
|
179
|
+
* `dist/logo.svg`; `site/pages/blog/cover.png` → `dist/blog/cover.png`. Routes
|
|
180
|
+
* (`.md`/`.wd`) and the compiler's colocated `.skin`/`.js` files are skipped —
|
|
181
|
+
* those have their own emit paths. A backstop guard skips any asset whose output
|
|
182
|
+
* would clobber an already-emitted route/framework file (in practice their
|
|
183
|
+
* extensions differ, so this only fires on a genuine name clash).
|
|
184
|
+
* @param {Paths} paths
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
187
|
+
function emitPageAssets(paths) {
|
|
188
|
+
if (!fs.existsSync(paths.routesRoot)) return;
|
|
189
|
+
for (const file of walk(paths.routesRoot)) {
|
|
190
|
+
const ext = path.extname(file).toLowerCase();
|
|
191
|
+
if ([".md", ".wd", ".skin", ".js"].includes(ext)) continue;
|
|
192
|
+
const rel = path.relative(paths.routesRoot, file);
|
|
193
|
+
const out = path.join(paths.distRoot, rel);
|
|
194
|
+
if (fs.existsSync(out)) {
|
|
195
|
+
console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
199
|
+
fs.copyFileSync(file, out);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
/**
|
|
172
204
|
* @param {string} dir
|
|
173
205
|
* @returns {string[]}
|
package/src/compiler.js
CHANGED
|
@@ -377,6 +377,11 @@ function compileBody(lines, ctx) {
|
|
|
377
377
|
out.push(handleComputed(line, ctx));
|
|
378
378
|
continue;
|
|
379
379
|
}
|
|
380
|
+
if (/^:effect\s/.test(line)) {
|
|
381
|
+
flush();
|
|
382
|
+
out.push(handleEffect(line, ctx));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
380
385
|
if (/^:form\s/.test(line)) {
|
|
381
386
|
flush();
|
|
382
387
|
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
@@ -542,7 +547,13 @@ function scanContainer(lines, start, file) {
|
|
|
542
547
|
}
|
|
543
548
|
|
|
544
549
|
/**
|
|
545
|
-
* Split an `:if … :else … :endif`
|
|
550
|
+
* Split an `:if … :else if … :else … :endif` chain into the first condition's
|
|
551
|
+
* truthy body and a falsy body. A bare `:else` works as before. The first
|
|
552
|
+
* `:else if B` is **desugared** into a nested `:if` that lives in the falsy
|
|
553
|
+
* branch: the falsy body becomes `[":if B", …rest…, ":endif"]`, which re-enters
|
|
554
|
+
* `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
|
|
555
|
+
* therefore compiles to nested `data-wd-if` regions the runtime already drives —
|
|
556
|
+
* identical behavior for static, reactive, and loop-row conditionals.
|
|
546
557
|
* @param {string[]} lines
|
|
547
558
|
* @param {number} start Index of the `:if` line.
|
|
548
559
|
* @param {string} file Source path for errors.
|
|
@@ -554,6 +565,11 @@ function scanConditional(lines, start, file) {
|
|
|
554
565
|
/** @type {string[]} */
|
|
555
566
|
const falsy = [];
|
|
556
567
|
let current = truthy;
|
|
568
|
+
// "truthy": collecting the :if body. "else": a bare :else closed the chain —
|
|
569
|
+
// no further branches allowed. "elseif": the first :else if was desugared into
|
|
570
|
+
// a nested :if inside `falsy`; subsequent depth-0 :else if/:else lines belong to
|
|
571
|
+
// that nested chain and are emitted verbatim for the recursive compile to split.
|
|
572
|
+
let mode = "truthy";
|
|
557
573
|
let depth = 0;
|
|
558
574
|
let fence = null;
|
|
559
575
|
for (let i = start + 1; i < lines.length; i++) {
|
|
@@ -571,12 +587,28 @@ function scanConditional(lines, start, file) {
|
|
|
571
587
|
}
|
|
572
588
|
if (/^:if\s/.test(line)) depth++;
|
|
573
589
|
if (line.trim() === ":endif") {
|
|
574
|
-
if (depth === 0)
|
|
590
|
+
if (depth === 0) {
|
|
591
|
+
if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
|
|
592
|
+
return { truthy, falsy, end: i };
|
|
593
|
+
}
|
|
575
594
|
depth--;
|
|
576
595
|
}
|
|
577
|
-
if (
|
|
578
|
-
|
|
579
|
-
|
|
596
|
+
if (depth === 0 && mode !== "elseif") {
|
|
597
|
+
const t = line.trim();
|
|
598
|
+
const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
|
|
599
|
+
if (elseIf) {
|
|
600
|
+
if (mode === "else") throw new Error(`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`);
|
|
601
|
+
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
602
|
+
current = falsy;
|
|
603
|
+
mode = "elseif";
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (t === ":else") {
|
|
607
|
+
if (mode === "else") throw new Error(`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`);
|
|
608
|
+
current = falsy;
|
|
609
|
+
mode = "else";
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
580
612
|
}
|
|
581
613
|
current.push(line);
|
|
582
614
|
}
|
|
@@ -634,20 +666,44 @@ function parseIncludeArgs(raw, ctx) {
|
|
|
634
666
|
* @returns {string}
|
|
635
667
|
*/
|
|
636
668
|
function handleContainer(header, bodyLines, ctx) {
|
|
637
|
-
|
|
669
|
+
let rest = header.trim();
|
|
638
670
|
let tag = "section";
|
|
639
|
-
/** @type {string[]} */
|
|
640
|
-
|
|
671
|
+
/** @type {string[]} Static classes baked into class="". */
|
|
672
|
+
const extraClass = [];
|
|
673
|
+
/** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
|
|
674
|
+
const eachClasses = [];
|
|
675
|
+
/** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
|
|
676
|
+
const stateClasses = [];
|
|
641
677
|
let id = "";
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
678
|
+
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
679
|
+
// the <section> tag; any other name becomes a <div> and also a class.
|
|
680
|
+
const lead = rest.match(/^([^\s.#]\S*)/);
|
|
681
|
+
let nameToken = "section";
|
|
682
|
+
if (lead) { nameToken = lead[1]; rest = rest.slice(lead[0].length).trim(); }
|
|
683
|
+
if (nameToken !== "section") { tag = "div"; extraClass.push(nameToken); }
|
|
684
|
+
while (rest.length) {
|
|
685
|
+
if (rest.startsWith("#")) {
|
|
686
|
+
const m = rest.match(/^#(\S+)/);
|
|
687
|
+
id = (m ? m[1] : "");
|
|
688
|
+
rest = rest.slice((m ? m[0] : rest).length).trim();
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
|
|
692
|
+
if (!cm) throw new Error(`Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${ctx.file}`);
|
|
693
|
+
const cls = cm[1];
|
|
694
|
+
rest = rest.slice(cm[0].length).trim();
|
|
695
|
+
// Optional `when <predicate>` makes the class reactive. The predicate runs to
|
|
696
|
+
// the next ` .`/` #` token or the end of the header.
|
|
697
|
+
const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
|
|
698
|
+
if (whenMatch) {
|
|
699
|
+
rest = rest.slice(whenMatch[0].length).trim();
|
|
700
|
+
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
701
|
+
if (compiled.static) { if (compiled.value) extraClass.push(cls); }
|
|
702
|
+
else if (compiled.item) eachClasses.push([cls, compiled.body]);
|
|
703
|
+
else stateClasses.push([cls, compiled.body]);
|
|
704
|
+
} else {
|
|
705
|
+
extraClass.push(cls);
|
|
706
|
+
}
|
|
651
707
|
}
|
|
652
708
|
const explicitId = Boolean(id);
|
|
653
709
|
if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
|
|
@@ -661,7 +717,10 @@ function handleContainer(header, bodyLines, ctx) {
|
|
|
661
717
|
}
|
|
662
718
|
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
663
719
|
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
664
|
-
|
|
720
|
+
let classBind = "";
|
|
721
|
+
if (eachClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`; }
|
|
722
|
+
if (stateClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`; }
|
|
723
|
+
return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
665
724
|
}
|
|
666
725
|
|
|
667
726
|
/**
|
|
@@ -740,7 +799,7 @@ function declareErrorState(key, ctx) {
|
|
|
740
799
|
}
|
|
741
800
|
|
|
742
801
|
const FETCH_USE =
|
|
743
|
-
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key]';
|
|
802
|
+
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
|
|
744
803
|
|
|
745
804
|
/**
|
|
746
805
|
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
@@ -755,14 +814,14 @@ function handleFetch(line, ctx) {
|
|
|
755
814
|
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
756
815
|
if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
|
|
757
816
|
const name = head[1];
|
|
758
|
-
const url = stripQuotes(head[2]);
|
|
817
|
+
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
759
818
|
/** @type {Record<string, string>} */
|
|
760
819
|
const opts = {};
|
|
761
820
|
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
762
821
|
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
763
822
|
if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
|
|
764
823
|
const optName = kv[1];
|
|
765
|
-
if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
|
|
824
|
+
if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
|
|
766
825
|
throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
|
|
767
826
|
}
|
|
768
827
|
opts[optName] = stripQuotes(kv[2]);
|
|
@@ -779,6 +838,14 @@ function handleFetch(line, ctx) {
|
|
|
779
838
|
throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
|
|
780
839
|
}
|
|
781
840
|
}
|
|
841
|
+
if (opts.refresh !== undefined) {
|
|
842
|
+
// Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
|
|
843
|
+
// The new token is written back into the `headers=` state, so it is required.
|
|
844
|
+
if (!opts.headers) {
|
|
845
|
+
throw new Error(`:fetch refresh= needs headers= (the state key holding the token to renew) in ${ctx.file}. ${FETCH_USE}`);
|
|
846
|
+
}
|
|
847
|
+
opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
|
|
848
|
+
}
|
|
782
849
|
|
|
783
850
|
// Extract `{ path }` dependency keys from the URL (validated against poison
|
|
784
851
|
// segments). The runtime fills them from state on each (re)fetch.
|
|
@@ -811,11 +878,40 @@ function handleFetch(line, ctx) {
|
|
|
811
878
|
attr("retry", opts.retry) +
|
|
812
879
|
attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
|
|
813
880
|
attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
|
|
881
|
+
attr("refresh", opts.refresh) +
|
|
814
882
|
attr("deps", deps.join(",")) +
|
|
815
883
|
`></span>`;
|
|
816
884
|
return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
|
|
817
885
|
}
|
|
818
886
|
|
|
887
|
+
/**
|
|
888
|
+
* Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
|
|
889
|
+
* `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
|
|
890
|
+
* URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
|
|
891
|
+
* `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
|
|
892
|
+
* rejected. Interpolated values are percent-encoded by the runtime, so a scheme
|
|
893
|
+
* cannot be injected through state at request time.
|
|
894
|
+
* @param {string} url
|
|
895
|
+
* @param {Ctx} ctx
|
|
896
|
+
* @param {string} [what] Option name for the error message.
|
|
897
|
+
* @returns {string}
|
|
898
|
+
*/
|
|
899
|
+
function validateFetchUrl(url, ctx, what = ":fetch") {
|
|
900
|
+
const value = url.trim();
|
|
901
|
+
if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
902
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`);
|
|
903
|
+
}
|
|
904
|
+
if (value.startsWith("//")) {
|
|
905
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`);
|
|
906
|
+
}
|
|
907
|
+
if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
|
|
908
|
+
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
909
|
+
if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
|
|
910
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`);
|
|
911
|
+
}
|
|
912
|
+
return value;
|
|
913
|
+
}
|
|
914
|
+
|
|
819
915
|
/**
|
|
820
916
|
* @param {string} line
|
|
821
917
|
* @param {Ctx} ctx
|
|
@@ -1031,6 +1127,30 @@ function handleButton(line, ctx) {
|
|
|
1031
1127
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
1032
1128
|
}
|
|
1033
1129
|
|
|
1130
|
+
const EFFECT_USE = "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
1134
|
+
* watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
|
|
1135
|
+
* action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
|
|
1136
|
+
* (derive state) and fetch deps (auto-refetch).
|
|
1137
|
+
* @param {string} line
|
|
1138
|
+
* @param {Ctx} ctx
|
|
1139
|
+
* @returns {string}
|
|
1140
|
+
*/
|
|
1141
|
+
function handleEffect(line, ctx) {
|
|
1142
|
+
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
1143
|
+
if (!match) throw new Error(`Malformed :effect in ${ctx.file}: ${line}. ${EFFECT_USE}`);
|
|
1144
|
+
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
1145
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1146
|
+
if (!key) throw new Error(`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
|
|
1147
|
+
ctx.comp.assets.runtime = true;
|
|
1148
|
+
const watch = [key, ...segs.slice(1)].join(".");
|
|
1149
|
+
const action = parseAction(match[2], ctx);
|
|
1150
|
+
const actions = Array.isArray(action) ? action : [action];
|
|
1151
|
+
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1034
1154
|
/**
|
|
1035
1155
|
* @param {string} line
|
|
1036
1156
|
* @param {string[]} truthyLines
|
|
@@ -1039,48 +1159,71 @@ function handleButton(line, ctx) {
|
|
|
1039
1159
|
* @returns {string}
|
|
1040
1160
|
*/
|
|
1041
1161
|
function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1162
|
+
const condition = line.replace(/^:if\s+/, "").trim();
|
|
1163
|
+
// Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
|
|
1164
|
+
// existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
|
|
1165
|
+
// `not`, …) falls through to the predicate path below.
|
|
1166
|
+
const match = condition.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/);
|
|
1167
|
+
if (match) {
|
|
1168
|
+
const segs = match[1].split(".");
|
|
1169
|
+
const head = segs[0];
|
|
1170
|
+
|
|
1171
|
+
// Per-row meta vars in :if — only valid inside a loop.
|
|
1172
|
+
if (LOOP_META[head]) {
|
|
1173
|
+
if (!ctx.loopMeta) throw new Error(`":if ${match[1]}" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Use it inside a loop body.`);
|
|
1174
|
+
if (ctx.loopItem) {
|
|
1175
|
+
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1176
|
+
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1177
|
+
return `<span data-wd-each-if data-wd-meta="${LOOP_META[head]}"><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1178
|
+
}
|
|
1179
|
+
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1180
|
+
}
|
|
1046
1181
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1182
|
+
const staticValue = lookupVar(ctx.scope, head);
|
|
1183
|
+
if (staticValue.found) {
|
|
1184
|
+
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
1185
|
+
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (ctx.loopItem && head === ctx.loopItem) {
|
|
1051
1189
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1052
1190
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1053
|
-
|
|
1191
|
+
const rest = segs.slice(1).join(".");
|
|
1192
|
+
const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
|
|
1193
|
+
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>`;
|
|
1054
1194
|
}
|
|
1055
|
-
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
const staticValue = lookupVar(ctx.scope, head);
|
|
1059
|
-
if (staticValue.found) {
|
|
1060
|
-
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
1061
|
-
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
1062
|
-
}
|
|
1063
1195
|
|
|
1064
|
-
|
|
1196
|
+
const key = resolveStateKey(head, ctx);
|
|
1197
|
+
if (!key) {
|
|
1198
|
+
throw new Error(`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`);
|
|
1199
|
+
}
|
|
1200
|
+
ctx.comp.assets.runtime = true;
|
|
1065
1201
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1066
1202
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1067
|
-
const
|
|
1068
|
-
const pathAttr = ` data-wd-path="${escapeHtml(
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1203
|
+
const restPath = segs.slice(1).join(".");
|
|
1204
|
+
const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
|
|
1205
|
+
const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
|
|
1206
|
+
const active = initialTruthy ? truthy : falsy;
|
|
1207
|
+
return `<div data-wd-if="${key}"${pathAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Predicate path: a comparison / logical condition. Compiles through the same
|
|
1211
|
+
// whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
|
|
1212
|
+
// build when static, drives a per-row each-if when it reads the loop item, and
|
|
1213
|
+
// a global if-region (evaluated each render) when it reads state. No raw eval.
|
|
1214
|
+
if (!condition) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`);
|
|
1215
|
+
const compiled = compileWhen(condition, ctx, '":if"');
|
|
1216
|
+
if (compiled.static) return compileBody(compiled.value ? truthyLines : falsyLines, ctx);
|
|
1076
1217
|
ctx.comp.assets.runtime = true;
|
|
1077
1218
|
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1078
1219
|
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
|
|
1220
|
+
const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
|
|
1221
|
+
if (compiled.item) {
|
|
1222
|
+
return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1223
|
+
}
|
|
1224
|
+
const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
|
|
1082
1225
|
const active = initialTruthy ? truthy : falsy;
|
|
1083
|
-
return `<div data-wd-if="
|
|
1226
|
+
return `<div data-wd-if=""${exprAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1084
1227
|
}
|
|
1085
1228
|
|
|
1086
1229
|
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
@@ -1375,6 +1518,82 @@ function evalPredicate(body, item, ctx) {
|
|
|
1375
1518
|
}
|
|
1376
1519
|
}
|
|
1377
1520
|
|
|
1521
|
+
/**
|
|
1522
|
+
* Compile one operand of a `::: … .class when <predicate>` expression. Like the
|
|
1523
|
+
* `@loop where` operand, but also folds a static-scope value (a loop-unrolled
|
|
1524
|
+
* item field or include arg) to its build-time literal, so a fully-static
|
|
1525
|
+
* predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
|
|
1526
|
+
* resolution order.
|
|
1527
|
+
* @param {string} tok
|
|
1528
|
+
* @param {Ctx} ctx
|
|
1529
|
+
* @param {string} what Directive label for error messages.
|
|
1530
|
+
* @returns {{ code: string, state: boolean, item: boolean }}
|
|
1531
|
+
*/
|
|
1532
|
+
function compileWhenOperand(tok, ctx, what) {
|
|
1533
|
+
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
|
|
1534
|
+
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
|
|
1535
|
+
if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
|
|
1536
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
1537
|
+
throw new Error(`Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
|
|
1538
|
+
}
|
|
1539
|
+
const segs = tok.split(".");
|
|
1540
|
+
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1541
|
+
throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
|
|
1542
|
+
}
|
|
1543
|
+
const scoped = lookupVar(ctx.scope, segs[0]);
|
|
1544
|
+
if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
|
|
1545
|
+
if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
|
|
1546
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1547
|
+
if (!key) throw new Error(`${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
|
|
1548
|
+
const rest = segs.slice(1).join(".");
|
|
1549
|
+
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
|
|
1554
|
+
* paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
|
|
1555
|
+
* Folds to a static verdict when every operand is build-known, else returns a
|
|
1556
|
+
* runtime body over I()/S()/C() plus whether it reads the reactive loop item
|
|
1557
|
+
* (which decides data-wd-each-class vs the global data-wd-class).
|
|
1558
|
+
* @param {string} raw
|
|
1559
|
+
* @param {Ctx} ctx
|
|
1560
|
+
* @param {string} [what] Directive label for error messages.
|
|
1561
|
+
* @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
|
|
1562
|
+
*/
|
|
1563
|
+
function compileWhen(raw, ctx, what = '"::: … when"') {
|
|
1564
|
+
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
1565
|
+
/** @type {string[]} */
|
|
1566
|
+
const pieces = [];
|
|
1567
|
+
let usesState = false;
|
|
1568
|
+
let usesItem = false;
|
|
1569
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1570
|
+
if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
|
|
1571
|
+
let seg = parts[i].trim();
|
|
1572
|
+
// Optional leading `not` negates the whole sub-condition.
|
|
1573
|
+
let negate = false;
|
|
1574
|
+
if (/^not\s+/i.test(seg)) { negate = true; seg = seg.replace(/^not\s+/i, "").trim(); }
|
|
1575
|
+
const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
1576
|
+
let expr;
|
|
1577
|
+
if (opMatch) {
|
|
1578
|
+
const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
|
|
1579
|
+
const right = compileWhenOperand(opMatch[3].trim(), ctx, what);
|
|
1580
|
+
usesState = usesState || left.state || right.state;
|
|
1581
|
+
usesItem = usesItem || left.item || right.item;
|
|
1582
|
+
const op = opMatch[2].toLowerCase();
|
|
1583
|
+
expr = op === "contains" ? `C(${left.code}, ${right.code})` : `${left.code} ${op} ${right.code}`;
|
|
1584
|
+
} else {
|
|
1585
|
+
const only = compileWhenOperand(seg, ctx, what);
|
|
1586
|
+
usesState = usesState || only.state;
|
|
1587
|
+
usesItem = usesItem || only.item;
|
|
1588
|
+
expr = only.code;
|
|
1589
|
+
}
|
|
1590
|
+
pieces.push(negate ? `(!(${expr}))` : `(${expr})`);
|
|
1591
|
+
}
|
|
1592
|
+
const body = pieces.join(" ");
|
|
1593
|
+
if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
|
|
1594
|
+
return { static: false, body, item: usesItem };
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1378
1597
|
/**
|
|
1379
1598
|
* @param {unknown[]} rows
|
|
1380
1599
|
* @param {string} itemName
|