sjabloon 0.10.0 → 0.12.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 ADDED
@@ -0,0 +1,209 @@
1
+ # Embedding sjabloon
2
+
3
+ For hosts that compile templates out of a larger document — a cell in a report, a
4
+ field in a form, a block in a page builder — and that need to seed their own
5
+ scope chain, introspect templates, drive an editor, or report faults in their own
6
+ coordinates.
7
+
8
+ None of this is needed to render templates. [README.md](README.md) covers the
9
+ ordinary surface: the editions, the syntax, `template`, `render`, and the error
10
+ codes.
11
+
12
+ - [Seeding the anchors](#seeding-the-anchors)
13
+ - [`renderer.scoped(values)`](#rendererscopedvalues)
14
+ - [Introspection](#introspection)
15
+ - [`options.bound`](#optionsbound)
16
+ - [`reads`](#reads)
17
+ - [Diagnostic identity](#diagnostic-identity)
18
+ - [`relocate(diagnostic, options)`](#relocatediagnostic-options)
19
+ - [The token contract](#the-token-contract)
20
+ - [`display(value)`](#displayvalue)
21
+
22
+ ## Seeding the anchors
23
+
24
+ By default the two anchors both point at the values you pass: `$` is the root and
25
+ `@` is the current `{{#each}}` item, which outside a loop is the root as well.
26
+ That is what an ordinary caller wants, and it needs no extra argument.
27
+
28
+ An engine with its own scope model usually wants them seeded from distinct
29
+ objects. Pass `{ root, item }` as the renderer's second argument:
30
+
31
+ ```js
32
+ const tpl = template("{{ $.report }} / {{ @.row }}");
33
+
34
+ tpl(base, { root: reportRoot, item: currentRow }); // $ = reportRoot, @ = currentRow
35
+ tpl(base, { root: reportRoot }); // no item → @.x throws
36
+ ```
37
+
38
+ Omitting `item` leaves `@` unbound, so `@.x` throws where there is no current
39
+ item. That is the useful behaviour for a banded report: a group header has no
40
+ representative row, and an unbound `@` turns a mistake into an error instead of a
41
+ silently plausible number. `{{#each}}` still re-points `@` to the current item
42
+ inside its body either way.
43
+
44
+ ## `renderer.scoped(values)`
45
+
46
+ The trusted-scope render, for an embedder whose scope chain already binds the
47
+ anchors. The default call wraps `values` in a fresh scope and seeds `$` and `@`
48
+ into it; `scoped` skips the wrapper. `$` and `@` resolve from `values` itself,
49
+ and a chain that omits `@` leaves it unbound. `{{#each}}` still re-points `@`
50
+ inside its body, and nothing is ever written to your objects.
51
+
52
+ ```js
53
+ const row = Object.create(base); // base binds $ once per render
54
+ row["@"] = item;
55
+ tpl.scoped(row);
56
+ ```
57
+
58
+ Rendering one template per cell per row over scopes you already build, this is
59
+ the path with zero per-call allocations beyond the output.
60
+
61
+ ## Introspection
62
+
63
+ Every renderer carries `names` and `functions`, documented in the
64
+ [README](README.md#templatestr-functions-options). The two below are for
65
+ validators and editors.
66
+
67
+ ### `options.bound`
68
+
69
+ `options.bound` lists names your engine already has in scope — a loop variable, a
70
+ handle, a `page` anchor. They are excluded from `names` and still resolve
71
+ normally at render time, the same contract as
72
+ [xprsn's own `bound`](https://github.com/getquario/xprsn/blob/main/EMBEDDING.md#optionsbound):
73
+
74
+ ```js
75
+ template("{{ run.total }} of {{ count }}", undefined, { bound: ["run"] }).names;
76
+ // => ['count']
77
+ ```
78
+
79
+ Without it, every stored template would appear to depend on variables its author
80
+ never supplied, and a schema check like `tpl.names.every(n => n in model)` would
81
+ reject valid templates.
82
+
83
+ ### `reads`
84
+
85
+ `reads` is every root-name read with its span in the template source, in source
86
+ order. Duplicates, anchors, loop variables and bound names are all kept — `names`
87
+ is the free, deduplicated view.
88
+
89
+ ```js
90
+ template("{{ title }}: {{ total }}").reads;
91
+ // => [{ name: 'title', start: 3, end: 8 }, { name: 'total', start: 16, end: 21 }]
92
+ ```
93
+
94
+ Spans are offsets into the original template, not into the expression inside the
95
+ tag, so an editor can squiggle, hover, and jump straight from them. An unknown
96
+ variable is not an error — it renders empty — so an editor that wants to warn
97
+ about typos has to do it from `reads` against a known data model.
98
+
99
+ ## Diagnostic identity
100
+
101
+ `isDiagnostic(error)` returns `true` only for errors produced or translated by
102
+ the same sjabloon module instance. It is an identity check, not a shape check:
103
+ copying a documented `code`, `start`, `end`, and `blocks` onto another error does
104
+ not authenticate it, and a diagnostic from another installed copy returns
105
+ `false`. All three editions share one core, so mixing them in a single process is
106
+ safe — an error thrown through `sjabloon/html` authenticates through `sjabloon`.
107
+
108
+ That matters because a host has to tell three kinds of failure apart:
109
+
110
+ 1. sjabloon's own faults, which have a span in the template;
111
+ 2. errors thrown by _your_ registered functions, getters, methods, or coercion
112
+ hooks, which sjabloon passes through unchanged and does not annotate;
113
+ 3. everything else.
114
+
115
+ Only the first can be pointed at a source location.
116
+
117
+ Every renderer also carries its own `isDiagnostic(error)`, `true` only for
118
+ runtime diagnostics thrown through _that_ renderer. An embedder holding many
119
+ compiled templates asks the one that just rendered, so a diagnostic that leaked
120
+ from an unrelated template is not mistaken for this cell's. Compile-time
121
+ diagnostics happen before a renderer exists, so they authenticate only through
122
+ the module-wide predicate.
123
+
124
+ ## `relocate(diagnostic, options)`
125
+
126
+ An embedder that compiles templates out of a larger document reports the fault in
127
+ its own coordinates, not the template's.
128
+ `relocate(diagnostic, { prefix, offset })` returns the copy to re-throw:
129
+
130
+ ```js
131
+ import { isDiagnostic, relocate, template } from "sjabloon";
132
+
133
+ try {
134
+ template(cell.value);
135
+ } catch (error) {
136
+ if (!isDiagnostic(error)) throw error;
137
+ throw relocate(error, { prefix: "detail.cells[0].value: " });
138
+ }
139
+ ```
140
+
141
+ The copy keeps the original's class, prepends `prefix` to the message verbatim,
142
+ moves the span, and carries every other field across by
143
+ descriptor — including the frozen `blocks` context, which stays frozen and
144
+ non-writable on the copy. `blocks` is the same array, so its openers' own
145
+ `start`/`end` stay in template coordinates while the error's span moves.
146
+
147
+ The copy is registered exactly as the original was, so it passes `isDiagnostic`.
148
+ An expression fault is an xprsn diagnostic that sjabloon translated into template
149
+ coordinates; relocating it goes through xprsn, so the copy stays authentic to
150
+ both packages just as the original is. The original is left untouched. Passing
151
+ anything but a sjabloon diagnostic throws a `TypeError`.
152
+
153
+ `offset` shifts the span, and it is right whenever the template was a verbatim
154
+ slice of your text. It is wrong when your text was **decoded** first — a template
155
+ read out of a JSON string literal, where an escape makes every later offset
156
+ slide. There is no offset that fixes that, so name the region the template came
157
+ from instead:
158
+
159
+ ```js
160
+ throw relocate(error, { prefix: "cells[3].template: ", span: [16, 41] });
161
+ ```
162
+
163
+ `span` replaces the span outright and wins if you pass both. Neither option adds
164
+ a span to a diagnostic that had none.
165
+
166
+ Relocation lives here rather than in the embedder because authentication is by
167
+ identity: a copy an embedder builds itself cannot be authenticated, and a field
168
+ added to a diagnostic here would be a field the embedder's copy silently drops.
169
+
170
+ ## The token contract
171
+
172
+ The root entry renders to `Token[]`, where a `Token` is either
173
+ `{ literal: string }` or `{ value: unknown }`. The guarantees a consumer can rely
174
+ on:
175
+
176
+ - **Values are pre-stringify.** `{{ total }}` holding `1000` yields the number
177
+ `1000`, not `"1000"`, and nullish stays nullish. Stringification is deferred to
178
+ `text()`, so a value with no primitive conversion reaches the stream intact and
179
+ only fails when something asks for text.
180
+ - **Order is render order.** Loop bodies append once per iteration, untaken
181
+ branches append nothing, and block expressions (`#if` conditions, `#each`
182
+ collections) never appear. They steer the render; they are not part of the
183
+ stream.
184
+ - **Literals are the template's static runs**, one token each, never merged and
185
+ never empty. The interleaving tells you the shape: a bare `{{ amount }}` is
186
+ exactly one value token, while `Total: {{ amount }}` is a literal followed by a
187
+ value. A spreadsheet cell that is _only_ a number is a different thing from one
188
+ that happens to contain one — which is why the engine emits tokens rather than
189
+ a string plus a list of values.
190
+ - Literal tokens are frozen and shared across loop iterations; value tokens are
191
+ fresh per emit. Do not mutate or key a cache on a literal token's identity
192
+ across iterations.
193
+
194
+ `text(tokens)` joins a stream the way `sjabloon/text` would have rendered it, and
195
+ the two are equal for every template and every set of values. The test suite and
196
+ the fuzzer both check that.
197
+
198
+ ### `display(value)`
199
+
200
+ The scalar display rule every edition and `text()` share, exported from the root
201
+ entry for embedders that stringify token values themselves.
202
+
203
+ A valid `Date` renders as ISO 8601 UTC (`toISOString()`) — the same bytes on
204
+ every machine, where `String(date)` would bake in the host's timezone and locale.
205
+ An invalid `Date` keeps its deterministic `'Invalid Date'` form, nullish displays
206
+ empty, and everything else is `String(value)`.
207
+
208
+ Use it rather than your own `String(...)` when you consume tokens and want a
209
+ target's output to agree with `sjabloon/text` on the same template.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sjabloon
2
2
 
3
- A tiny, CSP-safe, target-neutral template engine for JavaScript. **~1.9KB min+brotli (~3.7KB with [xprsn](https://www.npmjs.com/package/xprsn)), one dependency.**
3
+ A tiny, CSP-safe template engine for JavaScript. **~1.9KB min+brotli (~3.7KB with [xprsn](https://www.npmjs.com/package/xprsn)), two tiny dependencies.**
4
4
 
5
5
  [![NPM version](https://img.shields.io/npm/v/sjabloon.svg)](https://www.npmjs.com/package/sjabloon)
6
6
  [![Build Status](https://github.com/getquario/sjabloon/actions/workflows/test.yml/badge.svg)](https://github.com/getquario/sjabloon/actions/workflows/test.yml)
@@ -13,9 +13,24 @@ A tiny, CSP-safe, target-neutral template engine for JavaScript. **~1.9KB min+br
13
13
  </picture>
14
14
  </a>
15
15
 
16
- _Sjabloon_ is Dutch for "template". It renders templates with full [xprsn](https://github.com/getquario/xprsn) expressions inside every tag, without turning template text into JavaScript. There is no `eval` and no `new Function`, so it runs under a strict Content Security Policy where engines that compile templates to code cannot.
17
-
18
- The engine emits **tokens**, not text. A render gives you the literal runs and the interpolated values, interleaved in render order. Different targets need different things from the same template: HTML wants escaped text, a spreadsheet wants the number `1000` and a cell format. Escaping belongs at the output edge, not in the engine, so a string is just one way to consume the stream.
16
+ _Sjabloon_ is Dutch for "template". It renders familiar `{{ }}` templates — interpolation, `{{#if}}`, `{{#each}}` — with full [xprsn](https://github.com/getquario/xprsn) expressions inside every tag, and never turns template text into JavaScript. There is no `eval` and no `new Function`, so templates that arrive at runtime still render under a strict Content Security Policy, where engines that compile templates to code cannot.
17
+
18
+ ## Contents
19
+
20
+ - [Install](#install)
21
+ - [Usage](#usage)
22
+ - [Is sjabloon the right tool?](#is-sjabloon-the-right-tool)
23
+ - [Related packages](#related-packages)
24
+ - [Editions](#editions)
25
+ - [Syntax](#syntax)
26
+ - [API](#api)
27
+ - [The token stream](#the-token-stream)
28
+ - [Safety](#safety)
29
+ - [Content Security Policy](#content-security-policy)
30
+ - [Environments](#environments)
31
+ - [Embedding sjabloon](#embedding-sjabloon)
32
+ - [Contributing](#contributing)
33
+ - [License](#license)
19
34
 
20
35
  ## Install
21
36
 
@@ -23,23 +38,14 @@ The engine emits **tokens**, not text. A render gives you the literal runs and t
23
38
  npm install sjabloon
24
39
  ```
25
40
 
26
- Node.js 22.12 or newer, ESM only.
41
+ Node.js 22.12 or newer, ESM only. TypeScript declarations ship with the package; nothing extra to install.
27
42
 
28
43
  ## Usage
29
44
 
30
- ```js
31
- import { template, text } from "sjabloon";
32
-
33
- const cell = template("{{ total * 1.21 }}");
34
-
35
- cell({ total: 1000 }); // => [{ value: 1210 }] // still a number
36
- text(cell({ total: 1000 })); // => '1210' // when you want the string
37
- ```
38
-
39
- If you only want a string, import the edition that produces one directly:
45
+ If you're rendering HTML, import the HTML edition. It escapes every interpolated value, and it is the edition most projects want:
40
46
 
41
47
  ```js
42
- import { render } from "sjabloon/html"; // {{ }} HTML-escapes, {{{ }}} is raw
48
+ import { render } from "sjabloon/html";
43
49
 
44
50
  render(
45
51
  `<ul>{{#each items as it, i}}
@@ -51,161 +57,183 @@ render(
51
57
  );
52
58
  ```
53
59
 
60
+ `render` compiles and renders in one call. Compile once and render many times with `template`:
61
+
62
+ ```js
63
+ import { template } from "sjabloon/html";
64
+
65
+ const greet = template("<p>Hello {{ name }}!</p>");
66
+
67
+ greet({ name: "Robin" }); // => '<p>Hello Robin!</p>'
68
+ greet({ name: "<script>" }); // => '<p>Hello &lt;script&gt;!</p>'
69
+ ```
70
+
71
+ The third argument is your function registry. Templates can call only what you put there — there is no built-in helper library and no way for a template to reach anything you didn't pass in.
72
+
73
+ Not rendering HTML? There are two other editions with the same syntax and a different output. See [Editions](#editions).
74
+
75
+ ## Is sjabloon the right tool?
76
+
77
+ sjabloon renders a template against a values object. Templates can interpolate, branch, and loop. They cannot define variables, call out to anything you didn't register, or include other templates.
78
+
79
+ **It fits when:**
80
+
81
+ - Templates arrive at runtime — edited by users, stored in a CMS or database, pulled from a config file — so you can't precompile them at build time.
82
+ - You ship under a strict CSP, or into a runtime where string-to-code is unavailable. This is the reason the package exists.
83
+ - The people writing templates are not programmers, and `{{ }}` is a syntax they already recognise.
84
+ - Bundle size is a real constraint, or you want a dependency tree you can read in an afternoon.
85
+
86
+ **Look elsewhere when:**
87
+
88
+ - Your templates are known at build time and speed matters most. Engines that generate specialised JavaScript render faster; precompiling them at build time also avoids `unsafe-eval`. See [Content Security Policy](#content-security-policy) for the measured tradeoff.
89
+ - You need partials, includes, layout inheritance, macros, or custom block tags. sjabloon has a fixed set of blocks and no composition mechanism.
90
+ - You need a sandbox or an HTML sanitizer. The HTML edition escapes interpolated values, but literal template text is copied through, and your registered functions do whatever they do. See [SECURITY.md](SECURITY.md).
91
+ - You want a component model with state and lifecycle. This renders strings, once.
92
+ - You need CommonJS, or Node older than 22.12. See [Environments](#environments).
93
+
94
+ ## Related packages
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:
97
+
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
+ - **[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.
100
+
54
101
  ## Editions
55
102
 
56
103
  Three entry points, one engine. They share a parser, a syntax, and a diagnostics contract, and differ only in what a render produces.
57
104
 
58
105
  | Import | `template(str, funcs?)` returns | `{{ expr }}` | `{{{ expr }}}` |
59
106
  | --------------- | ------------------------------- | ------------ | -------------- |
60
- | `sjabloon` | `(values?, scope?) => Token[]` | value token | `SyntaxError` |
61
- | `sjabloon/text` | `(values?, scope?) => string` | unescaped | `SyntaxError` |
62
107
  | `sjabloon/html` | `(values?, scope?) => string` | HTML-escaped | raw |
108
+ | `sjabloon/text` | `(values?, scope?) => string` | unescaped | `SyntaxError` |
109
+ | `sjabloon` | `(values?, scope?) => Token[]` | value token | `SyntaxError` |
63
110
 
64
- `{{{ }}}` exists only in the HTML edition, where "raw" means something. Everywhere else `{{ }}` is already raw, so the triple form is a compile-time `SJABLOON_RAW_TAG` error.
111
+ - **`sjabloon/html`** for anything that ends up in a page or an email. Interpolated values are escaped; `{{{ expr }}}` opts out for a value you already trust.
112
+ - **`sjabloon/text`** for output with no markup — a subject line, a filename, a log format, a Markdown file. Nothing is escaped, because there is nothing to escape it for.
113
+ - **`sjabloon`** (the root entry) renders to a [token stream](#the-token-stream) instead of a string: the literal runs and the interpolated values, interleaved in render order, with values still in their original types. This is the one to reach for when the output isn't text at all — a spreadsheet cell that needs the number `1000` and a cell format, a PDF run, a structured document node.
65
114
 
66
- Every edition exports `template`, `render`, `isDiagnostic`, and `relocate`. They all resolve to one shared core, so a diagnostic thrown through any of them authenticates through all of them.
115
+ `{{{ }}}` exists only in the HTML edition, where "raw" means something. Everywhere else `{{ }}` is already raw, so the triple form is a compile-time `SJABLOON_RAW_TAG` error rather than a silently different meaning.
67
116
 
68
- ## API
117
+ Every edition exports `template`, `render`, `isDiagnostic`, and `relocate`. They all resolve to one shared core, so a diagnostic thrown through any of them authenticates through all of them, and mixing editions in one process is safe.
69
118
 
70
- ### `template(str, functions?, options?)`
119
+ ## Syntax
120
+
121
+ | Tag | Meaning |
122
+ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
123
+ | `{{ expr }}` | Interpolate an expression: a value token, or text escaped per edition |
124
+ | `{{{ expr }}}` | Interpolate raw. **`sjabloon/html` only**; a `SyntaxError` elsewhere |
125
+ | `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need |
126
+ | `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
127
+ | `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
128
+ | `{{#each expr as item}} … {{#else}} … {{/each}}` | The `{{#else}}` branch renders when the collection is empty or missing |
129
+ | `{{ loop.last }}` (inside `{{#each}}`) | Iteration metadata: `index` (1-based), `index0`, `first`, `last`, `length` |
130
+ | `{{! anything }}` | Comment, removed from output |
131
+ | `{{- expr -}}` | A dash hugging either brace trims the whitespace on that side, newlines included; works on every tag form |
71
132
 
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.
133
+ Every `expr` is an [xprsn expression](https://github.com/getquario/xprsn#syntax): literals, arithmetic, string concatenation with `~` (`{{ first ~ " " ~ last }}`), comparisons, `and`/`or`/`not`/`in`, ternaries, property and method access, and functions from the registry you pass in. `null` and `undefined` render as empty strings.
73
134
 
74
- The anchors `$` (root) and `@` (current `{{#each}}` item) work as [described below](#syntax) with no extra arguments. At the root, before any loop, both point at `values`. If you're embedding sjabloon under an engine with its own scope model, pass `{ root, item }` as the second argument to seed the two root anchors from distinct objects: `$` becomes `root` and `@` becomes `item`. Omit `item` and `@` stays unbound at the root, so reading `@.x` throws where there is no current item. Either way, `{{#each}}` still re-points `@` to the current item inside its body.
135
+ A loop body sees its loop variable plus the outer scope; reusing an outer name shadows it only inside that body. The engine keeps loop variables on a child scope, so the values you pass are never mutated.
75
136
 
76
- ```js
77
- const tpl = template("{{ $.report }} / {{ @.row }}");
78
- tpl(base, { root: reportRoot, item: currentRow }); // $ = reportRoot, @ = currentRow
79
- tpl(base, { root: reportRoot }); // no item → @.x throws
80
- ```
137
+ ### Loop state
81
138
 
82
- The renderer carries `names` (every variable the template reads from your values, loop variables excluded) and `functions` (the registry functions it calls, methods excluded), both deduplicated. Check a stored template against your data model and its allowed functions before you render it, or fetch only the fields it needs.
139
+ Inside `{{#each}}`, a `loop` object holds the iteration state: `index` (1-based), `index0`, `first`, `last`, and `length`. Use `loop.last` for separators and trailing borders, or `loop.index` with `loop.length` for "row X of Y". Each nested loop gets its own.
83
140
 
84
141
  ```js
85
- const tpl = template("{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}", {
86
- fmt: (s) => s,
87
- });
88
- tpl.names; // => ['title', 'items']
89
- tpl.functions; // => ['fmt']
142
+ render("{{#each xs as x}}{{ x }}{{#if not loop.last}}, {{/if}}{{/each}}", { xs: ["a", "b", "c"] });
143
+ // => 'a, b, c'
90
144
  ```
91
145
 
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`:
146
+ ### Anchors
147
+
148
+ Two anchors are always in scope: `$` is the root values and `@` is the current `{{#each}}` item (the root outside a loop). They let a nested body name the level it means instead of leaning on shadowing: `$.company` reaches the top, and `@.total` is whatever the innermost loop sits on.
93
149
 
94
150
  ```js
95
- template("{{ run.total }} of {{ count }}", undefined, { bound: ["run"] }).names; // => ['count']
151
+ render(
152
+ "{{#each regions as company}}{{ company }} of {{ $.company }}: {{#each rows as r}}{{ @.n }} {{/each}}{{/each}}",
153
+ { company: "ACME", regions: ["North", "South"], rows: [{ n: 1 }, { n: 2 }] },
154
+ );
155
+ // => 'North of ACME: 1 2 South of ACME: 1 2 '
96
156
  ```
97
157
 
98
- 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
-
100
- ### `renderer.scoped(values)`
158
+ Here the loop variable `company` shadows the root's for a bare name, but `$.company` still returns `'ACME'`. Anchors never count as `names`, and a blocked key through one (`$.constructor`) throws like anywhere else.
101
159
 
102
- 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.
160
+ A host with its own scope model can seed the two anchors from separate objects see [Embedding sjabloon](EMBEDDING.md#seeding-the-anchors).
103
161
 
104
- ```js
105
- const row = Object.create(base); // base binds $ once per render
106
- row["@"] = item;
107
- tpl.scoped(row);
108
- ```
162
+ ## API
109
163
 
110
- ### `render(str, values?, functions?)`
164
+ Identical across all three editions, except for what a render produces.
111
165
 
112
- Shorthand for `template(str, functions)(values)`, returning whatever its edition renders.
166
+ ### `template(str, functions?, options?)`
113
167
 
114
- ### `text(tokens)` (root entry only)
168
+ Compiles the template and returns a renderer. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time, so a template you compiled is a template that parses.
115
169
 
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.
170
+ The renderer carries `names` every variable the template reads from your values, loop variables and anchors excluded and `functions`, the registry functions it calls, methods excluded. Both are deduplicated.
117
171
 
118
172
  ```js
119
- import { template, text } from "sjabloon";
120
-
121
- const tokens = template("{{ qty }} × {{ name }}")({ qty: 2, name: "Koffie" });
122
- // => [{ value: 2 }, { literal: ' × ' }, { value: 'Koffie' }]
173
+ const tpl = template("{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}", {
174
+ fmt: (s) => s,
175
+ });
123
176
 
124
- text(tokens); // => '2 × Koffie'
177
+ tpl.names; // => ['title', 'items']
178
+ tpl.functions; // => ['fmt']
125
179
  ```
126
180
 
127
- A `Token` is either `{ literal: string }` or `{ value: unknown }`:
181
+ Use them to check a stored template against your data model and its allowed functions before you render it, or to fetch only the fields it actually needs.
182
+
183
+ Renderers carry three further members aimed at hosts: [`options.bound`](EMBEDDING.md#optionsbound) and [`reads`](EMBEDDING.md#reads) for validators and editors, and [`scoped`](EMBEDDING.md#rendererscopedvalues) for an engine that builds its own scope chain.
184
+
185
+ ### `render(str, values?, functions?)`
186
+
187
+ Shorthand for `template(str, functions)(values)`, returning whatever its edition renders. Compiles on every call, so prefer `template` in a loop.
128
188
 
129
- - **Values are pre-stringify.** `{{ total }}` holding `1000` yields the number `1000`, not `"1000"`, and nullish stays nullish. Stringification is deferred to `text()`, so a value with no primitive conversion reaches the stream intact and only fails when something asks for text.
130
- - **Order is render order.** Loop bodies append once per iteration, untaken branches append nothing, and block expressions (`#if` conditions, `#each` collections) never appear. They steer the render; they are not part of the stream.
131
- - **Literals are the template's static runs**, one token each, never merged and never empty. The interleaving tells you the shape: a bare `{{ amount }}` is exactly one value token, while `Total: {{ amount }}` is a literal followed by a value. That is why the engine emits tokens instead of a string plus a list of values. A spreadsheet cell that is _only_ a number is a different thing from one that happens to contain one.
189
+ ### `text(tokens)` and `display(value)` (root entry only)
132
190
 
133
- Literal tokens are frozen and shared across loop iterations; value tokens are fresh per emit.
191
+ `text` joins a token stream into a string the way `sjabloon/text` would have rendered it. `display` is the scalar rule both share, exported for embedders that stringify token values themselves. See [The token stream](#the-token-stream).
134
192
 
135
193
  ### Error diagnostics
136
194
 
137
195
  Sjabloon errors keep their native `SyntaxError` or `TypeError` class and expose:
138
196
 
139
- - `code`: a stable `SJABLOON_*` parser category or the original `XPRSN_*` expression category;
197
+ - `code`: a stable `SJABLOON_*` parser category, or the original `XPRSN_*` category for a fault inside an expression;
140
198
  - `start`: a zero-based offset in the original template;
141
199
  - `end`: the exclusive template offset;
142
200
  - `blocks`: a frozen, outermost-first array of `{ type, start, end }` opener spans.
143
201
 
144
- 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.
145
-
146
- Unauthenticated 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.
147
-
148
- Use `isDiagnostic(error)` when a host needs to distinguish those errors. It returns `true` only for errors produced or translated by the same sjabloon module instance. Copying a documented `code`, `start`, `end`, and `blocks` onto another error does not authenticate it. A diagnostic from another installed copy or module instance returns `false`. All three editions share one core, so mixing them in a single process is safe: an error thrown through `sjabloon/html` authenticates through `sjabloon`.
149
-
150
- #### Relocating a diagnostic
151
-
152
- An embedder that compiles templates out of a larger document — a cell in a report, a field in a form — reports the fault in its own coordinates, not the template's. `relocate(diagnostic, { prefix, offset })` returns the copy to re-throw:
202
+ Together they are enough to point whoever wrote the template at the character that broke it, and `blocks` says which enclosing block it happened in:
153
203
 
154
204
  ```js
155
- import { isDiagnostic, relocate, template } from "sjabloon";
205
+ import { isDiagnostic, template } from "sjabloon";
156
206
 
157
207
  try {
158
- template(cell.value);
208
+ template("{{#if a}}oops");
159
209
  } catch (error) {
160
210
  if (!isDiagnostic(error)) throw error;
161
- throw relocate(error, { prefix: "detail.cells[0].value: " });
211
+ error.code; // => 'SJABLOON_UNCLOSED_BLOCK'
212
+ error.start; // => 13 (end of template — nothing closed the block)
213
+ error.blocks; // => [{ type: 'if', start: 0, end: 9 }]
162
214
  }
163
215
  ```
164
216
 
165
- The copy keeps the original's class, prepends `prefix` to the message verbatim, shifts `start` and `end` by `offset`, and carries every other field across by descriptor — including the frozen `blocks` context, which stays frozen and non-writable on the copy. `blocks` is the same array, so its openers' own `start`/`end` stay in template coordinates while the error's span moves. It is registered exactly as the original was, so it passes `isDiagnostic` and an expression fault, which is an xprsn diagnostic sjabloon translated into template coordinates, is relocated by xprsn so the copy stays authentic to both packages just as the original is. The original is left untouched. Relocation belongs here rather than in the embedder because authentication is by identity: a copy an embedder builds itself cannot be authenticated, and a field added to a diagnostic here would be a field the embedder's copy silently drops. Passing anything but a sjabloon diagnostic throws a `TypeError`.
166
-
167
- ## Syntax
168
-
169
- | Tag | Meaning |
170
- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
171
- | `{{ expr }}` | Interpolate an expression: a value token, or text escaped per edition |
172
- | `{{{ expr }}}` | Interpolate raw. **`sjabloon/html` only**; a `SyntaxError` elsewhere |
173
- | `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need |
174
- | `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
175
- | `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
176
- | `{{#each expr as item}} … {{#else}} … {{/each}}` | The `{{#else}}` branch renders when the collection is empty or missing |
177
- | `{{ loop.last }}` (inside `{{#each}}`) | Iteration metadata: `index` (1-based), `index0`, `first`, `last`, `length` |
178
- | `{{! anything }}` | Comment, removed from output |
179
- | `{{- expr -}}` | A dash hugging either brace trims the whitespace on that side, newlines included; works on every tag form |
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.
180
218
 
181
- Every `expr` is an [xprsn expression](https://github.com/getquario/xprsn#syntax): literals, arithmetic, string concatenation with `~` (`{{ first ~ " " ~ last }}`), comparisons, `and`/`or`/`not`/`in`, ternaries, property and method access, and functions from the registry you pass in. `null` and `undefined` render as empty strings.
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).
182
220
 
183
- A loop body sees its loop variable plus the outer scope; reusing an outer name shadows it only inside that body. The engine keeps loop variables on a child scope, so the values you pass are never mutated.
221
+ ## The token stream
184
222
 
185
- Inside `{{#each}}`, a `loop` object holds the iteration state: `index` (1-based), `index0`, `first`, `last`, and `length`. Use `loop.last` for separators and trailing borders, or `loop.index` with `loop.length` for "row X of Y". Each nested loop gets its own.
223
+ The root entry renders to tokens rather than a string. A render gives you the literal runs and the interpolated values, interleaved in render order:
186
224
 
187
225
  ```js
188
- render("{{#each xs as x}}{{ x }}{{#if not loop.last}}, {{/if}}{{/each}}", { xs: ["a", "b", "c"] });
189
- // => 'a, b, c'
190
- ```
226
+ import { template, text } from "sjabloon";
191
227
 
192
- Two anchors are always in scope: `$` is the root values and `@` is the current `{{#each}}` item (the root outside a loop). They let a nested body name the level it means instead of leaning on shadowing: `$.company` reaches the top, and `@.total` is whatever the innermost loop sits on.
228
+ const tokens = template("{{ qty }} × {{ name }}")({ qty: 2, name: "Koffie" });
229
+ // => [{ value: 2 }, { literal: ' × ' }, { value: 'Koffie' }]
193
230
 
194
- ```js
195
- render(
196
- "{{#each regions as company}}{{ company }} of {{ $.company }}: {{#each rows as r}}{{ @.n }} {{/each}}{{/each}}",
197
- { company: "ACME", regions: ["North", "South"], rows: [{ n: 1 }, { n: 2 }] },
198
- );
199
- // => 'North of ACME: 1 2 South of ACME: 1 2 '
231
+ text(tokens); // => '2 × Koffie'
200
232
  ```
201
233
 
202
- Here the loop variable `company` shadows the root's for a bare name, but `$.company` still returns `'ACME'`. Anchors never count as `names`, and a blocked key through one (`$.constructor`) throws like anywhere else.
203
-
204
- ## Content Security Policy
205
-
206
- sjabloon works under `script-src 'self'` with no `unsafe-eval`. Templates parse into a tree of closures that call other closures; xprsn compiles the expressions the same way. The test suite runs under `node --disallow-code-generation-from-strings`, which throws on any string-to-code construct exactly like a strict CSP does.
234
+ Values keep their original type — `{{ total * 1.21 }}` over `{ total: 1000 }` gives you the number `1210`, not `"1210"`. That is the point: different targets need different things from the same template. HTML wants escaped text; a spreadsheet wants the number and a cell format; a PDF wants a run with a font. Escaping and stringification belong at the output edge, not in the engine, so a string is just one way to consume the stream.
207
235
 
208
- That runtime CSP support costs some render speed. Handlebars and tempura generate specialized JavaScript, so their compiled renderers are faster but runtime compilation requires `unsafe-eval`. Build-time precompilation avoids that restriction when templates are known in advance. If templates arrive at runtime (user-edited templates, CMS content, email templates) and your CSP is strict, sjabloon fits. See the [comparison benchmarks](bench/comparison/) for cold-compile and hot-render comparisons.
236
+ If you are writing a renderer against these tokens, [EMBEDDING.md](EMBEDDING.md#the-token-contract) has the guarantees you can rely on ordering, literal identity, and the `display` rule.
209
237
 
210
238
  ## Safety
211
239
 
@@ -214,12 +242,26 @@ That runtime CSP support costs some render speed. Handlebars and tempura generat
214
242
  - Templates read your values; they cannot assign to them.
215
243
  - Registered functions are host-provided capabilities, not a sandbox boundary. Only register helpers that template authors are allowed to invoke; likewise, treat explicit raw output as trusted HTML.
216
244
 
245
+ sjabloon is not an HTML sanitizer: literal template text is copied to the output verbatim, so a template author can always write raw markup. [SECURITY.md](SECURITY.md) has the checklist to work through before accepting templates from people you don't trust, and the process for reporting a vulnerability.
246
+
247
+ ## Content Security Policy
248
+
249
+ sjabloon works under `script-src 'self'` with no `unsafe-eval`. Templates parse into a tree of closures that call other closures; xprsn compiles the expressions the same way. The test suite runs under `node --disallow-code-generation-from-strings`, which throws on any string-to-code construct exactly like a strict CSP does.
250
+
251
+ That runtime CSP support costs some render speed. Handlebars and tempura generate specialised JavaScript, so their compiled renderers are faster — but runtime compilation requires `unsafe-eval`, and build-time precompilation only avoids that when the templates are known in advance. If templates arrive at runtime and your CSP is strict, sjabloon fits; if they don't, an engine that precompiles is the faster answer. See the [comparison benchmarks](bench/comparison/) for cold-compile and hot-render numbers.
252
+
217
253
  ## Environments
218
254
 
219
255
  Node.js 22.12 and newer, ESM only. Browser use is supported through a standards-based ESM bundler in environments supporting ES2024. Direct `<script>` globals, UMD, and CommonJS builds are not provided.
220
256
 
221
257
  Shipping CommonJS alongside ESM would put two copies of the core in any process that mixed `require` and `import`. Each copy would have its own diagnostic identity, so `isDiagnostic` would return `false` across the seam.
222
258
 
259
+ TypeScript declarations are hand-written and ship in the package; `npm run check` runs `attw` against them.
260
+
261
+ ## Embedding sjabloon
262
+
263
+ If you compile templates out of a larger document — a cell in a report, a field in a form, a block in a page builder — [EMBEDDING.md](EMBEDDING.md) covers the surface built for that: seeding the anchors from your own scope chain, `scoped` renders, introspection for validators and editors, diagnostic identity, relocating a fault into your own coordinates, and the token contract.
264
+
223
265
  ## Contributing
224
266
 
225
267
  ```bash
@@ -229,7 +271,9 @@ npm install
229
271
  npm run check
230
272
  ```
231
273
 
232
- `npm run check` is the local gate. Conventions for this repo live in [AGENTS.md](AGENTS.md).
274
+ `npm run check` is the local gate: formatting, lint, dead-code and dependency checks, the size budgets, the unit and type suites, the fuzz regression corpus, and the browser CSP run. It is the same gate CI runs, so a green `check` locally means a green pull request.
275
+
276
+ Conventions for this repo — the parser, the semantics that look like bugs if you tidy them, and the commit format — live in [AGENTS.md](AGENTS.md).
233
277
 
234
278
  ## License
235
279