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