sjabloon 0.6.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/README.md +61 -23
- package/lib/core.js +446 -0
- package/lib/html.d.ts +17 -0
- package/lib/html.js +21 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.js +35 -0
- package/lib/text.d.ts +17 -0
- package/lib/text.js +19 -0
- package/lib/types.d.ts +83 -0
- package/package.json +51 -33
- package/dist/index.cjs +0 -1
- package/dist/index.js +0 -1
- package/index.d.ts +0 -82
- package/src/index.js +0 -291
package/src/index.js
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
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
|
-
import { compile, isDiagnostic as isXprsnDiagnostic } from 'xprsn';
|
|
7
|
-
|
|
8
|
-
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
9
|
-
const esc = s => String(s).replace(/[&<>"']/g, c => ESC[c]);
|
|
10
|
-
const BLOCKED = /^(?:__proto__|constructor|prototype)$/;
|
|
11
|
-
const DIAGNOSTICS = new WeakSet();
|
|
12
|
-
const mark = DIAGNOSTICS.add.bind(DIAGNOSTICS);
|
|
13
|
-
const owns = DIAGNOSTICS.has.bind(DIAGNOSTICS);
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Check whether an error was produced or translated by sjabloon.
|
|
17
|
-
*
|
|
18
|
-
* @param {unknown} error Any thrown value.
|
|
19
|
-
* @returns {boolean} Whether `error` is an authentic sjabloon diagnostic.
|
|
20
|
-
*/
|
|
21
|
-
export const isDiagnostic = error => owns(error);
|
|
22
|
-
|
|
23
|
-
// Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
|
|
24
|
-
// `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
|
|
25
|
-
// off once }}} is gone so {{{...}}×N does not rescan to EOF (stays O(n)).
|
|
26
|
-
let lex = s => {
|
|
27
|
-
const out = [];
|
|
28
|
-
for (let i = 0, triple = 1; i < s.length; ) {
|
|
29
|
-
const a = s.indexOf('{{', i);
|
|
30
|
-
if (a < 0) { out.push([0, s.slice(i)]); break; }
|
|
31
|
-
if (a > i) out.push([0, s.slice(i, a)]);
|
|
32
|
-
let raw = s[a + 2] === '{', p = a + 2 + raw, l = s[p] === '-', b = -1;
|
|
33
|
-
if (l) p++;
|
|
34
|
-
if (raw && triple) { b = s.indexOf('}}}', p); if (b < 0) triple = 0; }
|
|
35
|
-
if (b < 0) {
|
|
36
|
-
if (raw) { raw = !1; p = a + 2; l = s[p] === '-'; if (l) p++; }
|
|
37
|
-
b = s.indexOf('}}', p);
|
|
38
|
-
}
|
|
39
|
-
if (b < 0) { out.push([0, s.slice(a)]); break; }
|
|
40
|
-
const r = b > p && s[b - 1] === '-';
|
|
41
|
-
const q = r ? b - 1 : b, whole = s.slice(p, q), body = whole.trim();
|
|
42
|
-
const start = p + whole.length - whole.trimStart().length, end = b + 2 + raw;
|
|
43
|
-
const t = [raw ? 1 : 2, body, a, end, start];
|
|
44
|
-
const prev = out.at(-1);
|
|
45
|
-
if (l && prev?.[0] === 0 && prev[1]) prev[1] = prev[1].trimEnd();
|
|
46
|
-
out.push(t);
|
|
47
|
-
i = end;
|
|
48
|
-
if (r) while (/\s/.test(s[i])) i++;
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
// Shared parser state; parsing is synchronous so this is safe.
|
|
54
|
-
// `nms` collects free variables, `fnms` the registry functions called.
|
|
55
|
-
let toks, i, fns, last, bound, nms, fnms, src, blocks;
|
|
56
|
-
|
|
57
|
-
// The opt-in raw-value collector rides the render's root scope under a
|
|
58
|
-
// symbol: invisible to expressions, inherited by `#each` child scopes, and
|
|
59
|
-
// naturally per render, so re-entrant renders each collect into their own.
|
|
60
|
-
const RAWS = Symbol();
|
|
61
|
-
|
|
62
|
-
let snap = () => Object.freeze(blocks.slice());
|
|
63
|
-
// Block nesting is capped so a pathological template fails as a deterministic
|
|
64
|
-
// SyntaxError at the offending opener, far below the native stack limit.
|
|
65
|
-
const DEPTH = 256;
|
|
66
|
-
let opener = (type, t) => {
|
|
67
|
-
blocks.length < DEPTH || fault('Template too deeply nested', 'SJABLOON_TOO_DEEP', t);
|
|
68
|
-
return Object.freeze({ type, start: t[2], end: t[3] });
|
|
69
|
-
};
|
|
70
|
-
let attach = (e, context) => {
|
|
71
|
-
Object.defineProperty(e, 'blocks', { value: context, enumerable: true });
|
|
72
|
-
mark(e);
|
|
73
|
-
return e;
|
|
74
|
-
};
|
|
75
|
-
let fault = (msg, code, t, start = t?.[2] ?? src.length, end = t?.[3] ?? src.length) => {
|
|
76
|
-
const e = SyntaxError(msg);
|
|
77
|
-
e.code = code;
|
|
78
|
-
e.start = start;
|
|
79
|
-
e.end = end;
|
|
80
|
-
throw attach(e, snap());
|
|
81
|
-
};
|
|
82
|
-
let translated = (e, start, context, owns = isXprsnDiagnostic) => {
|
|
83
|
-
if (!owns(e)) throw e;
|
|
84
|
-
e.start += start;
|
|
85
|
-
e.end += start;
|
|
86
|
-
throw attach(e, context);
|
|
87
|
-
};
|
|
88
|
-
let unexpected = t => fault('Unexpected {{' + t[1] + '}}', 'SJABLOON_UNEXPECTED_TAG', t);
|
|
89
|
-
|
|
90
|
-
// Render a list of nodes against a scope.
|
|
91
|
-
let run = (nodes, v) => nodes.map(n => n(v)).join('');
|
|
92
|
-
|
|
93
|
-
// A leaf interpolation node: compile `src`, render nullish as '', apply `wrap`
|
|
94
|
-
// (`esc` for `{{ }}`, `String` for the raw `{{{ }}}` form). The pre-stringify
|
|
95
|
-
// result feeds the opt-in `raws` collector; block expressions never do.
|
|
96
|
-
let interp = (t, wrap) => (e => (v, x) => (x = e(v), v[RAWS]?.push(x), wrap(x ?? '')))(cp(t[1], t[4], snap()));
|
|
97
|
-
|
|
98
|
-
// Compile one expression and collect its free variables (minus the loop
|
|
99
|
-
// variables currently in scope, which belong to the template) and the registry
|
|
100
|
-
// functions it calls.
|
|
101
|
-
let cp = (s, start, context) => {
|
|
102
|
-
let e;
|
|
103
|
-
try {
|
|
104
|
-
e = compile(s, fns);
|
|
105
|
-
} catch (x) {
|
|
106
|
-
translated(x, start, context);
|
|
107
|
-
}
|
|
108
|
-
for (const n of e.names) bound.includes(n) || nms.add(n);
|
|
109
|
-
for (const fn of e.functions) fnms.add(fn);
|
|
110
|
-
return v => {
|
|
111
|
-
try {
|
|
112
|
-
return e(v);
|
|
113
|
-
} catch (x) {
|
|
114
|
-
translated(x, start, context, e.isDiagnostic);
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
|
|
120
|
-
let branch = cond => {
|
|
121
|
-
const then = parse(['#elif', '#else', '/if']);
|
|
122
|
-
const tag = last[1];
|
|
123
|
-
let els = [];
|
|
124
|
-
if (tag.startsWith('#elif ')) els = [branch(cp(tag.slice(6), last[4] + 6, snap()))];
|
|
125
|
-
else if (tag === '#else') {
|
|
126
|
-
els = parse(['/if']);
|
|
127
|
-
last[1] === '/if' || unexpected(last);
|
|
128
|
-
} else if (tag !== '/if') unexpected(last);
|
|
129
|
-
return v => run(cond(v) ? then : els, v);
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
let parse = stops => {
|
|
133
|
-
const nodes = [];
|
|
134
|
-
for (let t; (t = toks[i++]); ) {
|
|
135
|
-
const tag = t[1];
|
|
136
|
-
if (!t[0]) {
|
|
137
|
-
nodes.push((s => () => s)(tag));
|
|
138
|
-
} else if (t[0] === 1) {
|
|
139
|
-
nodes.push(interp(t, String));
|
|
140
|
-
} else if (stops.includes(tag.split(' ')[0])) {
|
|
141
|
-
last = t;
|
|
142
|
-
return nodes;
|
|
143
|
-
} else if (tag[0] === '!') {
|
|
144
|
-
// comment
|
|
145
|
-
} else if (tag.startsWith('#if ')) {
|
|
146
|
-
blocks.push(opener('if', t));
|
|
147
|
-
nodes.push(branch(cp(tag.slice(4), t[4] + 4, snap())));
|
|
148
|
-
blocks.pop();
|
|
149
|
-
} else if (/^#each(?:\s|$)/.test(tag)) {
|
|
150
|
-
blocks.push(opener('each', t));
|
|
151
|
-
const m = /^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag);
|
|
152
|
-
m || fault('Bad {{' + tag + '}}', 'SJABLOON_EACH_SYNTAX', t);
|
|
153
|
-
const name = m[3], idx = m[4], at = t[4] + tag.length - m[2].length;
|
|
154
|
-
if (BLOCKED.test(name)) fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, at, at + name.length);
|
|
155
|
-
if (idx && BLOCKED.test(idx)) {
|
|
156
|
-
const p = t[4] + tag.length - idx.length;
|
|
157
|
-
fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, p, p + idx.length);
|
|
158
|
-
}
|
|
159
|
-
const list = cp(m[1], t[4] + 6, snap());
|
|
160
|
-
// `name`, `idx`, and `loop` are engine-bound inside the body, so
|
|
161
|
-
// exclude them from names there and restore outer bindings after.
|
|
162
|
-
const mark = bound.length;
|
|
163
|
-
bound.push(name);
|
|
164
|
-
if (idx) bound.push(idx);
|
|
165
|
-
bound.push('loop');
|
|
166
|
-
const body = parse(['#else', '/each']);
|
|
167
|
-
bound.length = mark;
|
|
168
|
-
let empty = [];
|
|
169
|
-
if (last[1] === '#else') {
|
|
170
|
-
empty = parse(['/each']);
|
|
171
|
-
last[1] === '/each' || unexpected(last);
|
|
172
|
-
} else if (last[1] !== '/each') unexpected(last);
|
|
173
|
-
blocks.pop();
|
|
174
|
-
// Child scopes inherit the parent via the prototype chain, so outer
|
|
175
|
-
// variables stay visible inside the loop body. `@` re-points to the
|
|
176
|
-
// current item at each level, `$` (root) rides the chain, and `loop`
|
|
177
|
-
// carries the iteration metadata (index/first/last/length).
|
|
178
|
-
nodes.push(v => {
|
|
179
|
-
const lv = list(v), arr = Array.isArray(lv);
|
|
180
|
-
const ps = arr ? lv.slice() : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k]) : [];
|
|
181
|
-
if (!ps.length) return run(empty, v);
|
|
182
|
-
return ps.map((x, j) => {
|
|
183
|
-
const item = arr ? x : x[0], key = arr ? j : x[1];
|
|
184
|
-
const s = Object.create(v);
|
|
185
|
-
s[name] = item;
|
|
186
|
-
if (idx) s[idx] = key;
|
|
187
|
-
s['@'] = item;
|
|
188
|
-
s.loop = { index: j + 1, index0: j, first: !j, last: j === ps.length - 1, length: ps.length };
|
|
189
|
-
return run(body, s);
|
|
190
|
-
}).join('');
|
|
191
|
-
});
|
|
192
|
-
} else if (/^#(?:if|elif|else)(?:\s|$)/.test(tag) || tag[0] === '/') {
|
|
193
|
-
unexpected(t);
|
|
194
|
-
} else if (tag[0] === '#') {
|
|
195
|
-
fault('Unknown {{' + tag + '}}', 'SJABLOON_UNKNOWN_BLOCK', t);
|
|
196
|
-
} else {
|
|
197
|
-
nodes.push(interp(t, esc));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
stops.length && fault('Missing {{' + stops[stops.length - 1] + '}}', 'SJABLOON_UNCLOSED_BLOCK');
|
|
201
|
-
return nodes;
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Compile a template once, render it many times.
|
|
206
|
-
*
|
|
207
|
-
* The returned renderer exposes `names`: the variables the template reads
|
|
208
|
-
* from your values, deduplicated. Loop variables the template introduces are
|
|
209
|
-
* not included. It also exposes `functions`: the registry functions the
|
|
210
|
-
* template calls, deduplicated.
|
|
211
|
-
*
|
|
212
|
-
* Two anchors are always in scope: `$` is the root values, and `@` is the
|
|
213
|
-
* current `#each` item (the root outside any loop). They let a nested loop
|
|
214
|
-
* reach the root (`$.company`) or the current item (`@.total`) explicitly,
|
|
215
|
-
* past any shadowing. Neither counts as a `name`.
|
|
216
|
-
*
|
|
217
|
-
* An embedder with its own scope model can override the anchors per render by
|
|
218
|
-
* passing `{ root, item }` as the renderer's second argument: `$` becomes
|
|
219
|
-
* `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
|
|
220
|
-
* `@` unbound, so reading `@.x` throws through xprsn's guard.
|
|
221
|
-
*
|
|
222
|
-
* The renderer also exposes `withRaw(values, scope)`: one render, both channels.
|
|
223
|
-
* It returns `{ text, raws }` — the rendered string plus each interpolation's
|
|
224
|
-
* pre-escape, pre-stringify value (`{{ }}` and `{{{ }}}` alike, nullish
|
|
225
|
-
* included), in render order: loop bodies push once per iteration, untaken
|
|
226
|
-
* branches push nothing. Block expressions (`#if` conditions, `#each`
|
|
227
|
-
* collections) are never captured.
|
|
228
|
-
*
|
|
229
|
-
* @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
|
|
230
|
-
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
231
|
-
* @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, withRaw: (values?: Record<string, any>, scope?: { root?: any, item?: any }) => { text: string, raws: unknown[] }, names: string[], functions: string[]}} Renderer for the compiled template.
|
|
232
|
-
* @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
|
|
233
|
-
*/
|
|
234
|
-
export function template(str, funcs) {
|
|
235
|
-
fns = funcs;
|
|
236
|
-
// `$` (root) and `@` (current item) are engine-bound anchors, always in
|
|
237
|
-
// scope, so they never count as caller-supplied `names`.
|
|
238
|
-
bound = ['$', '@'];
|
|
239
|
-
nms = new Set();
|
|
240
|
-
fnms = new Set();
|
|
241
|
-
src = String(str);
|
|
242
|
-
blocks = [];
|
|
243
|
-
toks = lex(src);
|
|
244
|
-
i = 0;
|
|
245
|
-
// Deeply nested blocks overflow the recursive-descent parser; surface that
|
|
246
|
-
// as a SyntaxError so malformed input keeps its documented compile-time
|
|
247
|
-
// contract (mirroring xprsn's XPRSN_TOO_DEEP for expressions).
|
|
248
|
-
let nodes;
|
|
249
|
-
try {
|
|
250
|
-
nodes = parse([]);
|
|
251
|
-
} catch (x) {
|
|
252
|
-
// An empty span at the end, like an unclosed block.
|
|
253
|
-
if (x instanceof RangeError) fault('Template too deeply nested', 'SJABLOON_TOO_DEEP');
|
|
254
|
-
throw x;
|
|
255
|
-
}
|
|
256
|
-
// Wrap the values in a root scope carrying the anchors, without mutating
|
|
257
|
-
// what the caller passed: by default `$` and `@` both point at the root.
|
|
258
|
-
// An embedder can override the anchors with a `{ root, item }` second arg:
|
|
259
|
-
// `$` = root, `@` = item (distinct objects). Omitting `item` leaves `@`
|
|
260
|
-
// unbound, so `@.x` throws through xprsn's guard — a group-header band that
|
|
261
|
-
// has no current row wants exactly that.
|
|
262
|
-
const g = (v, o, w) => {
|
|
263
|
-
v = v || {};
|
|
264
|
-
const r = Object.create(v);
|
|
265
|
-
r['$'] = o ? o.root : v;
|
|
266
|
-
if (!o) r['@'] = v;
|
|
267
|
-
else if ('item' in o) r['@'] = o.item;
|
|
268
|
-
r[RAWS] = w;
|
|
269
|
-
return run(nodes, r);
|
|
270
|
-
};
|
|
271
|
-
const f = (v, o) => g(v, o);
|
|
272
|
-
// One render, both channels: the rendered string plus the ordered raws.
|
|
273
|
-
f.withRaw = (v, o, w = []) => ({ text: g(v, o, w), raws: w });
|
|
274
|
-
// Array.from, not a spread: the bundler's transpile turns `[...set]` into
|
|
275
|
-
// `[].concat(set)`, which wraps the Set instead of unpacking it.
|
|
276
|
-
f.names = Array.from(nms);
|
|
277
|
-
f.functions = Array.from(fnms);
|
|
278
|
-
return f;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Compile and render a template in one go.
|
|
283
|
-
*
|
|
284
|
-
* @param {string} str The template to render.
|
|
285
|
-
* @param {Record<string, any>} [values] Variables available to the template.
|
|
286
|
-
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
287
|
-
* @returns {string} The rendered output.
|
|
288
|
-
*/
|
|
289
|
-
export function render(str, values, funcs) {
|
|
290
|
-
return template(str, funcs)(values);
|
|
291
|
-
}
|