sjabloon 0.11.0 → 0.12.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,14 +8,16 @@
8
8
  * backs every entry, so the diagnostics store below authenticates diagnostics
9
9
  * across all of them.
10
10
  */
11
+ import { adopt, mint, relocate as relocateFault, store } from "waarmerk";
11
12
  import { compile, isDiagnostic as isXprsnDiagnostic, relocate as relocateXprsn } from "xprsn";
12
13
 
13
14
  /**
14
15
  * @import { SjabloonDiagnostic, SjabloonErrorCode, SjabloonFunctions, SjabloonRenderer, SjabloonValues } from './types.js'
16
+ * @import { Relocation, Store } from 'waarmerk'
17
+ * @import { XprsnErrorCode } from 'xprsn'
15
18
  * @template A
16
- * @typedef {(scope: any, acc: A, scratch?: any) => void} Node One compiled node: appends into
17
- * `acc` and returns nothing. The third slot is a scratch local some editions declare as a
18
- * parameter to save a `let`; callers pass two arguments.
19
+ * @typedef {(scope: any, acc: A) => void} Node One compiled node: appends into
20
+ * `acc` and returns nothing.
19
21
  */
20
22
 
21
23
  /**
@@ -29,81 +31,54 @@ import { compile, isDiagnostic as isXprsnDiagnostic, relocate as relocateXprsn }
29
31
  * @typedef {any[]} Tok
30
32
  */
31
33
 
32
- const BLOCKED = /^(?:__proto__|constructor|prototype)$/;
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);
39
- const owns = DIAGNOSTICS.has.bind(DIAGNOSTICS);
40
- const origin = DIAGNOSTICS.get.bind(DIAGNOSTICS);
34
+ /**
35
+ * This module's identity, carrying its code union. Naming the union here is
36
+ * what puts every code below under `tsc`: waarmerk checks a code against the
37
+ * store it is thrown into, so one this module does not declare fails at the
38
+ * line that throws it rather than shipping — which is how SJABLOON_TOO_DEEP
39
+ * got out for two releases.
40
+ *
41
+ * The union is spelled with xprsn's beside this module's own, because most
42
+ * diagnostics here are xprsn errors translated into template coordinates.
43
+ * Saying so here is what makes `SjabloonDiagnostic.code` true by construction,
44
+ * and it keeps `SjabloonErrorCode` meaning what its siblings' unions mean: the
45
+ * codes this package mints. The store's value is the per-template origin each
46
+ * renderer's own `isDiagnostic` compares against.
47
+ *
48
+ * @type {Store<SjabloonErrorCode | XprsnErrorCode>}
49
+ */
50
+ const diags = store("sjabloon");
41
51
 
42
52
  /**
43
53
  * Check whether an error was produced or translated by sjabloon.
44
54
  *
45
55
  * Every entry shares one core, so a diagnostic thrown through any of them
46
56
  * authenticates through all of them.
47
- *
48
- * @param {unknown} error Any thrown value.
49
- * @returns {error is SjabloonDiagnostic} Whether `error` is an authentic sjabloon diagnostic.
50
57
  */
51
- export const isDiagnostic = owns;
58
+ export const isDiagnostic = diags.isDiagnostic;
52
59
 
53
- /**
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.
61
- */
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
60
  /**
69
61
  * Copy a diagnostic into an embedder's coordinates.
70
62
  *
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.
63
+ * Most diagnostics here are xprsn errors translated into template coordinates,
64
+ * and those are registered in both stores. Letting xprsn make that half of the
65
+ * copy is what keeps the copy registered in both — the same reason relocation
66
+ * lives with authentication in the first place. `adopt` with no fields then,
67
+ * not with `blocks`: the descriptors already carried that across, and defining
68
+ * it again would redefine a non-configurable property.
77
69
  *
78
70
  * @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`.
71
+ * @param {Relocation} [opts]
81
72
  * @returns {SjabloonDiagnostic} The relocated copy.
82
73
  * @throws {TypeError} When `diag` is not a sjabloon diagnostic.
83
74
  */
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));
75
+ export const relocate = (diag, opts) => {
76
+ const d = /** @type {any} */ (diag);
77
+ return /** @type {any} */ (
78
+ isXprsnDiagnostic(d)
79
+ ? adopt(diags, relocateXprsn(d, opts), undefined, diags.origin(d))
80
+ : relocateFault(diags, d, opts)
81
+ );
107
82
  };
108
83
 
109
84
  // Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
@@ -120,38 +95,19 @@ const EMPTY = Object.freeze({});
120
95
  // Shared parser state; parsing is synchronous so this is safe.
121
96
  // LIT/VAL/RAW are the compiling profile's node builders — read only while
122
97
  // parsing, never at render time, so the hot path stays free of indirection.
123
- /** @type {Tok[]} */
124
- let tokens;
125
- /** @type {number} */
126
- let i;
127
- /** @type {SjabloonFunctions | undefined} */
128
- let fns;
129
- /** @type {Tok} */
130
- let last;
131
- /** @type {string[]} */
132
- let bound;
133
- /** @type {Set<string>} */
134
- let names;
98
+ // `nest` is the nesting budget shared by `#if`/`#each`/`#elif` (see opener).
99
+ let /** @type {Tok[]} */ tokens, /** @type {Tok} */ last, /** @type {string} */ source;
100
+ let /** @type {number} */ i, /** @type {number} */ nest;
101
+ let /** @type {SjabloonFunctions | undefined} */ fns, /** @type {string[]} */ bound;
102
+ let /** @type {Set<string>} */ names, /** @type {Set<string>} */ functions;
135
103
  /** @type {{ name: string, start: number, end: number }[]} */
136
104
  let reads;
137
- /** @type {Set<string>} */
138
- let functions;
139
- /** @type {string} */
140
- let source;
141
105
  /** @type {{ type: string, start: number, end: number }[]} */
142
106
  let blocks;
143
- /** Nesting budget shared by `#if`/`#each`/`#elif` (see DEPTH). */
144
- /** @type {number} */
145
- let nest;
146
107
  // The profile's node builders. `any` rather than `Node<A>`: `make()` is generic
147
108
  // per edition, but these are module-level and shared across all three, so no
148
109
  // single A applies here.
149
- /** @type {any} */
150
- let LIT;
151
- /** @type {any} */
152
- let VAL;
153
- /** @type {any} */
154
- let RAW;
110
+ let /** @type {any} */ LIT, /** @type {any} */ VAL, /** @type {any} */ RAW;
155
111
  let lexTriple = 1,
156
112
  lxRaw = 0,
157
113
  lxP = 0,
@@ -189,51 +145,38 @@ let takeScanned = (a) => {
189
145
 
190
146
  /** @param {number} [a] */
191
147
  let lexStep = (a = source.indexOf("{{", i)) => {
192
- if (a < 0) return (tokens.push([0, source.slice(i)]), 0);
193
- if (a > i) tokens.push([0, source.slice(i, a)]);
194
- lxRaw = +(source[a + 2] === "{");
195
- lxP = a + 2 + lxRaw;
196
- lxL = +(source[lxP] === "-");
197
- lxP += lxL;
198
- findEnd(a);
199
- if (lxB < 0) return (tokens.push([0, source.slice(a)]), 0);
200
- takeScanned(a);
201
- return 1;
202
- };
203
-
204
- let lex = () => {
205
- tokens = [];
206
- i = 0;
207
- lexTriple = 1;
208
- while (i < source.length && lexStep());
148
+ if (a >= 0) {
149
+ if (a > i) tokens.push([0, source.slice(i, a)]);
150
+ lxRaw = +(source[a + 2] === "{");
151
+ lxP = a + 2 + lxRaw;
152
+ lxL = +(source[lxP] === "-");
153
+ lxP += lxL;
154
+ findEnd(a);
155
+ if (lxB >= 0) return (takeScanned(a), 1);
156
+ // An opener with no closer is text from its own `{{` on: rewind to it and
157
+ // fall into the shared tail below.
158
+ i = a;
159
+ }
160
+ // Both dead ends end the scan the same way — the rest of the source is one
161
+ // final text token.
162
+ return (tokens.push([0, source.slice(i)]), 0);
209
163
  };
210
164
 
211
165
  let snap = () => Object.freeze(blocks.slice());
212
- // Block nesting is capped so a pathological template fails as a deterministic
213
- // SyntaxError at the offending opener, far below the native stack limit.
214
- const DEPTH = 256;
215
166
  /**
167
+ * Mint one block frame. Nesting is capped at 256 so a pathological template
168
+ * fails as a deterministic SyntaxError at the offending opener, far below the
169
+ * native stack limit.
170
+ *
216
171
  * @param {string} type
217
172
  * @param {Tok} t
218
173
  */
219
174
  let opener = (type, t) => {
220
175
  // oxlint-disable-next-line no-unused-expressions
221
- nest < DEPTH || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
176
+ nest < 256 || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
222
177
  nest++;
223
178
  return Object.freeze({ type, start: t[2], end: t[3] });
224
179
  };
225
- /**
226
- * @template {object} E
227
- * @param {E} e
228
- * @param {any} context
229
- * @param {any} own The owning compile's `names` set, this diagnostic's origin.
230
- * @returns {E}
231
- */
232
- let attach = (e, context, own) => {
233
- Object.defineProperty(e, "blocks", { value: context, enumerable: true });
234
- mark(e, own);
235
- return e;
236
- };
237
180
  /**
238
181
  * Throw a located compile-time diagnostic. `code` is typed to the published
239
182
  * union, so a code that is not declared in `types.d.ts` fails to compile here
@@ -242,24 +185,11 @@ let attach = (e, context, own) => {
242
185
  *
243
186
  * @param {string} msg
244
187
  * @param {SjabloonErrorCode} code
245
- * @param {any[]} [t] The token to point at; omitted for end-of-source faults.
188
+ * @param {any[]} [t] The token to point at; defaults to an end-of-source point.
246
189
  * @returns {never}
247
190
  */
248
- const fault = (
249
- msg,
250
- code,
251
- t,
252
- start = t == null ? source.length : t[2],
253
- end = t == null ? source.length : t[3],
254
- ) => {
255
- const e = /** @type {SyntaxError & { code: SjabloonErrorCode, start: number, end: number }} */ (
256
- SyntaxError(msg)
257
- );
258
- e.code = code;
259
- e.start = start;
260
- e.end = end;
261
- throw attach(e, snap(), names);
262
- };
191
+ const fault = (msg, code, t = [0, 0, source.length, source.length], start = t[2], end = t[3]) =>
192
+ mint(diags, SyntaxError, msg, { code, start, end, blocks: snap() }, names);
263
193
  /**
264
194
  * Re-locate a diagnostic thrown by a nested compile or render into this
265
195
  * template's coordinates, then rethrow it as ours. Always throws.
@@ -273,7 +203,7 @@ const fault = (
273
203
  */
274
204
  const translated = (e, start, context, own, guard = isXprsnDiagnostic) => {
275
205
  if (!guard(e)) throw e;
276
- throw attach(relocateXprsn(e, { offset: start }), context, own);
206
+ throw adopt(diags, relocateXprsn(e, { offset: start }), { blocks: context }, own);
277
207
  };
278
208
  /**
279
209
  * @param {Tok} t
@@ -300,10 +230,11 @@ let run = (nodes, scope, acc) => {
300
230
  /**
301
231
  * @param {string} expr
302
232
  * @param {number} start
303
- * @param {any} context
233
+ * @param {any} [context] The block context this expression sits in; every
234
+ * caller wants the frames as they stand at the call, so it defaults to them.
304
235
  * @returns {(v: any) => any}
305
236
  */
306
- let compileExpr = (expr, start, context) => {
237
+ let compileExpr = (expr, start, context = snap()) => {
307
238
  // The compiling template's origin, captured now: the render-time catch below
308
239
  // runs long after the module-level `names` has moved on to other compiles.
309
240
  const own = names;
@@ -340,61 +271,64 @@ let compileExpr = (expr, start, context) => {
340
271
  };
341
272
 
342
273
  /**
343
- * @param {string} stop
274
+ * The shared else tail of `#if` and `#each`. An `{{#else}}` parses one more
275
+ * branch; with or without one, the tag standing here has to be the closer, and
276
+ * anything else is an unexpected tag either way — so both paths leave through
277
+ * the one check below, and the else branch is whatever was parsed or nothing.
278
+ *
279
+ * @param {string} close
344
280
  * @param {Node<any>[]} [nodes]
345
- */
346
- let closeTail = (stop, nodes = parse([stop])) =>
347
- // oxlint-disable-next-line no-unused-expressions
348
- (last[1] === stop || unexpected(last), nodes);
349
-
350
- // One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
351
- /**
352
- * @param {string} tag
353
- * @param {Tok} [t]
354
281
  * @returns {Node<any>[]}
355
282
  */
356
- let nestElif = (tag, t = last) => {
357
- // Elif chains share the nest budget so they fail closed before the native
358
- // stack, without appearing as extra `#if` frames in diagnostic context.
283
+ let elseTail = (close, nodes = []) => {
359
284
  // oxlint-disable-next-line no-unused-expressions
360
- nest < DEPTH || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
361
- nest++;
362
- const next = branch(compileExpr(tag.slice(6), t[4] + 6, snap()));
363
- nest--;
364
- return [next];
365
- };
366
-
367
- /** @param {string} tag @returns {Node<any>[]} */
368
- let elseChain = (tag) => {
369
- if (tag.startsWith("#elif ")) return nestElif(tag);
370
- if (tag === "#else") return closeTail("/if");
371
- // oxlint-disable-next-line no-unused-expressions
372
- tag === "/if" || unexpected(last);
373
- return [];
285
+ last[1] === "#else" && (nodes = parse([close]));
286
+ return last[1] === close ? nodes : unexpected(last);
374
287
  };
375
288
 
376
289
  /**
290
+ * One `#if` branch and whatever hangs off it. An `{{#elif}}` link is itself a
291
+ * branch, so the chain tail recurses straight back in here rather than through
292
+ * a helper. Elif links share the nest budget so they fail closed before the
293
+ * native stack, and the block `opener` mints for one is discarded, never
294
+ * pushed: an elif link must not appear as an extra `#if` frame in diagnostic
295
+ * context. `t` doubles as the scratch slot for the link it builds.
296
+ *
377
297
  * @param {(v: any) => any} cond
298
+ * @param {Node<any>[]} [then]
299
+ * @param {any} [t]
300
+ * @param {any} [els]
378
301
  * @returns {Node<any>}
379
302
  */
380
- let branch = (cond) => {
381
- const then = parse(["#elif", "#else", "/if"]);
382
- const els = elseChain(last[1]);
383
- return (scope, acc) => run(cond(scope) ? then : els, scope, acc);
384
- };
303
+ let branch =
304
+ (
305
+ cond,
306
+ then = parse(["/if", "#elif", "#else"]),
307
+ t = last,
308
+ els = t[1].startsWith("#elif ")
309
+ ? (opener("elif", t), (t = branch(compileExpr(t[1].slice(6), t[4] + 6))), nest--, [t])
310
+ : elseTail("/if"),
311
+ ) =>
312
+ (scope, acc) =>
313
+ run(cond(scope) ? then : els, scope, acc);
385
314
 
386
315
  /**
316
+ * What one `#each` walks: the values themselves for an array, the own keys for
317
+ * an object. The caller has already asked whether the collection is an array —
318
+ * that answer decides the key shape too, so it is passed in rather than asked
319
+ * twice, and it is what tells the loop whether an element is a value or a key
320
+ * to index back through. No pair array is built: the key list is the walk.
321
+ *
387
322
  * @param {any} listValue
323
+ * @param {boolean} arr
324
+ * @returns {any[]}
388
325
  */
389
- let eachPairs = (listValue) => {
390
- if (Array.isArray(listValue)) return listValue.slice();
391
- if (listValue && typeof listValue === "object")
392
- return Object.keys(listValue).map((k) => [listValue[k], k]);
393
- return [];
394
- };
395
-
396
- let eachEmpty = () =>
397
- last[1] === "#else" ? closeTail("/each") : last[1] === "/each" ? [] : unexpected(last);
326
+ let eachPairs = (listValue, arr) =>
327
+ arr
328
+ ? listValue.slice()
329
+ : listValue && typeof listValue === "object"
330
+ ? Object.keys(listValue)
331
+ : [];
398
332
 
399
333
  /**
400
334
  * @param {Tok} t
@@ -404,7 +338,7 @@ let eachEmpty = () =>
404
338
  */
405
339
  let checkBinding = (t, tag, name, at) => {
406
340
  // oxlint-disable-next-line no-unused-expressions
407
- BLOCKED.test(name) &&
341
+ /^(?:__proto__|constructor|prototype)$/.test(name) &&
408
342
  fault("Bad {{" + tag + "}}", "SJABLOON_BLOCKED_BINDING", t, at, at + name.length);
409
343
  };
410
344
 
@@ -422,7 +356,7 @@ let parseEach = (t, tag, nodes) => {
422
356
  idx = m[4],
423
357
  at = t[4] + tag.length - m[2].length;
424
358
  checkBinding(t, tag, name, at);
425
- const list = compileExpr(m[1], t[4] + 6, snap());
359
+ const list = compileExpr(m[1], t[4] + 6);
426
360
  const mark = bound.length;
427
361
  bound.push(name);
428
362
  if (idx) {
@@ -430,23 +364,24 @@ let parseEach = (t, tag, nodes) => {
430
364
  bound.push(idx);
431
365
  }
432
366
  bound.push("loop");
433
- const body = parse(["#else", "/each"]);
367
+ const body = parse(["/each", "#else"]);
434
368
  bound.length = mark;
435
- const empty = eachEmpty();
369
+ const empty = elseTail("/each");
436
370
  blocks.pop();
437
371
  nest--;
438
372
  nodes.push((scope, acc) => {
439
373
  const listValue = list(scope),
440
374
  arr = Array.isArray(listValue);
441
- const pairs = eachPairs(listValue);
375
+ const pairs = eachPairs(listValue, arr);
442
376
  if (!pairs.length) return run(empty, scope, acc);
443
377
  pairs.forEach((x, j) => {
444
- const item = arr ? x : x[0],
445
- key = arr ? j : x[1];
446
378
  const child = Object.create(scope);
447
- child[name] = item;
448
- if (idx) child[idx] = key;
449
- child["@"] = item;
379
+ // The loop variable and the `@` anchor name the same element, so one
380
+ // write lands in both slots. `x` is the element itself for an array and
381
+ // the own key to index back through for an object; the index variable,
382
+ // wanted only when the template declared one, is the mirror of that.
383
+ child[name] = child["@"] = arr ? x : listValue[x];
384
+ if (idx) child[idx] = arr ? j : x;
450
385
  child.loop = {
451
386
  index: j + 1,
452
387
  index0: j,
@@ -476,7 +411,7 @@ let emitLeaf = (t, nodes) => {
476
411
  "SJABLOON_RAW_TAG",
477
412
  t,
478
413
  );
479
- nodes.push(RAW(compileExpr(t[1], t[4], snap())));
414
+ nodes.push(RAW(compileExpr(t[1], t[4])));
480
415
  };
481
416
 
482
417
  /**
@@ -487,7 +422,7 @@ let emitLeaf = (t, nodes) => {
487
422
  let emitBlock = (t, tag, nodes) => {
488
423
  if (tag.startsWith("#if ")) {
489
424
  blocks.push(opener("if", t));
490
- nodes.push(branch(compileExpr(tag.slice(4), t[4] + 4, snap())));
425
+ nodes.push(branch(compileExpr(tag.slice(4), t[4] + 4)));
491
426
  blocks.pop();
492
427
  nest--;
493
428
  return;
@@ -506,7 +441,7 @@ let emitTag = (t, tag, nodes) => {
506
441
  if (tag[0] === "!") return;
507
442
  if (tag[0] === "#") return emitBlock(t, tag, nodes);
508
443
  if (tag[0] === "/") unexpected(t);
509
- nodes.push(VAL(compileExpr(t[1], t[4], snap())));
444
+ nodes.push(VAL(compileExpr(t[1], t[4])));
510
445
  };
511
446
 
512
447
  /**
@@ -522,6 +457,12 @@ let takeToken = (t, stops, nodes) => {
522
457
  };
523
458
 
524
459
  /**
460
+ * Parse until one of `stops` is reached, or to the end of the token stream.
461
+ *
462
+ * `stops[0]` is the block's closer and the rest are its interior tags, so the
463
+ * unclosed diagnostic below reads the head of the list rather than measuring
464
+ * its way to the tail. Membership is order-blind; keep the closer first.
465
+ *
525
466
  * @param {string[]} stops
526
467
  * @returns {Node<any>[]}
527
468
  */
@@ -531,20 +472,10 @@ let parse = (stops) => {
531
472
  if (takeToken(t, stops, nodes)) return nodes;
532
473
  }
533
474
  // oxlint-disable-next-line no-unused-expressions
534
- stops.length && fault("Missing {{" + stops[stops.length - 1] + "}}", "SJABLOON_UNCLOSED_BLOCK");
475
+ stops.length && fault("Missing {{" + stops[0] + "}}", "SJABLOON_UNCLOSED_BLOCK");
535
476
  return nodes;
536
477
  };
537
478
 
538
- /**
539
- * Literal text node for the string editions: append `text` onto `acc.text`.
540
- *
541
- * @param {string} text
542
- * @returns {Node<{ text: string }>}
543
- */
544
- export const litNode = (text) => (scope, acc) => {
545
- acc.text += text;
546
- };
547
-
548
479
  /**
549
480
  * Display text for one interpolated value — the scalar rule every edition and
550
481
  * the root `text()` join share. A valid `Date` renders as ISO 8601 UTC
@@ -641,20 +572,27 @@ export let make = ([lit, val, raw, seed, take]) => {
641
572
  fns = funcs;
642
573
  // `$` (root) and `@` (current item) are engine-bound anchors, always in
643
574
  // scope, so they never count as caller-supplied `names` — and neither does
644
- // anything the embedder declares bound. A loop, not a spread: the
575
+ // anything the embedder declares bound. `Array.from`, not a spread: the
645
576
  // bundler's transpile turns an iterable spread into a concat that would
646
- // wrap a Set instead of unpacking it.
647
- bound = ["$", "@"];
648
- if (opts && opts.bound) for (const name of opts.bound) bound.push(name);
577
+ // wrap a Set instead of unpacking it. `Object(opts)` stands in for the
578
+ // missing-opts check; nothing iterable comes out of an empty string.
579
+ bound = ["$", "@"].concat(
580
+ Array.from(/** @type {Iterable<string>} */ (Object(opts).bound || "")),
581
+ );
649
582
  names = new Set();
650
583
  reads = [];
651
584
  functions = new Set();
652
585
  source = String(str);
653
586
  blocks = [];
654
587
  nest = 0;
655
- lex();
588
+ // The lexer's own state, then one linear pass: inline here because it runs
589
+ // exactly once per compile and the parser rewinds `i` straight after.
590
+ tokens = [];
591
+ i = 0;
592
+ lexTriple = 1;
593
+ while (i < source.length && lexStep());
656
594
  i = 0;
657
- // Deeply nested blocks fail as SJABLOON_TOO_DEEP at DEPTH via opener(),
595
+ // Deeply nested blocks fail as SJABLOON_TOO_DEEP at the cap in opener(),
658
596
  // including elif chains — well below the native stack.
659
597
  let nodes = parse([]);
660
598
  // The trusted-scope render, and the one render body: the caller's chain
@@ -690,7 +628,7 @@ export let make = ([lit, val, raw, seed, take]) => {
690
628
  // `fault` and at render time by the closures `compileExpr` built. Captured
691
629
  // now — the module-level `names` moves on to the next compile.
692
630
  const o = names;
693
- f.isDiagnostic = (/** @type {unknown} */ x) => origin(x) === o;
631
+ f.isDiagnostic = (/** @type {unknown} */ x) => diags.origin(x) === o;
694
632
  f.scoped = scoped;
695
633
  return f;
696
634
  }
package/lib/html.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./types.js";
2
- import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues } from "./types.js";
2
+ import type { SjabloonRender, SjabloonTemplate } from "./types.js";
3
3
 
4
4
  /**
5
5
  * Compile a template once, render it many times to an HTML string.
@@ -7,15 +7,6 @@ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues } from "./type
7
7
  * `{{ expr }}` HTML-escapes (`& < > " '`) and `{{{ expr }}}` interpolates raw.
8
8
  * This is the only edition that knows what HTML is; the rest of sjabloon leaves
9
9
  * escaping to the output edge.
10
- *
11
- * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
- * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
10
  */
14
- export function template(
15
- str: string,
16
- funcs?: SjabloonFunctions,
17
- opts?: { bound?: Iterable<string> },
18
- ): SjabloonRenderer<string>;
19
-
20
- /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
21
- export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): string;
11
+ export const template: SjabloonTemplate<string>;
12
+ export const render: SjabloonRender<string>;
package/lib/html.js CHANGED
@@ -3,19 +3,21 @@
3
3
  * 0.6's behaviour, kept for templates that target HTML directly. Everything
4
4
  * else in sjabloon is output-neutral; escaping lives here and nowhere else.
5
5
  */
6
- import { display, litNode, make } from "./core.js";
6
+ import { display, make } from "./core.js";
7
7
 
8
8
  /** @type {Record<string, string>} */
9
9
  const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
10
- /** @param {string} s Already display text — both call sites pass `display()`. */
11
- const esc = (s) => s.replace(/[&<>"']/g, (c) => ESC[c]);
12
10
 
13
11
  export { isDiagnostic, relocate } from "./core.js";
14
12
 
13
+ // The string accumulator is this edition's own shape, so its profile is stated
14
+ // here rather than named in the core: every node is shorter than an import of
15
+ // it, and the core stays the parser alone. `{{ }}` escapes at the markup edge;
16
+ // `{{{ }}}` is the same display text, verbatim.
15
17
  export const { template, render } = make([
16
- litNode,
17
- (expr) => (scope, acc, value) => ((value = expr(scope)), (acc.text += esc(display(value)))),
18
- (expr) => (scope, acc, value) => ((value = expr(scope)), (acc.text += display(value))),
18
+ (text) => (scope, acc) => (acc.text += text),
19
+ (expr) => (scope, acc) => (acc.text += display(expr(scope)).replace(/[&<>"']/g, (c) => ESC[c])),
20
+ (expr) => (scope, acc) => (acc.text += display(expr(scope))),
19
21
  () => ({ text: "" }),
20
22
  (acc) => acc.text,
21
23
  ]);
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./types.js";
2
- import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues, Token } from "./types.js";
2
+ import type { SjabloonRender, SjabloonTemplate, Token } from "./types.js";
3
3
 
4
4
  /**
5
5
  * Compile a template once, render it many times to a token stream.
@@ -7,18 +7,9 @@ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues, Token } from
7
7
  * `{{ expr }}` emits a value token; escaping belongs to whoever consumes the
8
8
  * stream, so `{{{ expr }}}` has no meaning here and is a compile-time
9
9
  * `SJABLOON_RAW_TAG` error.
10
- *
11
- * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
- * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
10
  */
14
- export function template(
15
- str: string,
16
- funcs?: SjabloonFunctions,
17
- opts?: { bound?: Iterable<string> },
18
- ): SjabloonRenderer<Token[]>;
19
-
20
- /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
21
- export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): Token[];
11
+ export const template: SjabloonTemplate<Token[]>;
12
+ export const render: SjabloonRender<Token[]>;
22
13
 
23
14
  /**
24
15
  * Join a token stream into the string `sjabloon/text` would have produced: