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 +31 -2
- package/lib/core.js +105 -39
- package/lib/html.d.ts +5 -1
- package/lib/html.js +5 -5
- package/lib/index.d.ts +15 -2
- package/lib/index.js +8 -10
- package/lib/text.d.ts +5 -1
- package/lib/text.js +2 -2
- package/lib/types.d.ts +26 -3
- package/package.json +5 -5
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
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
69
|
-
*
|
|
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
|
-
* `
|
|
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,
|
|
271
|
+
* @type {(e: any, start: number, context: any, own: any,
|
|
272
|
+
* guard?: (e: unknown) => boolean) => never}
|
|
264
273
|
*/
|
|
265
|
-
const translated = (e, start, context,
|
|
266
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
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
|
|
543
|
-
*
|
|
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
|
|
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
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
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
|
-
|
|
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(
|
|
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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
10
|
-
/** @param {
|
|
11
|
-
const esc = (s) =>
|
|
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 +=
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
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(
|
|
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 +=
|
|
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
|
|
53
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
104
|
+
"limit": "2.4 kB"
|
|
105
105
|
}
|
|
106
106
|
],
|
|
107
107
|
"engines": {
|