sjabloon 0.12.0 → 0.13.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/EMBEDDING.md +36 -0
- package/README.md +3 -2
- package/lib/core.js +23 -21
- package/lib/index.js +8 -1
- package/lib/types.d.ts +24 -2
- package/package.json +5 -2
package/EMBEDDING.md
CHANGED
|
@@ -191,6 +191,42 @@ on:
|
|
|
191
191
|
fresh per emit. Do not mutate or key a cache on a literal token's identity
|
|
192
192
|
across iterations.
|
|
193
193
|
|
|
194
|
+
### Naming an interpolation with `tag`
|
|
195
|
+
|
|
196
|
+
A value token carries its value and nothing about where it came from, so an
|
|
197
|
+
embedder that has to treat one interpolation differently from the rest — a page
|
|
198
|
+
number a word processor writes as a live field, rather than the number the
|
|
199
|
+
render happened to see — cannot tell them apart from the stream. `tag` is that
|
|
200
|
+
seam:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
const FIELD = { "page.number": { field: "page.number" } };
|
|
204
|
+
const tpl = template("Page {{ page.number }} of {{ page.total }}", fns, {
|
|
205
|
+
tag: (expr) => FIELD[expr],
|
|
206
|
+
});
|
|
207
|
+
tpl({ page: { number: 1, total: 2 } });
|
|
208
|
+
// [{ literal: 'Page ' }, { value: 1, field: 'page.number' }, { literal: ' of ' }, { value: 2 }]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- **Called once per interpolation, while compiling.** Never at render time, so
|
|
212
|
+
what a token carries is a compile-time constant and a hot render pays nothing
|
|
213
|
+
for the keys it does not have. A template compiled without `tag` emits the
|
|
214
|
+
exact closure and the exact stream it always did.
|
|
215
|
+
- **The argument is the expression source as written and trimmed.** `{{ x }}`,
|
|
216
|
+
`{{x}}` and `{{- x -}}` all arrive as `'x'`, so an equality test against the
|
|
217
|
+
spelling you are looking for is exact. Match it, or return `undefined` and the
|
|
218
|
+
token is untouched.
|
|
219
|
+
- **The returned keys join every value token that interpolation emits** — once
|
|
220
|
+
per loop iteration, under the names you chose. `value` and `literal` are the
|
|
221
|
+
stream's own: `value` is written last so a tag cannot take it over, and
|
|
222
|
+
returning `literal` would make a token answer to both kinds, so do not.
|
|
223
|
+
- **It runs inside the parse**, which is shared, synchronous state. Read the
|
|
224
|
+
expression and return; do not compile another template from within it.
|
|
225
|
+
- **Block expressions are not offered.** `#if` conditions and `#each`
|
|
226
|
+
collections steer the render and emit no token, so there is nothing to name.
|
|
227
|
+
- The token edition alone has tokens to carry the keys; `sjabloon/text` and
|
|
228
|
+
`sjabloon/html` ignore the option.
|
|
229
|
+
|
|
194
230
|
`text(tokens)` joins a stream the way `sjabloon/text` would have rendered it, and
|
|
195
231
|
the two are equal for every template and every set of values. The test suite and
|
|
196
232
|
the fuzzer both check that.
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ sjabloon renders a template against a values object. Templates can interpolate,
|
|
|
93
93
|
|
|
94
94
|
## Related packages
|
|
95
95
|
|
|
96
|
-
sjabloon is the template layer of a
|
|
96
|
+
sjabloon is the template layer of a set that shares one approach — parse to closures, never to code — and whose only runtime dependencies are each other and [waarmerk](https://github.com/getquario/waarmerk), the located-diagnostic module they mint through:
|
|
97
97
|
|
|
98
98
|
- **[xprsn](https://github.com/getquario/xprsn)** — the expression language sjabloon runs inside every tag, usable on its own if you need to evaluate _one_ expression against data rather than render text. Its [syntax reference](https://github.com/getquario/xprsn#syntax) is the reference for everything between the braces here.
|
|
99
99
|
- **[padvinder](https://github.com/getquario/padvinder)** — a JSONPath engine, if you need to _select nodes_ out of a document. Filter evaluation is the part of JSONPath that has produced real code-injection CVEs elsewhere; padvinder parses filters to closures with no route to code execution, and passes the full RFC 9535 compliance suite.
|
|
@@ -214,7 +214,7 @@ try {
|
|
|
214
214
|
}
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
-
Parser codes are `SJABLOON_EACH_SYNTAX`, `SJABLOON_BLOCKED_BINDING`, `SJABLOON_UNEXPECTED_TAG`, `SJABLOON_UNKNOWN_BLOCK`, `SJABLOON_UNCLOSED_BLOCK`, `SJABLOON_RAW_TAG` (a `{{{ }}}` tag outside the HTML edition, located at the whole tag), and `SJABLOON_TOO_DEEP` (block nesting past 256 levels, located at the opener that crossed the cap). A missing closer uses an empty span at the end of the template. Expression offsets refer to the original template, so surrounding braces, whitespace, and trim markers contribute to their absolute position.
|
|
217
|
+
Parser codes are `SJABLOON_EACH_SYNTAX`, `SJABLOON_BLOCKED_BINDING`, `SJABLOON_UNEXPECTED_TAG`, `SJABLOON_UNKNOWN_BLOCK`, `SJABLOON_UNCLOSED_BLOCK`, `SJABLOON_RAW_TAG` (a `{{{ }}}` tag outside the HTML edition, located at the whole tag), and `SJABLOON_TOO_DEEP` (block nesting past 256 levels, located at the opener that crossed the cap; `#elif` links are not nesting and do not count). A missing closer uses an empty span at the end of the template. Expression offsets refer to the original template, so surrounding braces, whitespace, and trim markers contribute to their absolute position.
|
|
218
218
|
|
|
219
219
|
Errors thrown by registered functions, getters, methods, or value coercion hooks are host errors. Sjabloon passes them through unchanged and does not attach template diagnostic fields. `isDiagnostic(error)` is how you tell the two apart; it authenticates by identity rather than by shape, which has consequences worth knowing if you embed sjabloon — see [EMBEDDING.md](EMBEDDING.md#diagnostic-identity).
|
|
220
220
|
|
|
@@ -268,6 +268,7 @@ If you compile templates out of a larger document — a cell in a report, a fiel
|
|
|
268
268
|
git clone https://github.com/getquario/sjabloon.git
|
|
269
269
|
cd sjabloon
|
|
270
270
|
npm install
|
|
271
|
+
git config core.hooksPath .githooks # enable the commit-msg hook
|
|
271
272
|
npm run check
|
|
272
273
|
```
|
|
273
274
|
|
package/lib/core.js
CHANGED
|
@@ -95,10 +95,14 @@ const EMPTY = Object.freeze({});
|
|
|
95
95
|
// Shared parser state; parsing is synchronous so this is safe.
|
|
96
96
|
// LIT/VAL/RAW are the compiling profile's node builders — read only while
|
|
97
97
|
// parsing, never at render time, so the hot path stays free of indirection.
|
|
98
|
-
// `nest` is the nesting budget shared by `#if`/`#each`/`#elif` (see opener).
|
|
99
98
|
let /** @type {Tok[]} */ tokens, /** @type {Tok} */ last, /** @type {string} */ source;
|
|
100
|
-
let /** @type {number} */ i
|
|
99
|
+
let /** @type {number} */ i;
|
|
101
100
|
let /** @type {SjabloonFunctions | undefined} */ fns, /** @type {string[]} */ bound;
|
|
101
|
+
// The compile's `tag`, read once per interpolation while parsing and never at
|
|
102
|
+
// render time. Always callable, so the parse has no branch to take for the
|
|
103
|
+
// embedders that do not ask: this one names nothing.
|
|
104
|
+
let NO_TAG = () => undefined;
|
|
105
|
+
let /** @type {(expr: string) => object | undefined} */ tagOf = NO_TAG;
|
|
102
106
|
let /** @type {Set<string>} */ names, /** @type {Set<string>} */ functions;
|
|
103
107
|
/** @type {{ name: string, start: number, end: number }[]} */
|
|
104
108
|
let reads;
|
|
@@ -134,7 +138,8 @@ let takeScanned = (a) => {
|
|
|
134
138
|
const r = +(lxB > lxP) & +(source[lxB - 1] === "-"),
|
|
135
139
|
whole = source.slice(lxP, lxB - r),
|
|
136
140
|
body = whole.trim(),
|
|
137
|
-
|
|
141
|
+
// search returns -1 on all-whitespace, so start sits on the last `{`.
|
|
142
|
+
start = lxP + whole.search(/\S/),
|
|
138
143
|
end = lxB + 2 + lxRaw;
|
|
139
144
|
// oxlint-disable-next-line no-unused-expressions
|
|
140
145
|
lxL && trimPrev();
|
|
@@ -173,8 +178,7 @@ let snap = () => Object.freeze(blocks.slice());
|
|
|
173
178
|
*/
|
|
174
179
|
let opener = (type, t) => {
|
|
175
180
|
// oxlint-disable-next-line no-unused-expressions
|
|
176
|
-
|
|
177
|
-
nest++;
|
|
181
|
+
blocks.length < 256 || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
|
|
178
182
|
return Object.freeze({ type, start: t[2], end: t[3] });
|
|
179
183
|
};
|
|
180
184
|
/**
|
|
@@ -289,10 +293,10 @@ let elseTail = (close, nodes = []) => {
|
|
|
289
293
|
/**
|
|
290
294
|
* One `#if` branch and whatever hangs off it. An `{{#elif}}` link is itself a
|
|
291
295
|
* branch, so the chain tail recurses straight back in here rather than through
|
|
292
|
-
* a helper.
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
+
* a helper. The block `opener` mints for one is discarded, never pushed: an
|
|
297
|
+
* elif link is not an extra `#if` frame in diagnostic context and does not
|
|
298
|
+
* count toward the nesting cap. `t` doubles as the scratch slot for the link
|
|
299
|
+
* it builds.
|
|
296
300
|
*
|
|
297
301
|
* @param {(v: any) => any} cond
|
|
298
302
|
* @param {Node<any>[]} [then]
|
|
@@ -306,7 +310,7 @@ let branch =
|
|
|
306
310
|
then = parse(["/if", "#elif", "#else"]),
|
|
307
311
|
t = last,
|
|
308
312
|
els = t[1].startsWith("#elif ")
|
|
309
|
-
? (opener("elif", t), (t = branch(compileExpr(t[1].slice(6), t[4] + 6))),
|
|
313
|
+
? (opener("elif", t), (t = branch(compileExpr(t[1].slice(6), t[4] + 6))), [t])
|
|
310
314
|
: elseTail("/if"),
|
|
311
315
|
) =>
|
|
312
316
|
(scope, acc) =>
|
|
@@ -368,7 +372,6 @@ let parseEach = (t, tag, nodes) => {
|
|
|
368
372
|
bound.length = mark;
|
|
369
373
|
const empty = elseTail("/each");
|
|
370
374
|
blocks.pop();
|
|
371
|
-
nest--;
|
|
372
375
|
nodes.push((scope, acc) => {
|
|
373
376
|
const listValue = list(scope),
|
|
374
377
|
arr = Array.isArray(listValue);
|
|
@@ -424,7 +427,6 @@ let emitBlock = (t, tag, nodes) => {
|
|
|
424
427
|
blocks.push(opener("if", t));
|
|
425
428
|
nodes.push(branch(compileExpr(tag.slice(4), t[4] + 4)));
|
|
426
429
|
blocks.pop();
|
|
427
|
-
nest--;
|
|
428
430
|
return;
|
|
429
431
|
}
|
|
430
432
|
if (/^#each(?:\s|$)/.test(tag)) return parseEach(t, tag, nodes);
|
|
@@ -441,7 +443,7 @@ let emitTag = (t, tag, nodes) => {
|
|
|
441
443
|
if (tag[0] === "!") return;
|
|
442
444
|
if (tag[0] === "#") return emitBlock(t, tag, nodes);
|
|
443
445
|
if (tag[0] === "/") unexpected(t);
|
|
444
|
-
nodes.push(VAL(compileExpr(t[1], t[4])));
|
|
446
|
+
nodes.push(VAL(compileExpr(t[1], t[4]), tagOf(t[1])));
|
|
445
447
|
};
|
|
446
448
|
|
|
447
449
|
/**
|
|
@@ -536,7 +538,8 @@ export const display = (value) =>
|
|
|
536
538
|
*
|
|
537
539
|
* A profile is `[lit, val, raw, seed, take]`:
|
|
538
540
|
* lit(text) node emitting one static text run
|
|
539
|
-
* val(expr) node emitting a `{{ }}` interpolation
|
|
541
|
+
* val(expr, tagged) node emitting a `{{ }}` interpolation, carrying what
|
|
542
|
+
* the compile's `tag` returned for it (undefined when untagged)
|
|
540
543
|
* raw(expr) node emitting a `{{{ }}}` interpolation
|
|
541
544
|
* seed() a fresh output accumulator, one per render
|
|
542
545
|
* take(acc) the render's return value
|
|
@@ -546,7 +549,7 @@ export const display = (value) =>
|
|
|
546
549
|
* @template T What one render returns.
|
|
547
550
|
* @param {[
|
|
548
551
|
* lit: (text: string) => Node<A>,
|
|
549
|
-
* val: (expr: (scope: any) => any) => Node<A>,
|
|
552
|
+
* val: (expr: (scope: any) => any, tagged: object | undefined) => Node<A>,
|
|
550
553
|
* raw: ((expr: (scope: any) => any) => Node<A>) | 0,
|
|
551
554
|
* seed: () => A,
|
|
552
555
|
* take: (acc: A) => T,
|
|
@@ -563,7 +566,7 @@ export let make = ([lit, val, raw, seed, take]) => {
|
|
|
563
566
|
/**
|
|
564
567
|
* @param {string} str
|
|
565
568
|
* @param {SjabloonFunctions} [funcs]
|
|
566
|
-
* @param {{ bound?: Iterable<string
|
|
569
|
+
* @param {{ bound?: Iterable<string>, tag?: (expr: string) => object | undefined }} [opts]
|
|
567
570
|
* @returns {SjabloonRenderer<T>}
|
|
568
571
|
*/
|
|
569
572
|
function template(str, funcs, opts) {
|
|
@@ -576,15 +579,14 @@ export let make = ([lit, val, raw, seed, take]) => {
|
|
|
576
579
|
// bundler's transpile turns an iterable spread into a concat that would
|
|
577
580
|
// wrap a Set instead of unpacking it. `Object(opts)` stands in for the
|
|
578
581
|
// missing-opts check; nothing iterable comes out of an empty string.
|
|
579
|
-
bound =
|
|
580
|
-
|
|
581
|
-
|
|
582
|
+
let { bound: declared = "", tag = NO_TAG } = Object(opts);
|
|
583
|
+
bound = ["$", "@"].concat(Array.from(/** @type {Iterable<string>} */ (declared)));
|
|
584
|
+
tagOf = tag;
|
|
582
585
|
names = new Set();
|
|
583
586
|
reads = [];
|
|
584
587
|
functions = new Set();
|
|
585
588
|
source = String(str);
|
|
586
589
|
blocks = [];
|
|
587
|
-
nest = 0;
|
|
588
590
|
// The lexer's own state, then one linear pass: inline here because it runs
|
|
589
591
|
// exactly once per compile and the parser rewinds `i` straight after.
|
|
590
592
|
tokens = [];
|
|
@@ -593,7 +595,7 @@ export let make = ([lit, val, raw, seed, take]) => {
|
|
|
593
595
|
while (i < source.length && lexStep());
|
|
594
596
|
i = 0;
|
|
595
597
|
// Deeply nested blocks fail as SJABLOON_TOO_DEEP at the cap in opener(),
|
|
596
|
-
//
|
|
598
|
+
// well below the native stack.
|
|
597
599
|
let nodes = parse([]);
|
|
598
600
|
// The trusted-scope render, and the one render body: the caller's chain
|
|
599
601
|
// already carries the anchors, so no wrapper is created and nothing is
|
package/lib/index.js
CHANGED
|
@@ -14,7 +14,14 @@ export const { template, render } = make([
|
|
|
14
14
|
(text, token = Object.freeze({ literal: text })) =>
|
|
15
15
|
(scope, acc) =>
|
|
16
16
|
acc.push(token),
|
|
17
|
-
|
|
17
|
+
// A tagged interpolation spreads what the compile's `tag` returned; the
|
|
18
|
+
// value is written last, so the token's own key cannot be taken over. The
|
|
19
|
+
// branch is taken once at compile time, so an untagged template emits the
|
|
20
|
+
// exact closure it always did.
|
|
21
|
+
(expr, tagged) =>
|
|
22
|
+
tagged
|
|
23
|
+
? (scope, acc) => acc.push({ ...tagged, value: expr(scope) })
|
|
24
|
+
: (scope, acc) => acc.push({ value: expr(scope) }),
|
|
18
25
|
0,
|
|
19
26
|
() => /** @type {import('./types.js').Token[]} */ ([]),
|
|
20
27
|
(acc) => acc,
|
package/lib/types.d.ts
CHANGED
|
@@ -104,7 +104,23 @@ export interface SjabloonRenderer<T> {
|
|
|
104
104
|
export type SjabloonTemplate<T> = (
|
|
105
105
|
str: string,
|
|
106
106
|
funcs?: SjabloonFunctions,
|
|
107
|
-
opts?: {
|
|
107
|
+
opts?: {
|
|
108
|
+
bound?: Iterable<string>;
|
|
109
|
+
/**
|
|
110
|
+
* Name an interpolation by what it says. Called once per `{{ }}` while
|
|
111
|
+
* compiling, with that interpolation's expression source as written and
|
|
112
|
+
* trimmed (`{{- page.number -}}` is `"page.number"`), and never again at
|
|
113
|
+
* render time. Returned keys join every value token that interpolation
|
|
114
|
+
* emits; `value` and `literal` are the stream's own, so a tag may not
|
|
115
|
+
* supply either. It runs inside the parse, which is shared synchronous
|
|
116
|
+
* state: read the expression and return, and compile no template from
|
|
117
|
+
* within it. Block expressions
|
|
118
|
+
* (`#if` conditions, `#each` collections) emit no token and are not
|
|
119
|
+
* offered. The token edition alone has tokens to carry them; the string
|
|
120
|
+
* editions ignore this.
|
|
121
|
+
*/
|
|
122
|
+
tag?: (expr: string) => object | undefined;
|
|
123
|
+
},
|
|
108
124
|
) => SjabloonRenderer<T>;
|
|
109
125
|
|
|
110
126
|
/** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
|
|
@@ -119,7 +135,13 @@ export interface LiteralToken {
|
|
|
119
135
|
literal: string;
|
|
120
136
|
}
|
|
121
137
|
|
|
122
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* One `{{ }}` interpolation, pre-stringify. Nullish values are preserved.
|
|
140
|
+
*
|
|
141
|
+
* A compile that passed `tag` also carries that interpolation's returned keys
|
|
142
|
+
* here, under the names the embedder chose; declare them by intersecting this
|
|
143
|
+
* type with your own, as `ValueToken & { field?: string }`.
|
|
144
|
+
*/
|
|
123
145
|
export interface ValueToken {
|
|
124
146
|
value: unknown;
|
|
125
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sjabloon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"bench": "node --disallow-code-generation-from-strings bench/index.js",
|
|
43
43
|
"bench:comparison": "npm --prefix bench/comparison run bench",
|
|
44
44
|
"check": "run-s fmt:check lint fallow size test fuzz:regression test:browser",
|
|
45
|
+
"commitlint": "commitlint",
|
|
45
46
|
"fallow": "fallow",
|
|
46
47
|
"fmt": "oxfmt",
|
|
47
48
|
"fmt:check": "oxfmt --check",
|
|
@@ -67,13 +68,15 @@
|
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
|
69
70
|
"@arethetypeswrong/cli": "^0.18.3",
|
|
71
|
+
"@commitlint/cli": "^21.2.2",
|
|
72
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
70
73
|
"@jazzer.js/bug-detectors": "^4.0.0",
|
|
71
74
|
"@jazzer.js/core": "^4.0.0",
|
|
72
75
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
73
76
|
"c8": "^12.0.0",
|
|
74
77
|
"fallow": "^3.17.0",
|
|
75
78
|
"npm-run-all": "^4.1.5",
|
|
76
|
-
"oxfmt": "^0.
|
|
79
|
+
"oxfmt": "^0.66.0",
|
|
77
80
|
"oxlint": "^1.78.0",
|
|
78
81
|
"oxlint-tsgolint": "^7.0.2001",
|
|
79
82
|
"playwright": "^1.61.1",
|