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