sjabloon 0.8.0 → 0.9.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/lib/core.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * backs every entry, so the WeakSet below authenticates diagnostics across all
9
9
  * of them.
10
10
  */
11
- import { compile, isDiagnostic as isXprsnDiagnostic } from 'xprsn';
11
+ import { compile, isDiagnostic as isXprsnDiagnostic, relocate as relocateXprsn } from "xprsn";
12
12
 
13
13
  /**
14
14
  * @import { SjabloonDiagnostic, SjabloonErrorCode, SjabloonFunctions, SjabloonRenderer, SjabloonValues } from './types.js'
@@ -44,42 +44,67 @@ const owns = DIAGNOSTICS.has.bind(DIAGNOSTICS);
44
44
  * @param {unknown} error Any thrown value.
45
45
  * @returns {error is SjabloonDiagnostic} Whether `error` is an authentic sjabloon diagnostic.
46
46
  */
47
- export const isDiagnostic = error => owns(error);
47
+ export const isDiagnostic = owns;
48
48
 
49
- // Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
50
- // `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
51
- // off once }}} is gone so {{{...}}×N does not rescan to EOF (stays O(n)).
52
49
  /**
53
- * @param {string} s
54
- * @returns {Tok[]}
50
+ * Intrinsics captured at module load, exactly as `mark` is: a copy is built
51
+ * from a captured prototype table rather than through the original's
52
+ * `constructor`, so replacing a prototype's `constructor` cannot make
53
+ * `relocate` mint an authenticated value that is not an Error. Only
54
+ * `SyntaxError` is listed because `fault` mints nothing else; every other
55
+ * class a sjabloon diagnostic can carry arrives via xprsn and is relocated by
56
+ * xprsn above.
55
57
  */
56
- let lex = s => {
57
- const out = /** @type {Tok[]} */ ([]);
58
- for (let i = 0, triple = 1; i < s.length; ) {
59
- const a = s.indexOf('{{', i);
60
- if (a < 0) { out.push([0, s.slice(i)]); break; }
61
- if (a > i) out.push([0, s.slice(i, a)]);
62
- let raw = +(s[a + 2] === '{'), p = a + 2 + raw, l = s[p] === '-', b = -1;
63
- if (l) p++;
64
- if (raw && triple) { b = s.indexOf('}}}', p); if (b < 0) triple = 0; }
65
- if (b < 0) {
66
- if (raw) { raw = 0; p = a + 2; l = s[p] === '-'; if (l) p++; }
67
- b = s.indexOf('}}', p);
68
- }
69
- if (b < 0) { out.push([0, s.slice(a)]); break; }
70
- const r = b > p && s[b - 1] === '-';
71
- const q = r ? b - 1 : b, whole = s.slice(p, q), body = whole.trim();
72
- const start = p + whole.length - whole.trimStart().length, end = b + 2 + raw;
73
- const t = [raw ? 1 : 2, body, a, end, start];
74
- const prev = out.at(-1);
75
- if (l && prev?.[0] === 0 && prev[1]) prev[1] = prev[1].trimEnd();
76
- out.push(t);
77
- i = end;
78
- if (r) while (/\s/.test(s[i])) i++;
79
- }
80
- return out;
58
+ const DESCS = Object.getOwnPropertyDescriptors,
59
+ DEFINE = Object.defineProperties,
60
+ PROTO = Object.getPrototypeOf,
61
+ SYNTAX = SyntaxError.prototype;
62
+ /** @type {(p: any) => (msg: string) => Error} */
63
+ const kindOf = (p) => (p === SYNTAX ? SyntaxError : Error);
64
+ /**
65
+ * Copy a diagnostic into an embedder's coordinates.
66
+ *
67
+ * Relocation lives here, beside the authentication it has to satisfy: the copy
68
+ * is registered in the same WeakSet as the original, so it passes
69
+ * `isDiagnostic`. The original is never mutated, and every own field comes
70
+ * across by descriptor — the frozen `blocks` context stays frozen and
71
+ * non-writable so a field added here is never a field an embedder forgets.
72
+ *
73
+ * @param {unknown} diag A diagnostic produced or translated by sjabloon.
74
+ * @param {{ prefix?: string, offset?: number }} [opts] `prefix` is prepended to
75
+ * the message verbatim; `offset` shifts `start` and `end`.
76
+ * @returns {SjabloonDiagnostic} The relocated copy.
77
+ * @throws {TypeError} When `diag` is not a sjabloon diagnostic.
78
+ */
79
+ export const relocate = (diag, { prefix = "", offset = 0 } = {}) => {
80
+ if (!isDiagnostic(diag)) throw TypeError("Not a sjabloon diagnostic");
81
+ // Most diagnostics here are xprsn errors translated into template
82
+ // coordinates, and those are registered in both stores. Letting xprsn make
83
+ // that half of the copy is what keeps the copy registered in both — the same
84
+ // reason relocation lives with authentication in the first place. Bare
85
+ // `mark`, not `attach`: the descriptors already carried `blocks` across,
86
+ // and attach would redefine a non-configurable property.
87
+ if (isXprsnDiagnostic(diag)) {
88
+ const moved = relocateXprsn(diag, { prefix, offset });
89
+ return (mark(moved), /** @type {SjabloonDiagnostic} */ (moved));
90
+ }
91
+ let d = /** @type {any} */ (diag),
92
+ props = DESCS(d),
93
+ copy = kindOf(PROTO(d))(prefix + d.message);
94
+ delete props.message;
95
+ delete props.stack;
96
+ if (props.start) {
97
+ props.start.value += offset;
98
+ props.end.value += offset;
99
+ }
100
+ DEFINE(copy, props);
101
+ return (mark(copy), /** @type {SjabloonDiagnostic} */ (copy));
81
102
  };
82
103
 
104
+ // Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
105
+ // `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
106
+ // off once }}} is gone so {{{...}}×N does not rescan to EOF (stays O(n)).
107
+
83
108
  // One shared prototype for renders that omit `values` — the shape an embedder
84
109
  // passing `{ root, item }` hits on every cell. A fresh `{}` here would give each
85
110
  // wrapper its own hidden class, so lookups go megamorphic and such a render
@@ -88,11 +113,10 @@ let lex = s => {
88
113
  const EMPTY = Object.freeze({});
89
114
 
90
115
  // Shared parser state; parsing is synchronous so this is safe.
91
- // `nms` collects free variables, `fnms` the registry functions called.
92
116
  // LIT/VAL/RAW are the compiling profile's node builders — read only while
93
117
  // parsing, never at render time, so the hot path stays free of indirection.
94
118
  /** @type {Tok[]} */
95
- let toks;
119
+ let tokens;
96
120
  /** @type {number} */
97
121
  let i;
98
122
  /** @type {SjabloonFunctions | undefined} */
@@ -102,13 +126,16 @@ let last;
102
126
  /** @type {string[]} */
103
127
  let bound;
104
128
  /** @type {Set<string>} */
105
- let nms;
129
+ let names;
106
130
  /** @type {Set<string>} */
107
- let fnms;
131
+ let functions;
108
132
  /** @type {string} */
109
- let src;
110
- /** @type {any[]} */
133
+ let source;
134
+ /** @type {{ type: string, start: number, end: number }[]} */
111
135
  let blocks;
136
+ /** Nesting budget shared by `#if`/`#each`/`#elif` (see DEPTH). */
137
+ /** @type {number} */
138
+ let nest;
112
139
  // The profile's node builders. `any` rather than `Node<A>`: `make()` is generic
113
140
  // per edition, but these are module-level and shared across all three, so no
114
141
  // single A applies here.
@@ -118,6 +145,61 @@ let LIT;
118
145
  let VAL;
119
146
  /** @type {any} */
120
147
  let RAW;
148
+ let lexTriple = 1,
149
+ lxRaw = 0,
150
+ lxP = 0,
151
+ lxL = 0,
152
+ lxB = 0;
153
+
154
+ /** @param {number} a */
155
+ let findEnd = (a) => {
156
+ lxB = lxRaw & lexTriple ? source.indexOf("}}}", lxP) : -1;
157
+ // oxlint-disable-next-line no-unused-expressions
158
+ lxB < 0 &&
159
+ (lxRaw &&
160
+ ((lexTriple = 0), (lxRaw = 0), (lxP = a + 2), (lxL = +(source[lxP] === "-")), (lxP += lxL)),
161
+ (lxB = source.indexOf("}}", lxP)));
162
+ };
163
+
164
+ let trimPrev = (prev = tokens.at(-1)) => {
165
+ // oxlint-disable-next-line no-unused-expressions
166
+ prev && prev[0] === 0 && prev[1] && (prev[1] = prev[1].trimEnd());
167
+ };
168
+
169
+ /** @param {number} a */
170
+ let takeScanned = (a) => {
171
+ const r = +(lxB > lxP) & +(source[lxB - 1] === "-"),
172
+ whole = source.slice(lxP, lxB - r),
173
+ body = whole.trim(),
174
+ start = lxP + whole.length - whole.trimStart().length,
175
+ end = lxB + 2 + lxRaw;
176
+ // oxlint-disable-next-line no-unused-expressions
177
+ lxL && trimPrev();
178
+ tokens.push([2 - lxRaw, body, a, end, start]);
179
+ i = end;
180
+ if (r) while (/\s/.test(source[i])) i++;
181
+ };
182
+
183
+ /** @param {number} [a] */
184
+ let lexStep = (a = source.indexOf("{{", i)) => {
185
+ if (a < 0) return (tokens.push([0, source.slice(i)]), 0);
186
+ if (a > i) tokens.push([0, source.slice(i, a)]);
187
+ lxRaw = +(source[a + 2] === "{");
188
+ lxP = a + 2 + lxRaw;
189
+ lxL = +(source[lxP] === "-");
190
+ lxP += lxL;
191
+ findEnd(a);
192
+ if (lxB < 0) return (tokens.push([0, source.slice(a)]), 0);
193
+ takeScanned(a);
194
+ return 1;
195
+ };
196
+
197
+ let lex = () => {
198
+ tokens = [];
199
+ i = 0;
200
+ lexTriple = 1;
201
+ while (i < source.length && lexStep());
202
+ };
121
203
 
122
204
  let snap = () => Object.freeze(blocks.slice());
123
205
  // Block nesting is capped so a pathological template fails as a deterministic
@@ -128,8 +210,10 @@ const DEPTH = 256;
128
210
  * @param {Tok} t
129
211
  */
130
212
  let opener = (type, t) => {
131
- blocks.length < DEPTH || fault('Template too deeply nested', 'SJABLOON_TOO_DEEP', t);
132
- return Object.freeze({ type, start: t[2], end: t[3] });
213
+ // oxlint-disable-next-line no-unused-expressions
214
+ nest < DEPTH || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
215
+ nest++;
216
+ return Object.freeze({ type, start: t[2], end: t[3] });
133
217
  };
134
218
  /**
135
219
  * @template {object} E
@@ -138,9 +222,9 @@ let opener = (type, t) => {
138
222
  * @returns {E}
139
223
  */
140
224
  let attach = (e, context) => {
141
- Object.defineProperty(e, 'blocks', { value: context, enumerable: true });
142
- mark(e);
143
- return e;
225
+ Object.defineProperty(e, "blocks", { value: context, enumerable: true });
226
+ mark(e);
227
+ return e;
144
228
  };
145
229
  /**
146
230
  * Throw a located compile-time diagnostic. `code` is typed to the published
@@ -153,12 +237,20 @@ let attach = (e, context) => {
153
237
  * @param {any[]} [t] The token to point at; omitted for end-of-source faults.
154
238
  * @returns {never}
155
239
  */
156
- const fault = (msg, code, t, start = t?.[2] ?? src.length, end = t?.[3] ?? src.length) => {
157
- const e = /** @type {SyntaxError & { code: SjabloonErrorCode, start: number, end: number }} */ (SyntaxError(msg));
158
- e.code = code;
159
- e.start = start;
160
- e.end = end;
161
- throw attach(e, snap());
240
+ const fault = (
241
+ msg,
242
+ code,
243
+ t,
244
+ start = t == null ? source.length : t[2],
245
+ end = t == null ? source.length : t[3],
246
+ ) => {
247
+ const e = /** @type {SyntaxError & { code: SjabloonErrorCode, start: number, end: number }} */ (
248
+ SyntaxError(msg)
249
+ );
250
+ e.code = code;
251
+ e.start = start;
252
+ e.end = end;
253
+ throw attach(e, snap());
162
254
  };
163
255
  /**
164
256
  * Re-locate a diagnostic thrown by a nested compile or render into this
@@ -171,170 +263,271 @@ const fault = (msg, code, t, start = t?.[2] ?? src.length, end = t?.[3] ?? src.l
171
263
  * @type {(e: any, start: number, context: any, owns?: (e: unknown) => boolean) => never}
172
264
  */
173
265
  const translated = (e, start, context, owns = isXprsnDiagnostic) => {
174
- if (!owns(e)) throw e;
175
- e.start += start;
176
- e.end += start;
177
- throw attach(e, context);
266
+ if (!owns(e)) throw e;
267
+ throw attach(relocateXprsn(e, { offset: start }), context);
178
268
  };
179
269
  /**
180
270
  * @param {Tok} t
181
271
  * @returns {never}
182
272
  */
183
- let unexpected = t => fault('Unexpected {{' + t[1] + '}}', 'SJABLOON_UNEXPECTED_TAG', t);
273
+ let unexpected = (t) => fault("Unexpected {{" + t[1] + "}}", "SJABLOON_UNEXPECTED_TAG", t);
184
274
 
185
- // Append every node's output into the accumulator `o`, which the root wrapper
275
+ // Append every node's output into the accumulator `acc`, which the root wrapper
186
276
  // creates once per render and threads all the way down. Nodes return nothing:
187
277
  // no intermediate array per node list, no join, and render order is just push
188
278
  // order.
189
279
  /**
190
280
  * @param {Node<any>[]} nodes
191
- * @param {any} v
192
- * @param {any} o
193
- */
194
- let run = (nodes, v, o) => { for (const n of nodes) n(v, o); };
195
-
196
- // A leaf interpolation node: compile the expression, then let the profile turn
197
- // the evaluated value into output. `k` is VAL for `{{ }}`, RAW for `{{{ }}}`.
198
- /**
199
- * @param {Tok} t
200
- * @param {any} k
201
- * @returns {Node<any>}
281
+ * @param {any} scope
282
+ * @param {any} acc
202
283
  */
203
- let interp = (t, k) => k(cp(t[1], t[4], snap()));
284
+ let run = (nodes, scope, acc) => {
285
+ for (const n of nodes) n(scope, acc);
286
+ };
204
287
 
205
288
  // Compile one expression and collect its free variables (minus the loop
206
289
  // variables currently in scope, which belong to the template) and the registry
207
290
  // functions it calls.
208
291
  /**
209
- * @param {string} s
292
+ * @param {string} expr
210
293
  * @param {number} start
211
294
  * @param {any} context
212
295
  * @returns {(v: any) => any}
213
296
  */
214
- let cp = (s, start, context) => {
215
- /** @type {ReturnType<typeof compile>} */
216
- let e;
217
- try {
218
- // `SjabloonFunctions` is `Record<string, Function>`; xprsn's registry wants
219
- // `Record<string, (...args: any[]) => any>`, and TypeScript deliberately
220
- // refuses `Function` against a call signature. The registry is passed
221
- // straight through untouched, so this is a published-type mismatch rather
222
- // than a real one — narrowing `SjabloonFunctions` would change the API.
223
- e = compile(s, /** @type {any} */ (fns));
224
- } catch (x) {
225
- translated(x, start, context);
226
- }
227
- for (const n of e.names) bound.includes(n) || nms.add(n);
228
- for (const fn of e.functions) fnms.add(fn);
229
- return v => {
230
- try {
231
- return e(v);
232
- } catch (x) {
233
- translated(x, start, context, e.isDiagnostic);
234
- }
235
- };
297
+ let compileExpr = (expr, start, context) => {
298
+ /** @type {ReturnType<typeof compile>} */
299
+ let e;
300
+ try {
301
+ // `SjabloonFunctions` is `Record<string, Function>`; xprsn's registry wants
302
+ // `Record<string, (...args: any[]) => any>`, and TypeScript deliberately
303
+ // refuses `Function` against a call signature. The registry is passed
304
+ // straight through untouched, so this is a published-type mismatch rather
305
+ // than a real one — narrowing `SjabloonFunctions` would change the API.
306
+ e = compile(expr, /** @type {any} */ (fns));
307
+ } catch (x) {
308
+ translated(x, start, context);
309
+ }
310
+ e.names.forEach((n) => {
311
+ // oxlint-disable-next-line no-unused-expressions
312
+ bound.includes(n) || names.add(n);
313
+ });
314
+ for (const fn of e.functions) functions.add(fn);
315
+ return (v) => {
316
+ try {
317
+ return e(v);
318
+ } catch (x) {
319
+ // Read off the compiled expression to pass along, never called through
320
+ // `e` — xprsn's `isDiagnostic` is a closure over a WeakSet, not a method.
321
+ // oxlint-disable-next-line typescript/unbound-method
322
+ translated(x, start, context, e.isDiagnostic);
323
+ }
324
+ };
236
325
  };
237
326
 
327
+ /**
328
+ * @param {string} stop
329
+ * @param {Node<any>[]} [nodes]
330
+ */
331
+ let closeTail = (stop, nodes = parse([stop])) =>
332
+ // oxlint-disable-next-line no-unused-expressions
333
+ (last[1] === stop || unexpected(last), nodes);
334
+
238
335
  // One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
336
+ /**
337
+ * @param {string} tag
338
+ * @param {Tok} [t]
339
+ * @returns {Node<any>[]}
340
+ */
341
+ let nestElif = (tag, t = last) => {
342
+ // Elif chains share the nest budget so they fail closed before the native
343
+ // stack, without appearing as extra `#if` frames in diagnostic context.
344
+ // oxlint-disable-next-line no-unused-expressions
345
+ nest < DEPTH || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
346
+ nest++;
347
+ const next = branch(compileExpr(tag.slice(6), t[4] + 6, snap()));
348
+ nest--;
349
+ return [next];
350
+ };
351
+
352
+ /** @param {string} tag @returns {Node<any>[]} */
353
+ let elseChain = (tag) => {
354
+ if (tag.startsWith("#elif ")) return nestElif(tag);
355
+ if (tag === "#else") return closeTail("/if");
356
+ // oxlint-disable-next-line no-unused-expressions
357
+ tag === "/if" || unexpected(last);
358
+ return [];
359
+ };
360
+
239
361
  /**
240
362
  * @param {(v: any) => any} cond
241
363
  * @returns {Node<any>}
242
364
  */
243
- let branch = cond => {
244
- const then = parse(['#elif', '#else', '/if']);
245
- const tag = last[1];
246
- let els = /** @type {Node<any>[]} */ ([]);
247
- if (tag.startsWith('#elif ')) els = [branch(cp(tag.slice(6), last[4] + 6, snap()))];
248
- else if (tag === '#else') {
249
- els = parse(['/if']);
250
- last[1] === '/if' || unexpected(last);
251
- } else if (tag !== '/if') unexpected(last);
252
- return (v, o) => run(cond(v) ? then : els, v, o);
365
+ let branch = (cond) => {
366
+ const then = parse(["#elif", "#else", "/if"]);
367
+ const els = elseChain(last[1]);
368
+ return (scope, acc) => run(cond(scope) ? then : els, scope, acc);
369
+ };
370
+
371
+ /**
372
+ * @param {any} listValue
373
+ */
374
+ let eachPairs = (listValue) => {
375
+ if (Array.isArray(listValue)) return listValue.slice();
376
+ if (listValue && typeof listValue === "object")
377
+ return Object.keys(listValue).map((k) => [listValue[k], k]);
378
+ return [];
379
+ };
380
+
381
+ let eachEmpty = () =>
382
+ last[1] === "#else" ? closeTail("/each") : last[1] === "/each" ? [] : unexpected(last);
383
+
384
+ /**
385
+ * @param {Tok} t
386
+ * @param {string} tag
387
+ * @param {string} name
388
+ * @param {number} at
389
+ */
390
+ let checkBinding = (t, tag, name, at) => {
391
+ // oxlint-disable-next-line no-unused-expressions
392
+ BLOCKED.test(name) &&
393
+ fault("Bad {{" + tag + "}}", "SJABLOON_BLOCKED_BINDING", t, at, at + name.length);
394
+ };
395
+
396
+ /**
397
+ * @param {Tok} t
398
+ * @param {string} tag
399
+ * @param {Node<any>[]} nodes
400
+ */
401
+ let parseEach = (t, tag, nodes) => {
402
+ blocks.push(opener("each", t));
403
+ const m =
404
+ /^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag) ||
405
+ fault("Bad {{" + tag + "}}", "SJABLOON_EACH_SYNTAX", t);
406
+ const name = m[3],
407
+ idx = m[4],
408
+ at = t[4] + tag.length - m[2].length;
409
+ checkBinding(t, tag, name, at);
410
+ const list = compileExpr(m[1], t[4] + 6, snap());
411
+ const mark = bound.length;
412
+ bound.push(name);
413
+ if (idx) {
414
+ checkBinding(t, tag, idx, t[4] + tag.length - idx.length);
415
+ bound.push(idx);
416
+ }
417
+ bound.push("loop");
418
+ const body = parse(["#else", "/each"]);
419
+ bound.length = mark;
420
+ const empty = eachEmpty();
421
+ blocks.pop();
422
+ nest--;
423
+ nodes.push((scope, acc) => {
424
+ const listValue = list(scope),
425
+ arr = Array.isArray(listValue);
426
+ const pairs = eachPairs(listValue);
427
+ if (!pairs.length) return run(empty, scope, acc);
428
+ pairs.forEach((x, j) => {
429
+ const item = arr ? x : x[0],
430
+ key = arr ? j : x[1];
431
+ const child = Object.create(scope);
432
+ child[name] = item;
433
+ if (idx) child[idx] = key;
434
+ child["@"] = item;
435
+ child.loop = {
436
+ index: j + 1,
437
+ index0: j,
438
+ first: !j,
439
+ last: j === pairs.length - 1,
440
+ length: pairs.length,
441
+ };
442
+ run(body, child, acc);
443
+ });
444
+ });
445
+ };
446
+
447
+ /**
448
+ * @param {Tok} t
449
+ * @param {Node<any>[]} nodes
450
+ */
451
+ let emitLeaf = (t, nodes) => {
452
+ if (!t[0]) {
453
+ // oxlint-disable-next-line no-unused-expressions
454
+ t[1] && nodes.push(LIT(t[1]));
455
+ return;
456
+ }
457
+ // oxlint-disable-next-line no-unused-expressions
458
+ RAW ||
459
+ fault(
460
+ "Raw {{{" + t[1] + "}}} is not available here; {{ " + t[1] + " }} is already raw",
461
+ "SJABLOON_RAW_TAG",
462
+ t,
463
+ );
464
+ nodes.push(RAW(compileExpr(t[1], t[4], snap())));
465
+ };
466
+
467
+ /**
468
+ * @param {Tok} t
469
+ * @param {string} tag
470
+ * @param {Node<any>[]} nodes
471
+ */
472
+ let emitBlock = (t, tag, nodes) => {
473
+ if (tag.startsWith("#if ")) {
474
+ blocks.push(opener("if", t));
475
+ nodes.push(branch(compileExpr(tag.slice(4), t[4] + 4, snap())));
476
+ blocks.pop();
477
+ nest--;
478
+ return;
479
+ }
480
+ if (/^#each(?:\s|$)/.test(tag)) return parseEach(t, tag, nodes);
481
+ if (/^#(?:if|elif|else)(?:\s|$)/.test(tag)) unexpected(t);
482
+ fault("Unknown {{" + tag + "}}", "SJABLOON_UNKNOWN_BLOCK", t);
483
+ };
484
+
485
+ /**
486
+ * @param {Tok} t
487
+ * @param {string} tag
488
+ * @param {Node<any>[]} nodes
489
+ */
490
+ let emitTag = (t, tag, nodes) => {
491
+ if (tag[0] === "!") return;
492
+ if (tag[0] === "#") return emitBlock(t, tag, nodes);
493
+ if (tag[0] === "/") unexpected(t);
494
+ nodes.push(VAL(compileExpr(t[1], t[4], snap())));
495
+ };
496
+
497
+ /**
498
+ * @param {Tok} t
499
+ * @param {string[]} stops
500
+ * @param {Node<any>[]} nodes
501
+ */
502
+ let takeToken = (t, stops, nodes) => {
503
+ if (t[0] < 2) return (emitLeaf(t, nodes), 0);
504
+ if (stops.includes(t[1].split(" ")[0])) return ((last = t), 1);
505
+ emitTag(t, t[1], nodes);
506
+ return 0;
253
507
  };
254
508
 
255
509
  /**
256
510
  * @param {string[]} stops
257
511
  * @returns {Node<any>[]}
258
512
  */
259
- let parse = stops => {
260
- const nodes = /** @type {Node<any>[]} */ ([]);
261
- for (let t; (t = toks[i++]); ) {
262
- const tag = t[1];
263
- if (!t[0]) {
264
- // Left-trim can shave a text run down to nothing; never emit it.
265
- tag && nodes.push(LIT(tag));
266
- } else if (t[0] === 1) {
267
- // The lexer always tokenizes `}}}` — dropping it would cost the
268
- // `triple` latch that keeps lexing linear — so editions without a
269
- // raw form reject it here, at the parser, with a located span.
270
- RAW || fault('Raw {{{' + tag + '}}} is not available here; {{ ' + tag + ' }} is already raw', 'SJABLOON_RAW_TAG', t);
271
- nodes.push(interp(t, RAW));
272
- } else if (stops.includes(tag.split(' ')[0])) {
273
- last = t;
274
- return nodes;
275
- } else if (tag[0] === '!') {
276
- // comment
277
- } else if (tag.startsWith('#if ')) {
278
- blocks.push(opener('if', t));
279
- nodes.push(branch(cp(tag.slice(4), t[4] + 4, snap())));
280
- blocks.pop();
281
- } else if (/^#each(?:\s|$)/.test(tag)) {
282
- blocks.push(opener('each', t));
283
- // `|| fault()` in the initializer, not as a follow-up statement: fault
284
- // returns never, so `m` is non-null from here without a second check.
285
- const m = /^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag) || fault('Bad {{' + tag + '}}', 'SJABLOON_EACH_SYNTAX', t);
286
- const name = m[3], idx = m[4], at = t[4] + tag.length - m[2].length;
287
- if (BLOCKED.test(name)) fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, at, at + name.length);
288
- if (idx && BLOCKED.test(idx)) {
289
- const p = t[4] + tag.length - idx.length;
290
- fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, p, p + idx.length);
291
- }
292
- const list = cp(m[1], t[4] + 6, snap());
293
- // `name`, `idx`, and `loop` are engine-bound inside the body, so
294
- // exclude them from names there and restore outer bindings after.
295
- const mark = bound.length;
296
- bound.push(name);
297
- if (idx) bound.push(idx);
298
- bound.push('loop');
299
- const body = parse(['#else', '/each']);
300
- bound.length = mark;
301
- let empty = /** @type {Node<any>[]} */ ([]);
302
- if (last[1] === '#else') {
303
- empty = parse(['/each']);
304
- last[1] === '/each' || unexpected(last);
305
- } else if (last[1] !== '/each') unexpected(last);
306
- blocks.pop();
307
- // Child scopes inherit the parent via the prototype chain, so outer
308
- // variables stay visible inside the loop body. `@` re-points to the
309
- // current item at each level, `$` (root) rides the chain, and `loop`
310
- // carries the iteration metadata (index/first/last/length).
311
- nodes.push((v, o) => {
312
- const lv = list(v), arr = Array.isArray(lv);
313
- const ps = arr ? lv.slice() : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k]) : [];
314
- if (!ps.length) return run(empty, v, o);
315
- // forEach, not a counted loop: `slice()` keeps holes and forEach
316
- // skips them exactly as the `.map()` this replaced did, so sparse
317
- // arrays iterate the same way with surrounding indexes unshifted.
318
- ps.forEach((x, j) => {
319
- const item = arr ? x : x[0], key = arr ? j : x[1];
320
- const s = Object.create(v);
321
- s[name] = item;
322
- if (idx) s[idx] = key;
323
- s['@'] = item;
324
- s.loop = { index: j + 1, index0: j, first: !j, last: j === ps.length - 1, length: ps.length };
325
- run(body, s, o);
326
- });
327
- });
328
- } else if (/^#(?:if|elif|else)(?:\s|$)/.test(tag) || tag[0] === '/') {
329
- unexpected(t);
330
- } else if (tag[0] === '#') {
331
- fault('Unknown {{' + tag + '}}', 'SJABLOON_UNKNOWN_BLOCK', t);
332
- } else {
333
- nodes.push(interp(t, VAL));
334
- }
335
- }
336
- stops.length && fault('Missing {{' + stops[stops.length - 1] + '}}', 'SJABLOON_UNCLOSED_BLOCK');
337
- return nodes;
513
+ let parse = (stops) => {
514
+ const nodes = /** @type {Node<any>[]} */ ([]);
515
+ for (let t; (t = tokens[i++]);) {
516
+ if (takeToken(t, stops, nodes)) return nodes;
517
+ }
518
+ // oxlint-disable-next-line no-unused-expressions
519
+ stops.length && fault("Missing {{" + stops[stops.length - 1] + "}}", "SJABLOON_UNCLOSED_BLOCK");
520
+ return nodes;
521
+ };
522
+
523
+ /**
524
+ * Literal text node for the string editions: append `text` onto `acc.text`.
525
+ *
526
+ * @param {string} text
527
+ * @returns {Node<{ text: string }>}
528
+ */
529
+ export const litNode = (text) => (scope, acc) => {
530
+ acc.text += text;
338
531
  };
339
532
 
340
533
  /**
@@ -389,58 +582,51 @@ let parse = stops => {
389
582
  * or bad expressions.
390
583
  */
391
584
  export let make = ([lit, val, raw, seed, take]) => {
392
- /**
393
- * @param {string} str
394
- * @param {SjabloonFunctions} [funcs]
395
- * @returns {SjabloonRenderer<T>}
396
- */
397
- function template(str, funcs) {
398
- LIT = lit, VAL = val, RAW = raw;
399
- fns = funcs;
400
- // `$` (root) and `@` (current item) are engine-bound anchors, always in
401
- // scope, so they never count as caller-supplied `names`.
402
- bound = ['$', '@'];
403
- nms = new Set();
404
- fnms = new Set();
405
- src = String(str);
406
- blocks = [];
407
- toks = lex(src);
408
- i = 0;
409
- // Deeply nested blocks overflow the recursive-descent parser; surface that
410
- // as a SyntaxError so malformed input keeps its documented compile-time
411
- // contract (mirroring xprsn's XPRSN_TOO_DEEP for expressions).
412
- let nodes;
413
- try {
414
- nodes = parse([]);
415
- } catch (x) {
416
- // An empty span at the end, like an unclosed block.
417
- if (x instanceof RangeError) fault('Template too deeply nested', 'SJABLOON_TOO_DEEP');
418
- throw x;
419
- }
420
- // Wrap the values in a root scope carrying the anchors, without mutating
421
- // what the caller passed: by default `$` and `@` both point at the root.
422
- // An embedder can override the anchors with a `{ root, item }` second arg:
423
- // `$` = root, `@` = item (distinct objects). Omitting `item` leaves `@`
424
- // unbound, so `@.x` throws through xprsn's guard — a group-header band that
425
- // has no current row wants exactly that.
426
- const f = (/** @type {any} */ v, /** @type {any} */ o) => {
427
- v = v || EMPTY;
428
- const r = Object.create(v);
429
- r['$'] = o ? o.root : v;
430
- if (!o) r['@'] = v;
431
- else if ('item' in o) r['@'] = o.item;
432
- // One accumulator per render, owned here and threaded down. A registry
433
- // function that renders another template gets its own, so re-entrancy
434
- // needs no bookkeeping.
435
- const acc = seed();
436
- run(nodes, r, acc);
437
- return take(acc);
438
- };
439
- // Array.from, not a spread: the bundler's transpile turns `[...set]` into
440
- // `[].concat(set)`, which wraps the Set instead of unpacking it.
441
- f.names = Array.from(nms);
442
- f.functions = Array.from(fnms);
443
- return f;
444
- }
445
- return { template, render: (str, values, funcs) => template(str, funcs)(values) };
585
+ /**
586
+ * @param {string} str
587
+ * @param {SjabloonFunctions} [funcs]
588
+ * @returns {SjabloonRenderer<T>}
589
+ */
590
+ function template(str, funcs) {
591
+ // oxlint-disable-next-line no-unused-expressions
592
+ ((LIT = lit), (VAL = val), (RAW = raw));
593
+ fns = funcs;
594
+ // `$` (root) and `@` (current item) are engine-bound anchors, always in
595
+ // scope, so they never count as caller-supplied `names`.
596
+ bound = ["$", "@"];
597
+ names = new Set();
598
+ functions = new Set();
599
+ source = String(str);
600
+ blocks = [];
601
+ nest = 0;
602
+ lex();
603
+ i = 0;
604
+ // Deeply nested blocks fail as SJABLOON_TOO_DEEP at DEPTH via opener(),
605
+ // including elif chains — well below the native stack.
606
+ let nodes = parse([]);
607
+ // Wrap the values in a root scope carrying the anchors, without mutating
608
+ // what the caller passed: by default `$` and `@` both point at the root.
609
+ // An embedder can override the anchors with a `{ root, item }` second arg:
610
+ // `$` = root, `@` = item (distinct objects). Omitting `item` leaves `@`
611
+ // unbound, so `@.x` throws through xprsn's guard — a group-header band that
612
+ // has no current row wants exactly that.
613
+ const f = (/** @type {any} */ values, /** @type {any} */ anchors) => {
614
+ values = values || EMPTY;
615
+ const r = Object.create(values);
616
+ r["$"] = anchors ? anchors.root : values;
617
+ r["@"] = anchors ? anchors.item : values;
618
+ // One accumulator per render, owned here and threaded down. A registry
619
+ // function that renders another template gets its own, so re-entrancy
620
+ // needs no bookkeeping.
621
+ const acc = seed();
622
+ run(nodes, r, acc);
623
+ return take(acc);
624
+ };
625
+ // Array.from, not a spread: the bundler's transpile turns `[...set]` into
626
+ // `[].concat(set)`, which wraps the Set instead of unpacking it.
627
+ f.names = Array.from(names);
628
+ f.functions = Array.from(functions);
629
+ return f;
630
+ }
631
+ return { template, render: (str, values, funcs) => template(str, funcs)(values) };
446
632
  };