@zvndev/darkmown 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +213 -9
- package/docs/spec-alignment.md +10 -2
- package/package.json +1 -1
- package/site/_/nav.wd +2 -0
- package/site/pages/docs/index.wd +100 -4
- package/site/pages/loops.skin +80 -0
- package/site/pages/loops.wd +86 -0
- package/site/pages/store.skin +70 -0
- package/site/pages/store.wd +53 -0
- package/src/builder.js +14 -9
- package/src/compiler.js +494 -72
- package/src/runtime.js +295 -63
- package/types/compiler.d.ts +49 -10
package/src/compiler.js
CHANGED
|
@@ -30,6 +30,7 @@ import MarkdownIt from "markdown-it";
|
|
|
30
30
|
* @typedef {object} Compilation
|
|
31
31
|
* @property {Assets} assets
|
|
32
32
|
* @property {Map<string, unknown>} state Declared state keys → initial values.
|
|
33
|
+
* @property {Set<string>} stores Page-global store names (a subset of state keys).
|
|
33
34
|
* @property {string[]} warnings Non-fatal authoring hints.
|
|
34
35
|
* @property {number} sectionCounter Counter for auto-generated section ids.
|
|
35
36
|
*/
|
|
@@ -52,6 +53,7 @@ import MarkdownIt from "markdown-it";
|
|
|
52
53
|
* @property {string[]} sections Active section-id scope chain.
|
|
53
54
|
* @property {string | null} loopItem Name of the current reactive loop item, if any.
|
|
54
55
|
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
56
|
+
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
55
57
|
* @property {MarkdownIt} [md] Markdown-it instance selected for this file.
|
|
56
58
|
*/
|
|
57
59
|
|
|
@@ -164,6 +166,7 @@ function createCompilation() {
|
|
|
164
166
|
return {
|
|
165
167
|
assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
|
|
166
168
|
state: new Map(),
|
|
169
|
+
stores: new Set(),
|
|
167
170
|
warnings: [],
|
|
168
171
|
sectionCounter: 0
|
|
169
172
|
};
|
|
@@ -329,7 +332,8 @@ function compileBody(lines, ctx) {
|
|
|
329
332
|
if (/^@loop\s/.test(line)) {
|
|
330
333
|
flush();
|
|
331
334
|
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx.file);
|
|
332
|
-
|
|
335
|
+
const split = splitEmptyBranch(block.body);
|
|
336
|
+
out.push(handleLoop(line, split.body, split.empty, ctx));
|
|
333
337
|
i = block.end;
|
|
334
338
|
continue;
|
|
335
339
|
}
|
|
@@ -347,6 +351,11 @@ function compileBody(lines, ctx) {
|
|
|
347
351
|
out.push(handleState(line, ctx));
|
|
348
352
|
continue;
|
|
349
353
|
}
|
|
354
|
+
if (/^:store\s/.test(line)) {
|
|
355
|
+
flush();
|
|
356
|
+
out.push(handleStore(line, ctx));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
350
359
|
if (/^:fetch\s/.test(line)) {
|
|
351
360
|
flush();
|
|
352
361
|
out.push(handleFetch(line, ctx));
|
|
@@ -450,6 +459,37 @@ function scanBlock(lines, start, openRe, endToken, file) {
|
|
|
450
459
|
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
|
|
451
460
|
}
|
|
452
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Split a `@loop` body at a top-level `@empty` into the rows body and the empty
|
|
464
|
+
* branch. Honors nested `@loop … @endloop` (so an inner loop's `@empty` is not
|
|
465
|
+
* mistaken for the outer one) and fenced code.
|
|
466
|
+
* @param {string[]} body
|
|
467
|
+
* @returns {{ body: string[], empty: string[] | null }}
|
|
468
|
+
*/
|
|
469
|
+
function splitEmptyBranch(body) {
|
|
470
|
+
/** @type {string[]} */
|
|
471
|
+
const rows = [];
|
|
472
|
+
/** @type {string[] | null} */
|
|
473
|
+
let empty = null;
|
|
474
|
+
let target = rows;
|
|
475
|
+
let depth = 0;
|
|
476
|
+
let fence = null;
|
|
477
|
+
for (const line of body) {
|
|
478
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
479
|
+
if (fence) {
|
|
480
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
481
|
+
target.push(line);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (fenceMatch) { fence = fenceMatch[1]; target.push(line); continue; }
|
|
485
|
+
if (/^@loop\s/.test(line)) depth++;
|
|
486
|
+
if (line.trim() === "@endloop") depth--;
|
|
487
|
+
if (depth === 0 && line.trim() === "@empty") { empty = []; target = empty; continue; }
|
|
488
|
+
target.push(line);
|
|
489
|
+
}
|
|
490
|
+
return { body: rows, empty };
|
|
491
|
+
}
|
|
492
|
+
|
|
453
493
|
/**
|
|
454
494
|
* Capture the body of a `:::` container up to its matching close, honoring nesting.
|
|
455
495
|
* @param {string[]} lines
|
|
@@ -636,6 +676,7 @@ function handleState(line, ctx) {
|
|
|
636
676
|
*/
|
|
637
677
|
function declareState(name, value, ctx) {
|
|
638
678
|
if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
679
|
+
if (ctx.comp.stores.has(name)) throw new Error(`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`);
|
|
639
680
|
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
640
681
|
if (ctx.comp.state.has(key)) throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
|
|
641
682
|
ctx.comp.state.set(key, value);
|
|
@@ -648,19 +689,36 @@ function declareState(name, value, ctx) {
|
|
|
648
689
|
* @param {Ctx} ctx
|
|
649
690
|
* @returns {string}
|
|
650
691
|
*/
|
|
651
|
-
function
|
|
652
|
-
const match = line.match(/^:
|
|
653
|
-
if (!match) {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
const url = stripQuotes(match[2]);
|
|
659
|
-
const when = match[3] ? ` data-wd-fetch-when="visible"` : "";
|
|
660
|
-
return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
|
|
692
|
+
function handleStore(line, ctx) {
|
|
693
|
+
const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
|
|
694
|
+
if (!match) throw new Error(`Malformed :store in ${ctx.file}: ${line}. Use: :store name = value [ephemeral]`);
|
|
695
|
+
const value = parseStateValue(match[2]);
|
|
696
|
+
const name = declareStore(match[1], value, ctx);
|
|
697
|
+
const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
|
|
698
|
+
return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
|
|
661
699
|
}
|
|
662
700
|
|
|
663
701
|
/**
|
|
702
|
+
* Register a page-global store. The bare name is added to `comp.state` so every
|
|
703
|
+
* resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
|
|
704
|
+
* in `comp.stores` for collision checks. Never section-scoped.
|
|
705
|
+
* @param {string} name
|
|
706
|
+
* @param {unknown} value
|
|
707
|
+
* @param {Ctx} ctx
|
|
708
|
+
* @returns {string} The store name (also its bare state key).
|
|
709
|
+
*/
|
|
710
|
+
function declareStore(name, value, ctx) {
|
|
711
|
+
if (ctx.comp.stores.has(name)) throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
|
|
712
|
+
if (ctx.comp.state.has(name)) throw new Error(`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`);
|
|
713
|
+
ctx.comp.stores.add(name);
|
|
714
|
+
ctx.comp.state.set(name, value);
|
|
715
|
+
ctx.comp.assets.runtime = true;
|
|
716
|
+
return name;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
|
|
721
|
+
* round-trip :form so error fallbacks have a key to bind.
|
|
664
722
|
* @param {string} key
|
|
665
723
|
* @param {Ctx} ctx
|
|
666
724
|
* @returns {void}
|
|
@@ -670,6 +728,83 @@ function declareErrorState(key, ctx) {
|
|
|
670
728
|
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
671
729
|
}
|
|
672
730
|
|
|
731
|
+
const FETCH_USE =
|
|
732
|
+
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key]';
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
736
|
+
* Auto-declares four state keys (value/error/loading/empty), seeds them, and
|
|
737
|
+
* emits `data-wd-fetch-*` attributes (url/method/when/timeout/retry/headers/
|
|
738
|
+
* body/deps) consumed by the runtime's `startFetch`.
|
|
739
|
+
* @param {string} line
|
|
740
|
+
* @param {Ctx} ctx
|
|
741
|
+
* @returns {string}
|
|
742
|
+
*/
|
|
743
|
+
function handleFetch(line, ctx) {
|
|
744
|
+
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
745
|
+
if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
|
|
746
|
+
const name = head[1];
|
|
747
|
+
const url = stripQuotes(head[2]);
|
|
748
|
+
/** @type {Record<string, string>} */
|
|
749
|
+
const opts = {};
|
|
750
|
+
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
751
|
+
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
752
|
+
if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
|
|
753
|
+
const optName = kv[1];
|
|
754
|
+
if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
|
|
755
|
+
throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
|
|
756
|
+
}
|
|
757
|
+
opts[optName] = stripQuotes(kv[2]);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (opts.method && !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())) {
|
|
761
|
+
throw new Error(`:fetch method "${opts.method}" is not allowed in ${ctx.file}. ${FETCH_USE}`);
|
|
762
|
+
}
|
|
763
|
+
if (opts.when && !["load", "visible"].includes(opts.when)) {
|
|
764
|
+
throw new Error(`:fetch when "${opts.when}" is not allowed in ${ctx.file}. ${FETCH_USE}`);
|
|
765
|
+
}
|
|
766
|
+
for (const n of ["timeout", "retry"]) {
|
|
767
|
+
if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
|
|
768
|
+
throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Extract `{ path }` dependency keys from the URL (validated against poison
|
|
773
|
+
// segments). The runtime fills them from state on each (re)fetch.
|
|
774
|
+
/** @type {string[]} */
|
|
775
|
+
const deps = [];
|
|
776
|
+
for (const dep of url.matchAll(/\{\s*([A-Za-z_$][\w$.]*)\s*\}/g)) {
|
|
777
|
+
validatePath(dep[1], ctx, FETCH_USE);
|
|
778
|
+
const headKey = dep[1].split(".")[0];
|
|
779
|
+
if (!deps.includes(headKey)) deps.push(headKey);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Auto-declare the four lifecycle keys. `name` is declared via declareState
|
|
783
|
+
// (collision-checked); the derived keys are seeded directly.
|
|
784
|
+
const key = declareState(name, null, ctx);
|
|
785
|
+
/** @type {Record<string, unknown>} */
|
|
786
|
+
const seeds = { [key]: null };
|
|
787
|
+
for (const [suffix, seed] of [["_error", null], ["_loading", false], ["_empty", false]]) {
|
|
788
|
+
const k = `${key}${suffix}`;
|
|
789
|
+
if (!ctx.comp.state.has(k)) ctx.comp.state.set(k, seed);
|
|
790
|
+
seeds[k] = seed;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** @param {string} n @param {string | null | false | undefined} v */
|
|
794
|
+
const attr = (n, v) => (v != null && v !== false && v !== "" ? ` data-wd-fetch-${n}="${escapeHtml(v)}"` : "");
|
|
795
|
+
const marker =
|
|
796
|
+
`<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"` +
|
|
797
|
+
attr("method", opts.method && opts.method.toUpperCase()) +
|
|
798
|
+
attr("when", opts.when === "visible" ? "visible" : "") +
|
|
799
|
+
attr("timeout", opts.timeout) +
|
|
800
|
+
attr("retry", opts.retry) +
|
|
801
|
+
attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
|
|
802
|
+
attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
|
|
803
|
+
attr("deps", deps.join(",")) +
|
|
804
|
+
`></span>`;
|
|
805
|
+
return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
673
808
|
/**
|
|
674
809
|
* @param {string} line
|
|
675
810
|
* @param {Ctx} ctx
|
|
@@ -855,6 +990,9 @@ function handleButton(line, ctx) {
|
|
|
855
990
|
if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
|
|
856
991
|
ctx.comp.assets.runtime = true;
|
|
857
992
|
const action = parseAction(match[2], ctx);
|
|
993
|
+
if (Array.isArray(action)) {
|
|
994
|
+
return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
|
|
995
|
+
}
|
|
858
996
|
const valueAttr = action.value === undefined ? "" : ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
|
|
859
997
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
860
998
|
}
|
|
@@ -872,6 +1010,17 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
|
872
1010
|
const segs = match[1].split(".");
|
|
873
1011
|
const head = segs[0];
|
|
874
1012
|
|
|
1013
|
+
// Per-row meta vars in :if — only valid inside a loop.
|
|
1014
|
+
if (LOOP_META[head]) {
|
|
1015
|
+
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.`);
|
|
1016
|
+
if (ctx.loopItem) {
|
|
1017
|
+
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1018
|
+
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1019
|
+
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>`;
|
|
1020
|
+
}
|
|
1021
|
+
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1022
|
+
}
|
|
1023
|
+
|
|
875
1024
|
const staticValue = lookupVar(ctx.scope, head);
|
|
876
1025
|
if (staticValue.found) {
|
|
877
1026
|
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
@@ -900,24 +1049,116 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
|
900
1049
|
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>`;
|
|
901
1050
|
}
|
|
902
1051
|
|
|
1052
|
+
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
1053
|
+
const LOOP_USAGE = "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N]";
|
|
1054
|
+
|
|
1055
|
+
// Reserved per-row meta variables, mapped to their runtime marker token.
|
|
1056
|
+
/** @type {Record<string, string>} */
|
|
1057
|
+
const LOOP_META = { $index: "index", $number: "number", $first: "first", $last: "last", $count: "count" };
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Parse the optional clause tail of a `@loop` header in FIXED order:
|
|
1061
|
+
* `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`.
|
|
1062
|
+
* @param {string} tail Everything after `@loop src into item`.
|
|
1063
|
+
* @param {string} itemName
|
|
1064
|
+
* @param {Ctx} ctx
|
|
1065
|
+
* @returns {{ where: string|null, sort: {key:string,dir:string}|null, reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
|
|
1066
|
+
*/
|
|
1067
|
+
function parseLoopClauses(tail, itemName, ctx) {
|
|
1068
|
+
// Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
|
|
1069
|
+
// sort by). Parsing from the tail avoids mistaking a state operand named
|
|
1070
|
+
// `limit`/`offset` inside the `where` predicate for a clause keyword. Whatever
|
|
1071
|
+
// remains must be `where …` (or empty); a clause keyword surviving in the
|
|
1072
|
+
// remainder means the author wrote the clauses out of order.
|
|
1073
|
+
let s = tail.trim();
|
|
1074
|
+
/** @type {{key:string,dir:string}|null} */ let sort = null;
|
|
1075
|
+
let reverse = false;
|
|
1076
|
+
/** @type {NumArg|null} */ let offset = null;
|
|
1077
|
+
/** @type {NumArg|null} */ let limit = null;
|
|
1078
|
+
let refsState = false;
|
|
1079
|
+
/** @returns {never} */
|
|
1080
|
+
const bad = () => { throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`); };
|
|
1081
|
+
|
|
1082
|
+
let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
|
|
1083
|
+
if (m) { limit = parseNumArg(m[2], ctx); refsState = refsState || limit.kind === "key"; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
|
|
1084
|
+
|
|
1085
|
+
m = s.match(/(^|\s)offset\s+(\d+|[A-Za-z_$][\w$]*)$/);
|
|
1086
|
+
if (m) { offset = parseNumArg(m[2], ctx); refsState = refsState || offset.kind === "key"; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
|
|
1087
|
+
|
|
1088
|
+
m = s.match(/(^|\s)reverse$/);
|
|
1089
|
+
if (m) { reverse = true; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
|
|
1090
|
+
|
|
1091
|
+
m = s.match(/(^|\s)sort\s+by\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(asc|desc))?$/);
|
|
1092
|
+
if (m) {
|
|
1093
|
+
const segs = m[2].split(".");
|
|
1094
|
+
if (segs[0] !== itemName) throw new Error(`@loop sort key "${m[2]}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`);
|
|
1095
|
+
const rest = segs.slice(1);
|
|
1096
|
+
if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1097
|
+
throw new Error(`Sort key "${m[2]}" is not allowed in @loop (${ctx.file})`);
|
|
1098
|
+
}
|
|
1099
|
+
sort = { key: rest.join("."), dir: m[3] || "asc" };
|
|
1100
|
+
s = s.slice(0, s.length - m[0].length + m[1].length).trim();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** @type {string|null} */ let where = null;
|
|
1104
|
+
if (s.length) {
|
|
1105
|
+
m = s.match(/^where\s+(.+)$/);
|
|
1106
|
+
if (!m) return bad(); // leftover non-where text → clauses written out of order
|
|
1107
|
+
where = m[1].trim();
|
|
1108
|
+
// `sort by`/`reverse` are never valid where operands; their presence in the
|
|
1109
|
+
// predicate means a clause was written before `where` finished (wrong order).
|
|
1110
|
+
if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
|
|
1111
|
+
}
|
|
1112
|
+
return { where, sort, reverse, offset, limit, refsState };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* A loop offset/limit argument: a non-negative integer literal or a state key.
|
|
1117
|
+
* @typedef {{ kind: "literal", value: number } | { kind: "key", value: string }} NumArg
|
|
1118
|
+
*/
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* @param {string} tok
|
|
1122
|
+
* @param {Ctx} ctx
|
|
1123
|
+
* @returns {NumArg}
|
|
1124
|
+
*/
|
|
1125
|
+
function parseNumArg(tok, ctx) {
|
|
1126
|
+
if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
|
|
1127
|
+
if (/^[A-Za-z_$][\w$]*$/.test(tok)) {
|
|
1128
|
+
const key = resolveStateKey(tok, ctx);
|
|
1129
|
+
if (!key) throw new Error(`@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`);
|
|
1130
|
+
return { kind: "key", value: key };
|
|
1131
|
+
}
|
|
1132
|
+
throw new Error(`@loop offset/limit "${tok}" in ${ctx.file} is invalid. ${LOOP_USAGE}`);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** @param {NumArg} arg @returns {string} */
|
|
1136
|
+
function numArgAttr(arg) {
|
|
1137
|
+
return arg.kind === "literal" ? String(arg.value) : `key:${arg.value}`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
903
1140
|
/**
|
|
904
1141
|
* @param {string} line
|
|
905
1142
|
* @param {string[]} bodyLines
|
|
1143
|
+
* @param {string[] | null} emptyLines
|
|
906
1144
|
* @param {Ctx} ctx
|
|
907
1145
|
* @returns {string}
|
|
908
1146
|
*/
|
|
909
|
-
function handleLoop(line, bodyLines, ctx) {
|
|
910
|
-
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(
|
|
911
|
-
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}.
|
|
1147
|
+
function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
1148
|
+
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
1149
|
+
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
|
|
912
1150
|
const source = stripQuotes(match[1].trim());
|
|
913
1151
|
const itemName = match[2];
|
|
914
|
-
const
|
|
1152
|
+
const clauses = match[3] ? parseLoopClauses(match[3], itemName, ctx) : { where: null, sort: null, reverse: false, offset: null, limit: null, refsState: false };
|
|
1153
|
+
const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
|
|
1154
|
+
/** @type {LoopOpts} */
|
|
1155
|
+
const opts = { where, sort: clauses.sort, reverse: clauses.reverse, offset: clauses.offset, limit: clauses.limit, empty: emptyLines, clauseRefsState: clauses.refsState };
|
|
915
1156
|
|
|
916
1157
|
if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.endsWith(".json")) {
|
|
917
1158
|
const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
|
|
918
1159
|
const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
|
919
1160
|
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
920
|
-
return loopOverData(rows, itemName, bodyLines, ctx,
|
|
1161
|
+
return loopOverData(rows, itemName, bodyLines, ctx, opts);
|
|
921
1162
|
}
|
|
922
1163
|
|
|
923
1164
|
const resolved = lookupPath(source, ctx);
|
|
@@ -925,12 +1166,20 @@ function handleLoop(line, bodyLines, ctx) {
|
|
|
925
1166
|
if (!Array.isArray(resolved.value)) {
|
|
926
1167
|
throw new Error(`@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`);
|
|
927
1168
|
}
|
|
928
|
-
return loopOverData(resolved.value, itemName, bodyLines, ctx,
|
|
1169
|
+
return loopOverData(resolved.value, itemName, bodyLines, ctx, opts);
|
|
929
1170
|
}
|
|
930
1171
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1172
|
+
// A bare name OR a dotted path resolving to declared :state (e.g. team.members).
|
|
1173
|
+
const segs = source.split(".");
|
|
1174
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
|
|
1175
|
+
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1176
|
+
throw new Error(`@loop source "${source}" is not allowed in ${ctx.file}`);
|
|
1177
|
+
}
|
|
1178
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1179
|
+
if (key) {
|
|
1180
|
+
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
1181
|
+
return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
|
|
1182
|
+
}
|
|
934
1183
|
}
|
|
935
1184
|
|
|
936
1185
|
throw new Error(
|
|
@@ -948,18 +1197,59 @@ function handleLoop(line, bodyLines, ctx) {
|
|
|
948
1197
|
* @property {boolean} refsState Whether the predicate reads declared state.
|
|
949
1198
|
*/
|
|
950
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* Loop clause configuration shared by the static and reactive paths.
|
|
1202
|
+
* @typedef {object} LoopOpts
|
|
1203
|
+
* @property {Predicate | null} where
|
|
1204
|
+
* @property {{ key: string, dir: string } | null} sort
|
|
1205
|
+
* @property {boolean} reverse
|
|
1206
|
+
* @property {NumArg | null} offset
|
|
1207
|
+
* @property {NumArg | null} limit
|
|
1208
|
+
* @property {string[] | null} empty Empty-branch body lines, if any.
|
|
1209
|
+
* @property {boolean} clauseRefsState Whether offset/limit reference state.
|
|
1210
|
+
*/
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Build-time pipeline over already-resolved rows: filter → sort → reverse →
|
|
1214
|
+
* offset → limit. Used when source + every clause arg are static.
|
|
1215
|
+
* @param {unknown[]} rows
|
|
1216
|
+
* @param {Predicate | null} where
|
|
1217
|
+
* @param {LoopOpts} opts
|
|
1218
|
+
* @param {Ctx} ctx
|
|
1219
|
+
* @returns {unknown[]}
|
|
1220
|
+
*/
|
|
1221
|
+
function pipelineRows(rows, where, opts, ctx) {
|
|
1222
|
+
let list = where ? rows.filter((row) => evalPredicate(where.body, row, ctx)) : rows.slice();
|
|
1223
|
+
if (opts.sort) {
|
|
1224
|
+
const k = opts.sort.key;
|
|
1225
|
+
const dir = opts.sort.dir === "desc" ? -1 : 1;
|
|
1226
|
+
list = list.map((value, index) => ({ value, index })).sort((a, b) => {
|
|
1227
|
+
const av = getPath(a.value, k ? k.split(".") : []);
|
|
1228
|
+
const bv = getPath(b.value, k ? k.split(".") : []);
|
|
1229
|
+
let c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
|
1230
|
+
return (c || a.index - b.index) * dir;
|
|
1231
|
+
}).map((w) => w.value);
|
|
1232
|
+
}
|
|
1233
|
+
if (opts.reverse) list.reverse();
|
|
1234
|
+
const off = opts.offset && opts.offset.kind === "literal" ? opts.offset.value : 0;
|
|
1235
|
+
if (off) list = list.slice(off);
|
|
1236
|
+
if (opts.limit && opts.limit.kind === "literal") list = list.slice(0, opts.limit.value);
|
|
1237
|
+
return list;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
951
1240
|
/**
|
|
952
1241
|
* @param {unknown[]} rows
|
|
953
1242
|
* @param {string} itemName
|
|
954
1243
|
* @param {string[]} bodyLines
|
|
955
1244
|
* @param {Ctx} ctx
|
|
956
|
-
* @param {
|
|
1245
|
+
* @param {LoopOpts} opts
|
|
957
1246
|
* @returns {string}
|
|
958
1247
|
*/
|
|
959
|
-
function loopOverData(rows, itemName, bodyLines, ctx,
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
return reactiveLoop(null, itemName, bodyLines, ctx, {
|
|
1248
|
+
function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
1249
|
+
// Reactive when the where reads state OR an offset/limit references a state key.
|
|
1250
|
+
const reactive = (opts.where && opts.where.refsState) || opts.clauseRefsState;
|
|
1251
|
+
if (reactive) return reactiveLoop(null, itemName, bodyLines, ctx, { ...opts, data: rows });
|
|
1252
|
+
return staticUnroll(pipelineRows(rows, opts.where, opts, ctx), itemName, bodyLines, ctx, opts.empty);
|
|
963
1253
|
}
|
|
964
1254
|
|
|
965
1255
|
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
@@ -1056,30 +1346,36 @@ function evalPredicate(body, item, ctx) {
|
|
|
1056
1346
|
* @param {string} itemName
|
|
1057
1347
|
* @param {string[]} bodyLines
|
|
1058
1348
|
* @param {Ctx} ctx
|
|
1349
|
+
* @param {string[] | null} [empty] Empty-branch body, rendered when 0 rows.
|
|
1059
1350
|
* @returns {string}
|
|
1060
1351
|
*/
|
|
1061
|
-
function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
1352
|
+
function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
|
|
1353
|
+
if (rows.length === 0 && empty) return compileBody(empty, { ...ctx, loopMeta: true });
|
|
1062
1354
|
const out = [];
|
|
1063
|
-
|
|
1064
|
-
|
|
1355
|
+
const count = rows.length;
|
|
1356
|
+
for (let i = 0; i < count; i++) {
|
|
1357
|
+
const meta = { $index: i, $number: i + 1, $first: i === 0, $last: i === count - 1, $count: count };
|
|
1358
|
+
const rowCtx = { ...ctx, loopMeta: true, scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta }) };
|
|
1065
1359
|
out.push(compileBody(bodyLines, rowCtx));
|
|
1066
1360
|
}
|
|
1067
1361
|
return out.join("\n");
|
|
1068
1362
|
}
|
|
1069
1363
|
|
|
1070
1364
|
/**
|
|
1071
|
-
* Emit a reactive loop region (template + initial rows) for the
|
|
1072
|
-
*
|
|
1365
|
+
* Emit a reactive loop region (template + initial rows + clause config) for the
|
|
1366
|
+
* runtime. `key` may be a dotted path (e.g. `team.members`); the runtime reads
|
|
1367
|
+
* it via getPath. `opts` carries where/sort/reverse/offset/limit/empty.
|
|
1368
|
+
* @param {string | null} key State key/path of the list, or null for baked data.
|
|
1073
1369
|
* @param {string} itemName
|
|
1074
1370
|
* @param {string[]} bodyLines
|
|
1075
1371
|
* @param {Ctx} ctx
|
|
1076
|
-
* @param {
|
|
1372
|
+
* @param {Partial<LoopOpts> & { data?: unknown[] } | null} [opts]
|
|
1077
1373
|
* @returns {string}
|
|
1078
1374
|
*/
|
|
1079
1375
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
1080
1376
|
ctx.comp.assets.runtime = true;
|
|
1081
1377
|
/** @type {Ctx} */
|
|
1082
|
-
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, scope: createScope(ctx.scope) };
|
|
1378
|
+
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, loopMeta: true, scope: createScope(ctx.scope) };
|
|
1083
1379
|
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
1084
1380
|
|
|
1085
1381
|
let wrapperTag = "div";
|
|
@@ -1093,23 +1389,44 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
1093
1389
|
}
|
|
1094
1390
|
|
|
1095
1391
|
const where = opts?.where || null;
|
|
1392
|
+
const sort = opts?.sort || null;
|
|
1393
|
+
const reverse = opts?.reverse || false;
|
|
1394
|
+
const offset = opts?.offset || null;
|
|
1395
|
+
const limit = opts?.limit || null;
|
|
1396
|
+
const emptyLines = opts?.empty || null;
|
|
1096
1397
|
const baked = opts?.data || null; // rows for a static source filtered by state
|
|
1097
|
-
|
|
1398
|
+
// Resolve the (possibly dotted) source for the initial paint.
|
|
1399
|
+
const segs = key ? key.split(".") : [];
|
|
1400
|
+
const stateRows = key ? getPath(ctx.comp.state.get(segs[0]), segs.slice(1)) : null;
|
|
1098
1401
|
/** @type {unknown[]} */
|
|
1099
1402
|
const allRows = key ? (Array.isArray(stateRows) ? stateRows : []) : (baked || []);
|
|
1100
|
-
|
|
1403
|
+
// Initial paint runs the same pipeline the runtime will, reading state-key
|
|
1404
|
+
// offset/limit from their current declared values.
|
|
1405
|
+
/** @param {NumArg|null} a */
|
|
1406
|
+
const num = (a) => a ? (a.kind === "literal" ? a.value : Number(ctx.comp.state.get(a.value) ?? 0)) : null;
|
|
1407
|
+
const rows = pipelineRows(allRows, where, { where, sort, reverse, offset: offset && offset.kind === "key" ? { kind: "literal", value: num(offset) || 0 } : offset, limit: limit && limit.kind === "key" ? { kind: "literal", value: num(limit) ?? allRows.length } : limit, empty: null, clauseRefsState: false }, ctx);
|
|
1101
1408
|
/** @type {Map<string, number>} */
|
|
1102
1409
|
const counts = new Map();
|
|
1410
|
+
const count = rows.length;
|
|
1103
1411
|
const initial = rows
|
|
1104
|
-
.map((/** @type {unknown} */ item) => {
|
|
1412
|
+
.map((/** @type {unknown} */ item, i) => {
|
|
1105
1413
|
const itemKey = loopKeyOf(item, counts);
|
|
1106
|
-
|
|
1414
|
+
const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
|
|
1415
|
+
return fillTemplateString(withLoopKey(itemTemplate, itemKey), item, meta);
|
|
1107
1416
|
})
|
|
1108
1417
|
.join("");
|
|
1109
1418
|
|
|
1110
|
-
const
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1419
|
+
const emptyTemplate = emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
|
|
1420
|
+
const initialOut = count === 0 && emptyLines ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim() : initial;
|
|
1421
|
+
|
|
1422
|
+
const attrs =
|
|
1423
|
+
(where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "") +
|
|
1424
|
+
(sort ? ` data-wd-loop-sort="${escapeHtml(sort.key)}" data-wd-loop-sort-dir="${sort.dir}"` : "") +
|
|
1425
|
+
(reverse ? ` data-wd-loop-reverse` : "") +
|
|
1426
|
+
(offset ? ` data-wd-loop-offset="${numArgAttr(offset)}"` : "") +
|
|
1427
|
+
(limit ? ` data-wd-loop-limit="${numArgAttr(limit)}"` : "") +
|
|
1428
|
+
(baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
|
|
1429
|
+
return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
|
|
1113
1430
|
}
|
|
1114
1431
|
|
|
1115
1432
|
/**
|
|
@@ -1124,12 +1441,26 @@ function withLoopKey(template, itemKey) {
|
|
|
1124
1441
|
/**
|
|
1125
1442
|
* @param {string} template
|
|
1126
1443
|
* @param {unknown} item
|
|
1444
|
+
* @param {Record<string, unknown>} [meta] Per-row meta values for the initial paint.
|
|
1127
1445
|
* @returns {string}
|
|
1128
1446
|
*/
|
|
1129
|
-
function fillTemplateString(template, item) {
|
|
1447
|
+
function fillTemplateString(template, item, meta = {}) {
|
|
1130
1448
|
// Resolve per-item :if regions first (recursively, so nested conditionals are
|
|
1131
|
-
// pre-rendered for the initial paint), then fill
|
|
1132
|
-
return fillEachText(fillEachIfRegions(template, item), item);
|
|
1449
|
+
// pre-rendered for the initial paint), then fill text binds and meta markers.
|
|
1450
|
+
return fillEachMeta(fillEachText(fillEachIfRegions(template, item, meta), item), meta);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Fill `<span data-wd-each-meta="…">` markers with their per-row value.
|
|
1455
|
+
* @param {string} str
|
|
1456
|
+
* @param {Record<string, unknown>} meta
|
|
1457
|
+
* @returns {string}
|
|
1458
|
+
*/
|
|
1459
|
+
function fillEachMeta(str, meta) {
|
|
1460
|
+
return str.replace(/<span data-wd-each-meta="([a-z]+)"><\/span>/g, (_, name) => {
|
|
1461
|
+
const value = name in meta ? meta[name] : "";
|
|
1462
|
+
return `<span data-wd-each-meta="${name}">${escapeHtml(value ?? "")}</span>`;
|
|
1463
|
+
});
|
|
1133
1464
|
}
|
|
1134
1465
|
|
|
1135
1466
|
// Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
|
|
@@ -1138,9 +1469,10 @@ function fillTemplateString(template, item) {
|
|
|
1138
1469
|
/**
|
|
1139
1470
|
* @param {string} str
|
|
1140
1471
|
* @param {unknown} item
|
|
1472
|
+
* @param {Record<string, unknown>} [meta]
|
|
1141
1473
|
* @returns {string}
|
|
1142
1474
|
*/
|
|
1143
|
-
function fillEachIfRegions(str, item) {
|
|
1475
|
+
function fillEachIfRegions(str, item, meta = {}) {
|
|
1144
1476
|
const marker = '<span data-wd-each-if ';
|
|
1145
1477
|
let result = "";
|
|
1146
1478
|
let i = 0;
|
|
@@ -1149,7 +1481,7 @@ function fillEachIfRegions(str, item) {
|
|
|
1149
1481
|
if (start === -1) return result + str.slice(i);
|
|
1150
1482
|
result += str.slice(i, start);
|
|
1151
1483
|
const end = matchElement(str, start, "span");
|
|
1152
|
-
result += fillOneEachIf(str.slice(start, end), item);
|
|
1484
|
+
result += fillOneEachIf(str.slice(start, end), item, meta);
|
|
1153
1485
|
i = end;
|
|
1154
1486
|
}
|
|
1155
1487
|
}
|
|
@@ -1157,9 +1489,11 @@ function fillEachIfRegions(str, item) {
|
|
|
1157
1489
|
/**
|
|
1158
1490
|
* @param {string} region
|
|
1159
1491
|
* @param {unknown} item
|
|
1492
|
+
* @param {Record<string, unknown>} meta
|
|
1160
1493
|
* @returns {string}
|
|
1161
1494
|
*/
|
|
1162
|
-
function fillOneEachIf(region, item) {
|
|
1495
|
+
function fillOneEachIf(region, item, meta) {
|
|
1496
|
+
const metaMatch = region.match(/^<span data-wd-each-if data-wd-meta="([a-z]+)">/);
|
|
1163
1497
|
const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
|
|
1164
1498
|
const trueStart = region.indexOf("<template data-wd-if-true>");
|
|
1165
1499
|
const trueEnd = matchElement(region, trueStart, "template");
|
|
@@ -1169,9 +1503,10 @@ function fillOneEachIf(region, item) {
|
|
|
1169
1503
|
const close = "</template>".length;
|
|
1170
1504
|
const truthy = region.slice(trueStart + open, trueEnd - close);
|
|
1171
1505
|
const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
|
|
1172
|
-
const
|
|
1506
|
+
const test = metaMatch ? Boolean(meta[metaMatch[1]]) : Boolean(getPath(item, path ? path.split(".") : []));
|
|
1507
|
+
const branch = test ? truthy : falsy;
|
|
1173
1508
|
const head = region.slice(0, falseEnd);
|
|
1174
|
-
return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
|
|
1509
|
+
return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item, meta)}</span></span>`;
|
|
1175
1510
|
}
|
|
1176
1511
|
|
|
1177
1512
|
/**
|
|
@@ -1298,6 +1633,13 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
1298
1633
|
const segs = expr.split(".");
|
|
1299
1634
|
const head = segs[0];
|
|
1300
1635
|
|
|
1636
|
+
// Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
|
|
1637
|
+
if (LOOP_META[head]) {
|
|
1638
|
+
if (!ctx.loopMeta) throw new Error(`"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`);
|
|
1639
|
+
if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"></span>`; // reactive: filled per row
|
|
1640
|
+
// static: the value is in scope (injected by staticUnroll); fall through.
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1301
1643
|
if (ctx.loopItem && head === ctx.loopItem) {
|
|
1302
1644
|
const rest = segs.slice(1).join(".");
|
|
1303
1645
|
return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}></span>`;
|
|
@@ -1384,6 +1726,22 @@ function getPath(value, segments) {
|
|
|
1384
1726
|
return current;
|
|
1385
1727
|
}
|
|
1386
1728
|
|
|
1729
|
+
/**
|
|
1730
|
+
* Validate a dotted path shape, rejecting prototype-pollution segments at every
|
|
1731
|
+
* level. Shared by actions/loops/fetch. Throws with a corrective `Use:` hint.
|
|
1732
|
+
* @param {string} path Dotted path, e.g. `cart.items`.
|
|
1733
|
+
* @param {Ctx} ctx
|
|
1734
|
+
* @param {string} use Corrective suggestion for the error message.
|
|
1735
|
+
* @returns {string[]} The validated segments.
|
|
1736
|
+
*/
|
|
1737
|
+
function validatePath(path, ctx, use) {
|
|
1738
|
+
const segs = path.split(".");
|
|
1739
|
+
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1740
|
+
throw new Error(`Path segment in "${path}" is not allowed in ${ctx.file}. Use: ${use}`);
|
|
1741
|
+
}
|
|
1742
|
+
return segs;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1387
1745
|
/**
|
|
1388
1746
|
* Resolve a bare state name to its fully-qualified key, walking section scopes.
|
|
1389
1747
|
* @param {string} name
|
|
@@ -1405,35 +1763,56 @@ function resolveStateKey(name, ctx) {
|
|
|
1405
1763
|
/**
|
|
1406
1764
|
* A parsed `:button` action.
|
|
1407
1765
|
* @typedef {object} Action
|
|
1408
|
-
* @property {string} op Runtime operation (inc/dec/add/append/set/remove/append-row).
|
|
1409
|
-
* @property {string} target State key the action mutates.
|
|
1766
|
+
* @property {string} op Runtime operation (inc/dec/add/sub/append/prepend/set/toggle/member-toggle/remove/remove-value/clear/merge/delete/reset/append-row).
|
|
1767
|
+
* @property {string} target State key (possibly dotted) the action mutates.
|
|
1410
1768
|
* @property {unknown} [value] Literal value for value-carrying ops.
|
|
1411
1769
|
*/
|
|
1412
1770
|
|
|
1771
|
+
const ACTION_USE =
|
|
1772
|
+
'Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";".';
|
|
1773
|
+
|
|
1413
1774
|
/**
|
|
1414
|
-
* Parse a `:button` action expression into a validated `{ op, target, value }
|
|
1775
|
+
* Parse a `:button` action expression into a validated `{ op, target, value }`,
|
|
1776
|
+
* or — when `;`-separated — an ordered array of them applied in sequence.
|
|
1415
1777
|
* @param {string} raw
|
|
1416
1778
|
* @param {Ctx} ctx
|
|
1417
|
-
* @returns {Action}
|
|
1779
|
+
* @returns {Action | Action[]}
|
|
1418
1780
|
*/
|
|
1419
1781
|
function parseAction(raw, ctx) {
|
|
1420
|
-
const
|
|
1782
|
+
const parts = raw.split(";").map((p) => p.trim()).filter(Boolean);
|
|
1783
|
+
if (parts.length > 1) return parts.map((part) => parseSingleAction(part, raw, ctx));
|
|
1784
|
+
return parseSingleAction(raw.trim(), raw, ctx);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Parse one (non-`;`) action expression into a validated `{ op, target, value }`.
|
|
1789
|
+
* Targets may be dotted paths (`cart.count`); a bare name is a 1-segment path.
|
|
1790
|
+
* @param {string} expression
|
|
1791
|
+
* @param {string} raw The full original action source (for error context).
|
|
1792
|
+
* @param {Ctx} ctx
|
|
1793
|
+
* @returns {Action}
|
|
1794
|
+
*/
|
|
1795
|
+
function parseSingleAction(expression, raw, ctx) {
|
|
1796
|
+
const PATH = "[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*";
|
|
1421
1797
|
/** @param {string} name @returns {string} */
|
|
1422
1798
|
const resolveTarget = (name) => {
|
|
1423
|
-
const
|
|
1799
|
+
const segs = validatePath(name, ctx, ACTION_USE);
|
|
1800
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1424
1801
|
if (!key) {
|
|
1425
|
-
throw new Error(`Button action targets unknown state "${
|
|
1802
|
+
throw new Error(`Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
|
|
1426
1803
|
}
|
|
1427
|
-
return key;
|
|
1804
|
+
return [key, ...segs.slice(1)].join(".");
|
|
1428
1805
|
};
|
|
1429
1806
|
|
|
1430
|
-
const increment = expression.match(
|
|
1807
|
+
const increment = expression.match(new RegExp(`^(${PATH})\\+\\+$`));
|
|
1431
1808
|
if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
|
|
1432
|
-
const decrement = expression.match(
|
|
1809
|
+
const decrement = expression.match(new RegExp(`^(${PATH})--$`));
|
|
1433
1810
|
if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
|
|
1811
|
+
const sub = expression.match(new RegExp(`^(${PATH})\\s*-=\\s*(.+)$`));
|
|
1812
|
+
if (sub) return { op: "sub", target: resolveTarget(sub[1]), value: parseActionLiteral(sub[2]) };
|
|
1434
1813
|
// Per-row: carry the current loop item into another list — `cart += product`.
|
|
1435
1814
|
// Only inside a reactive @loop; the runtime resolves the row from the DOM.
|
|
1436
|
-
const add = expression.match(
|
|
1815
|
+
const add = expression.match(new RegExp(`^(${PATH})\\s*\\+=\\s*(.+)$`));
|
|
1437
1816
|
if (add) {
|
|
1438
1817
|
const rhs = add[2].trim();
|
|
1439
1818
|
if (ctx.loopItem && rhs === ctx.loopItem) {
|
|
@@ -1449,23 +1828,66 @@ function parseAction(raw, ctx) {
|
|
|
1449
1828
|
if (typeof value === "number") return { op: "add", target, value };
|
|
1450
1829
|
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`);
|
|
1451
1830
|
}
|
|
1831
|
+
// `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
|
|
1832
|
+
const toggle = expression.match(new RegExp(`^(${PATH})\\s+toggle(?:\\s+(.+))?$`));
|
|
1833
|
+
if (toggle) {
|
|
1834
|
+
const target = resolveTarget(toggle[1]);
|
|
1835
|
+
if (toggle[2] === undefined) return { op: "toggle", target };
|
|
1836
|
+
return { op: "member-toggle", target, value: parseActionLiteral(toggle[2]) };
|
|
1837
|
+
}
|
|
1838
|
+
const prepend = expression.match(new RegExp(`^(${PATH})\\s+(?:append|prepend)\\s+(.+)$`));
|
|
1839
|
+
if (prepend) {
|
|
1840
|
+
const op = expression.includes(" prepend ") ? "prepend" : "append";
|
|
1841
|
+
return { op, target: resolveTarget(prepend[1]), value: parseActionLiteral(prepend[2]) };
|
|
1842
|
+
}
|
|
1452
1843
|
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
1453
|
-
|
|
1844
|
+
// Disambiguation: operand IS the loop item name → `remove` (current row);
|
|
1845
|
+
// otherwise the operand is a value → `remove-value`.
|
|
1846
|
+
const remove = expression.match(new RegExp(`^(${PATH})\\s+remove\\s+(.+)$`));
|
|
1454
1847
|
if (remove) {
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
1462
|
-
throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
|
|
1848
|
+
const operand = remove[2].trim();
|
|
1849
|
+
if (ctx.loopItem && operand === ctx.loopItem) {
|
|
1850
|
+
if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
1851
|
+
throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
|
|
1852
|
+
}
|
|
1853
|
+
return { op: "remove", target: ctx.loopKey };
|
|
1463
1854
|
}
|
|
1464
|
-
return { op: "remove", target:
|
|
1855
|
+
return { op: "remove-value", target: resolveTarget(remove[1]), value: parseActionLiteral(operand) };
|
|
1465
1856
|
}
|
|
1466
|
-
const
|
|
1857
|
+
const clear = expression.match(new RegExp(`^(${PATH})\\s+clear$`));
|
|
1858
|
+
if (clear) return { op: "clear", target: resolveTarget(clear[1]) };
|
|
1859
|
+
const merge = expression.match(new RegExp(`^(${PATH})\\s+merge\\s+(.+)$`));
|
|
1860
|
+
if (merge) return { op: "merge", target: resolveTarget(merge[1]), value: parseMergeOperand(merge[2], ctx) };
|
|
1861
|
+
const del = expression.match(new RegExp(`^(${PATH})\\s+delete\\s+(.+)$`));
|
|
1862
|
+
if (del) return { op: "delete", target: resolveTarget(del[1]), value: parseActionLiteral(del[2]) };
|
|
1863
|
+
const reset = expression.match(new RegExp(`^(${PATH})\\s+reset$`));
|
|
1864
|
+
if (reset) return { op: "reset", target: resolveTarget(reset[1]) };
|
|
1865
|
+
// `name refetch` re-invokes the matching :fetch node. The target is the fetch
|
|
1866
|
+
// key (bare name); the runtime finds the [data-wd-fetch-key] node and re-runs.
|
|
1867
|
+
const refetch = expression.match(new RegExp(`^(${PATH})\\s+refetch$`));
|
|
1868
|
+
if (refetch) return { op: "refetch", target: resolveTarget(refetch[1]) };
|
|
1869
|
+
const assign = expression.match(new RegExp(`^(${PATH})\\s*=\\s*(.+)$`));
|
|
1467
1870
|
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
1468
|
-
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}.
|
|
1871
|
+
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* `merge` operand: either an inline JSON object literal, or a state/store key
|
|
1876
|
+
* name (emitted as a string the runtime resolves via getPath).
|
|
1877
|
+
* @param {string} raw
|
|
1878
|
+
* @param {Ctx} ctx
|
|
1879
|
+
* @returns {unknown}
|
|
1880
|
+
*/
|
|
1881
|
+
function parseMergeOperand(raw, ctx) {
|
|
1882
|
+
const value = raw.trim();
|
|
1883
|
+
if (/^[A-Za-z_$][\w$]*$/.test(value)) {
|
|
1884
|
+
const key = resolveStateKey(value, ctx);
|
|
1885
|
+
if (!key) throw new Error(`Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`);
|
|
1886
|
+
return key;
|
|
1887
|
+
}
|
|
1888
|
+
const parsed = parseActionLiteral(value);
|
|
1889
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1890
|
+
throw new Error(`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object).`);
|
|
1469
1891
|
}
|
|
1470
1892
|
|
|
1471
1893
|
/**
|
|
@@ -1483,10 +1905,10 @@ function parseActionLiteral(raw) {
|
|
|
1483
1905
|
try {
|
|
1484
1906
|
return JSON.parse(value);
|
|
1485
1907
|
} catch {
|
|
1486
|
-
throw new Error(`Unsupported action literal "${raw}". Use a quoted string, number, boolean, null, or valid JSON.`);
|
|
1908
|
+
throw new Error(`Unsupported action literal "${raw}". Use: a quoted string, number, boolean, null, or valid JSON.`);
|
|
1487
1909
|
}
|
|
1488
1910
|
}
|
|
1489
|
-
throw new Error(`Unsupported action literal "${raw}". Use a quoted string, number, boolean, null, or valid JSON.`);
|
|
1911
|
+
throw new Error(`Unsupported action literal "${raw}". Use: a quoted string, number, boolean, null, or valid JSON.`);
|
|
1490
1912
|
}
|
|
1491
1913
|
|
|
1492
1914
|
// ---------------------------------------------------------------------------
|