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