sjabloon 0.7.0 → 0.8.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,446 @@
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 } 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 = error => owns(error);
48
+
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
+ * @param {string} s
54
+ * @returns {Tok[]}
55
+ */
56
+ let lex = s => {
57
+ const out = /** @type {Tok[]} */ ([]);
58
+ for (let i = 0, triple = 1; i < s.length; ) {
59
+ const a = s.indexOf('{{', i);
60
+ if (a < 0) { out.push([0, s.slice(i)]); break; }
61
+ if (a > i) out.push([0, s.slice(i, a)]);
62
+ let raw = +(s[a + 2] === '{'), p = a + 2 + raw, l = s[p] === '-', b = -1;
63
+ if (l) p++;
64
+ if (raw && triple) { b = s.indexOf('}}}', p); if (b < 0) triple = 0; }
65
+ if (b < 0) {
66
+ if (raw) { raw = 0; p = a + 2; l = s[p] === '-'; if (l) p++; }
67
+ b = s.indexOf('}}', p);
68
+ }
69
+ if (b < 0) { out.push([0, s.slice(a)]); break; }
70
+ const r = b > p && s[b - 1] === '-';
71
+ const q = r ? b - 1 : b, whole = s.slice(p, q), body = whole.trim();
72
+ const start = p + whole.length - whole.trimStart().length, end = b + 2 + raw;
73
+ const t = [raw ? 1 : 2, body, a, end, start];
74
+ const prev = out.at(-1);
75
+ if (l && prev?.[0] === 0 && prev[1]) prev[1] = prev[1].trimEnd();
76
+ out.push(t);
77
+ i = end;
78
+ if (r) while (/\s/.test(s[i])) i++;
79
+ }
80
+ return out;
81
+ };
82
+
83
+ // One shared prototype for renders that omit `values` — the shape an embedder
84
+ // passing `{ root, item }` hits on every cell. A fresh `{}` here would give each
85
+ // wrapper its own hidden class, so lookups go megamorphic and such a render
86
+ // costs ~12x one that passes values. Frozen: nothing may write to a prototype
87
+ // shared across renders.
88
+ const EMPTY = Object.freeze({});
89
+
90
+ // Shared parser state; parsing is synchronous so this is safe.
91
+ // `nms` collects free variables, `fnms` the registry functions called.
92
+ // LIT/VAL/RAW are the compiling profile's node builders — read only while
93
+ // parsing, never at render time, so the hot path stays free of indirection.
94
+ /** @type {Tok[]} */
95
+ let toks;
96
+ /** @type {number} */
97
+ let i;
98
+ /** @type {SjabloonFunctions | undefined} */
99
+ let fns;
100
+ /** @type {Tok} */
101
+ let last;
102
+ /** @type {string[]} */
103
+ let bound;
104
+ /** @type {Set<string>} */
105
+ let nms;
106
+ /** @type {Set<string>} */
107
+ let fnms;
108
+ /** @type {string} */
109
+ let src;
110
+ /** @type {any[]} */
111
+ let blocks;
112
+ // The profile's node builders. `any` rather than `Node<A>`: `make()` is generic
113
+ // per edition, but these are module-level and shared across all three, so no
114
+ // single A applies here.
115
+ /** @type {any} */
116
+ let LIT;
117
+ /** @type {any} */
118
+ let VAL;
119
+ /** @type {any} */
120
+ let RAW;
121
+
122
+ let snap = () => Object.freeze(blocks.slice());
123
+ // Block nesting is capped so a pathological template fails as a deterministic
124
+ // SyntaxError at the offending opener, far below the native stack limit.
125
+ const DEPTH = 256;
126
+ /**
127
+ * @param {string} type
128
+ * @param {Tok} t
129
+ */
130
+ let opener = (type, t) => {
131
+ blocks.length < DEPTH || fault('Template too deeply nested', 'SJABLOON_TOO_DEEP', t);
132
+ return Object.freeze({ type, start: t[2], end: t[3] });
133
+ };
134
+ /**
135
+ * @template {object} E
136
+ * @param {E} e
137
+ * @param {any} context
138
+ * @returns {E}
139
+ */
140
+ let attach = (e, context) => {
141
+ Object.defineProperty(e, 'blocks', { value: context, enumerable: true });
142
+ mark(e);
143
+ return e;
144
+ };
145
+ /**
146
+ * Throw a located compile-time diagnostic. `code` is typed to the published
147
+ * union, so a code that is not declared in `types.d.ts` fails to compile here
148
+ * rather than shipping undeclared — which is exactly how SJABLOON_TOO_DEEP got
149
+ * out for two releases.
150
+ *
151
+ * @param {string} msg
152
+ * @param {SjabloonErrorCode} code
153
+ * @param {any[]} [t] The token to point at; omitted for end-of-source faults.
154
+ * @returns {never}
155
+ */
156
+ const fault = (msg, code, t, start = t?.[2] ?? src.length, end = t?.[3] ?? src.length) => {
157
+ const e = /** @type {SyntaxError & { code: SjabloonErrorCode, start: number, end: number }} */ (SyntaxError(msg));
158
+ e.code = code;
159
+ e.start = start;
160
+ e.end = end;
161
+ throw attach(e, snap());
162
+ };
163
+ /**
164
+ * Re-locate a diagnostic thrown by a nested compile or render into this
165
+ * template's coordinates, then rethrow it as ours. Always throws.
166
+ *
167
+ * `owns` is a plain predicate rather than a type guard: `e` is retyped here,
168
+ * not narrowed. `const` with an explicit `never` type is what lets callers
169
+ * treat the catch block as terminal.
170
+ *
171
+ * @type {(e: any, start: number, context: any, owns?: (e: unknown) => boolean) => never}
172
+ */
173
+ const translated = (e, start, context, owns = isXprsnDiagnostic) => {
174
+ if (!owns(e)) throw e;
175
+ e.start += start;
176
+ e.end += start;
177
+ throw attach(e, context);
178
+ };
179
+ /**
180
+ * @param {Tok} t
181
+ * @returns {never}
182
+ */
183
+ let unexpected = t => fault('Unexpected {{' + t[1] + '}}', 'SJABLOON_UNEXPECTED_TAG', t);
184
+
185
+ // Append every node's output into the accumulator `o`, which the root wrapper
186
+ // creates once per render and threads all the way down. Nodes return nothing:
187
+ // no intermediate array per node list, no join, and render order is just push
188
+ // order.
189
+ /**
190
+ * @param {Node<any>[]} nodes
191
+ * @param {any} v
192
+ * @param {any} o
193
+ */
194
+ let run = (nodes, v, o) => { for (const n of nodes) n(v, o); };
195
+
196
+ // A leaf interpolation node: compile the expression, then let the profile turn
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()));
204
+
205
+ // Compile one expression and collect its free variables (minus the loop
206
+ // variables currently in scope, which belong to the template) and the registry
207
+ // functions it calls.
208
+ /**
209
+ * @param {string} s
210
+ * @param {number} start
211
+ * @param {any} context
212
+ * @returns {(v: any) => any}
213
+ */
214
+ let cp = (s, start, context) => {
215
+ /** @type {ReturnType<typeof compile>} */
216
+ let e;
217
+ try {
218
+ // `SjabloonFunctions` is `Record<string, Function>`; xprsn's registry wants
219
+ // `Record<string, (...args: any[]) => any>`, and TypeScript deliberately
220
+ // refuses `Function` against a call signature. The registry is passed
221
+ // straight through untouched, so this is a published-type mismatch rather
222
+ // than a real one — narrowing `SjabloonFunctions` would change the API.
223
+ e = compile(s, /** @type {any} */ (fns));
224
+ } catch (x) {
225
+ translated(x, start, context);
226
+ }
227
+ for (const n of e.names) bound.includes(n) || nms.add(n);
228
+ for (const fn of e.functions) fnms.add(fn);
229
+ return v => {
230
+ try {
231
+ return e(v);
232
+ } catch (x) {
233
+ translated(x, start, context, e.isDiagnostic);
234
+ }
235
+ };
236
+ };
237
+
238
+ // One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
239
+ /**
240
+ * @param {(v: any) => any} cond
241
+ * @returns {Node<any>}
242
+ */
243
+ let branch = cond => {
244
+ const then = parse(['#elif', '#else', '/if']);
245
+ const tag = last[1];
246
+ let els = /** @type {Node<any>[]} */ ([]);
247
+ if (tag.startsWith('#elif ')) els = [branch(cp(tag.slice(6), last[4] + 6, snap()))];
248
+ else if (tag === '#else') {
249
+ els = parse(['/if']);
250
+ last[1] === '/if' || unexpected(last);
251
+ } else if (tag !== '/if') unexpected(last);
252
+ return (v, o) => run(cond(v) ? then : els, v, o);
253
+ };
254
+
255
+ /**
256
+ * @param {string[]} stops
257
+ * @returns {Node<any>[]}
258
+ */
259
+ let parse = stops => {
260
+ const nodes = /** @type {Node<any>[]} */ ([]);
261
+ for (let t; (t = toks[i++]); ) {
262
+ const tag = t[1];
263
+ if (!t[0]) {
264
+ // Left-trim can shave a text run down to nothing; never emit it.
265
+ tag && nodes.push(LIT(tag));
266
+ } else if (t[0] === 1) {
267
+ // The lexer always tokenizes `}}}` — dropping it would cost the
268
+ // `triple` latch that keeps lexing linear — so editions without a
269
+ // raw form reject it here, at the parser, with a located span.
270
+ RAW || fault('Raw {{{' + tag + '}}} is not available here; {{ ' + tag + ' }} is already raw', 'SJABLOON_RAW_TAG', t);
271
+ nodes.push(interp(t, RAW));
272
+ } else if (stops.includes(tag.split(' ')[0])) {
273
+ last = t;
274
+ return nodes;
275
+ } else if (tag[0] === '!') {
276
+ // comment
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;
338
+ };
339
+
340
+ /**
341
+ * Bind the parser to an output profile. Each edition calls this once at module
342
+ * load and gets back its own `template` and `render`; the parser itself stays
343
+ * module-level and shared, so there is exactly one diagnostics WeakSet.
344
+ *
345
+ * `template(str, funcs?)` compiles a template once, to render it many times.
346
+ *
347
+ * The returned renderer exposes `names`: the variables the template reads
348
+ * from your values, deduplicated. Loop variables the template introduces are
349
+ * not included. It also exposes `functions`: the registry functions the
350
+ * template calls, deduplicated.
351
+ *
352
+ * Two anchors are always in scope: `$` is the root values, and `@` is the
353
+ * current `#each` item (the root outside any loop). They let a nested loop
354
+ * reach the root (`$.company`) or the current item (`@.total`) explicitly,
355
+ * past any shadowing. Neither counts as a `name`.
356
+ *
357
+ * An embedder with its own scope model can override the anchors per render by
358
+ * passing `{ root, item }` as the renderer's second argument: `$` becomes
359
+ * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
360
+ * `@` unbound, so reading `@.x` throws through xprsn's guard.
361
+ *
362
+ * Render order is push order into a single accumulator: loop bodies append once
363
+ * per iteration, untaken branches append nothing, and block expressions (`#if`
364
+ * conditions, `#each` collections) never append at all. The token edition
365
+ * exposes that ordering directly; the string editions collapse it to text.
366
+ *
367
+ * A profile is `[lit, val, raw, seed, take]`:
368
+ * lit(text) node emitting one static text run
369
+ * val(expr) node emitting a `{{ }}` interpolation
370
+ * raw(expr) node emitting a `{{{ }}}` interpolation
371
+ * seed() a fresh output accumulator, one per render
372
+ * take(acc) the render's return value
373
+ * Nodes are `(scope, acc) => void`; see run().
374
+ *
375
+ * @template A The accumulator this edition threads through its nodes.
376
+ * @template T What one render returns.
377
+ * @param {[
378
+ * lit: (text: string) => Node<A>,
379
+ * val: (expr: (scope: any) => any) => Node<A>,
380
+ * raw: ((expr: (scope: any) => any) => Node<A>) | 0,
381
+ * seed: () => A,
382
+ * take: (acc: A) => T,
383
+ * ]} profile The output profile, as above.
384
+ * @returns {{
385
+ * template: (str: string, funcs?: SjabloonFunctions) => SjabloonRenderer<T>,
386
+ * render: (str: string, values?: SjabloonValues, funcs?: SjabloonFunctions) => T,
387
+ * }} That edition's API.
388
+ * @throws {SyntaxError} `template` throws on malformed tags, unclosed blocks,
389
+ * or bad expressions.
390
+ */
391
+ export let make = ([lit, val, raw, seed, take]) => {
392
+ /**
393
+ * @param {string} str
394
+ * @param {SjabloonFunctions} [funcs]
395
+ * @returns {SjabloonRenderer<T>}
396
+ */
397
+ function template(str, funcs) {
398
+ LIT = lit, VAL = val, RAW = raw;
399
+ fns = funcs;
400
+ // `$` (root) and `@` (current item) are engine-bound anchors, always in
401
+ // scope, so they never count as caller-supplied `names`.
402
+ bound = ['$', '@'];
403
+ nms = new Set();
404
+ fnms = new Set();
405
+ src = String(str);
406
+ blocks = [];
407
+ toks = lex(src);
408
+ i = 0;
409
+ // Deeply nested blocks overflow the recursive-descent parser; surface that
410
+ // as a SyntaxError so malformed input keeps its documented compile-time
411
+ // contract (mirroring xprsn's XPRSN_TOO_DEEP for expressions).
412
+ let nodes;
413
+ try {
414
+ nodes = parse([]);
415
+ } catch (x) {
416
+ // An empty span at the end, like an unclosed block.
417
+ if (x instanceof RangeError) fault('Template too deeply nested', 'SJABLOON_TOO_DEEP');
418
+ throw x;
419
+ }
420
+ // Wrap the values in a root scope carrying the anchors, without mutating
421
+ // what the caller passed: by default `$` and `@` both point at the root.
422
+ // An embedder can override the anchors with a `{ root, item }` second arg:
423
+ // `$` = root, `@` = item (distinct objects). Omitting `item` leaves `@`
424
+ // unbound, so `@.x` throws through xprsn's guard — a group-header band that
425
+ // has no current row wants exactly that.
426
+ const f = (/** @type {any} */ v, /** @type {any} */ o) => {
427
+ v = v || EMPTY;
428
+ const r = Object.create(v);
429
+ r['$'] = o ? o.root : v;
430
+ if (!o) r['@'] = v;
431
+ else if ('item' in o) r['@'] = o.item;
432
+ // One accumulator per render, owned here and threaded down. A registry
433
+ // function that renders another template gets its own, so re-entrancy
434
+ // needs no bookkeeping.
435
+ const acc = seed();
436
+ run(nodes, r, acc);
437
+ return take(acc);
438
+ };
439
+ // Array.from, not a spread: the bundler's transpile turns `[...set]` into
440
+ // `[].concat(set)`, which wraps the Set instead of unpacking it.
441
+ f.names = Array.from(nms);
442
+ f.functions = Array.from(fnms);
443
+ return f;
444
+ }
445
+ return { template, render: (str, values, funcs) => template(str, funcs)(values) };
446
+ };
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 { 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 } from './core.js';
14
+
15
+ export const { template, render } = make([
16
+ s => (v, o) => { o.s += s; },
17
+ e => (v, o, x) => (x = e(v), o.s += esc(x ?? '')),
18
+ e => (v, o, x) => (x = e(v), o.s += String(x ?? '')),
19
+ () => ({ s: '' }),
20
+ o => o.s,
21
+ ]);
package/lib/index.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The token edition, and the engine proper: a template renders to a stream of
3
+ * literal and value tokens. Escaping belongs to whoever consumes the stream,
4
+ * so nothing here is HTML-aware and `{{{ }}}` has no meaning — `{{ }}` is
5
+ * already raw.
6
+ */
7
+ import { make } from './core.js';
8
+
9
+ export { isDiagnostic } from './core.js';
10
+
11
+ export const { template, render } = make([
12
+ // Static text is a compile-time constant: hoist and freeze one token per
13
+ // text node rather than allocating a fresh object every loop iteration.
14
+ s => (t => (v, o) => { o.push(t); })(Object.freeze({ literal: s })),
15
+ e => (v, o) => { o.push({ value: e(v) }); },
16
+ 0,
17
+ () => /** @type {import('./types.js').Token[]} */ ([]),
18
+ o => o,
19
+ ]);
20
+
21
+ /**
22
+ * Join a token stream into the string `sjabloon/text` would have produced:
23
+ * literals verbatim, values as `String(value ?? '')`.
24
+ *
25
+ * @param {readonly import('./types.js').Token[]} tokens A render's output.
26
+ * @returns {string} The joined text.
27
+ */
28
+ export const text = tokens => {
29
+ let s = '';
30
+ // One `?? ''` per token, so a literal never stringifies and a nullish value
31
+ // still renders empty. Widened here because each token carries one key or
32
+ // the other, which the public union deliberately does not model.
33
+ for (const t of /** @type {readonly { literal?: string, value?: unknown }[]} */ (tokens)) s += t.literal ?? String(t.value ?? '');
34
+ return s;
35
+ };
package/lib/text.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The plain-text edition: `{{ }}` interpolates unescaped and renders to a
3
+ * string. Escaping belongs at the output edge, so there is no raw form —
4
+ * `{{ }}` is already raw and `{{{ }}}` is a compile-time error.
5
+ *
6
+ * Definitionally `text(template(str)(values))` from the root entry, but built
7
+ * as a string accumulator so casual string users never allocate tokens.
8
+ */
9
+ import { make } from './core.js';
10
+
11
+ export { isDiagnostic } from './core.js';
12
+
13
+ export const { template, render } = make([
14
+ s => (v, o) => { o.s += s; },
15
+ e => (v, o, x) => (x = e(v), o.s += String(x ?? '')),
16
+ 0,
17
+ () => ({ s: '' }),
18
+ o => o.s,
19
+ ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Tiny, CSP-safe template engine for JavaScript, powered by xprsn expressions. No eval, no new Function.",
5
5
  "repository": "getquario/sjabloon",
6
6
  "license": "Apache-2.0",
@@ -10,20 +10,20 @@
10
10
  "url": "https://robinvdvleuten.nl"
11
11
  },
12
12
  "type": "module",
13
- "module": "dist/index.js",
14
- "types": "dist/index.d.ts",
13
+ "module": "lib/index.js",
14
+ "types": "lib/index.d.ts",
15
15
  "exports": {
16
16
  ".": {
17
- "types": "./dist/index.d.ts",
18
- "default": "./dist/index.js"
17
+ "types": "./lib/index.d.ts",
18
+ "default": "./lib/index.js"
19
19
  },
20
20
  "./text": {
21
- "types": "./dist/text.d.ts",
22
- "default": "./dist/text.js"
21
+ "types": "./lib/text.d.ts",
22
+ "default": "./lib/text.js"
23
23
  },
24
24
  "./html": {
25
- "types": "./dist/html.d.ts",
26
- "default": "./dist/html.js"
25
+ "types": "./lib/html.d.ts",
26
+ "default": "./lib/html.js"
27
27
  },
28
28
  "./package.json": "./package.json"
29
29
  },
@@ -31,59 +31,52 @@
31
31
  "node": ">=22.12.0"
32
32
  },
33
33
  "files": [
34
- "dist"
34
+ "lib"
35
35
  ],
36
36
  "size-limit": [
37
37
  {
38
38
  "name": "sjabloon",
39
- "path": [
40
- "dist/index.js",
41
- "dist/core.js"
39
+ "path": "lib/index.js",
40
+ "ignore": [
41
+ "xprsn"
42
42
  ],
43
43
  "limit": "1.95 kB"
44
44
  },
45
45
  {
46
46
  "name": "sjabloon/text",
47
- "path": [
48
- "dist/text.js",
49
- "dist/core.js"
47
+ "path": "lib/text.js",
48
+ "ignore": [
49
+ "xprsn"
50
50
  ],
51
51
  "limit": "1.9 kB"
52
52
  },
53
53
  {
54
54
  "name": "sjabloon/html",
55
- "path": [
56
- "dist/html.js",
57
- "dist/core.js"
55
+ "path": "lib/html.js",
56
+ "ignore": [
57
+ "xprsn"
58
58
  ],
59
59
  "limit": "1.97 kB"
60
- },
61
- {
62
- "name": "core chunk (informational)",
63
- "path": "dist/core.js",
64
- "limit": "1.75 kB"
65
60
  }
66
61
  ],
67
62
  "scripts": {
68
63
  "bench": "node --disallow-code-generation-from-strings bench/index.js",
69
- "bench:comparison": "npm run build && npm --prefix bench/comparison run bench",
70
- "build": "tsdown",
71
- "check": "run-s build size test fuzz:regression test:browser",
64
+ "bench:comparison": "npm --prefix bench/comparison run bench",
65
+ "check": "run-s size test fuzz:regression test:browser",
72
66
  "fuzz": "npm run fuzz:prepare && run-s fuzz:compile fuzz:render fuzz:structured",
73
67
  "fuzz:prepare": "node -e \"for (const x of ['compile','render','structured']) require('fs').mkdirSync('.fuzz-corpus/'+x,{recursive:true})\"",
74
- "fuzz:compile": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/compile.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/compile fuzz/corpus/compile -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
75
- "fuzz:render": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/render.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/render fuzz/corpus/render -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
76
- "fuzz:structured": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/structured.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/structured fuzz/corpus/structured -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
68
+ "fuzz:compile": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/compile.fuzz.js -i lib/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/compile fuzz/corpus/compile -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
69
+ "fuzz:render": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/render.fuzz.js -i lib/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/render fuzz/corpus/render -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
70
+ "fuzz:structured": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/structured.fuzz.js -i lib/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/structured fuzz/corpus/structured -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
77
71
  "fuzz:regression": "run-s fuzz:regression:compile fuzz:regression:render fuzz:regression:structured",
78
- "fuzz:regression:compile": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/compile.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/compile -- -artifact_prefix=fuzz/",
79
- "fuzz:regression:render": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/render.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/render -- -artifact_prefix=fuzz/",
80
- "fuzz:regression:structured": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/structured.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/structured -- -artifact_prefix=fuzz/",
81
- "prepublishOnly": "npm run build",
72
+ "fuzz:regression:compile": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/compile.fuzz.js -i lib/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/compile -- -artifact_prefix=fuzz/",
73
+ "fuzz:regression:render": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/render.fuzz.js -i lib/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/render -- -artifact_prefix=fuzz/",
74
+ "fuzz:regression:structured": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/structured.fuzz.js -i lib/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/structured -- -artifact_prefix=fuzz/",
82
75
  "size": "size-limit",
83
76
  "test": "run-s test:unit test:types",
84
77
  "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
85
- "test:browser": "npm run build && playwright install chromium && node test/browser/harness.js",
86
- "test:types": "tsc"
78
+ "test:browser": "playwright install chromium && node test/browser/harness.js",
79
+ "test:types": "tsc && attw --pack . --profile esm-only"
87
80
  },
88
81
  "keywords": [
89
82
  "template",
@@ -93,16 +86,16 @@
93
86
  "handlebars"
94
87
  ],
95
88
  "dependencies": {
96
- "xprsn": "^0.8.0"
89
+ "xprsn": "^0.9.0"
97
90
  },
98
91
  "devDependencies": {
92
+ "@arethetypeswrong/cli": "^0.18.3",
99
93
  "@jazzer.js/bug-detectors": "^4.0.0",
100
94
  "@jazzer.js/core": "^4.0.0",
101
- "@size-limit/file": "^12.1.0",
95
+ "@size-limit/preset-small-lib": "^12.1.0",
102
96
  "npm-run-all": "^4.1.5",
103
97
  "playwright": "^1.61.1",
104
98
  "size-limit": "^12.1.0",
105
- "tsdown": "^0.22.12",
106
- "typescript": "^7.0.2"
99
+ "typescript": "7.0.2"
107
100
  }
108
101
  }
package/dist/core.js DELETED
@@ -1 +0,0 @@
1
- import{compile as e,isDiagnostic as t}from"xprsn";const n=/^(?:__proto__|constructor|prototype)$/,r=new WeakSet,i=r.add.bind(r),a=r.has.bind(r),o=e=>a(e);let s=e=>{let t=[];for(let n=0,r=1;n<e.length;){let i=e.indexOf(`{{`,n);if(i<0){t.push([0,e.slice(n)]);break}i>n&&t.push([0,e.slice(n,i)]);let a=+(e[i+2]===`{`),o=i+2+a,s=e[o]===`-`,c=-1;if(s&&o++,a&&r&&(c=e.indexOf(`}}}`,o),c<0&&(r=0)),c<0&&(a&&(a=0,o=i+2,s=e[o]===`-`,s&&o++),c=e.indexOf(`}}`,o)),c<0){t.push([0,e.slice(i)]);break}let l=c>o&&e[c-1]===`-`,u=l?c-1:c,d=e.slice(o,u),f=d.trim(),p=o+d.length-d.trimStart().length,m=c+2+a,h=[a?1:2,f,i,m,p],g=t.at(-1);if(s&&g?.[0]===0&&g[1]&&(g[1]=g[1].trimEnd()),t.push(h),n=m,l)for(;/\s/.test(e[n]);)n++}return t},c,l,u,d,f,p,m,h,g,_,v,y,b=()=>Object.freeze(g.slice()),x=(e,t)=>(g.length<256||C(`Template too deeply nested`,`SJABLOON_TOO_DEEP`,t),Object.freeze({type:e,start:t[2],end:t[3]})),S=(e,t)=>(Object.defineProperty(e,"blocks",{value:t,enumerable:!0}),i(e),e);const C=(e,t,n,r=n?.[2]??h.length,i=n?.[3]??h.length)=>{let a=SyntaxError(e);throw a.code=t,a.start=r,a.end=i,S(a,b())};let w=(e,n,r,i=t)=>{throw i(e)?(e.start+=n,e.end+=n,S(e,r)):e},T=e=>C(`Unexpected {{`+e[1]+`}}`,`SJABLOON_UNEXPECTED_TAG`,e),E=(e,t,n)=>{for(let r of e)r(t,n)},D=(e,t)=>t(O(e[1],e[4],b())),O=(t,n,r)=>{let i;try{i=e(t,u)}catch(e){w(e,n,r)}for(let e of i.names)f.includes(e)||p.add(e);for(let e of i.functions)m.add(e);return e=>{try{return i(e)}catch(e){w(e,n,r,i.isDiagnostic)}}},k=e=>{let t=A([`#elif`,`#else`,`/if`]),n=d[1],r=[];return n.startsWith(`#elif `)?r=[k(O(n.slice(6),d[4]+6,b()))]:n===`#else`?(r=A([`/if`]),d[1]===`/if`||T(d)):n!==`/if`&&T(d),(n,i)=>E(e(n)?t:r,n,i)},A=e=>{let t=[];for(let r;r=c[l++];){let i=r[1];if(!r[0])i&&t.push(_(i));else if(r[0]===1)y||C(`Raw {{{`+i+`}}} is not available here; {{ `+i+` }} is already raw`,`SJABLOON_RAW_TAG`,r),t.push(D(r,y));else if(e.includes(i.split(` `)[0]))return d=r,t;else if(i[0]!==`!`)if(i.startsWith(`#if `))g.push(x(`if`,r)),t.push(k(O(i.slice(4),r[4]+4,b()))),g.pop();else if(/^#each(?:\s|$)/.test(i)){g.push(x(`each`,r));let e=/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(i)||C(`Bad {{`+i+`}}`,`SJABLOON_EACH_SYNTAX`,r),a=e[3],o=e[4],s=r[4]+i.length-e[2].length;if(n.test(a)&&C(`Bad {{`+i+`}}`,`SJABLOON_BLOCKED_BINDING`,r,s,s+a.length),o&&n.test(o)){let e=r[4]+i.length-o.length;C(`Bad {{`+i+`}}`,`SJABLOON_BLOCKED_BINDING`,r,e,e+o.length)}let c=O(e[1],r[4]+6,b()),l=f.length;f.push(a),o&&f.push(o),f.push(`loop`);let u=A([`#else`,`/each`]);f.length=l;let p=[];d[1]===`#else`?(p=A([`/each`]),d[1]===`/each`||T(d)):d[1]!==`/each`&&T(d),g.pop(),t.push((e,t)=>{let n=c(e),r=Array.isArray(n),i=r?n.slice():n&&typeof n==`object`?Object.keys(n).map(e=>[n[e],e]):[];if(!i.length)return E(p,e,t);i.forEach((n,s)=>{let c=r?n:n[0],l=r?s:n[1],d=Object.create(e);d[a]=c,o&&(d[o]=l),d[`@`]=c,d.loop={index:s+1,index0:s,first:!s,last:s===i.length-1,length:i.length},E(u,d,t)})})}else/^#(?:if|elif|else)(?:\s|$)/.test(i)||i[0]===`/`?T(r):i[0]===`#`?C(`Unknown {{`+i+`}}`,`SJABLOON_UNKNOWN_BLOCK`,r):t.push(D(r,v))}return e.length&&C(`Missing {{`+e[e.length-1]+`}}`,`SJABLOON_UNCLOSED_BLOCK`),t},j=([e,t,n,r,i])=>{function a(a,o){_=e,v=t,y=n,u=o,f=[`$`,`@`],p=new Set,m=new Set,h=String(a),g=[],c=s(h),l=0;let d;try{d=A([])}catch(e){throw e instanceof RangeError&&C(`Template too deeply nested`,`SJABLOON_TOO_DEEP`),e}let b=(e,t)=>{e||={};let n=Object.create(e);n.$=t?t.root:e,t?`item`in t&&(n[`@`]=t.item):n[`@`]=e;let a=r();return E(d,n,a),i(a)};return b.names=Array.from(p),b.functions=Array.from(m),b}return{template:a,render:(e,t,n)=>a(e,n)(t)}};export{j as n,o as t};
package/dist/html.js DELETED
@@ -1 +0,0 @@
1
- import{n as e,t}from"./core.js";const n={"&":`&amp;`,"<":`&lt;`,">":`&gt;`,'"':`&quot;`,"'":`&#39;`},r=e=>String(e).replace(/[&<>"']/g,e=>n[e]),{template:i,render:a}=e([e=>(t,n)=>{n.s+=e},e=>(t,n,i)=>(i=e(t),n.s+=r(i??``)),e=>(t,n,r)=>(r=e(t),n.s+=String(r??``)),()=>({s:``}),e=>e.s]);export{t as isDiagnostic,a as render,i as template};
package/dist/index.js DELETED
@@ -1 +0,0 @@
1
- import{n as e,t}from"./core.js";const{template:n,render:r}=e([e=>(e=>(t,n)=>{n.push(e)})(Object.freeze({literal:e})),e=>(t,n)=>{n.push({value:e(t)})},0,()=>[],e=>e]),i=e=>{let t=``;for(let n of e)t+=n.literal??String(n.value??``);return t};export{t as isDiagnostic,r as render,n as template,i as text};
package/dist/text.js DELETED
@@ -1 +0,0 @@
1
- import{n as e,t}from"./core.js";const{template:n,render:r}=e([e=>(t,n)=>{n.s+=e},e=>(t,n,r)=>(r=e(t),n.s+=String(r??``)),0,()=>({s:``}),e=>e.s]);export{t as isDiagnostic,r as render,n as template};
File without changes
File without changes
File without changes
File without changes