sjabloon 0.11.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 +209 -0
- package/README.md +150 -117
- package/lib/core.js +149 -211
- package/lib/html.d.ts +3 -12
- package/lib/html.js +8 -6
- package/lib/index.d.ts +3 -12
- package/lib/index.js +11 -15
- package/lib/text.d.ts +3 -12
- package/lib/text.js +6 -3
- package/lib/types.d.ts +37 -7
- package/package.json +8 -3
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
|
|
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
|
[](https://www.npmjs.com/package/sjabloon)
|
|
6
6
|
[](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,
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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";
|
|
48
|
+
import { render } from "sjabloon/html";
|
|
43
49
|
|
|
44
50
|
render(
|
|
45
51
|
`<ul>{{#each items as it, i}}
|
|
@@ -51,172 +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 <script>!</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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
67
114
|
|
|
68
|
-
|
|
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.
|
|
69
116
|
|
|
70
|
-
|
|
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.
|
|
71
118
|
|
|
72
|
-
|
|
119
|
+
## Syntax
|
|
73
120
|
|
|
74
|
-
|
|
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 |
|
|
75
132
|
|
|
76
|
-
|
|
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
|
-
```
|
|
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.
|
|
81
134
|
|
|
82
|
-
|
|
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.
|
|
83
136
|
|
|
84
|
-
|
|
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']
|
|
90
|
-
```
|
|
137
|
+
### Loop state
|
|
91
138
|
|
|
92
|
-
`
|
|
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.
|
|
93
140
|
|
|
94
141
|
```js
|
|
95
|
-
|
|
142
|
+
render("{{#each xs as x}}{{ x }}{{#if not loop.last}}, {{/if}}{{/each}}", { xs: ["a", "b", "c"] });
|
|
143
|
+
// => 'a, b, c'
|
|
96
144
|
```
|
|
97
145
|
|
|
98
|
-
|
|
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.
|
|
99
149
|
|
|
100
150
|
```js
|
|
101
|
-
|
|
102
|
-
|
|
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 '
|
|
103
156
|
```
|
|
104
157
|
|
|
105
|
-
|
|
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.
|
|
106
159
|
|
|
107
|
-
|
|
160
|
+
A host with its own scope model can seed the two anchors from separate objects — see [Embedding sjabloon](EMBEDDING.md#seeding-the-anchors).
|
|
108
161
|
|
|
109
|
-
|
|
162
|
+
## API
|
|
110
163
|
|
|
111
|
-
|
|
112
|
-
const row = Object.create(base); // base binds $ once per render
|
|
113
|
-
row["@"] = item;
|
|
114
|
-
tpl.scoped(row);
|
|
115
|
-
```
|
|
164
|
+
Identical across all three editions, except for what a render produces.
|
|
116
165
|
|
|
117
|
-
### `
|
|
166
|
+
### `template(str, functions?, options?)`
|
|
118
167
|
|
|
119
|
-
|
|
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.
|
|
120
169
|
|
|
121
|
-
|
|
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.
|
|
122
171
|
|
|
123
|
-
|
|
172
|
+
```js
|
|
173
|
+
const tpl = template("{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}", {
|
|
174
|
+
fmt: (s) => s,
|
|
175
|
+
});
|
|
124
176
|
|
|
125
|
-
|
|
177
|
+
tpl.names; // => ['title', 'items']
|
|
178
|
+
tpl.functions; // => ['fmt']
|
|
179
|
+
```
|
|
126
180
|
|
|
127
|
-
|
|
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.
|
|
128
182
|
|
|
129
|
-
|
|
130
|
-
import { template, text } from "sjabloon";
|
|
131
|
-
|
|
132
|
-
const tokens = template("{{ qty }} × {{ name }}")({ qty: 2, name: "Koffie" });
|
|
133
|
-
// => [{ value: 2 }, { literal: ' × ' }, { value: 'Koffie' }]
|
|
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.
|
|
134
184
|
|
|
135
|
-
|
|
136
|
-
```
|
|
185
|
+
### `render(str, values?, functions?)`
|
|
137
186
|
|
|
138
|
-
|
|
187
|
+
Shorthand for `template(str, functions)(values)`, returning whatever its edition renders. Compiles on every call, so prefer `template` in a loop.
|
|
139
188
|
|
|
140
|
-
|
|
141
|
-
- **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.
|
|
142
|
-
- **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)
|
|
143
190
|
|
|
144
|
-
|
|
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).
|
|
145
192
|
|
|
146
193
|
### Error diagnostics
|
|
147
194
|
|
|
148
195
|
Sjabloon errors keep their native `SyntaxError` or `TypeError` class and expose:
|
|
149
196
|
|
|
150
|
-
- `code`: a stable `SJABLOON_*` parser category or the original `XPRSN_*` expression
|
|
197
|
+
- `code`: a stable `SJABLOON_*` parser category, or the original `XPRSN_*` category for a fault inside an expression;
|
|
151
198
|
- `start`: a zero-based offset in the original template;
|
|
152
199
|
- `end`: the exclusive template offset;
|
|
153
200
|
- `blocks`: a frozen, outermost-first array of `{ type, start, end }` opener spans.
|
|
154
201
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
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.
|
|
158
|
-
|
|
159
|
-
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`.
|
|
160
|
-
|
|
161
|
-
#### Relocating a diagnostic
|
|
162
|
-
|
|
163
|
-
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:
|
|
164
203
|
|
|
165
204
|
```js
|
|
166
|
-
import { isDiagnostic,
|
|
205
|
+
import { isDiagnostic, template } from "sjabloon";
|
|
167
206
|
|
|
168
207
|
try {
|
|
169
|
-
template(
|
|
208
|
+
template("{{#if a}}oops");
|
|
170
209
|
} catch (error) {
|
|
171
210
|
if (!isDiagnostic(error)) throw error;
|
|
172
|
-
|
|
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 }]
|
|
173
214
|
}
|
|
174
215
|
```
|
|
175
216
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
## Syntax
|
|
179
|
-
|
|
180
|
-
| Tag | Meaning |
|
|
181
|
-
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
182
|
-
| `{{ expr }}` | Interpolate an expression: a value token, or text escaped per edition |
|
|
183
|
-
| `{{{ expr }}}` | Interpolate raw. **`sjabloon/html` only**; a `SyntaxError` elsewhere |
|
|
184
|
-
| `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need |
|
|
185
|
-
| `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
|
|
186
|
-
| `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
|
|
187
|
-
| `{{#each expr as item}} … {{#else}} … {{/each}}` | The `{{#else}}` branch renders when the collection is empty or missing |
|
|
188
|
-
| `{{ loop.last }}` (inside `{{#each}}`) | Iteration metadata: `index` (1-based), `index0`, `first`, `last`, `length` |
|
|
189
|
-
| `{{! anything }}` | Comment, removed from output |
|
|
190
|
-
| `{{- 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.
|
|
191
218
|
|
|
192
|
-
|
|
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).
|
|
193
220
|
|
|
194
|
-
|
|
221
|
+
## The token stream
|
|
195
222
|
|
|
196
|
-
|
|
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:
|
|
197
224
|
|
|
198
225
|
```js
|
|
199
|
-
|
|
200
|
-
// => 'a, b, c'
|
|
201
|
-
```
|
|
226
|
+
import { template, text } from "sjabloon";
|
|
202
227
|
|
|
203
|
-
|
|
228
|
+
const tokens = template("{{ qty }} × {{ name }}")({ qty: 2, name: "Koffie" });
|
|
229
|
+
// => [{ value: 2 }, { literal: ' × ' }, { value: 'Koffie' }]
|
|
204
230
|
|
|
205
|
-
|
|
206
|
-
render(
|
|
207
|
-
"{{#each regions as company}}{{ company }} of {{ $.company }}: {{#each rows as r}}{{ @.n }} {{/each}}{{/each}}",
|
|
208
|
-
{ company: "ACME", regions: ["North", "South"], rows: [{ n: 1 }, { n: 2 }] },
|
|
209
|
-
);
|
|
210
|
-
// => 'North of ACME: 1 2 South of ACME: 1 2 '
|
|
231
|
+
text(tokens); // => '2 × Koffie'
|
|
211
232
|
```
|
|
212
233
|
|
|
213
|
-
|
|
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.
|
|
214
235
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
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.
|
|
218
|
-
|
|
219
|
-
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.
|
|
220
237
|
|
|
221
238
|
## Safety
|
|
222
239
|
|
|
@@ -225,12 +242,26 @@ That runtime CSP support costs some render speed. Handlebars and tempura generat
|
|
|
225
242
|
- Templates read your values; they cannot assign to them.
|
|
226
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.
|
|
227
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
|
+
|
|
228
253
|
## Environments
|
|
229
254
|
|
|
230
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.
|
|
231
256
|
|
|
232
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.
|
|
233
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
|
+
|
|
234
265
|
## Contributing
|
|
235
266
|
|
|
236
267
|
```bash
|
|
@@ -240,7 +271,9 @@ npm install
|
|
|
240
271
|
npm run check
|
|
241
272
|
```
|
|
242
273
|
|
|
243
|
-
`npm run check` is the local gate.
|
|
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).
|
|
244
277
|
|
|
245
278
|
## License
|
|
246
279
|
|