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 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 three-package set that share one approach — parse to closures, never to code — and no runtime dependencies beyond each other:
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, /** @type {number} */ nest;
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
- start = lxP + whole.length - whole.trimStart().length,
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
- nest < 256 || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
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. Elif links share the nest budget so they fail closed before the
293
- * native stack, and the block `opener` mints for one is discarded, never
294
- * pushed: an elif link must not appear as an extra `#if` frame in diagnostic
295
- * context. `t` doubles as the scratch slot for the link it builds.
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))), nest--, [t])
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> }} [opts]
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 = ["$", "@"].concat(
580
- Array.from(/** @type {Iterable<string>} */ (Object(opts).bound || "")),
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
- // including elif chains — well below the native stack.
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
- (expr) => (scope, acc) => acc.push({ value: expr(scope) }),
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?: { bound?: Iterable<string> },
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
- /** One `{{ }}` interpolation, pre-stringify. Nullish values are preserved. */
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.12.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.64.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",