sjabloon 0.8.0 → 0.10.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/README.md +102 -44
- package/lib/core.js +471 -247
- package/lib/html.d.ts +7 -3
- package/lib/html.js +9 -9
- package/lib/index.d.ts +7 -3
- package/lib/index.js +25 -16
- package/lib/text.d.ts +7 -3
- package/lib/text.js +7 -7
- package/lib/types.d.ts +50 -25
- package/package.json +108 -99
package/README.md
CHANGED
|
@@ -13,31 +13,41 @@ A tiny, CSP-safe, target-neutral template engine for JavaScript. **~1.9KB min+br
|
|
|
13
13
|
</picture>
|
|
14
14
|
</a>
|
|
15
15
|
|
|
16
|
-
|
|
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
17
|
|
|
18
|
-
The engine emits **tokens**, not text. A render gives you the literal runs and the interpolated values, interleaved in render order
|
|
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.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install sjabloon
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Node.js 22.12 or newer, ESM only.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
19
29
|
|
|
20
30
|
```js
|
|
21
|
-
import { template, text } from
|
|
31
|
+
import { template, text } from "sjabloon";
|
|
22
32
|
|
|
23
|
-
const cell = template(
|
|
33
|
+
const cell = template("{{ total * 1.21 }}");
|
|
24
34
|
|
|
25
|
-
cell({ total: 1000 });
|
|
26
|
-
text(cell({ total: 1000 }));
|
|
35
|
+
cell({ total: 1000 }); // => [{ value: 1210 }] // still a number
|
|
36
|
+
text(cell({ total: 1000 })); // => '1210' // when you want the string
|
|
27
37
|
```
|
|
28
38
|
|
|
29
39
|
If you only want a string, import the edition that produces one directly:
|
|
30
40
|
|
|
31
41
|
```js
|
|
32
|
-
import { render } from
|
|
42
|
+
import { render } from "sjabloon/html"; // {{ }} HTML-escapes, {{{ }}} is raw
|
|
33
43
|
|
|
34
44
|
render(
|
|
35
45
|
`<ul>{{#each items as it, i}}
|
|
36
46
|
<li>{{ i + 1 }}. {{ it.name }}: {{ fmt(it.price * it.qty) }}</li>
|
|
37
47
|
{{/each}}</ul>
|
|
38
48
|
{{#if total >= 100 and "vip" in user.roles}}Free shipping!{{#else}}Shipping: {{ fmt(5) }}{{/if}}`,
|
|
39
|
-
{ items: [{ name:
|
|
40
|
-
{ fmt: n =>
|
|
49
|
+
{ items: [{ name: "Koffie", price: 8, qty: 2 }], total: 120, user: { roles: ["vip"] } },
|
|
50
|
+
{ fmt: (n) => "€" + n.toFixed(2) },
|
|
41
51
|
);
|
|
42
52
|
```
|
|
43
53
|
|
|
@@ -45,50 +55,70 @@ render(
|
|
|
45
55
|
|
|
46
56
|
Three entry points, one engine. They share a parser, a syntax, and a diagnostics contract, and differ only in what a render produces.
|
|
47
57
|
|
|
48
|
-
| Import
|
|
49
|
-
|
|
|
50
|
-
| `sjabloon`
|
|
51
|
-
| `sjabloon/text` | `(values?, scope?) => string`
|
|
52
|
-
| `sjabloon/html` | `(values?, scope?) => string`
|
|
58
|
+
| Import | `template(str, funcs?)` returns | `{{ expr }}` | `{{{ expr }}}` |
|
|
59
|
+
| --------------- | ------------------------------- | ------------ | -------------- |
|
|
60
|
+
| `sjabloon` | `(values?, scope?) => Token[]` | value token | `SyntaxError` |
|
|
61
|
+
| `sjabloon/text` | `(values?, scope?) => string` | unescaped | `SyntaxError` |
|
|
62
|
+
| `sjabloon/html` | `(values?, scope?) => string` | HTML-escaped | raw |
|
|
53
63
|
|
|
54
|
-
`{{{ }}}` 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
|
|
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.
|
|
55
65
|
|
|
56
|
-
Every edition exports `template`, `render`, and `
|
|
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.
|
|
57
67
|
|
|
58
68
|
## API
|
|
59
69
|
|
|
60
|
-
### `template(str, functions?)`
|
|
70
|
+
### `template(str, functions?, options?)`
|
|
61
71
|
|
|
62
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.
|
|
63
73
|
|
|
64
|
-
The anchors `$` (root) and `@` (current `{{#each}}` item) work as [described below](#syntax) with no extra arguments
|
|
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.
|
|
65
75
|
|
|
66
76
|
```js
|
|
67
|
-
const tpl = template(
|
|
77
|
+
const tpl = template("{{ $.report }} / {{ @.row }}");
|
|
68
78
|
tpl(base, { root: reportRoot, item: currentRow }); // $ = reportRoot, @ = currentRow
|
|
69
|
-
tpl(base, { root: reportRoot });
|
|
79
|
+
tpl(base, { root: reportRoot }); // no item → @.x throws
|
|
70
80
|
```
|
|
71
81
|
|
|
72
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.
|
|
73
83
|
|
|
74
84
|
```js
|
|
75
|
-
const tpl = template(
|
|
76
|
-
|
|
85
|
+
const tpl = template("{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}", {
|
|
86
|
+
fmt: (s) => s,
|
|
87
|
+
});
|
|
88
|
+
tpl.names; // => ['title', 'items']
|
|
77
89
|
tpl.functions; // => ['fmt']
|
|
78
90
|
```
|
|
79
91
|
|
|
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`:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
template("{{ run.total }} of {{ count }}", undefined, { bound: ["run"] }).names; // => ['count']
|
|
96
|
+
```
|
|
97
|
+
|
|
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)`
|
|
101
|
+
|
|
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.
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const row = Object.create(base); // base binds $ once per render
|
|
106
|
+
row["@"] = item;
|
|
107
|
+
tpl.scoped(row);
|
|
108
|
+
```
|
|
109
|
+
|
|
80
110
|
### `render(str, values?, functions?)`
|
|
81
111
|
|
|
82
112
|
Shorthand for `template(str, functions)(values)`, returning whatever its edition renders.
|
|
83
113
|
|
|
84
|
-
### `text(tokens)`
|
|
114
|
+
### `text(tokens)` (root entry only)
|
|
85
115
|
|
|
86
|
-
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
|
|
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.
|
|
87
117
|
|
|
88
118
|
```js
|
|
89
|
-
import { template, text } from
|
|
119
|
+
import { template, text } from "sjabloon";
|
|
90
120
|
|
|
91
|
-
const tokens = template(
|
|
121
|
+
const tokens = template("{{ qty }} × {{ name }}")({ qty: 2, name: "Koffie" });
|
|
92
122
|
// => [{ value: 2 }, { literal: ' × ' }, { value: 'Koffie' }]
|
|
93
123
|
|
|
94
124
|
text(tokens); // => '2 × Koffie'
|
|
@@ -96,9 +126,9 @@ text(tokens); // => '2 × Koffie'
|
|
|
96
126
|
|
|
97
127
|
A `Token` is either `{ literal: string }` or `{ value: unknown }`:
|
|
98
128
|
|
|
99
|
-
- **Values are pre-stringify.** `{{ total }}` holding `1000` yields the number `1000`, not `"1000"`, and nullish stays nullish. Stringification is deferred to `text()
|
|
100
|
-
- **Order is render order.** Loop bodies append once per iteration, untaken branches append nothing, and block expressions (`#if` conditions, `#each` collections) never appear
|
|
101
|
-
- **Literals are the template's static runs**, one token each, never merged and never empty.
|
|
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.
|
|
102
132
|
|
|
103
133
|
Literal tokens are frozen and shared across loop iterations; value tokens are fresh per emit.
|
|
104
134
|
|
|
@@ -117,19 +147,36 @@ Unauthenticated errors thrown by registered functions, getters, methods, or valu
|
|
|
117
147
|
|
|
118
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`.
|
|
119
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:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
import { isDiagnostic, relocate, template } from "sjabloon";
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
template(cell.value);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (!isDiagnostic(error)) throw error;
|
|
161
|
+
throw relocate(error, { prefix: "detail.cells[0].value: " });
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
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
|
+
|
|
120
167
|
## Syntax
|
|
121
168
|
|
|
122
|
-
| Tag
|
|
123
|
-
|
|
|
124
|
-
| `{{ expr }}`
|
|
125
|
-
| `{{{ expr }}}`
|
|
126
|
-
| `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need
|
|
127
|
-
| `{{#each expr as item}} … {{/each}}`
|
|
128
|
-
| `{{#each expr as item, key}} … {{/each}}`
|
|
129
|
-
| `{{#each expr as item}} … {{#else}} … {{/each}}`
|
|
130
|
-
| `{{ loop.last }}` (inside `{{#each}}`)
|
|
131
|
-
| `{{! anything }}`
|
|
132
|
-
| `{{- expr -}}`
|
|
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 |
|
|
133
180
|
|
|
134
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.
|
|
135
182
|
|
|
@@ -138,7 +185,7 @@ A loop body sees its loop variable plus the outer scope; reusing an outer name s
|
|
|
138
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.
|
|
139
186
|
|
|
140
187
|
```js
|
|
141
|
-
render(
|
|
188
|
+
render("{{#each xs as x}}{{ x }}{{#if not loop.last}}, {{/if}}{{/each}}", { xs: ["a", "b", "c"] });
|
|
142
189
|
// => 'a, b, c'
|
|
143
190
|
```
|
|
144
191
|
|
|
@@ -146,8 +193,8 @@ Two anchors are always in scope: `$` is the root values and `@` is the current `
|
|
|
146
193
|
|
|
147
194
|
```js
|
|
148
195
|
render(
|
|
149
|
-
|
|
150
|
-
{ company:
|
|
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 }] },
|
|
151
198
|
);
|
|
152
199
|
// => 'North of ACME: 1 2 South of ACME: 1 2 '
|
|
153
200
|
```
|
|
@@ -171,7 +218,18 @@ That runtime CSP support costs some render speed. Handlebars and tempura generat
|
|
|
171
218
|
|
|
172
219
|
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.
|
|
173
220
|
|
|
174
|
-
Shipping CommonJS alongside ESM would put two copies of the core in any process that mixed `require` and `import
|
|
221
|
+
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
|
+
|
|
223
|
+
## Contributing
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git clone https://github.com/getquario/sjabloon.git
|
|
227
|
+
cd sjabloon
|
|
228
|
+
npm install
|
|
229
|
+
npm run check
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`npm run check` is the local gate. Conventions for this repo live in [AGENTS.md](AGENTS.md).
|
|
175
233
|
|
|
176
234
|
## License
|
|
177
235
|
|