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 ADDED
@@ -0,0 +1,245 @@
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
+ ### Naming an interpolation with `tag`
195
+
196
+ A value token carries its value and nothing about where it came from, so an
197
+ embedder that has to treat one interpolation differently from the rest — a page
198
+ number a word processor writes as a live field, rather than the number the
199
+ render happened to see — cannot tell them apart from the stream. `tag` is that
200
+ seam:
201
+
202
+ ```js
203
+ const FIELD = { "page.number": { field: "page.number" } };
204
+ const tpl = template("Page {{ page.number }} of {{ page.total }}", fns, {
205
+ tag: (expr) => FIELD[expr],
206
+ });
207
+ tpl({ page: { number: 1, total: 2 } });
208
+ // [{ literal: 'Page ' }, { value: 1, field: 'page.number' }, { literal: ' of ' }, { value: 2 }]
209
+ ```
210
+
211
+ - **Called once per interpolation, while compiling.** Never at render time, so
212
+ what a token carries is a compile-time constant and a hot render pays nothing
213
+ for the keys it does not have. A template compiled without `tag` emits the
214
+ exact closure and the exact stream it always did.
215
+ - **The argument is the expression source as written and trimmed.** `{{ x }}`,
216
+ `{{x}}` and `{{- x -}}` all arrive as `'x'`, so an equality test against the
217
+ spelling you are looking for is exact. Match it, or return `undefined` and the
218
+ token is untouched.
219
+ - **The returned keys join every value token that interpolation emits** — once
220
+ per loop iteration, under the names you chose. `value` and `literal` are the
221
+ stream's own: `value` is written last so a tag cannot take it over, and
222
+ returning `literal` would make a token answer to both kinds, so do not.
223
+ - **It runs inside the parse**, which is shared, synchronous state. Read the
224
+ expression and return; do not compile another template from within it.
225
+ - **Block expressions are not offered.** `#if` conditions and `#each`
226
+ collections steer the render and emit no token, so there is nothing to name.
227
+ - The token edition alone has tokens to carry the keys; `sjabloon/text` and
228
+ `sjabloon/html` ignore the option.
229
+
230
+ `text(tokens)` joins a stream the way `sjabloon/text` would have rendered it, and
231
+ the two are equal for every template and every set of values. The test suite and
232
+ the fuzzer both check that.
233
+
234
+ ### `display(value)`
235
+
236
+ The scalar display rule every edition and `text()` share, exported from the root
237
+ entry for embedders that stringify token values themselves.
238
+
239
+ A valid `Date` renders as ISO 8601 UTC (`toISOString()`) — the same bytes on
240
+ every machine, where `String(date)` would bake in the host's timezone and locale.
241
+ An invalid `Date` keeps its deterministic `'Invalid Date'` form, nullish displays
242
+ empty, and everything else is `String(value)`.
243
+
244
+ Use it rather than your own `String(...)` when you consume tokens and want a
245
+ target's output to agree with `sjabloon/text` on the same template.