sjabloon 0.9.0 → 0.11.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,13 +89,42 @@ 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 `reads`: every root-name read with its span in the template source, in source order. Duplicates, anchors, loop variables and bound names are all kept — `names` is the free, deduplicated view. This is what an editor squiggles, hovers, and jumps from:
99
+
100
+ ```js
101
+ template("{{ title }}: {{ total }}").reads;
102
+ // => [{ name: 'title', start: 3, end: 8 }, { name: 'total', start: 16, end: 21 }]
103
+ ```
104
+
105
+ 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.
106
+
107
+ ### `renderer.scoped(values)`
108
+
109
+ 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.
110
+
111
+ ```js
112
+ const row = Object.create(base); // base binds $ once per render
113
+ row["@"] = item;
114
+ tpl.scoped(row);
115
+ ```
116
+
92
117
  ### `render(str, values?, functions?)`
93
118
 
94
119
  Shorthand for `template(str, functions)(values)`, returning whatever its edition renders.
95
120
 
96
121
  ### `text(tokens)` (root entry only)
97
122
 
98
- Joins a token stream the way `sjabloon/text` would have rendered it: literals verbatim, values as `String(value ?? '')`. `text(template(str)(values))` and `sjabloon/text`'s `template(str)(values)` are equal for every template and every set of values. The test suite and the fuzzer both check that.
123
+ Joins a token stream the way `sjabloon/text` would have rendered it: literals verbatim, values through `display()`. `text(template(str)(values))` and `sjabloon/text`'s `template(str)(values)` are equal for every template and every set of values. The test suite and the fuzzer both check that.
124
+
125
+ ### `display(value)` (root entry only)
126
+
127
+ The scalar display rule every edition and `text()` share, exported for embedders that stringify token values themselves. A valid `Date` renders as ISO 8601 UTC (`toISOString()`) — the same bytes on every machine, where `String(date)` would bake in the host's timezone and locale. An invalid `Date` keeps its deterministic `'Invalid Date'` form, nullish displays empty, and everything else is `String(value)`.
99
128
 
100
129
  ```js
101
130
  import { template, text } from "sjabloon";
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;
@@ -127,6 +132,8 @@ let last;
127
132
  let bound;
128
133
  /** @type {Set<string>} */
129
134
  let names;
135
+ /** @type {{ name: string, start: number, end: number }[]} */
136
+ let reads;
130
137
  /** @type {Set<string>} */
131
138
  let functions;
132
139
  /** @type {string} */
@@ -219,11 +226,12 @@ let opener = (type, t) => {
219
226
  * @template {object} E
220
227
  * @param {E} e
221
228
  * @param {any} context
229
+ * @param {any} own The owning compile's `names` set, this diagnostic's origin.
222
230
  * @returns {E}
223
231
  */
224
- let attach = (e, context) => {
232
+ let attach = (e, context, own) => {
225
233
  Object.defineProperty(e, "blocks", { value: context, enumerable: true });
226
- mark(e);
234
+ mark(e, own);
227
235
  return e;
228
236
  };
229
237
  /**
@@ -250,21 +258,22 @@ const fault = (
250
258
  e.code = code;
251
259
  e.start = start;
252
260
  e.end = end;
253
- throw attach(e, snap());
261
+ throw attach(e, snap(), names);
254
262
  };
255
263
  /**
256
264
  * Re-locate a diagnostic thrown by a nested compile or render into this
257
265
  * template's coordinates, then rethrow it as ours. Always throws.
258
266
  *
259
- * `owns` is a plain predicate rather than a type guard: `e` is retyped here,
267
+ * `guard` is a plain predicate rather than a type guard: `e` is retyped here,
260
268
  * not narrowed. `const` with an explicit `never` type is what lets callers
261
269
  * treat the catch block as terminal.
262
270
  *
263
- * @type {(e: any, start: number, context: any, owns?: (e: unknown) => boolean) => never}
271
+ * @type {(e: any, start: number, context: any, own: any,
272
+ * guard?: (e: unknown) => boolean) => never}
264
273
  */
265
- const translated = (e, start, context, owns = isXprsnDiagnostic) => {
266
- if (!owns(e)) throw e;
267
- throw attach(relocateXprsn(e, { offset: start }), context);
274
+ const translated = (e, start, context, own, guard = isXprsnDiagnostic) => {
275
+ if (!guard(e)) throw e;
276
+ throw attach(relocateXprsn(e, { offset: start }), context, own);
268
277
  };
269
278
  /**
270
279
  * @param {Tok} t
@@ -295,6 +304,9 @@ let run = (nodes, scope, acc) => {
295
304
  * @returns {(v: any) => any}
296
305
  */
297
306
  let compileExpr = (expr, start, context) => {
307
+ // The compiling template's origin, captured now: the render-time catch below
308
+ // runs long after the module-level `names` has moved on to other compiles.
309
+ const own = names;
298
310
  /** @type {ReturnType<typeof compile>} */
299
311
  let e;
300
312
  try {
@@ -305,21 +317,24 @@ let compileExpr = (expr, start, context) => {
305
317
  // than a real one — narrowing `SjabloonFunctions` would change the API.
306
318
  e = compile(expr, /** @type {any} */ (fns));
307
319
  } catch (x) {
308
- translated(x, start, context);
320
+ translated(x, start, context, own);
309
321
  }
310
322
  e.names.forEach((n) => {
311
323
  // oxlint-disable-next-line no-unused-expressions
312
324
  bound.includes(n) || names.add(n);
313
325
  });
326
+ // Every read, shifted into template coordinates — bound names and loop
327
+ // variables included; `names` above stays the free, deduplicated view.
328
+ for (const r of e.reads) reads.push({ name: r.name, start: start + r.start, end: start + r.end });
314
329
  for (const fn of e.functions) functions.add(fn);
315
330
  return (v) => {
316
331
  try {
317
332
  return e(v);
318
333
  } catch (x) {
319
334
  // 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.
335
+ // `e` — xprsn's `isDiagnostic` is a closure over its store, not a method.
321
336
  // oxlint-disable-next-line typescript/unbound-method
322
- translated(x, start, context, e.isDiagnostic);
337
+ translated(x, start, context, own, e.isDiagnostic);
323
338
  }
324
339
  };
325
340
  };
@@ -530,17 +545,42 @@ export const litNode = (text) => (scope, acc) => {
530
545
  acc.text += text;
531
546
  };
532
547
 
548
+ /**
549
+ * Display text for one interpolated value — the scalar rule every edition and
550
+ * the root `text()` join share. A valid `Date` renders as ISO 8601 UTC
551
+ * (`toISOString()`), the same on every machine, where `String(date)` would
552
+ * bake in the host's timezone and locale; an invalid `Date` keeps its
553
+ * deterministic `"Invalid Date"` form. Nullish displays empty; everything
554
+ * else is `String(value)`.
555
+ *
556
+ * @param {unknown} value One rendered value.
557
+ * @returns {string} The display text.
558
+ */
559
+ export const display = (value) =>
560
+ value instanceof Date && Number.isFinite(value.getTime())
561
+ ? value.toISOString()
562
+ : // Stringifying an arbitrary value is this rule's documented contract, so
563
+ // `no-base-to-string` is describing the feature rather than a mistake.
564
+ // oxlint-disable-next-line typescript/no-base-to-string
565
+ String(value ?? "");
566
+
533
567
  /**
534
568
  * Bind the parser to an output profile. Each edition calls this once at module
535
569
  * 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.
570
+ * module-level and shared, so there is exactly one diagnostics store.
537
571
  *
538
- * `template(str, funcs?)` compiles a template once, to render it many times.
572
+ * `template(str, funcs?, opts?)` compiles a template once, to render it many
573
+ * times.
539
574
  *
540
575
  * The returned renderer exposes `names`: the variables the template reads
541
576
  * 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.
577
+ * not included, and neither is anything in `opts.bound` names the embedder
578
+ * already has in scope (still resolved normally at render time, exactly like
579
+ * xprsn's own `bound`). It also exposes `reads`: every root-name read with its
580
+ * span in the template source, in source order — duplicates, anchors, loop
581
+ * variables and bound names kept, so `names` is its free, deduplicated view.
582
+ * And `functions`: the registry functions the template calls, deduplicated. `isDiagnostic(error)` recognizes runtime
583
+ * diagnostics thrown through this renderer alone.
544
584
  *
545
585
  * Two anchors are always in scope: `$` is the root values, and `@` is the
546
586
  * current `#each` item (the root outside any loop). They let a nested loop
@@ -552,6 +592,12 @@ export const litNode = (text) => (scope, acc) => {
552
592
  * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
553
593
  * `@` unbound, so reading `@.x` throws through xprsn's guard.
554
594
  *
595
+ * An embedder whose scope chain already binds the anchors renders through
596
+ * `scoped(values)` instead: no wrapper scope is created, `$` and `@` resolve
597
+ * from `values` itself, and a chain that omits `@` leaves it unbound the same
598
+ * way. That is the zero-allocation seam for a host rendering one template per
599
+ * cell per row over scopes it already builds.
600
+ *
555
601
  * Render order is push order into a single accumulator: loop bodies append once
556
602
  * per iteration, untaken branches append nothing, and block expressions (`#if`
557
603
  * conditions, `#each` collections) never append at all. The token edition
@@ -575,7 +621,8 @@ export const litNode = (text) => (scope, acc) => {
575
621
  * take: (acc: A) => T,
576
622
  * ]} profile The output profile, as above.
577
623
  * @returns {{
578
- * template: (str: string, funcs?: SjabloonFunctions) => SjabloonRenderer<T>,
624
+ * template: (str: string, funcs?: SjabloonFunctions,
625
+ * opts?: { bound?: Iterable<string> }) => SjabloonRenderer<T>,
579
626
  * render: (str: string, values?: SjabloonValues, funcs?: SjabloonFunctions) => T,
580
627
  * }} That edition's API.
581
628
  * @throws {SyntaxError} `template` throws on malformed tags, unclosed blocks,
@@ -585,16 +632,22 @@ export let make = ([lit, val, raw, seed, take]) => {
585
632
  /**
586
633
  * @param {string} str
587
634
  * @param {SjabloonFunctions} [funcs]
635
+ * @param {{ bound?: Iterable<string> }} [opts]
588
636
  * @returns {SjabloonRenderer<T>}
589
637
  */
590
- function template(str, funcs) {
638
+ function template(str, funcs, opts) {
591
639
  // oxlint-disable-next-line no-unused-expressions
592
640
  ((LIT = lit), (VAL = val), (RAW = raw));
593
641
  fns = funcs;
594
642
  // `$` (root) and `@` (current item) are engine-bound anchors, always in
595
- // scope, so they never count as caller-supplied `names`.
643
+ // scope, so they never count as caller-supplied `names` — and neither does
644
+ // anything the embedder declares bound. A loop, not a spread: the
645
+ // bundler's transpile turns an iterable spread into a concat that would
646
+ // wrap a Set instead of unpacking it.
596
647
  bound = ["$", "@"];
648
+ if (opts && opts.bound) for (const name of opts.bound) bound.push(name);
597
649
  names = new Set();
650
+ reads = [];
598
651
  functions = new Set();
599
652
  source = String(str);
600
653
  blocks = [];
@@ -604,28 +657,41 @@ export let make = ([lit, val, raw, seed, take]) => {
604
657
  // Deeply nested blocks fail as SJABLOON_TOO_DEEP at DEPTH via opener(),
605
658
  // including elif chains — well below the native stack.
606
659
  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.
660
+ // The trusted-scope render, and the one render body: the caller's chain
661
+ // already carries the anchors, so no wrapper is created and nothing is
662
+ // written anywhere. One accumulator per render, owned here and threaded
663
+ // down. A registry function that renders another template gets its own,
664
+ // so re-entrancy needs no bookkeeping.
665
+ const scoped = (/** @type {any} */ values) => {
666
+ const acc = seed();
667
+ run(nodes, values, acc);
668
+ return take(acc);
669
+ };
670
+ // The default render wraps the values in a root scope carrying the
671
+ // anchors, without mutating what the caller passed: by default `$` and `@`
672
+ // both point at the root. An embedder can override the anchors with a
673
+ // `{ root, item }` second arg: `$` = root, `@` = item (distinct objects).
674
+ // Omitting `item` leaves `@` unbound, so `@.x` throws through xprsn's
675
+ // guard — a group-header band that has no current row wants exactly that.
613
676
  const f = (/** @type {any} */ values, /** @type {any} */ anchors) => {
614
677
  values = values || EMPTY;
615
678
  const r = Object.create(values);
616
679
  r["$"] = anchors ? anchors.root : values;
617
680
  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);
681
+ return scoped(r);
624
682
  };
625
683
  // Array.from, not a spread: the bundler's transpile turns `[...set]` into
626
684
  // `[].concat(set)`, which wraps the Set instead of unpacking it.
627
685
  f.names = Array.from(names);
686
+ f.reads = reads;
628
687
  f.functions = Array.from(functions);
688
+ // This compile's own `names` set doubles as its origin: every diagnostic
689
+ // thrown through this renderer was marked with it, at compile time by
690
+ // `fault` and at render time by the closures `compileExpr` built. Captured
691
+ // now — the module-level `names` moves on to the next compile.
692
+ const o = names;
693
+ f.isDiagnostic = (/** @type {unknown} */ x) => origin(x) === o;
694
+ f.scoped = scoped;
629
695
  return f;
630
696
  }
631
697
  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/html.js CHANGED
@@ -3,19 +3,19 @@
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 { litNode, make } from "./core.js";
6
+ import { display, litNode, make } from "./core.js";
7
7
 
8
8
  /** @type {Record<string, string>} */
9
9
  const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
10
- /** @param {any} s */
11
- const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ESC[c]);
10
+ /** @param {string} s Already display text — both call sites pass `display()`. */
11
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ESC[c]);
12
12
 
13
13
  export { isDiagnostic, relocate } from "./core.js";
14
14
 
15
15
  export const { template, render } = make([
16
16
  litNode,
17
- (expr) => (scope, acc, value) => ((value = expr(scope)), (acc.text += esc(value ?? ""))),
18
- (expr) => (scope, acc, value) => ((value = expr(scope)), (acc.text += String(value ?? ""))),
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))),
19
19
  () => ({ text: "" }),
20
20
  (acc) => acc.text,
21
21
  ]);
package/lib/index.d.ts CHANGED
@@ -11,13 +11,26 @@ 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[];
18
22
 
19
23
  /**
20
24
  * Join a token stream into the string `sjabloon/text` would have produced:
21
- * literals verbatim, values as `String(value ?? '')`.
25
+ * literals verbatim, values through `display()`.
22
26
  */
23
27
  export function text(tokens: readonly Token[]): string;
28
+
29
+ /**
30
+ * Display text for one interpolated value — the scalar rule `text()` and the
31
+ * string editions share. A valid `Date` renders as ISO 8601 UTC
32
+ * (`toISOString()`), deterministically across machines; an invalid `Date`
33
+ * stays `"Invalid Date"`; nullish displays empty; everything else is
34
+ * `String(value)`.
35
+ */
36
+ export function display(value: unknown): string;
package/lib/index.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * so nothing here is HTML-aware and `{{{ }}}` has no meaning — `{{ }}` is
5
5
  * already raw.
6
6
  */
7
- import { make } from "./core.js";
7
+ import { display, make } from "./core.js";
8
8
 
9
- export { isDiagnostic, relocate } from "./core.js";
9
+ export { display, isDiagnostic, relocate } from "./core.js";
10
10
 
11
11
  export const { template, render } = make([
12
12
  // Static text is a compile-time constant: hoist and freeze one token per
@@ -25,20 +25,18 @@ export const { template, render } = make([
25
25
 
26
26
  /**
27
27
  * Join a token stream into the string `sjabloon/text` would have produced:
28
- * literals verbatim, values as `String(value ?? '')`.
28
+ * literals verbatim, values through `display()` the one scalar rule, so a
29
+ * `Date` joins as ISO 8601 UTC here exactly as the string editions render it.
29
30
  *
30
31
  * @param {readonly import('./types.js').Token[]} tokens A render's output.
31
32
  * @returns {string} The joined text.
32
33
  */
33
34
  export const text = (tokens) => {
34
35
  let s = "";
35
- // One `?? ''` per token, so a literal never stringifies and a nullish value
36
- // still renders empty. Widened here because each token carries one key or
37
- // the other, which the public union deliberately does not model.
36
+ // A literal never stringifies and a nullish value still renders empty.
37
+ // Widened here because each token carries one key or the other, which the
38
+ // public union deliberately does not model.
38
39
  for (const t of /** @type {readonly { literal?: string, value?: unknown }[]} */ (tokens))
39
- // Stringifying an arbitrary value is this helper's documented contract, so
40
- // `no-base-to-string` is describing the feature rather than a mistake.
41
- // oxlint-disable-next-line typescript/no-base-to-string
42
- s += t.literal ?? String(t.value ?? "");
40
+ s += t.literal ?? display(t.value);
43
41
  return s;
44
42
  };
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/text.js CHANGED
@@ -6,13 +6,13 @@
6
6
  * Definitionally `text(template(str)(values))` from the root entry, but built
7
7
  * as a string accumulator so casual string users never allocate tokens.
8
8
  */
9
- import { litNode, make } from "./core.js";
9
+ import { display, litNode, make } from "./core.js";
10
10
 
11
11
  export { isDiagnostic, relocate } from "./core.js";
12
12
 
13
13
  export const { template, render } = make([
14
14
  litNode,
15
- (expr) => (scope, acc, value) => ((value = expr(scope)), (acc.text += String(value ?? ""))),
15
+ (expr) => (scope, acc, value) => ((value = expr(scope)), (acc.text += display(value))),
16
16
  0,
17
17
  () => ({ text: "" }),
18
18
  (acc) => acc.text,
package/lib/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { XprsnErrorCode } from "xprsn";
1
+ import type { XprsnErrorCode, XprsnRead } from "xprsn";
2
2
 
3
3
  export type SjabloonErrorCode =
4
4
  | XprsnErrorCode
@@ -24,6 +24,13 @@ export interface SjabloonDiagnostic extends Error {
24
24
  }
25
25
 
26
26
  export type SjabloonValues = Record<string, any>;
27
+
28
+ /**
29
+ * One root-name read, with its span in the template source — xprsn's read
30
+ * record, forwarded with only its coordinates shifted.
31
+ */
32
+ export type SjabloonRead = XprsnRead;
33
+
27
34
  export type SjabloonFunctions = Record<string, Function>;
28
35
 
29
36
  /**
@@ -49,13 +56,29 @@ export interface SjabloonScope {
49
56
  * A compiled template: render it many times.
50
57
  *
51
58
  * `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.
59
+ * loop variables the template introduces are not included, and neither is
60
+ * anything the compile's `bound` option declared. `reads` are every root-name
61
+ * read with its span in the template source, in source order — duplicates,
62
+ * anchors, loop variables and bound names kept, so `names` is its free,
63
+ * deduplicated view. `functions` are the registry functions the template
64
+ * calls, deduplicated.
65
+ *
66
+ * `isDiagnostic(error)` recognizes runtime diagnostics thrown through this
67
+ * renderer alone — the per-renderer twin of the module-wide `isDiagnostic`.
68
+ *
69
+ * `scoped(values)` renders over a scope chain that already binds the anchors:
70
+ * no wrapper scope is created, `$` and `@` resolve from `values` itself, and a
71
+ * chain that omits `@` leaves it unbound so `@.x` throws through xprsn's
72
+ * guard. The zero-allocation seam for an embedder rendering over scopes it
73
+ * already builds; `{{#each}}` still re-points `@` inside its body.
54
74
  */
55
75
  export interface SjabloonRenderer<T> {
56
76
  (values?: SjabloonValues, scope?: SjabloonScope): T;
57
77
  names: string[];
78
+ reads: SjabloonRead[];
58
79
  functions: string[];
80
+ isDiagnostic(error: unknown): boolean;
81
+ scoped(values: SjabloonValues): T;
59
82
  }
60
83
 
61
84
  /** 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.11.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",
@@ -61,7 +61,7 @@
61
61
  "test:unit": "c8 --100 --src lib/ node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js"
62
62
  },
63
63
  "dependencies": {
64
- "xprsn": "^0.10.0"
64
+ "xprsn": "^0.11.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@arethetypeswrong/cli": "^0.18.3",
@@ -85,7 +85,7 @@
85
85
  "ignore": [
86
86
  "xprsn"
87
87
  ],
88
- "limit": "2.25 kB"
88
+ "limit": "2.35 kB"
89
89
  },
90
90
  {
91
91
  "name": "sjabloon/text",
@@ -93,7 +93,7 @@
93
93
  "ignore": [
94
94
  "xprsn"
95
95
  ],
96
- "limit": "2.25 kB"
96
+ "limit": "2.35 kB"
97
97
  },
98
98
  {
99
99
  "name": "sjabloon/html",
@@ -101,7 +101,7 @@
101
101
  "ignore": [
102
102
  "xprsn"
103
103
  ],
104
- "limit": "2.3 kB"
104
+ "limit": "2.4 kB"
105
105
  }
106
106
  ],
107
107
  "engines": {