sjabloon 0.9.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/README.md CHANGED
@@ -67,7 +67,7 @@ Every edition exports `template`, `render`, `isDiagnostic`, and `relocate`. They
67
67
 
68
68
  ## API
69
69
 
70
- ### `template(str, functions?)`
70
+ ### `template(str, functions?, options?)`
71
71
 
72
72
  Compiles the template and returns a renderer. What it renders to depends on the edition you imported from (see [Editions](#editions)); everything else on this page is identical across all three. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
73
73
 
@@ -89,6 +89,24 @@ tpl.names; // => ['title', 'items']
89
89
  tpl.functions; // => ['fmt']
90
90
  ```
91
91
 
92
+ `options.bound` lists names your engine already has in scope — a loop variable, a handle, a `page` anchor. They are excluded from `names` and still resolve normally at render time, the same contract as xprsn's own `bound`:
93
+
94
+ ```js
95
+ template("{{ run.total }} of {{ count }}", undefined, { bound: ["run"] }).names; // => ['count']
96
+ ```
97
+
98
+ The renderer also carries its own `isDiagnostic(error)`: `true` only for runtime diagnostics thrown through this renderer, where the module-wide [`isDiagnostic`](#diagnostics) answers for every template. An embedder holding many compiled templates asks the one that just rendered, so a diagnostic that leaked from an unrelated template is not mistaken for this cell's.
99
+
100
+ ### `renderer.scoped(values)`
101
+
102
+ The trusted-scope render, for an embedder whose scope chain already binds the anchors. The default call wraps `values` in a fresh scope and seeds `$` and `@` into it; `scoped` skips the wrapper: `$` and `@` resolve from `values` itself, and a chain that omits `@` leaves it unbound so `@.x` throws where there is no current item. `{{#each}}` still re-points `@` inside its body, and nothing is ever written to your objects. Rendering one template per cell per row over scopes you already build, this is the path with zero per-call allocations beyond the output.
103
+
104
+ ```js
105
+ const row = Object.create(base); // base binds $ once per render
106
+ row["@"] = item;
107
+ tpl.scoped(row);
108
+ ```
109
+
92
110
  ### `render(str, values?, functions?)`
93
111
 
94
112
  Shorthand for `template(str, functions)(values)`, returning whatever its edition renders.
package/lib/core.js CHANGED
@@ -5,8 +5,8 @@
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
11
  import { compile, isDiagnostic as isXprsnDiagnostic, relocate as relocateXprsn } from "xprsn";
12
12
 
@@ -30,10 +30,14 @@ import { compile, isDiagnostic as isXprsnDiagnostic, relocate as relocateXprsn }
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.
@@ -65,8 +69,9 @@ const kindOf = (p) => (p === SYNTAX ? SyntaxError : Error);
65
69
  * Copy a diagnostic into an embedder's coordinates.
66
70
  *
67
71
  * 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
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
70
75
  * across by descriptor — the frozen `blocks` context stays frozen and
71
76
  * non-writable — so a field added here is never a field an embedder forgets.
72
77
  *
@@ -86,7 +91,7 @@ export const relocate = (diag, { prefix = "", offset = 0 } = {}) => {
86
91
  // and attach would redefine a non-configurable property.
87
92
  if (isXprsnDiagnostic(diag)) {
88
93
  const moved = relocateXprsn(diag, { prefix, offset });
89
- return (mark(moved), /** @type {SjabloonDiagnostic} */ (moved));
94
+ return (mark(moved, origin(diag)), /** @type {SjabloonDiagnostic} */ (moved));
90
95
  }
91
96
  let d = /** @type {any} */ (diag),
92
97
  props = DESCS(d),
@@ -98,7 +103,7 @@ export const relocate = (diag, { prefix = "", offset = 0 } = {}) => {
98
103
  props.end.value += offset;
99
104
  }
100
105
  DEFINE(copy, props);
101
- return (mark(copy), /** @type {SjabloonDiagnostic} */ (copy));
106
+ return (mark(copy, origin(d)), /** @type {SjabloonDiagnostic} */ (copy));
102
107
  };
103
108
 
104
109
  // Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
@@ -219,11 +224,12 @@ let opener = (type, t) => {
219
224
  * @template {object} E
220
225
  * @param {E} e
221
226
  * @param {any} context
227
+ * @param {any} own The owning compile's `names` set, this diagnostic's origin.
222
228
  * @returns {E}
223
229
  */
224
- let attach = (e, context) => {
230
+ let attach = (e, context, own) => {
225
231
  Object.defineProperty(e, "blocks", { value: context, enumerable: true });
226
- mark(e);
232
+ mark(e, own);
227
233
  return e;
228
234
  };
229
235
  /**
@@ -250,21 +256,22 @@ const fault = (
250
256
  e.code = code;
251
257
  e.start = start;
252
258
  e.end = end;
253
- throw attach(e, snap());
259
+ throw attach(e, snap(), names);
254
260
  };
255
261
  /**
256
262
  * Re-locate a diagnostic thrown by a nested compile or render into this
257
263
  * template's coordinates, then rethrow it as ours. Always throws.
258
264
  *
259
- * `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,
260
266
  * not narrowed. `const` with an explicit `never` type is what lets callers
261
267
  * treat the catch block as terminal.
262
268
  *
263
- * @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}
264
271
  */
265
- const translated = (e, start, context, owns = isXprsnDiagnostic) => {
266
- if (!owns(e)) throw e;
267
- throw attach(relocateXprsn(e, { offset: start }), 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);
268
275
  };
269
276
  /**
270
277
  * @param {Tok} t
@@ -295,6 +302,9 @@ let run = (nodes, scope, acc) => {
295
302
  * @returns {(v: any) => any}
296
303
  */
297
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;
298
308
  /** @type {ReturnType<typeof compile>} */
299
309
  let e;
300
310
  try {
@@ -305,7 +315,7 @@ let compileExpr = (expr, start, context) => {
305
315
  // than a real one — narrowing `SjabloonFunctions` would change the API.
306
316
  e = compile(expr, /** @type {any} */ (fns));
307
317
  } catch (x) {
308
- translated(x, start, context);
318
+ translated(x, start, context, own);
309
319
  }
310
320
  e.names.forEach((n) => {
311
321
  // oxlint-disable-next-line no-unused-expressions
@@ -317,9 +327,9 @@ let compileExpr = (expr, start, context) => {
317
327
  return e(v);
318
328
  } catch (x) {
319
329
  // 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.
330
+ // `e` — xprsn's `isDiagnostic` is a closure over its store, not a method.
321
331
  // oxlint-disable-next-line typescript/unbound-method
322
- translated(x, start, context, e.isDiagnostic);
332
+ translated(x, start, context, own, e.isDiagnostic);
323
333
  }
324
334
  };
325
335
  };
@@ -533,14 +543,18 @@ export const litNode = (text) => (scope, acc) => {
533
543
  /**
534
544
  * Bind the parser to an output profile. Each edition calls this once at module
535
545
  * load and gets back its own `template` and `render`; the parser itself stays
536
- * module-level and shared, so there is exactly one diagnostics WeakSet.
546
+ * module-level and shared, so there is exactly one diagnostics store.
537
547
  *
538
- * `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.
539
550
  *
540
551
  * The returned renderer exposes `names`: the variables the template reads
541
552
  * from your values, deduplicated. Loop variables the template introduces are
542
- * not included. It also exposes `functions`: the registry functions the
543
- * 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.
544
558
  *
545
559
  * Two anchors are always in scope: `$` is the root values, and `@` is the
546
560
  * current `#each` item (the root outside any loop). They let a nested loop
@@ -552,6 +566,12 @@ export const litNode = (text) => (scope, acc) => {
552
566
  * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
553
567
  * `@` unbound, so reading `@.x` throws through xprsn's guard.
554
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
+ *
555
575
  * Render order is push order into a single accumulator: loop bodies append once
556
576
  * per iteration, untaken branches append nothing, and block expressions (`#if`
557
577
  * conditions, `#each` collections) never append at all. The token edition
@@ -575,7 +595,8 @@ export const litNode = (text) => (scope, acc) => {
575
595
  * take: (acc: A) => T,
576
596
  * ]} profile The output profile, as above.
577
597
  * @returns {{
578
- * template: (str: string, funcs?: SjabloonFunctions) => SjabloonRenderer<T>,
598
+ * template: (str: string, funcs?: SjabloonFunctions,
599
+ * opts?: { bound?: Iterable<string> }) => SjabloonRenderer<T>,
579
600
  * render: (str: string, values?: SjabloonValues, funcs?: SjabloonFunctions) => T,
580
601
  * }} That edition's API.
581
602
  * @throws {SyntaxError} `template` throws on malformed tags, unclosed blocks,
@@ -585,15 +606,20 @@ export let make = ([lit, val, raw, seed, take]) => {
585
606
  /**
586
607
  * @param {string} str
587
608
  * @param {SjabloonFunctions} [funcs]
609
+ * @param {{ bound?: Iterable<string> }} [opts]
588
610
  * @returns {SjabloonRenderer<T>}
589
611
  */
590
- function template(str, funcs) {
612
+ function template(str, funcs, opts) {
591
613
  // oxlint-disable-next-line no-unused-expressions
592
614
  ((LIT = lit), (VAL = val), (RAW = raw));
593
615
  fns = funcs;
594
616
  // `$` (root) and `@` (current item) are engine-bound anchors, always in
595
- // scope, so they never count as caller-supplied `names`.
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.
596
621
  bound = ["$", "@"];
622
+ if (opts && opts.bound) for (const name of opts.bound) bound.push(name);
597
623
  names = new Set();
598
624
  functions = new Set();
599
625
  source = String(str);
@@ -604,28 +630,40 @@ export let make = ([lit, val, raw, seed, take]) => {
604
630
  // Deeply nested blocks fail as SJABLOON_TOO_DEEP at DEPTH via opener(),
605
631
  // including elif chains — well below the native stack.
606
632
  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.
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.
613
649
  const f = (/** @type {any} */ values, /** @type {any} */ anchors) => {
614
650
  values = values || EMPTY;
615
651
  const r = Object.create(values);
616
652
  r["$"] = anchors ? anchors.root : values;
617
653
  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);
654
+ return scoped(r);
624
655
  };
625
656
  // Array.from, not a spread: the bundler's transpile turns `[...set]` into
626
657
  // `[].concat(set)`, which wraps the Set instead of unpacking it.
627
658
  f.names = Array.from(names);
628
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;
629
667
  return f;
630
668
  }
631
669
  return { template, render: (str, values, funcs) => template(str, funcs)(values) };
package/lib/html.d.ts CHANGED
@@ -11,7 +11,11 @@ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues } from "./type
11
11
  * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
12
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
13
  */
14
- export function template(str: string, funcs?: SjabloonFunctions): SjabloonRenderer<string>;
14
+ export function template(
15
+ str: string,
16
+ funcs?: SjabloonFunctions,
17
+ opts?: { bound?: Iterable<string> },
18
+ ): SjabloonRenderer<string>;
15
19
 
16
20
  /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
17
21
  export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): string;
package/lib/index.d.ts CHANGED
@@ -11,7 +11,11 @@ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues, Token } from
11
11
  * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
12
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
13
  */
14
- export function template(str: string, funcs?: SjabloonFunctions): SjabloonRenderer<Token[]>;
14
+ export function template(
15
+ str: string,
16
+ funcs?: SjabloonFunctions,
17
+ opts?: { bound?: Iterable<string> },
18
+ ): SjabloonRenderer<Token[]>;
15
19
 
16
20
  /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
17
21
  export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): Token[];
package/lib/text.d.ts CHANGED
@@ -11,7 +11,11 @@ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues } from "./type
11
11
  * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
12
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
13
  */
14
- export function template(str: string, funcs?: SjabloonFunctions): SjabloonRenderer<string>;
14
+ export function template(
15
+ str: string,
16
+ funcs?: SjabloonFunctions,
17
+ opts?: { bound?: Iterable<string> },
18
+ ): SjabloonRenderer<string>;
15
19
 
16
20
  /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
17
21
  export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): string;
package/lib/types.d.ts CHANGED
@@ -49,13 +49,25 @@ export interface SjabloonScope {
49
49
  * A compiled template: render it many times.
50
50
  *
51
51
  * `names` are the variables the template reads from your values, deduplicated;
52
- * loop variables the template introduces are not included. `functions` are the
53
- * registry functions the template calls, deduplicated.
52
+ * loop variables the template introduces are not included, and neither is
53
+ * anything the compile's `bound` option declared. `functions` are the registry
54
+ * functions the template calls, deduplicated.
55
+ *
56
+ * `isDiagnostic(error)` recognizes runtime diagnostics thrown through this
57
+ * renderer alone — the per-renderer twin of the module-wide `isDiagnostic`.
58
+ *
59
+ * `scoped(values)` renders over a scope chain that already binds the anchors:
60
+ * no wrapper scope is created, `$` and `@` resolve from `values` itself, and a
61
+ * chain that omits `@` leaves it unbound so `@.x` throws through xprsn's
62
+ * guard. The zero-allocation seam for an embedder rendering over scopes it
63
+ * already builds; `{{#each}}` still re-points `@` inside its body.
54
64
  */
55
65
  export interface SjabloonRenderer<T> {
56
66
  (values?: SjabloonValues, scope?: SjabloonScope): T;
57
67
  names: string[];
58
68
  functions: string[];
69
+ isDiagnostic(error: unknown): boolean;
70
+ scoped(values: SjabloonValues): T;
59
71
  }
60
72
 
61
73
  /** One static text run of the template, verbatim. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Tiny, CSP-safe template engine for JavaScript, powered by xprsn expressions. No eval, no new Function.",
5
5
  "keywords": [
6
6
  "csp",