@zvndev/darkmown 0.19.0 → 0.20.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.
@@ -0,0 +1,644 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Loop pipeline: parse the `@loop` header + clauses, resolve the source (JSON
3
+ // file / in-scope value / declared :state / item-relative), and emit either a
4
+ // build-time static unroll (zero-JS) or a reactive `data-wd-loop` region whose
5
+ // row <template> + initial paint the runtime reconciles. Also the row-template
6
+ // string fill used for the initial paint (text binds, per-row `:if`, meta).
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import fs from "node:fs";
10
+ import { compileBody } from "./body.js";
11
+ import { createScope } from "./context.js";
12
+ import { resolveInclude } from "./includes.js";
13
+ import { escapeHtml, getPath, lookupPath, resolveStateKey, stripQuotes } from "./interpolation.js";
14
+ import { compilePredicate, evalPredicate } from "./predicates.js";
15
+
16
+ /**
17
+ * @typedef {import("./context.js").Ctx} Ctx
18
+ * @typedef {import("./context.js").Predicate} Predicate
19
+ * @typedef {import("./context.js").NumArg} NumArg
20
+ * @typedef {import("./context.js").LoopOpts} LoopOpts
21
+ */
22
+
23
+ // The fixed corrective suggestion shown for any malformed @loop header.
24
+ const LOOP_USAGE = "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N]";
25
+
26
+ /**
27
+ * Parse the optional clause tail of a `@loop` header in FIXED order:
28
+ * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`.
29
+ * @param {string} tail Everything after `@loop src into item`.
30
+ * @param {string} itemName
31
+ * @param {Ctx} ctx
32
+ * @returns {{ where: string|null, sort: {key:string,dir:string}|null, reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
33
+ */
34
+ function parseLoopClauses(tail, itemName, ctx) {
35
+ // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
36
+ // sort by). Parsing from the tail avoids mistaking a state operand named
37
+ // `limit`/`offset` inside the `where` predicate for a clause keyword. Whatever
38
+ // remains must be `where …` (or empty); a clause keyword surviving in the
39
+ // remainder means the author wrote the clauses out of order.
40
+ let s = tail.trim();
41
+ /** @type {{key:string,dir:string}|null} */ let sort = null;
42
+ let reverse = false;
43
+ /** @type {NumArg|null} */ let offset = null;
44
+ /** @type {NumArg|null} */ let limit = null;
45
+ let refsState = false;
46
+ /** @returns {never} */
47
+ const bad = () => {
48
+ throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
49
+ };
50
+
51
+ let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
52
+ if (m) {
53
+ limit = parseNumArg(m[2], ctx);
54
+ refsState = refsState || limit.kind === "key";
55
+ s = s.slice(0, s.length - m[0].length + m[1].length).trim();
56
+ }
57
+
58
+ m = s.match(/(^|\s)offset\s+(\d+|[A-Za-z_$][\w$]*)$/);
59
+ if (m) {
60
+ offset = parseNumArg(m[2], ctx);
61
+ refsState = refsState || offset.kind === "key";
62
+ s = s.slice(0, s.length - m[0].length + m[1].length).trim();
63
+ }
64
+
65
+ m = s.match(/(^|\s)reverse$/);
66
+ if (m) {
67
+ reverse = true;
68
+ s = s.slice(0, s.length - m[0].length + m[1].length).trim();
69
+ }
70
+
71
+ m = s.match(/(^|\s)sort\s+by\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(asc|desc))?$/);
72
+ if (m) {
73
+ const segs = m[2].split(".");
74
+ if (segs[0] !== itemName)
75
+ throw new Error(
76
+ `@loop sort key "${m[2]}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`
77
+ );
78
+ const rest = segs.slice(1);
79
+ if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
80
+ throw new Error(`Sort key "${m[2]}" is not allowed in @loop (${ctx.file})`);
81
+ }
82
+ sort = { key: rest.join("."), dir: m[3] || "asc" };
83
+ s = s.slice(0, s.length - m[0].length + m[1].length).trim();
84
+ }
85
+
86
+ /** @type {string|null} */ let where = null;
87
+ if (s.length) {
88
+ m = s.match(/^where\s+(.+)$/);
89
+ if (!m) return bad(); // leftover non-where text → clauses written out of order
90
+ where = m[1].trim();
91
+ // `sort by`/`reverse` are never valid where operands; their presence in the
92
+ // predicate means a clause was written before `where` finished (wrong order).
93
+ if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
94
+ }
95
+ return { where, sort, reverse, offset, limit, refsState };
96
+ }
97
+
98
+ /**
99
+ * @param {string} tok
100
+ * @param {Ctx} ctx
101
+ * @returns {NumArg}
102
+ */
103
+ function parseNumArg(tok, ctx) {
104
+ // Callers (parseLoopClauses) only ever pass a token the clause regex already
105
+ // constrained to `\d+` or an identifier, so the two branches below are
106
+ // exhaustive — a bare integer literal, or a name that must resolve to :state.
107
+ if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
108
+ const key = resolveStateKey(tok, ctx);
109
+ if (!key)
110
+ throw new Error(
111
+ `@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`
112
+ );
113
+ return { kind: "key", value: key };
114
+ }
115
+
116
+ /** @param {NumArg} arg @returns {string} */
117
+ function numArgAttr(arg) {
118
+ return arg.kind === "literal" ? String(arg.value) : `key:${arg.value}`;
119
+ }
120
+
121
+ /**
122
+ * @param {string} line
123
+ * @param {string[]} bodyLines
124
+ * @param {string[] | null} emptyLines
125
+ * @param {Ctx} ctx
126
+ * @returns {string}
127
+ */
128
+ export function handleLoop(line, bodyLines, emptyLines, ctx) {
129
+ const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
130
+ if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
131
+ const source = stripQuotes(match[1].trim());
132
+ const itemName = match[2];
133
+ const clauses = match[3]
134
+ ? parseLoopClauses(match[3], itemName, ctx)
135
+ : { where: null, sort: null, reverse: false, offset: null, limit: null, refsState: false };
136
+ const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
137
+ /** @type {LoopOpts} */
138
+ const opts = {
139
+ where,
140
+ sort: clauses.sort,
141
+ reverse: clauses.reverse,
142
+ offset: clauses.offset,
143
+ limit: clauses.limit,
144
+ empty: emptyLines,
145
+ clauseRefsState: clauses.refsState
146
+ };
147
+
148
+ if (
149
+ source.startsWith("/") ||
150
+ source.startsWith("./") ||
151
+ source.startsWith("../") ||
152
+ source.endsWith(".json")
153
+ ) {
154
+ const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
155
+ const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
156
+ if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
157
+ return loopOverData(rows, itemName, bodyLines, ctx, opts);
158
+ }
159
+
160
+ const resolved = lookupPath(source, ctx);
161
+ if (resolved.found) {
162
+ if (!Array.isArray(resolved.value)) {
163
+ throw new Error(
164
+ `@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`
165
+ );
166
+ }
167
+ return loopOverData(resolved.value, itemName, bodyLines, ctx, opts);
168
+ }
169
+
170
+ // A bare name OR a dotted path resolving to declared :state (e.g. team.members).
171
+ const segs = source.split(".");
172
+ if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
173
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
174
+ throw new Error(`@loop source "${source}" is not allowed in ${ctx.file}`);
175
+ }
176
+ const key = resolveStateKey(segs[0], ctx);
177
+ if (key) {
178
+ const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
179
+ return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
180
+ }
181
+ // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
182
+ // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
183
+ if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
184
+ return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
185
+ }
186
+ }
187
+
188
+ throw new Error(
189
+ `@loop source "${source}" in ${ctx.file} was not found. Loop over a JSON file (@loop /data.json into row), an in-scope value, or a :state list.`
190
+ );
191
+ }
192
+
193
+ // A static source (JSON file / in-scope value). No `where`, or a `where` that
194
+ // only reads the loop item → filter at build time and stay zero-JS. A `where`
195
+ // that reads :state → becomes a reactive filtered loop with the rows baked in.
196
+
197
+ /**
198
+ * Build-time pipeline over already-resolved rows: filter → sort → reverse →
199
+ * offset → limit. Used when source + every clause arg are static.
200
+ * @param {unknown[]} rows
201
+ * @param {Predicate | null} where
202
+ * @param {LoopOpts} opts
203
+ * @param {Ctx} ctx
204
+ * @returns {unknown[]}
205
+ */
206
+ function pipelineRows(rows, where, opts, ctx) {
207
+ let list = where ? rows.filter((row) => evalPredicate(where.body, row, ctx)) : rows.slice();
208
+ if (opts.sort) {
209
+ const k = opts.sort.key;
210
+ const dir = opts.sort.dir === "desc" ? -1 : 1;
211
+ list = list
212
+ .map((value, index) => ({ value, index }))
213
+ .sort((a, b) => {
214
+ const av = getPath(a.value, k ? k.split(".") : []);
215
+ const bv = getPath(b.value, k ? k.split(".") : []);
216
+ const c =
217
+ typeof av === "number" && typeof bv === "number"
218
+ ? av - bv
219
+ : String(av).localeCompare(String(bv));
220
+ return (c || a.index - b.index) * dir;
221
+ })
222
+ .map((w) => w.value);
223
+ }
224
+ if (opts.reverse) list.reverse();
225
+ const off = opts.offset && opts.offset.kind === "literal" ? opts.offset.value : 0;
226
+ if (off) list = list.slice(off);
227
+ if (opts.limit && opts.limit.kind === "literal") list = list.slice(0, opts.limit.value);
228
+ return list;
229
+ }
230
+
231
+ /**
232
+ * @param {unknown[]} rows
233
+ * @param {string} itemName
234
+ * @param {string[]} bodyLines
235
+ * @param {Ctx} ctx
236
+ * @param {LoopOpts} opts
237
+ * @returns {string}
238
+ */
239
+ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
240
+ // Reactive when the where reads state OR an offset/limit references a state key.
241
+ const reactive = (opts.where && opts.where.refsState) || opts.clauseRefsState;
242
+ if (reactive) return reactiveLoop(null, itemName, bodyLines, ctx, { ...opts, data: rows });
243
+ return staticUnroll(
244
+ pipelineRows(rows, opts.where, opts, ctx),
245
+ itemName,
246
+ bodyLines,
247
+ ctx,
248
+ opts.empty
249
+ );
250
+ }
251
+
252
+ /**
253
+ * @param {unknown[]} rows
254
+ * @param {string} itemName
255
+ * @param {string[]} bodyLines
256
+ * @param {Ctx} ctx
257
+ * @param {string[] | null} [empty] Empty-branch body, rendered when 0 rows.
258
+ * @returns {string}
259
+ */
260
+ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
261
+ if (rows.length === 0 && empty) return compileBody(empty, { ...ctx, loopMeta: true });
262
+ const out = [];
263
+ const count = rows.length;
264
+ for (let i = 0; i < count; i++) {
265
+ const meta = {
266
+ $index: i,
267
+ $number: i + 1,
268
+ $first: i === 0,
269
+ $last: i === count - 1,
270
+ $count: count
271
+ };
272
+ const rowCtx = {
273
+ ...ctx,
274
+ loopMeta: true,
275
+ scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta })
276
+ };
277
+ out.push(compileBody(bodyLines, rowCtx));
278
+ }
279
+ return out.join("\n");
280
+ }
281
+
282
+ /**
283
+ * Emit a reactive loop region (template + initial rows + clause config) for the
284
+ * runtime. `key` may be a dotted path (e.g. `team.members`); the runtime reads
285
+ * it via getPath. `opts` carries where/sort/reverse/offset/limit/empty.
286
+ * @param {string | null} key State key/path of the list, or null for baked data.
287
+ * @param {string} itemName
288
+ * @param {string[]} bodyLines
289
+ * @param {Ctx} ctx
290
+ * @param {Partial<LoopOpts> & { data?: unknown[] } | null} [opts]
291
+ * @returns {string}
292
+ */
293
+ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
294
+ ctx.comp.assets.runtime = true;
295
+ /** @type {Ctx} */
296
+ const templateCtx = {
297
+ ...ctx,
298
+ loopItem: itemName,
299
+ loopKey: key ?? undefined,
300
+ loopMeta: true,
301
+ scope: createScope(ctx.scope)
302
+ };
303
+ const templateHtml = compileBody(bodyLines, templateCtx).trim();
304
+
305
+ const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
306
+
307
+ const where = opts?.where || null;
308
+ const sort = opts?.sort || null;
309
+ const reverse = opts?.reverse || false;
310
+ const offset = opts?.offset || null;
311
+ const limit = opts?.limit || null;
312
+ const emptyLines = opts?.empty || null;
313
+ const baked = opts?.data || null; // rows for a static source filtered by state
314
+ // Resolve the (possibly dotted) source for the initial paint.
315
+ const segs = key ? key.split(".") : [];
316
+ const stateRows = key ? getPath(ctx.comp.state.get(segs[0]), segs.slice(1)) : null;
317
+ /** @type {unknown[]} */
318
+ const allRows = key ? (Array.isArray(stateRows) ? stateRows : []) : baked || [];
319
+ // Initial paint runs the same pipeline the runtime will, reading state-key
320
+ // offset/limit from their current declared values.
321
+ /** @param {NumArg|null} a */
322
+ const num = (a) =>
323
+ a ? (a.kind === "literal" ? a.value : Number(ctx.comp.state.get(a.value) ?? 0)) : null;
324
+ const rows = pipelineRows(
325
+ allRows,
326
+ where,
327
+ {
328
+ where,
329
+ sort,
330
+ reverse,
331
+ offset:
332
+ offset && offset.kind === "key" ? { kind: "literal", value: num(offset) || 0 } : offset,
333
+ limit:
334
+ limit && limit.kind === "key"
335
+ ? { kind: "literal", value: num(limit) ?? allRows.length }
336
+ : limit,
337
+ empty: null,
338
+ clauseRefsState: false
339
+ },
340
+ ctx
341
+ );
342
+ /** @type {Map<string, number>} */
343
+ const counts = new Map();
344
+ const count = rows.length;
345
+ // Inner ITEM-RELATIVE loop regions are filled per row by the runtime, not at
346
+ // build time; mask them so the outer initial-paint string fill leaves their
347
+ // pristine <template>/empty-out markup untouched, then restore.
348
+ const { masked, regions } = maskItemLoops(itemTemplate);
349
+ const initial = rows
350
+ .map((/** @type {unknown} */ item, i) => {
351
+ const itemKey = loopKeyOf(item, counts);
352
+ const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
353
+ return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
354
+ })
355
+ .join("");
356
+
357
+ const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
358
+ const initialOut =
359
+ count === 0 && emptyLines
360
+ ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()
361
+ : initial;
362
+
363
+ const attrs =
364
+ loopClauseAttrs({ where, sort, reverse, offset, limit }) +
365
+ (baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
366
+ return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
367
+ }
368
+
369
+ /**
370
+ * Unwrap a compiled row body into a wrapper tag + row template. A body that is a
371
+ * single-`<li>` `<ul>`/`<ol>` becomes an `<li>` row under that list wrapper;
372
+ * anything else is wrapped in a `<div data-wd-loop-piece>` under a `<div>`. Shared
373
+ * by top-level reactive loops and item-relative (nested) loops.
374
+ * @param {string} templateHtml
375
+ * @returns {{ wrapperTag: string, itemTemplate: string }}
376
+ */
377
+ function wrapRowTemplate(templateHtml) {
378
+ const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
379
+ if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
380
+ return { wrapperTag: listMatch[1], itemTemplate: listMatch[2].trim() };
381
+ }
382
+ return { wrapperTag: "div", itemTemplate: `<div data-wd-loop-piece>${templateHtml}</div>` };
383
+ }
384
+
385
+ /**
386
+ * Compile an `@empty` branch body into a `<template data-wd-loop-empty>` (or "" when
387
+ * there is no empty branch). Shared by top-level and item-relative loops.
388
+ * @param {string[] | null} emptyLines
389
+ * @param {Ctx} ctx
390
+ * @returns {string}
391
+ */
392
+ function loopEmptyTemplate(emptyLines, ctx) {
393
+ return emptyLines
394
+ ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>`
395
+ : "";
396
+ }
397
+
398
+ /**
399
+ * Serialize the shared `where`/`sort`/`reverse`/`offset`/`limit` clause config to
400
+ * the `data-wd-loop-*` attribute string consumed by the runtime pipeline. Shared
401
+ * by top-level reactive loops and item-relative (nested) loops.
402
+ * @param {Pick<LoopOpts, "where" | "sort" | "reverse" | "offset" | "limit">} c
403
+ * @returns {string}
404
+ */
405
+ function loopClauseAttrs(c) {
406
+ return (
407
+ (c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
408
+ (c.sort
409
+ ? ` data-wd-loop-sort="${escapeHtml(c.sort.key)}" data-wd-loop-sort-dir="${c.sort.dir}"`
410
+ : "") +
411
+ (c.reverse ? ` data-wd-loop-reverse` : "") +
412
+ (c.offset ? ` data-wd-loop-offset="${numArgAttr(c.offset)}"` : "") +
413
+ (c.limit ? ` data-wd-loop-limit="${numArgAttr(c.limit)}"` : "")
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Emit ONE level of nested reactive loop: a `@loop <outerItem>.<path> into x`
419
+ * whose rows are read off the enclosing loop row at runtime. The region carries
420
+ * its own row <template> + clause config but, unlike a top-level loop, its source
421
+ * is the relative `path` (not a global state key) — `fillItem` resolves the rows
422
+ * off the current outer item and reconciles per row. The output starts empty; the
423
+ * runtime fills it on first render (the page already ships the runtime).
424
+ * @param {string} path Dotted sub-path off the outer item (e.g. `items`).
425
+ * @param {string} itemName Inner loop item name.
426
+ * @param {string[]} bodyLines Inner loop body.
427
+ * @param {Ctx} ctx
428
+ * @param {Partial<LoopOpts>} opts
429
+ * @returns {string}
430
+ */
431
+ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
432
+ /** @type {Ctx} */
433
+ // loopKey: undefined — an item-relative loop's source is a path off the outer
434
+ // row, not a top-level state key, so a per-row `remove` inside it has no valid
435
+ // target and must be rejected (rather than inheriting the outer loop's key).
436
+ const templateCtx = {
437
+ ...ctx,
438
+ loopItem: itemName,
439
+ loopKey: undefined,
440
+ loopMeta: true,
441
+ scope: createScope(ctx.scope)
442
+ };
443
+ const templateHtml = compileBody(bodyLines, templateCtx).trim();
444
+
445
+ const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
446
+
447
+ const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
448
+ const attrs = loopClauseAttrs({
449
+ where: opts.where || null,
450
+ sort: opts.sort || null,
451
+ reverse: opts.reverse || false,
452
+ offset: opts.offset || null,
453
+ limit: opts.limit || null
454
+ });
455
+ return `<div data-wd-loop-item="${escapeHtml(path)}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out></${wrapperTag}></div>`;
456
+ }
457
+
458
+ /**
459
+ * Replace each top-level `data-wd-loop-item` region in a row template with an
460
+ * opaque placeholder so the outer initial-paint string fill cannot reach inside
461
+ * its pristine markup. Returns the masked string and the excised regions in order.
462
+ * @param {string} template
463
+ * @returns {{ masked: string, regions: string[] }}
464
+ */
465
+ function maskItemLoops(template) {
466
+ const marker = "<div data-wd-loop-item=";
467
+ /** @type {string[]} */
468
+ const regions = [];
469
+ let masked = "";
470
+ let i = 0;
471
+ for (;;) {
472
+ const start = template.indexOf(marker, i);
473
+ if (start === -1) return { masked: masked + template.slice(i), regions };
474
+ const end = matchElement(template, start, "div");
475
+ masked += template.slice(i, start) + `\x00WDLI${regions.length}\x00`;
476
+ regions.push(template.slice(start, end));
477
+ i = end;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Restore the masked `data-wd-loop-item` regions into a filled row string.
483
+ * @param {string} str
484
+ * @param {string[]} regions
485
+ * @returns {string}
486
+ */
487
+ function unmaskItemLoops(str, regions) {
488
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: \x00 is the internal nested-loop sentinel (paired with the mask above); it cannot appear in user content.
489
+ return regions.length ? str.replace(/\x00WDLI(\d+)\x00/g, (_, n) => regions[Number(n)]) : str;
490
+ }
491
+
492
+ /**
493
+ * @param {string} template
494
+ * @param {string} itemKey
495
+ * @returns {string}
496
+ */
497
+ function withLoopKey(template, itemKey) {
498
+ return template.replace(
499
+ /^<([a-zA-Z][a-zA-Z0-9-]*)/,
500
+ `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`
501
+ );
502
+ }
503
+
504
+ /**
505
+ * @param {string} template
506
+ * @param {unknown} item
507
+ * @param {Record<string, unknown>} [meta] Per-row meta values for the initial paint.
508
+ * @returns {string}
509
+ */
510
+ function fillTemplateString(template, item, meta = {}) {
511
+ // Resolve per-item :if regions first (recursively, so nested conditionals are
512
+ // pre-rendered for the initial paint), then fill text binds and meta markers.
513
+ return fillEachMeta(fillEachText(fillEachIfRegions(template, item, meta), item), meta);
514
+ }
515
+
516
+ /**
517
+ * Fill `<span data-wd-each-meta="…">` markers with their per-row value.
518
+ * @param {string} str
519
+ * @param {Record<string, unknown>} meta
520
+ * @returns {string}
521
+ */
522
+ function fillEachMeta(str, meta) {
523
+ return str.replace(/<span data-wd-each-meta="([a-z]+)"><\/span>/g, (_, name) => {
524
+ const value = name in meta ? meta[name] : "";
525
+ return `<span data-wd-each-meta="${name}">${escapeHtml(value ?? "")}</span>`;
526
+ });
527
+ }
528
+
529
+ // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
530
+ // chosen branch is recursed so nested conditionals resolve too. The <template>
531
+ // markup is left pristine so the runtime can keep toggling branches.
532
+ /**
533
+ * @param {string} str
534
+ * @param {unknown} item
535
+ * @param {Record<string, unknown>} [meta]
536
+ * @returns {string}
537
+ */
538
+ function fillEachIfRegions(str, item, meta = {}) {
539
+ const marker = "<span data-wd-each-if ";
540
+ let result = "";
541
+ let i = 0;
542
+ for (;;) {
543
+ const start = str.indexOf(marker, i);
544
+ if (start === -1) return result + str.slice(i);
545
+ result += str.slice(i, start);
546
+ const end = matchElement(str, start, "span");
547
+ result += fillOneEachIf(str.slice(start, end), item, meta);
548
+ i = end;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * @param {string} region
554
+ * @param {unknown} item
555
+ * @param {Record<string, unknown>} meta
556
+ * @returns {string}
557
+ */
558
+ function fillOneEachIf(region, item, meta) {
559
+ const metaMatch = region.match(/^<span data-wd-each-if data-wd-meta="([a-z]+)">/);
560
+ const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
561
+ const trueStart = region.indexOf("<template data-wd-if-true>");
562
+ const trueEnd = matchElement(region, trueStart, "template");
563
+ const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
564
+ const falseEnd = matchElement(region, falseStart, "template");
565
+ const open = "<template data-wd-if-true>".length;
566
+ const close = "</template>".length;
567
+ const truthy = region.slice(trueStart + open, trueEnd - close);
568
+ const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
569
+ const test = metaMatch
570
+ ? Boolean(meta[metaMatch[1]])
571
+ : Boolean(getPath(item, path ? path.split(".") : []));
572
+ const branch = test ? truthy : falsy;
573
+ const head = region.slice(0, falseEnd);
574
+ return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item, meta)}</span></span>`;
575
+ }
576
+
577
+ /**
578
+ * @param {string} str
579
+ * @param {unknown} item
580
+ * @returns {string}
581
+ */
582
+ function fillEachText(str, item) {
583
+ return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
584
+ const value = p ? getPath(item, p.split(".")) : item;
585
+ const pathAttr = p ? ` data-wd-path="${p}"` : "";
586
+ return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
587
+ });
588
+ }
589
+
590
+ // Return the index just past the balanced close of the element of `tag` that
591
+ // begins at `start`. Counts nested same-tag opens/closes so regions that embed
592
+ // their own spans/templates (nested :if) match correctly. Falls back to the end
593
+ // of the string for an unbalanced (never-closed) region — defensive, since the
594
+ // compiler only feeds it its own balanced output. Exported for that contract to
595
+ // be unit-tested directly.
596
+ /**
597
+ * @param {string} str
598
+ * @param {number} start
599
+ * @param {string} tag
600
+ * @returns {number}
601
+ */
602
+ export function matchElement(str, start, tag) {
603
+ const openPrefix = `<${tag}`;
604
+ const closeTag = `</${tag}>`;
605
+ let depth = 0;
606
+ let i = start;
607
+ while (i < str.length) {
608
+ if (
609
+ str.startsWith(openPrefix, i) &&
610
+ (str[i + openPrefix.length] === " " || str[i + openPrefix.length] === ">")
611
+ ) {
612
+ depth++;
613
+ const gt = str.indexOf(">", i);
614
+ i = gt === -1 ? str.length : gt + 1;
615
+ } else if (str.startsWith(closeTag, i)) {
616
+ depth--;
617
+ i += closeTag.length;
618
+ if (depth === 0) return i;
619
+ } else {
620
+ i++;
621
+ }
622
+ }
623
+ return str.length;
624
+ }
625
+
626
+ /**
627
+ * Stable per-render key for a loop row, disambiguating duplicates with `#n`.
628
+ * @param {unknown} item
629
+ * @param {Map<string, number>} counts Mutable seen-count accumulator.
630
+ * @returns {string}
631
+ */
632
+ export function loopKeyOf(item, counts) {
633
+ const base =
634
+ item && typeof item === "object"
635
+ ? String(
636
+ /** @type {Record<string, unknown>} */ (item).id ??
637
+ /** @type {Record<string, unknown>} */ (item).key ??
638
+ JSON.stringify(item)
639
+ )
640
+ : String(item);
641
+ const seen = counts.get(base) || 0;
642
+ counts.set(base, seen + 1);
643
+ return seen ? `${base}#${seen}` : base;
644
+ }