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/lib/core.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* This is the shared core: the lexer, parser and diagnostics, with output left
|
|
7
7
|
* to the profile each entry passes to `make()`. Exactly one copy of this module
|
|
8
|
-
* backs every entry, so the
|
|
9
|
-
* of them.
|
|
8
|
+
* backs every entry, so the diagnostics store below authenticates diagnostics
|
|
9
|
+
* across all of them.
|
|
10
10
|
*/
|
|
11
|
-
import { compile, isDiagnostic as isXprsnDiagnostic } from
|
|
11
|
+
import { compile, isDiagnostic as isXprsnDiagnostic, relocate as relocateXprsn } from "xprsn";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @import { SjabloonDiagnostic, SjabloonErrorCode, SjabloonFunctions, SjabloonRenderer, SjabloonValues } from './types.js'
|
|
@@ -30,10 +30,14 @@ import { compile, isDiagnostic as isXprsnDiagnostic } from 'xprsn';
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
const BLOCKED = /^(?:__proto__|constructor|prototype)$/;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// Error -> the `names` set of the compile that threw it. The map's keys are
|
|
34
|
+
// what authenticates a diagnostic module-wide; the value is the per-template
|
|
35
|
+
// origin each renderer's own `isDiagnostic` compares against.
|
|
36
|
+
/** @type {WeakMap<any, any>} */
|
|
37
|
+
const DIAGNOSTICS = new WeakMap();
|
|
38
|
+
const mark = DIAGNOSTICS.set.bind(DIAGNOSTICS);
|
|
36
39
|
const owns = DIAGNOSTICS.has.bind(DIAGNOSTICS);
|
|
40
|
+
const origin = DIAGNOSTICS.get.bind(DIAGNOSTICS);
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
43
|
* Check whether an error was produced or translated by sjabloon.
|
|
@@ -44,42 +48,68 @@ const owns = DIAGNOSTICS.has.bind(DIAGNOSTICS);
|
|
|
44
48
|
* @param {unknown} error Any thrown value.
|
|
45
49
|
* @returns {error is SjabloonDiagnostic} Whether `error` is an authentic sjabloon diagnostic.
|
|
46
50
|
*/
|
|
47
|
-
export const isDiagnostic =
|
|
51
|
+
export const isDiagnostic = owns;
|
|
48
52
|
|
|
49
|
-
// Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
|
|
50
|
-
// `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
|
|
51
|
-
// off once }}} is gone so {{{...}}×N does not rescan to EOF (stays O(n)).
|
|
52
53
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
54
|
+
* Intrinsics captured at module load, exactly as `mark` is: a copy is built
|
|
55
|
+
* from a captured prototype table rather than through the original's
|
|
56
|
+
* `constructor`, so replacing a prototype's `constructor` cannot make
|
|
57
|
+
* `relocate` mint an authenticated value that is not an Error. Only
|
|
58
|
+
* `SyntaxError` is listed because `fault` mints nothing else; every other
|
|
59
|
+
* class a sjabloon diagnostic can carry arrives via xprsn and is relocated by
|
|
60
|
+
* xprsn above.
|
|
55
61
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
62
|
+
const DESCS = Object.getOwnPropertyDescriptors,
|
|
63
|
+
DEFINE = Object.defineProperties,
|
|
64
|
+
PROTO = Object.getPrototypeOf,
|
|
65
|
+
SYNTAX = SyntaxError.prototype;
|
|
66
|
+
/** @type {(p: any) => (msg: string) => Error} */
|
|
67
|
+
const kindOf = (p) => (p === SYNTAX ? SyntaxError : Error);
|
|
68
|
+
/**
|
|
69
|
+
* Copy a diagnostic into an embedder's coordinates.
|
|
70
|
+
*
|
|
71
|
+
* Relocation lives here, beside the authentication it has to satisfy: the copy
|
|
72
|
+
* is registered in the same store as the original, under the original's origin,
|
|
73
|
+
* so it passes `isDiagnostic` and the owning renderer's own `isDiagnostic`
|
|
74
|
+
* alike. The original is never mutated, and every own field comes
|
|
75
|
+
* across by descriptor — the frozen `blocks` context stays frozen and
|
|
76
|
+
* non-writable — so a field added here is never a field an embedder forgets.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} diag A diagnostic produced or translated by sjabloon.
|
|
79
|
+
* @param {{ prefix?: string, offset?: number }} [opts] `prefix` is prepended to
|
|
80
|
+
* the message verbatim; `offset` shifts `start` and `end`.
|
|
81
|
+
* @returns {SjabloonDiagnostic} The relocated copy.
|
|
82
|
+
* @throws {TypeError} When `diag` is not a sjabloon diagnostic.
|
|
83
|
+
*/
|
|
84
|
+
export const relocate = (diag, { prefix = "", offset = 0 } = {}) => {
|
|
85
|
+
if (!isDiagnostic(diag)) throw TypeError("Not a sjabloon diagnostic");
|
|
86
|
+
// Most diagnostics here are xprsn errors translated into template
|
|
87
|
+
// coordinates, and those are registered in both stores. Letting xprsn make
|
|
88
|
+
// that half of the copy is what keeps the copy registered in both — the same
|
|
89
|
+
// reason relocation lives with authentication in the first place. Bare
|
|
90
|
+
// `mark`, not `attach`: the descriptors already carried `blocks` across,
|
|
91
|
+
// and attach would redefine a non-configurable property.
|
|
92
|
+
if (isXprsnDiagnostic(diag)) {
|
|
93
|
+
const moved = relocateXprsn(diag, { prefix, offset });
|
|
94
|
+
return (mark(moved, origin(diag)), /** @type {SjabloonDiagnostic} */ (moved));
|
|
95
|
+
}
|
|
96
|
+
let d = /** @type {any} */ (diag),
|
|
97
|
+
props = DESCS(d),
|
|
98
|
+
copy = kindOf(PROTO(d))(prefix + d.message);
|
|
99
|
+
delete props.message;
|
|
100
|
+
delete props.stack;
|
|
101
|
+
if (props.start) {
|
|
102
|
+
props.start.value += offset;
|
|
103
|
+
props.end.value += offset;
|
|
104
|
+
}
|
|
105
|
+
DEFINE(copy, props);
|
|
106
|
+
return (mark(copy, origin(d)), /** @type {SjabloonDiagnostic} */ (copy));
|
|
81
107
|
};
|
|
82
108
|
|
|
109
|
+
// Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
|
|
110
|
+
// `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
|
|
111
|
+
// off once }}} is gone so {{{...}}×N does not rescan to EOF (stays O(n)).
|
|
112
|
+
|
|
83
113
|
// One shared prototype for renders that omit `values` — the shape an embedder
|
|
84
114
|
// passing `{ root, item }` hits on every cell. A fresh `{}` here would give each
|
|
85
115
|
// wrapper its own hidden class, so lookups go megamorphic and such a render
|
|
@@ -88,11 +118,10 @@ let lex = s => {
|
|
|
88
118
|
const EMPTY = Object.freeze({});
|
|
89
119
|
|
|
90
120
|
// Shared parser state; parsing is synchronous so this is safe.
|
|
91
|
-
// `nms` collects free variables, `fnms` the registry functions called.
|
|
92
121
|
// LIT/VAL/RAW are the compiling profile's node builders — read only while
|
|
93
122
|
// parsing, never at render time, so the hot path stays free of indirection.
|
|
94
123
|
/** @type {Tok[]} */
|
|
95
|
-
let
|
|
124
|
+
let tokens;
|
|
96
125
|
/** @type {number} */
|
|
97
126
|
let i;
|
|
98
127
|
/** @type {SjabloonFunctions | undefined} */
|
|
@@ -102,13 +131,16 @@ let last;
|
|
|
102
131
|
/** @type {string[]} */
|
|
103
132
|
let bound;
|
|
104
133
|
/** @type {Set<string>} */
|
|
105
|
-
let
|
|
134
|
+
let names;
|
|
106
135
|
/** @type {Set<string>} */
|
|
107
|
-
let
|
|
136
|
+
let functions;
|
|
108
137
|
/** @type {string} */
|
|
109
|
-
let
|
|
110
|
-
/** @type {
|
|
138
|
+
let source;
|
|
139
|
+
/** @type {{ type: string, start: number, end: number }[]} */
|
|
111
140
|
let blocks;
|
|
141
|
+
/** Nesting budget shared by `#if`/`#each`/`#elif` (see DEPTH). */
|
|
142
|
+
/** @type {number} */
|
|
143
|
+
let nest;
|
|
112
144
|
// The profile's node builders. `any` rather than `Node<A>`: `make()` is generic
|
|
113
145
|
// per edition, but these are module-level and shared across all three, so no
|
|
114
146
|
// single A applies here.
|
|
@@ -118,6 +150,61 @@ let LIT;
|
|
|
118
150
|
let VAL;
|
|
119
151
|
/** @type {any} */
|
|
120
152
|
let RAW;
|
|
153
|
+
let lexTriple = 1,
|
|
154
|
+
lxRaw = 0,
|
|
155
|
+
lxP = 0,
|
|
156
|
+
lxL = 0,
|
|
157
|
+
lxB = 0;
|
|
158
|
+
|
|
159
|
+
/** @param {number} a */
|
|
160
|
+
let findEnd = (a) => {
|
|
161
|
+
lxB = lxRaw & lexTriple ? source.indexOf("}}}", lxP) : -1;
|
|
162
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
163
|
+
lxB < 0 &&
|
|
164
|
+
(lxRaw &&
|
|
165
|
+
((lexTriple = 0), (lxRaw = 0), (lxP = a + 2), (lxL = +(source[lxP] === "-")), (lxP += lxL)),
|
|
166
|
+
(lxB = source.indexOf("}}", lxP)));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let trimPrev = (prev = tokens.at(-1)) => {
|
|
170
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
171
|
+
prev && prev[0] === 0 && prev[1] && (prev[1] = prev[1].trimEnd());
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** @param {number} a */
|
|
175
|
+
let takeScanned = (a) => {
|
|
176
|
+
const r = +(lxB > lxP) & +(source[lxB - 1] === "-"),
|
|
177
|
+
whole = source.slice(lxP, lxB - r),
|
|
178
|
+
body = whole.trim(),
|
|
179
|
+
start = lxP + whole.length - whole.trimStart().length,
|
|
180
|
+
end = lxB + 2 + lxRaw;
|
|
181
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
182
|
+
lxL && trimPrev();
|
|
183
|
+
tokens.push([2 - lxRaw, body, a, end, start]);
|
|
184
|
+
i = end;
|
|
185
|
+
if (r) while (/\s/.test(source[i])) i++;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** @param {number} [a] */
|
|
189
|
+
let lexStep = (a = source.indexOf("{{", i)) => {
|
|
190
|
+
if (a < 0) return (tokens.push([0, source.slice(i)]), 0);
|
|
191
|
+
if (a > i) tokens.push([0, source.slice(i, a)]);
|
|
192
|
+
lxRaw = +(source[a + 2] === "{");
|
|
193
|
+
lxP = a + 2 + lxRaw;
|
|
194
|
+
lxL = +(source[lxP] === "-");
|
|
195
|
+
lxP += lxL;
|
|
196
|
+
findEnd(a);
|
|
197
|
+
if (lxB < 0) return (tokens.push([0, source.slice(a)]), 0);
|
|
198
|
+
takeScanned(a);
|
|
199
|
+
return 1;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let lex = () => {
|
|
203
|
+
tokens = [];
|
|
204
|
+
i = 0;
|
|
205
|
+
lexTriple = 1;
|
|
206
|
+
while (i < source.length && lexStep());
|
|
207
|
+
};
|
|
121
208
|
|
|
122
209
|
let snap = () => Object.freeze(blocks.slice());
|
|
123
210
|
// Block nesting is capped so a pathological template fails as a deterministic
|
|
@@ -128,19 +215,22 @@ const DEPTH = 256;
|
|
|
128
215
|
* @param {Tok} t
|
|
129
216
|
*/
|
|
130
217
|
let opener = (type, t) => {
|
|
131
|
-
|
|
132
|
-
|
|
218
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
219
|
+
nest < DEPTH || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
|
|
220
|
+
nest++;
|
|
221
|
+
return Object.freeze({ type, start: t[2], end: t[3] });
|
|
133
222
|
};
|
|
134
223
|
/**
|
|
135
224
|
* @template {object} E
|
|
136
225
|
* @param {E} e
|
|
137
226
|
* @param {any} context
|
|
227
|
+
* @param {any} own The owning compile's `names` set, this diagnostic's origin.
|
|
138
228
|
* @returns {E}
|
|
139
229
|
*/
|
|
140
|
-
let attach = (e, context) => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
230
|
+
let attach = (e, context, own) => {
|
|
231
|
+
Object.defineProperty(e, "blocks", { value: context, enumerable: true });
|
|
232
|
+
mark(e, own);
|
|
233
|
+
return e;
|
|
144
234
|
};
|
|
145
235
|
/**
|
|
146
236
|
* Throw a located compile-time diagnostic. `code` is typed to the published
|
|
@@ -153,201 +243,318 @@ let attach = (e, context) => {
|
|
|
153
243
|
* @param {any[]} [t] The token to point at; omitted for end-of-source faults.
|
|
154
244
|
* @returns {never}
|
|
155
245
|
*/
|
|
156
|
-
const fault = (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
246
|
+
const fault = (
|
|
247
|
+
msg,
|
|
248
|
+
code,
|
|
249
|
+
t,
|
|
250
|
+
start = t == null ? source.length : t[2],
|
|
251
|
+
end = t == null ? source.length : t[3],
|
|
252
|
+
) => {
|
|
253
|
+
const e = /** @type {SyntaxError & { code: SjabloonErrorCode, start: number, end: number }} */ (
|
|
254
|
+
SyntaxError(msg)
|
|
255
|
+
);
|
|
256
|
+
e.code = code;
|
|
257
|
+
e.start = start;
|
|
258
|
+
e.end = end;
|
|
259
|
+
throw attach(e, snap(), names);
|
|
162
260
|
};
|
|
163
261
|
/**
|
|
164
262
|
* Re-locate a diagnostic thrown by a nested compile or render into this
|
|
165
263
|
* template's coordinates, then rethrow it as ours. Always throws.
|
|
166
264
|
*
|
|
167
|
-
* `
|
|
265
|
+
* `guard` is a plain predicate rather than a type guard: `e` is retyped here,
|
|
168
266
|
* not narrowed. `const` with an explicit `never` type is what lets callers
|
|
169
267
|
* treat the catch block as terminal.
|
|
170
268
|
*
|
|
171
|
-
* @type {(e: any, start: number, context: any,
|
|
269
|
+
* @type {(e: any, start: number, context: any, own: any,
|
|
270
|
+
* guard?: (e: unknown) => boolean) => never}
|
|
172
271
|
*/
|
|
173
|
-
const translated = (e, start, context,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
e.end += start;
|
|
177
|
-
throw attach(e, context);
|
|
272
|
+
const translated = (e, start, context, own, guard = isXprsnDiagnostic) => {
|
|
273
|
+
if (!guard(e)) throw e;
|
|
274
|
+
throw attach(relocateXprsn(e, { offset: start }), context, own);
|
|
178
275
|
};
|
|
179
276
|
/**
|
|
180
277
|
* @param {Tok} t
|
|
181
278
|
* @returns {never}
|
|
182
279
|
*/
|
|
183
|
-
let unexpected = t => fault(
|
|
280
|
+
let unexpected = (t) => fault("Unexpected {{" + t[1] + "}}", "SJABLOON_UNEXPECTED_TAG", t);
|
|
184
281
|
|
|
185
|
-
// Append every node's output into the accumulator `
|
|
282
|
+
// Append every node's output into the accumulator `acc`, which the root wrapper
|
|
186
283
|
// creates once per render and threads all the way down. Nodes return nothing:
|
|
187
284
|
// no intermediate array per node list, no join, and render order is just push
|
|
188
285
|
// order.
|
|
189
286
|
/**
|
|
190
287
|
* @param {Node<any>[]} nodes
|
|
191
|
-
* @param {any}
|
|
192
|
-
* @param {any}
|
|
288
|
+
* @param {any} scope
|
|
289
|
+
* @param {any} acc
|
|
193
290
|
*/
|
|
194
|
-
let run = (nodes,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// the evaluated value into output. `k` is VAL for `{{ }}`, RAW for `{{{ }}}`.
|
|
198
|
-
/**
|
|
199
|
-
* @param {Tok} t
|
|
200
|
-
* @param {any} k
|
|
201
|
-
* @returns {Node<any>}
|
|
202
|
-
*/
|
|
203
|
-
let interp = (t, k) => k(cp(t[1], t[4], snap()));
|
|
291
|
+
let run = (nodes, scope, acc) => {
|
|
292
|
+
for (const n of nodes) n(scope, acc);
|
|
293
|
+
};
|
|
204
294
|
|
|
205
295
|
// Compile one expression and collect its free variables (minus the loop
|
|
206
296
|
// variables currently in scope, which belong to the template) and the registry
|
|
207
297
|
// functions it calls.
|
|
208
298
|
/**
|
|
209
|
-
* @param {string}
|
|
299
|
+
* @param {string} expr
|
|
210
300
|
* @param {number} start
|
|
211
301
|
* @param {any} context
|
|
212
302
|
* @returns {(v: any) => any}
|
|
213
303
|
*/
|
|
214
|
-
let
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
304
|
+
let compileExpr = (expr, start, context) => {
|
|
305
|
+
// The compiling template's origin, captured now: the render-time catch below
|
|
306
|
+
// runs long after the module-level `names` has moved on to other compiles.
|
|
307
|
+
const own = names;
|
|
308
|
+
/** @type {ReturnType<typeof compile>} */
|
|
309
|
+
let e;
|
|
310
|
+
try {
|
|
311
|
+
// `SjabloonFunctions` is `Record<string, Function>`; xprsn's registry wants
|
|
312
|
+
// `Record<string, (...args: any[]) => any>`, and TypeScript deliberately
|
|
313
|
+
// refuses `Function` against a call signature. The registry is passed
|
|
314
|
+
// straight through untouched, so this is a published-type mismatch rather
|
|
315
|
+
// than a real one — narrowing `SjabloonFunctions` would change the API.
|
|
316
|
+
e = compile(expr, /** @type {any} */ (fns));
|
|
317
|
+
} catch (x) {
|
|
318
|
+
translated(x, start, context, own);
|
|
319
|
+
}
|
|
320
|
+
e.names.forEach((n) => {
|
|
321
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
322
|
+
bound.includes(n) || names.add(n);
|
|
323
|
+
});
|
|
324
|
+
for (const fn of e.functions) functions.add(fn);
|
|
325
|
+
return (v) => {
|
|
326
|
+
try {
|
|
327
|
+
return e(v);
|
|
328
|
+
} catch (x) {
|
|
329
|
+
// Read off the compiled expression to pass along, never called through
|
|
330
|
+
// `e` — xprsn's `isDiagnostic` is a closure over its store, not a method.
|
|
331
|
+
// oxlint-disable-next-line typescript/unbound-method
|
|
332
|
+
translated(x, start, context, own, e.isDiagnostic);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
236
335
|
};
|
|
237
336
|
|
|
337
|
+
/**
|
|
338
|
+
* @param {string} stop
|
|
339
|
+
* @param {Node<any>[]} [nodes]
|
|
340
|
+
*/
|
|
341
|
+
let closeTail = (stop, nodes = parse([stop])) =>
|
|
342
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
343
|
+
(last[1] === stop || unexpected(last), nodes);
|
|
344
|
+
|
|
238
345
|
// One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
|
|
346
|
+
/**
|
|
347
|
+
* @param {string} tag
|
|
348
|
+
* @param {Tok} [t]
|
|
349
|
+
* @returns {Node<any>[]}
|
|
350
|
+
*/
|
|
351
|
+
let nestElif = (tag, t = last) => {
|
|
352
|
+
// Elif chains share the nest budget so they fail closed before the native
|
|
353
|
+
// stack, without appearing as extra `#if` frames in diagnostic context.
|
|
354
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
355
|
+
nest < DEPTH || fault("Template too deeply nested", "SJABLOON_TOO_DEEP", t);
|
|
356
|
+
nest++;
|
|
357
|
+
const next = branch(compileExpr(tag.slice(6), t[4] + 6, snap()));
|
|
358
|
+
nest--;
|
|
359
|
+
return [next];
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** @param {string} tag @returns {Node<any>[]} */
|
|
363
|
+
let elseChain = (tag) => {
|
|
364
|
+
if (tag.startsWith("#elif ")) return nestElif(tag);
|
|
365
|
+
if (tag === "#else") return closeTail("/if");
|
|
366
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
367
|
+
tag === "/if" || unexpected(last);
|
|
368
|
+
return [];
|
|
369
|
+
};
|
|
370
|
+
|
|
239
371
|
/**
|
|
240
372
|
* @param {(v: any) => any} cond
|
|
241
373
|
* @returns {Node<any>}
|
|
242
374
|
*/
|
|
243
|
-
let branch = cond => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
375
|
+
let branch = (cond) => {
|
|
376
|
+
const then = parse(["#elif", "#else", "/if"]);
|
|
377
|
+
const els = elseChain(last[1]);
|
|
378
|
+
return (scope, acc) => run(cond(scope) ? then : els, scope, acc);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {any} listValue
|
|
383
|
+
*/
|
|
384
|
+
let eachPairs = (listValue) => {
|
|
385
|
+
if (Array.isArray(listValue)) return listValue.slice();
|
|
386
|
+
if (listValue && typeof listValue === "object")
|
|
387
|
+
return Object.keys(listValue).map((k) => [listValue[k], k]);
|
|
388
|
+
return [];
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
let eachEmpty = () =>
|
|
392
|
+
last[1] === "#else" ? closeTail("/each") : last[1] === "/each" ? [] : unexpected(last);
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {Tok} t
|
|
396
|
+
* @param {string} tag
|
|
397
|
+
* @param {string} name
|
|
398
|
+
* @param {number} at
|
|
399
|
+
*/
|
|
400
|
+
let checkBinding = (t, tag, name, at) => {
|
|
401
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
402
|
+
BLOCKED.test(name) &&
|
|
403
|
+
fault("Bad {{" + tag + "}}", "SJABLOON_BLOCKED_BINDING", t, at, at + name.length);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {Tok} t
|
|
408
|
+
* @param {string} tag
|
|
409
|
+
* @param {Node<any>[]} nodes
|
|
410
|
+
*/
|
|
411
|
+
let parseEach = (t, tag, nodes) => {
|
|
412
|
+
blocks.push(opener("each", t));
|
|
413
|
+
const m =
|
|
414
|
+
/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag) ||
|
|
415
|
+
fault("Bad {{" + tag + "}}", "SJABLOON_EACH_SYNTAX", t);
|
|
416
|
+
const name = m[3],
|
|
417
|
+
idx = m[4],
|
|
418
|
+
at = t[4] + tag.length - m[2].length;
|
|
419
|
+
checkBinding(t, tag, name, at);
|
|
420
|
+
const list = compileExpr(m[1], t[4] + 6, snap());
|
|
421
|
+
const mark = bound.length;
|
|
422
|
+
bound.push(name);
|
|
423
|
+
if (idx) {
|
|
424
|
+
checkBinding(t, tag, idx, t[4] + tag.length - idx.length);
|
|
425
|
+
bound.push(idx);
|
|
426
|
+
}
|
|
427
|
+
bound.push("loop");
|
|
428
|
+
const body = parse(["#else", "/each"]);
|
|
429
|
+
bound.length = mark;
|
|
430
|
+
const empty = eachEmpty();
|
|
431
|
+
blocks.pop();
|
|
432
|
+
nest--;
|
|
433
|
+
nodes.push((scope, acc) => {
|
|
434
|
+
const listValue = list(scope),
|
|
435
|
+
arr = Array.isArray(listValue);
|
|
436
|
+
const pairs = eachPairs(listValue);
|
|
437
|
+
if (!pairs.length) return run(empty, scope, acc);
|
|
438
|
+
pairs.forEach((x, j) => {
|
|
439
|
+
const item = arr ? x : x[0],
|
|
440
|
+
key = arr ? j : x[1];
|
|
441
|
+
const child = Object.create(scope);
|
|
442
|
+
child[name] = item;
|
|
443
|
+
if (idx) child[idx] = key;
|
|
444
|
+
child["@"] = item;
|
|
445
|
+
child.loop = {
|
|
446
|
+
index: j + 1,
|
|
447
|
+
index0: j,
|
|
448
|
+
first: !j,
|
|
449
|
+
last: j === pairs.length - 1,
|
|
450
|
+
length: pairs.length,
|
|
451
|
+
};
|
|
452
|
+
run(body, child, acc);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @param {Tok} t
|
|
459
|
+
* @param {Node<any>[]} nodes
|
|
460
|
+
*/
|
|
461
|
+
let emitLeaf = (t, nodes) => {
|
|
462
|
+
if (!t[0]) {
|
|
463
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
464
|
+
t[1] && nodes.push(LIT(t[1]));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
468
|
+
RAW ||
|
|
469
|
+
fault(
|
|
470
|
+
"Raw {{{" + t[1] + "}}} is not available here; {{ " + t[1] + " }} is already raw",
|
|
471
|
+
"SJABLOON_RAW_TAG",
|
|
472
|
+
t,
|
|
473
|
+
);
|
|
474
|
+
nodes.push(RAW(compileExpr(t[1], t[4], snap())));
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @param {Tok} t
|
|
479
|
+
* @param {string} tag
|
|
480
|
+
* @param {Node<any>[]} nodes
|
|
481
|
+
*/
|
|
482
|
+
let emitBlock = (t, tag, nodes) => {
|
|
483
|
+
if (tag.startsWith("#if ")) {
|
|
484
|
+
blocks.push(opener("if", t));
|
|
485
|
+
nodes.push(branch(compileExpr(tag.slice(4), t[4] + 4, snap())));
|
|
486
|
+
blocks.pop();
|
|
487
|
+
nest--;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (/^#each(?:\s|$)/.test(tag)) return parseEach(t, tag, nodes);
|
|
491
|
+
if (/^#(?:if|elif|else)(?:\s|$)/.test(tag)) unexpected(t);
|
|
492
|
+
fault("Unknown {{" + tag + "}}", "SJABLOON_UNKNOWN_BLOCK", t);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* @param {Tok} t
|
|
497
|
+
* @param {string} tag
|
|
498
|
+
* @param {Node<any>[]} nodes
|
|
499
|
+
*/
|
|
500
|
+
let emitTag = (t, tag, nodes) => {
|
|
501
|
+
if (tag[0] === "!") return;
|
|
502
|
+
if (tag[0] === "#") return emitBlock(t, tag, nodes);
|
|
503
|
+
if (tag[0] === "/") unexpected(t);
|
|
504
|
+
nodes.push(VAL(compileExpr(t[1], t[4], snap())));
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @param {Tok} t
|
|
509
|
+
* @param {string[]} stops
|
|
510
|
+
* @param {Node<any>[]} nodes
|
|
511
|
+
*/
|
|
512
|
+
let takeToken = (t, stops, nodes) => {
|
|
513
|
+
if (t[0] < 2) return (emitLeaf(t, nodes), 0);
|
|
514
|
+
if (stops.includes(t[1].split(" ")[0])) return ((last = t), 1);
|
|
515
|
+
emitTag(t, t[1], nodes);
|
|
516
|
+
return 0;
|
|
253
517
|
};
|
|
254
518
|
|
|
255
519
|
/**
|
|
256
520
|
* @param {string[]} stops
|
|
257
521
|
* @returns {Node<any>[]}
|
|
258
522
|
*/
|
|
259
|
-
let parse = stops => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
} else if (tag.startsWith('#if ')) {
|
|
278
|
-
blocks.push(opener('if', t));
|
|
279
|
-
nodes.push(branch(cp(tag.slice(4), t[4] + 4, snap())));
|
|
280
|
-
blocks.pop();
|
|
281
|
-
} else if (/^#each(?:\s|$)/.test(tag)) {
|
|
282
|
-
blocks.push(opener('each', t));
|
|
283
|
-
// `|| fault()` in the initializer, not as a follow-up statement: fault
|
|
284
|
-
// returns never, so `m` is non-null from here without a second check.
|
|
285
|
-
const m = /^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag) || fault('Bad {{' + tag + '}}', 'SJABLOON_EACH_SYNTAX', t);
|
|
286
|
-
const name = m[3], idx = m[4], at = t[4] + tag.length - m[2].length;
|
|
287
|
-
if (BLOCKED.test(name)) fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, at, at + name.length);
|
|
288
|
-
if (idx && BLOCKED.test(idx)) {
|
|
289
|
-
const p = t[4] + tag.length - idx.length;
|
|
290
|
-
fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, p, p + idx.length);
|
|
291
|
-
}
|
|
292
|
-
const list = cp(m[1], t[4] + 6, snap());
|
|
293
|
-
// `name`, `idx`, and `loop` are engine-bound inside the body, so
|
|
294
|
-
// exclude them from names there and restore outer bindings after.
|
|
295
|
-
const mark = bound.length;
|
|
296
|
-
bound.push(name);
|
|
297
|
-
if (idx) bound.push(idx);
|
|
298
|
-
bound.push('loop');
|
|
299
|
-
const body = parse(['#else', '/each']);
|
|
300
|
-
bound.length = mark;
|
|
301
|
-
let empty = /** @type {Node<any>[]} */ ([]);
|
|
302
|
-
if (last[1] === '#else') {
|
|
303
|
-
empty = parse(['/each']);
|
|
304
|
-
last[1] === '/each' || unexpected(last);
|
|
305
|
-
} else if (last[1] !== '/each') unexpected(last);
|
|
306
|
-
blocks.pop();
|
|
307
|
-
// Child scopes inherit the parent via the prototype chain, so outer
|
|
308
|
-
// variables stay visible inside the loop body. `@` re-points to the
|
|
309
|
-
// current item at each level, `$` (root) rides the chain, and `loop`
|
|
310
|
-
// carries the iteration metadata (index/first/last/length).
|
|
311
|
-
nodes.push((v, o) => {
|
|
312
|
-
const lv = list(v), arr = Array.isArray(lv);
|
|
313
|
-
const ps = arr ? lv.slice() : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k]) : [];
|
|
314
|
-
if (!ps.length) return run(empty, v, o);
|
|
315
|
-
// forEach, not a counted loop: `slice()` keeps holes and forEach
|
|
316
|
-
// skips them exactly as the `.map()` this replaced did, so sparse
|
|
317
|
-
// arrays iterate the same way with surrounding indexes unshifted.
|
|
318
|
-
ps.forEach((x, j) => {
|
|
319
|
-
const item = arr ? x : x[0], key = arr ? j : x[1];
|
|
320
|
-
const s = Object.create(v);
|
|
321
|
-
s[name] = item;
|
|
322
|
-
if (idx) s[idx] = key;
|
|
323
|
-
s['@'] = item;
|
|
324
|
-
s.loop = { index: j + 1, index0: j, first: !j, last: j === ps.length - 1, length: ps.length };
|
|
325
|
-
run(body, s, o);
|
|
326
|
-
});
|
|
327
|
-
});
|
|
328
|
-
} else if (/^#(?:if|elif|else)(?:\s|$)/.test(tag) || tag[0] === '/') {
|
|
329
|
-
unexpected(t);
|
|
330
|
-
} else if (tag[0] === '#') {
|
|
331
|
-
fault('Unknown {{' + tag + '}}', 'SJABLOON_UNKNOWN_BLOCK', t);
|
|
332
|
-
} else {
|
|
333
|
-
nodes.push(interp(t, VAL));
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
stops.length && fault('Missing {{' + stops[stops.length - 1] + '}}', 'SJABLOON_UNCLOSED_BLOCK');
|
|
337
|
-
return nodes;
|
|
523
|
+
let parse = (stops) => {
|
|
524
|
+
const nodes = /** @type {Node<any>[]} */ ([]);
|
|
525
|
+
for (let t; (t = tokens[i++]);) {
|
|
526
|
+
if (takeToken(t, stops, nodes)) return nodes;
|
|
527
|
+
}
|
|
528
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
529
|
+
stops.length && fault("Missing {{" + stops[stops.length - 1] + "}}", "SJABLOON_UNCLOSED_BLOCK");
|
|
530
|
+
return nodes;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Literal text node for the string editions: append `text` onto `acc.text`.
|
|
535
|
+
*
|
|
536
|
+
* @param {string} text
|
|
537
|
+
* @returns {Node<{ text: string }>}
|
|
538
|
+
*/
|
|
539
|
+
export const litNode = (text) => (scope, acc) => {
|
|
540
|
+
acc.text += text;
|
|
338
541
|
};
|
|
339
542
|
|
|
340
543
|
/**
|
|
341
544
|
* Bind the parser to an output profile. Each edition calls this once at module
|
|
342
545
|
* load and gets back its own `template` and `render`; the parser itself stays
|
|
343
|
-
* module-level and shared, so there is exactly one diagnostics
|
|
546
|
+
* module-level and shared, so there is exactly one diagnostics store.
|
|
344
547
|
*
|
|
345
|
-
* `template(str, funcs?)` compiles a template once, to render it many
|
|
548
|
+
* `template(str, funcs?, opts?)` compiles a template once, to render it many
|
|
549
|
+
* times.
|
|
346
550
|
*
|
|
347
551
|
* The returned renderer exposes `names`: the variables the template reads
|
|
348
552
|
* from your values, deduplicated. Loop variables the template introduces are
|
|
349
|
-
* not included
|
|
350
|
-
*
|
|
553
|
+
* not included, and neither is anything in `opts.bound` — names the embedder
|
|
554
|
+
* already has in scope (still resolved normally at render time, exactly like
|
|
555
|
+
* xprsn's own `bound`). It also exposes `functions`: the registry functions
|
|
556
|
+
* the template calls, deduplicated. `isDiagnostic(error)` recognizes runtime
|
|
557
|
+
* diagnostics thrown through this renderer alone.
|
|
351
558
|
*
|
|
352
559
|
* Two anchors are always in scope: `$` is the root values, and `@` is the
|
|
353
560
|
* current `#each` item (the root outside any loop). They let a nested loop
|
|
@@ -359,6 +566,12 @@ let parse = stops => {
|
|
|
359
566
|
* `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
|
|
360
567
|
* `@` unbound, so reading `@.x` throws through xprsn's guard.
|
|
361
568
|
*
|
|
569
|
+
* An embedder whose scope chain already binds the anchors renders through
|
|
570
|
+
* `scoped(values)` instead: no wrapper scope is created, `$` and `@` resolve
|
|
571
|
+
* from `values` itself, and a chain that omits `@` leaves it unbound the same
|
|
572
|
+
* way. That is the zero-allocation seam for a host rendering one template per
|
|
573
|
+
* cell per row over scopes it already builds.
|
|
574
|
+
*
|
|
362
575
|
* Render order is push order into a single accumulator: loop bodies append once
|
|
363
576
|
* per iteration, untaken branches append nothing, and block expressions (`#if`
|
|
364
577
|
* conditions, `#each` collections) never append at all. The token edition
|
|
@@ -382,65 +595,76 @@ let parse = stops => {
|
|
|
382
595
|
* take: (acc: A) => T,
|
|
383
596
|
* ]} profile The output profile, as above.
|
|
384
597
|
* @returns {{
|
|
385
|
-
* template: (str: string, funcs?: SjabloonFunctions
|
|
598
|
+
* template: (str: string, funcs?: SjabloonFunctions,
|
|
599
|
+
* opts?: { bound?: Iterable<string> }) => SjabloonRenderer<T>,
|
|
386
600
|
* render: (str: string, values?: SjabloonValues, funcs?: SjabloonFunctions) => T,
|
|
387
601
|
* }} That edition's API.
|
|
388
602
|
* @throws {SyntaxError} `template` throws on malformed tags, unclosed blocks,
|
|
389
603
|
* or bad expressions.
|
|
390
604
|
*/
|
|
391
605
|
export let make = ([lit, val, raw, seed, take]) => {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
606
|
+
/**
|
|
607
|
+
* @param {string} str
|
|
608
|
+
* @param {SjabloonFunctions} [funcs]
|
|
609
|
+
* @param {{ bound?: Iterable<string> }} [opts]
|
|
610
|
+
* @returns {SjabloonRenderer<T>}
|
|
611
|
+
*/
|
|
612
|
+
function template(str, funcs, opts) {
|
|
613
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
614
|
+
((LIT = lit), (VAL = val), (RAW = raw));
|
|
615
|
+
fns = funcs;
|
|
616
|
+
// `$` (root) and `@` (current item) are engine-bound anchors, always in
|
|
617
|
+
// scope, so they never count as caller-supplied `names` — and neither does
|
|
618
|
+
// anything the embedder declares bound. A loop, not a spread: the
|
|
619
|
+
// bundler's transpile turns an iterable spread into a concat that would
|
|
620
|
+
// wrap a Set instead of unpacking it.
|
|
621
|
+
bound = ["$", "@"];
|
|
622
|
+
if (opts && opts.bound) for (const name of opts.bound) bound.push(name);
|
|
623
|
+
names = new Set();
|
|
624
|
+
functions = new Set();
|
|
625
|
+
source = String(str);
|
|
626
|
+
blocks = [];
|
|
627
|
+
nest = 0;
|
|
628
|
+
lex();
|
|
629
|
+
i = 0;
|
|
630
|
+
// Deeply nested blocks fail as SJABLOON_TOO_DEEP at DEPTH via opener(),
|
|
631
|
+
// including elif chains — well below the native stack.
|
|
632
|
+
let nodes = parse([]);
|
|
633
|
+
// The trusted-scope render, and the one render body: the caller's chain
|
|
634
|
+
// already carries the anchors, so no wrapper is created and nothing is
|
|
635
|
+
// written anywhere. One accumulator per render, owned here and threaded
|
|
636
|
+
// down. A registry function that renders another template gets its own,
|
|
637
|
+
// so re-entrancy needs no bookkeeping.
|
|
638
|
+
const scoped = (/** @type {any} */ values) => {
|
|
639
|
+
const acc = seed();
|
|
640
|
+
run(nodes, values, acc);
|
|
641
|
+
return take(acc);
|
|
642
|
+
};
|
|
643
|
+
// The default render wraps the values in a root scope carrying the
|
|
644
|
+
// anchors, without mutating what the caller passed: by default `$` and `@`
|
|
645
|
+
// both point at the root. An embedder can override the anchors with a
|
|
646
|
+
// `{ root, item }` second arg: `$` = root, `@` = item (distinct objects).
|
|
647
|
+
// Omitting `item` leaves `@` unbound, so `@.x` throws through xprsn's
|
|
648
|
+
// guard — a group-header band that has no current row wants exactly that.
|
|
649
|
+
const f = (/** @type {any} */ values, /** @type {any} */ anchors) => {
|
|
650
|
+
values = values || EMPTY;
|
|
651
|
+
const r = Object.create(values);
|
|
652
|
+
r["$"] = anchors ? anchors.root : values;
|
|
653
|
+
r["@"] = anchors ? anchors.item : values;
|
|
654
|
+
return scoped(r);
|
|
655
|
+
};
|
|
656
|
+
// Array.from, not a spread: the bundler's transpile turns `[...set]` into
|
|
657
|
+
// `[].concat(set)`, which wraps the Set instead of unpacking it.
|
|
658
|
+
f.names = Array.from(names);
|
|
659
|
+
f.functions = Array.from(functions);
|
|
660
|
+
// This compile's own `names` set doubles as its origin: every diagnostic
|
|
661
|
+
// thrown through this renderer was marked with it, at compile time by
|
|
662
|
+
// `fault` and at render time by the closures `compileExpr` built. Captured
|
|
663
|
+
// now — the module-level `names` moves on to the next compile.
|
|
664
|
+
const o = names;
|
|
665
|
+
f.isDiagnostic = (/** @type {unknown} */ x) => origin(x) === o;
|
|
666
|
+
f.scoped = scoped;
|
|
667
|
+
return f;
|
|
668
|
+
}
|
|
669
|
+
return { template, render: (str, values, funcs) => template(str, funcs)(values) };
|
|
446
670
|
};
|