sjabloon 0.11.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 +245 -0
- package/README.md +151 -117
- package/lib/core.js +160 -220
- package/lib/html.d.ts +3 -12
- package/lib/html.js +8 -6
- package/lib/index.d.ts +3 -12
- package/lib/index.js +18 -15
- package/lib/text.d.ts +3 -12
- package/lib/text.js +6 -3
- package/lib/types.d.ts +60 -8
- package/package.json +12 -4
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 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
|
+
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
### `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.
|
|
108
159
|
|
|
109
|
-
|
|
160
|
+
A host with its own scope model can seed the two anchors from separate objects — see [Embedding sjabloon](EMBEDDING.md#seeding-the-anchors).
|
|
110
161
|
|
|
111
|
-
|
|
112
|
-
const row = Object.create(base); // base binds $ once per render
|
|
113
|
-
row["@"] = item;
|
|
114
|
-
tpl.scoped(row);
|
|
115
|
-
```
|
|
162
|
+
## API
|
|
116
163
|
|
|
117
|
-
|
|
164
|
+
Identical across all three editions, except for what a render produces.
|
|
118
165
|
|
|
119
|
-
|
|
166
|
+
### `template(str, functions?, options?)`
|
|
120
167
|
|
|
121
|
-
|
|
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.
|
|
122
169
|
|
|
123
|
-
|
|
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.
|
|
124
171
|
|
|
125
|
-
|
|
172
|
+
```js
|
|
173
|
+
const tpl = template("{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}", {
|
|
174
|
+
fmt: (s) => s,
|
|
175
|
+
});
|
|
126
176
|
|
|
127
|
-
|
|
177
|
+
tpl.names; // => ['title', 'items']
|
|
178
|
+
tpl.functions; // => ['fmt']
|
|
179
|
+
```
|
|
128
180
|
|
|
129
|
-
|
|
130
|
-
import { template, text } from "sjabloon";
|
|
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.
|
|
131
182
|
|
|
132
|
-
|
|
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
|
-
|
|
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.
|
|
177
218
|
|
|
178
|
-
|
|
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).
|
|
179
220
|
|
|
180
|
-
|
|
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 |
|
|
221
|
+
## The token stream
|
|
191
222
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
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.
|
|
195
|
-
|
|
196
|
-
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:
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
## Content Security Policy
|
|
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.
|
|
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.
|
|
218
235
|
|
|
219
|
-
|
|
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,22 +242,39 @@ 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
|
|
237
268
|
git clone https://github.com/getquario/sjabloon.git
|
|
238
269
|
cd sjabloon
|
|
239
270
|
npm install
|
|
271
|
+
git config core.hooksPath .githooks # enable the commit-msg hook
|
|
240
272
|
npm run check
|
|
241
273
|
```
|
|
242
274
|
|
|
243
|
-
`npm run check` is the local gate.
|
|
275
|
+
`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.
|
|
276
|
+
|
|
277
|
+
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
278
|
|
|
245
279
|
## License
|
|
246
280
|
|