sjabloon 0.10.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
@@ -95,6 +95,13 @@ tpl.functions; // => ['fmt']
95
95
  template("{{ run.total }} of {{ count }}", undefined, { bound: ["run"] }).names; // => ['count']
96
96
  ```
97
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
+
98
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.
99
106
 
100
107
  ### `renderer.scoped(values)`
@@ -113,7 +120,11 @@ Shorthand for `template(str, functions)(values)`, returning whatever its edition
113
120
 
114
121
  ### `text(tokens)` (root entry only)
115
122
 
116
- 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)`.
117
128
 
118
129
  ```js
119
130
  import { template, text } from "sjabloon";
package/lib/core.js CHANGED
@@ -132,6 +132,8 @@ let last;
132
132
  let bound;
133
133
  /** @type {Set<string>} */
134
134
  let names;
135
+ /** @type {{ name: string, start: number, end: number }[]} */
136
+ let reads;
135
137
  /** @type {Set<string>} */
136
138
  let functions;
137
139
  /** @type {string} */
@@ -321,6 +323,9 @@ let compileExpr = (expr, start, context) => {
321
323
  // oxlint-disable-next-line no-unused-expressions
322
324
  bound.includes(n) || names.add(n);
323
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 });
324
329
  for (const fn of e.functions) functions.add(fn);
325
330
  return (v) => {
326
331
  try {
@@ -540,6 +545,25 @@ export const litNode = (text) => (scope, acc) => {
540
545
  acc.text += text;
541
546
  };
542
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
+
543
567
  /**
544
568
  * Bind the parser to an output profile. Each edition calls this once at module
545
569
  * load and gets back its own `template` and `render`; the parser itself stays
@@ -552,8 +576,10 @@ export const litNode = (text) => (scope, acc) => {
552
576
  * from your values, deduplicated. Loop variables the template introduces are
553
577
  * not included, and neither is anything in `opts.bound` — names the embedder
554
578
  * 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
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
557
583
  * diagnostics thrown through this renderer alone.
558
584
  *
559
585
  * Two anchors are always in scope: `$` is the root values, and `@` is the
@@ -621,6 +647,7 @@ export let make = ([lit, val, raw, seed, take]) => {
621
647
  bound = ["$", "@"];
622
648
  if (opts && opts.bound) for (const name of opts.bound) bound.push(name);
623
649
  names = new Set();
650
+ reads = [];
624
651
  functions = new Set();
625
652
  source = String(str);
626
653
  blocks = [];
@@ -656,6 +683,7 @@ export let make = ([lit, val, raw, seed, take]) => {
656
683
  // Array.from, not a spread: the bundler's transpile turns `[...set]` into
657
684
  // `[].concat(set)`, which wraps the Set instead of unpacking it.
658
685
  f.names = Array.from(names);
686
+ f.reads = reads;
659
687
  f.functions = Array.from(functions);
660
688
  // This compile's own `names` set doubles as its origin: every diagnostic
661
689
  // thrown through this renderer was marked with it, at compile time by
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
@@ -22,6 +22,15 @@ export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFun
22
22
 
23
23
  /**
24
24
  * Join a token stream into the string `sjabloon/text` would have produced:
25
- * literals verbatim, values as `String(value ?? '')`.
25
+ * literals verbatim, values through `display()`.
26
26
  */
27
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.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
  /**
@@ -50,8 +57,11 @@ export interface SjabloonScope {
50
57
  *
51
58
  * `names` are the variables the template reads from your values, deduplicated;
52
59
  * 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.
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.
55
65
  *
56
66
  * `isDiagnostic(error)` recognizes runtime diagnostics thrown through this
57
67
  * renderer alone — the per-renderer twin of the module-wide `isDiagnostic`.
@@ -65,6 +75,7 @@ export interface SjabloonScope {
65
75
  export interface SjabloonRenderer<T> {
66
76
  (values?: SjabloonValues, scope?: SjabloonScope): T;
67
77
  names: string[];
78
+ reads: SjabloonRead[];
68
79
  functions: string[];
69
80
  isDiagnostic(error: unknown): boolean;
70
81
  scoped(values: SjabloonValues): T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.10.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": {