@zvndev/darkmown 0.18.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.
- package/CHANGELOG.md +22 -0
- package/README.md +60 -3
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +9 -0
- package/package.json +23 -5
- package/site/_/nav.wd +1 -0
- package/site/pages/loops.wd +22 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +66 -10
- package/src/cli.js +216 -77
- package/src/compiler/body.js +432 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1149 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +189 -0
- package/src/compiler/loops.js +644 -0
- package/src/compiler/markdown.js +233 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +281 -0
- package/src/compiler.js +26 -2753
- package/src/dev.js +3 -1
- package/src/headers.js +93 -0
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +83 -59
- package/src/scaffold.js +158 -131
- package/src/skin.js +62 -3
- package/src/statics.js +19 -2
- package/site/_/og.png +0 -0
|
@@ -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
|
+
}
|