@rhinostone/swig-core 2.0.0-alpha.3

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/backend.js ADDED
@@ -0,0 +1,718 @@
1
+ var utils = require('./utils'),
2
+ _security = require('./security'),
3
+ ir = require('./ir');
4
+
5
+ /**
6
+ * JS-codegen backend shared across @rhinostone/swig-family frontends.
7
+ *
8
+ * Phase 2 — the template-level walker dispatches on IR node shape. At
9
+ * entry, each parse-tree token is lifted into an IR node: string tokens
10
+ * become `IRText` (value carried verbatim, escaped at emit time);
11
+ * VarToken / TagToken entries call `token.compile(...)` and the return
12
+ * value is lifted according to its shape: a JS source string becomes
13
+ * `IRLegacyJS` (userland `setTag` contract), a single IR node is spliced
14
+ * in directly, and an array of IR nodes is flattened. The walker then
15
+ * iterates the IR array and dispatches on node shape. Subsequent
16
+ * sessions introduce further real IR emitters (`Autoescape`, `If`,
17
+ * `For`, `Set`, etc.) alongside their matching tag migrations, and each
18
+ * new shape gets its own dispatch branch here.
19
+ *
20
+ * Userland tag `compile` functions keep returning JS source strings —
21
+ * the `(compiler, args, content, parents, options, blockName)` contract
22
+ * is unchanged. Built-in tags migrate per session by returning IR nodes
23
+ * directly. The `new Function(...)` wrapper stays with the native
24
+ * frontend (filename-aware error attribution, per the seam rule).
25
+ *
26
+ * See .claude/architecture/multi-flavor-ir.md § Phase 2.
27
+ */
28
+
29
+ /*!
30
+ * JSON-escape a literal text chunk for embedding inside a JS
31
+ * double-quoted string literal in the compiled template body.
32
+ * @private
33
+ */
34
+ function escapeTextValue(value) {
35
+ return value.replace(/\\/g, '\\\\').replace(/\n|\r/g, '\\n').replace(/"/g, '\\"');
36
+ }
37
+
38
+ /**
39
+ * Walk a parsed token tree and emit the JS source body for the compiled
40
+ * template function. Each token is lifted into an IR node (`IRText` for
41
+ * string chunks, `IRLegacyJS` for VarToken / TagToken) and the walker
42
+ * dispatches on node shape to produce JS source.
43
+ *
44
+ * @param {object|array} template Parsed token object (with `.tokens`) or a bare token array.
45
+ * @param {array} [parents] Parsed parent templates for extends/block resolution.
46
+ * @param {object} [options] Swig options object.
47
+ * @param {string} [blockName] Name of the enclosing `{% block %}`, if any.
48
+ * @return {string} JS source body. Does not include the `new Function(...)` wrapper.
49
+ */
50
+ exports.compile = function (template, parents, options, blockName) {
51
+ var out = '',
52
+ tokens = utils.isArray(template) ? template : template.tokens,
53
+ nodes = [];
54
+
55
+ utils.each(tokens, function (token) {
56
+ if (typeof token === 'string') {
57
+ nodes.push(ir.text(token));
58
+ return;
59
+ }
60
+ if (token && typeof token === 'object' && typeof token.type === 'string' && typeof token.compile !== 'function') {
61
+ // Pre-built IR node handed in directly (e.g. the import tag
62
+ // renders an isolated macro IR to JS via this pathway). Splice
63
+ // in without a second compile pass.
64
+ nodes.push(token);
65
+ return;
66
+ }
67
+ var result = token.compile(exports.compile, token.args ? token.args.slice(0) : [], token.content ? token.content.slice(0) : [], parents, options, blockName, token);
68
+ if (result === undefined || result === null || result === '') {
69
+ return;
70
+ }
71
+ if (typeof result === 'string') {
72
+ nodes.push(ir.legacyJS(result));
73
+ return;
74
+ }
75
+ if (utils.isArray(result)) {
76
+ utils.each(result, function (n) { nodes.push(n); });
77
+ return;
78
+ }
79
+ if (typeof result === 'object' && typeof result.type === 'string') {
80
+ nodes.push(result);
81
+ return;
82
+ }
83
+ nodes.push(ir.legacyJS(String(result)));
84
+ });
85
+
86
+ utils.each(nodes, function (node) {
87
+ if (node.type === 'Text' || node.type === 'Raw') {
88
+ out += '_output += "' + escapeTextValue(node.value) + '";\n';
89
+ return;
90
+ }
91
+ if (node.type === 'LegacyJS') {
92
+ out += node.js;
93
+ return;
94
+ }
95
+ if (node.type === 'Autoescape') {
96
+ utils.each(node.body, function (b) {
97
+ if (b.type === 'LegacyJS') { out += b.js; return; }
98
+ if (b.type === 'Text' || b.type === 'Raw') {
99
+ out += '_output += "' + escapeTextValue(b.value) + '";\n';
100
+ return;
101
+ }
102
+ });
103
+ return;
104
+ }
105
+ if (node.type === 'If') {
106
+ // Phase 2 Session 14c: multi-branch shape. The native if tag owns
107
+ // content scanning and splits at else/elseif marker tokens so each
108
+ // IRIfBranch carries its own test + body. Session 14b Commit 11
109
+ // widened `test` to `IRExpr | IRLegacyJS | null`: `IRExpr` for
110
+ // clean expressions, `null` for the trailing else, `IRLegacyJS`
111
+ // for the filter-in-test fallback (`if.lowerExpr` bails on
112
+ // FILTER/FILTEREMPTY because per-operand filter precedence can't
113
+ // be represented in flat IR — same pattern as `IROutput.expr`).
114
+ // Raw JS strings stay supported for userland `setTag` compile
115
+ // functions that may still hand in a string.
116
+ //
117
+ // Emission shape matches the pre-carve `} else if (...) {` /
118
+ // `} else {` fragments that else.js and elseif.js used to return
119
+ // inline — byte-identity held on the session baseline (see
120
+ // Session 14c notes in roadmap).
121
+ var ifOut = '';
122
+ utils.each(node.branches, function (br, bi) {
123
+ var bodyJS = '',
124
+ testJS;
125
+ utils.each(br.body, function (b) {
126
+ if (b.type === 'LegacyJS') { bodyJS += b.js; return; }
127
+ if (b.type === 'Text' || b.type === 'Raw') {
128
+ bodyJS += '_output += "' + escapeTextValue(b.value) + '";\n';
129
+ return;
130
+ }
131
+ });
132
+ if (br.test === null) {
133
+ ifOut += '} else {\n' + bodyJS;
134
+ return;
135
+ }
136
+ if (br.test && typeof br.test === 'object' && br.test.type === 'LegacyJS') {
137
+ testJS = br.test.js;
138
+ } else if (typeof br.test === 'object' && typeof br.test.type === 'string') {
139
+ testJS = exports.emitExpr(br.test);
140
+ } else {
141
+ testJS = br.test;
142
+ }
143
+ if (bi === 0) {
144
+ ifOut += 'if (' + testJS + ') { \n' + bodyJS;
145
+ } else {
146
+ ifOut += '} else if (' + testJS + ') {\n' + bodyJS;
147
+ }
148
+ });
149
+ out += ifOut + '\n' + '}';
150
+ return;
151
+ }
152
+ if (node.type === 'Set') {
153
+ // Phase 2 Session 14b Commit 10: target is structured IRVarRef
154
+ // for pure-dot LHS shapes (`foo`, `foo.bar.baz`), emitted as a
155
+ // bare `_ctx.<dot.path>` lvalue with a per-segment _dangerousProps
156
+ // guard. Bracket-touched targets (`foo[bar]`, `foo["bar"]`, mixed
157
+ // dot+bracket) stay on the transitional string fragment — the
158
+ // bracket-lvalue contract is a cross-flavor design call and is
159
+ // deferred. The frontend's set-tag parse handler retains its own
160
+ // _dangerousProps guards on every LHS path segment per the
161
+ // duplication invariant in .claude/security.md.
162
+ // `value` is an IRExpr node (Session 14b) — backward-compat string
163
+ // fallback preserved for userland setTag tags that may still hand
164
+ // in a raw JS fragment. Emits `<target> <op> <value>;`.
165
+ var setTargetJS;
166
+ if (node.target && typeof node.target === 'object' && node.target.type === 'VarRef') {
167
+ var setDeps = resolveDeps();
168
+ if (!utils.isArray(node.target.path) || node.target.path.length === 0) {
169
+ setDeps.throwError('Set: target VarRef must have a non-empty path');
170
+ }
171
+ utils.each(node.target.path, function (segment) {
172
+ checkDangerousSegment(segment, setDeps, node.target);
173
+ });
174
+ setTargetJS = '_ctx.' + node.target.path.join('.');
175
+ } else {
176
+ setTargetJS = node.target;
177
+ }
178
+ var setValueJS;
179
+ if (node.value && typeof node.value === 'object' && typeof node.value.type === 'string') {
180
+ setValueJS = exports.emitExpr(node.value);
181
+ } else {
182
+ setValueJS = node.value;
183
+ }
184
+ out += setTargetJS + ' ' + node.op + ' ' + setValueJS + ';\n';
185
+ return;
186
+ }
187
+ if (node.type === 'For') {
188
+ // Phase 2: the full loopcache + _utils.each IIFE scaffolding is
189
+ // emitted here; the frontend tag surfaces only (value, key,
190
+ // iterable, body) and the backend owns all JS plumbing. `iterable`
191
+ // is an IRExpr node (Session 14b) — backward-compat string fallback
192
+ // preserved for userland setTag tags that may still hand in a raw
193
+ // JS fragment. The loopcache identifier uses `Math.random()`
194
+ // per-occurrence to keep nested loops from clobbering each other's
195
+ // cache (gh-433).
196
+ var forVal = node.value,
197
+ forKey = node.key,
198
+ forIterable,
199
+ forBodyJS = '',
200
+ ctxloopcache = ('_ctx.__loopcache' + Math.random()).replace(/\./g, ''),
201
+ ctx = '_ctx.',
202
+ ctxloop = '_ctx.loop';
203
+ if (node.iterable && typeof node.iterable === 'object' && typeof node.iterable.type === 'string') {
204
+ forIterable = exports.emitExpr(node.iterable);
205
+ } else {
206
+ forIterable = node.iterable;
207
+ }
208
+ utils.each(node.body, function (b) {
209
+ if (b.type === 'LegacyJS') { forBodyJS += b.js; return; }
210
+ if (b.type === 'Text' || b.type === 'Raw') {
211
+ forBodyJS += '_output += "' + escapeTextValue(b.value) + '";\n';
212
+ return;
213
+ }
214
+ });
215
+ out += '(function () {\n' +
216
+ ' var __l = ' + forIterable + ', __len = (_utils.isArray(__l) || typeof __l === "string") ? __l.length : _utils.keys(__l).length;\n' +
217
+ ' if (!__l) { return; }\n' +
218
+ ' var ' + ctxloopcache + ' = { loop: ' + ctxloop + ', ' + forVal + ': ' + ctx + forVal + ', ' + forKey + ': ' + ctx + forKey + ' };\n' +
219
+ ' ' + ctxloop + ' = { first: false, index: 1, index0: 0, revindex: __len, revindex0: __len - 1, length: __len, last: false };\n' +
220
+ ' _utils.each(__l, function (' + forVal + ', ' + forKey + ') {\n' +
221
+ ' ' + ctx + forVal + ' = ' + forVal + ';\n' +
222
+ ' ' + ctx + forKey + ' = ' + forKey + ';\n' +
223
+ ' ' + ctxloop + '.key = ' + forKey + ';\n' +
224
+ ' ' + ctxloop + '.first = (' + ctxloop + '.index0 === 0);\n' +
225
+ ' ' + ctxloop + '.last = (' + ctxloop + '.revindex0 === 0);\n' +
226
+ ' ' + forBodyJS +
227
+ ' ' + ctxloop + '.index += 1; ' + ctxloop + '.index0 += 1; ' + ctxloop + '.revindex -= 1; ' + ctxloop + '.revindex0 -= 1;\n' +
228
+ ' });\n' +
229
+ ' ' + ctxloop + ' = ' + ctxloopcache + '.loop;\n' +
230
+ ' ' + ctx + forVal + ' = ' + ctxloopcache + '.' + forVal + ';\n' +
231
+ ' ' + ctx + forKey + ' = ' + ctxloopcache + '.' + forKey + ';\n' +
232
+ ' ' + ctxloopcache + ' = undefined;\n' +
233
+ '})();\n';
234
+ return;
235
+ }
236
+ if (node.type === 'Macro') {
237
+ // Phase 2: `params` is IRMacroParam[] (Session 14b Commit 8) —
238
+ // structured `{name, default?}` entries. Backend builds the JS
239
+ // function param list via `names.join(', ')` and the _utils.each
240
+ // shadow-delete indexOf list via `names.map(JSON.stringify).join(',')`.
241
+ // A string[] fallback is preserved for userland setTag tags that
242
+ // may still hand in the pre-Phase-2 raw-token slice (including
243
+ // the `, ` separator quirk). The frontend's macro parse handler
244
+ // has already applied the CVE-2023-25345 guard on the macro name
245
+ // (FUNCTION/FUNCTIONEMPTY) and every param name (VAR).
246
+ var macroBodyJS = '';
247
+ utils.each(node.body, function (b) {
248
+ if (b.type === 'LegacyJS') { macroBodyJS += b.js; return; }
249
+ if (b.type === 'Text' || b.type === 'Raw') {
250
+ macroBodyJS += '_output += "' + escapeTextValue(b.value) + '";\n';
251
+ return;
252
+ }
253
+ });
254
+ var macroParams = node.params || [],
255
+ macroSigJS,
256
+ macroIndexOfJS;
257
+ if (macroParams.length && typeof macroParams[0] === 'object' && macroParams[0] !== null && typeof macroParams[0].name === 'string') {
258
+ var macroNames = [];
259
+ utils.each(macroParams, function (p) { macroNames.push(p.name); });
260
+ macroSigJS = macroNames.join(', ');
261
+ var macroJsonNames = [];
262
+ utils.each(macroNames, function (n) { macroJsonNames.push(JSON.stringify(n)); });
263
+ macroIndexOfJS = macroJsonNames.join(',');
264
+ } else {
265
+ macroSigJS = macroParams.join('');
266
+ macroIndexOfJS = '"' + macroParams.join('","') + '"';
267
+ }
268
+ out += '_ctx.' + node.name + ' = function (' + macroSigJS + ') {\n' +
269
+ ' var _output = "",\n' +
270
+ ' __ctx = _utils.extend({}, _ctx);\n' +
271
+ ' _utils.each(_ctx, function (v, k) {\n' +
272
+ ' if ([' + macroIndexOfJS + '].indexOf(k) !== -1) { delete _ctx[k]; }\n' +
273
+ ' });\n' +
274
+ macroBodyJS + '\n' +
275
+ ' _ctx = _utils.extend(_ctx, __ctx);\n' +
276
+ ' return _output;\n' +
277
+ '};\n' +
278
+ '_ctx.' + node.name + '.safe = true;\n';
279
+ return;
280
+ }
281
+ if (node.type === 'Parent') {
282
+ // Phase 2: the parent tag walks the parents chain at compile time
283
+ // and splices the matched block's pre-resolved body into this node.
284
+ // Emit the body verbatim; no wrapper, no `super()`-style runtime
285
+ // plumbing is needed (the lookup is fully resolved at parse time).
286
+ utils.each(node.body || [], function (b) {
287
+ if (b.type === 'LegacyJS') { out += b.js; return; }
288
+ if (b.type === 'Text' || b.type === 'Raw') {
289
+ out += '_output += "' + escapeTextValue(b.value) + '";\n';
290
+ return;
291
+ }
292
+ });
293
+ return;
294
+ }
295
+ if (node.type === 'Block') {
296
+ // Phase 2: block tokens are resolved at parse time by the engine's
297
+ // remapBlocks / importNonBlocks — by the time the backend walks a
298
+ // block, its body carries whichever generation's content is active.
299
+ // Emit the body verbatim; the block name is carried as metadata for
300
+ // downstream tooling (parent-chain walks happen in the parent tag).
301
+ utils.each(node.body, function (b) {
302
+ if (b.type === 'LegacyJS') { out += b.js; return; }
303
+ if (b.type === 'Text' || b.type === 'Raw') {
304
+ out += '_output += "' + escapeTextValue(b.value) + '";\n';
305
+ return;
306
+ }
307
+ });
308
+ return;
309
+ }
310
+ if (node.type === 'Include') {
311
+ // Phase 2: `path` and `context` are IRExpr nodes (Session 14b
312
+ // Commit 7) — per-slot dispatch on object-with-.type → emitExpr,
313
+ // else verbatim string fallback preserves the userland setTag
314
+ // path (compile functions that still hand in raw JS-source
315
+ // fragments). `resolveFrom` is a plain filesystem path that must
316
+ // be JSON-escaped into a string literal — the frontend's
317
+ // include-tag parse handler has already applied a `\\` → `\\\\`
318
+ // backslash escape before handing it off. `ignoreMissing` wraps
319
+ // the emission in `try { ... } catch (e) {}` so missing-file
320
+ // errors collapse to the empty string.
321
+ var incPathJS, incCtxJS;
322
+ if (node.path && typeof node.path === 'object' && typeof node.path.type === 'string') {
323
+ incPathJS = exports.emitExpr(node.path);
324
+ } else {
325
+ incPathJS = node.path;
326
+ }
327
+ if (node.context !== undefined) {
328
+ if (typeof node.context === 'object' && typeof node.context.type === 'string') {
329
+ incCtxJS = exports.emitExpr(node.context);
330
+ } else {
331
+ incCtxJS = node.context;
332
+ }
333
+ }
334
+ var incSelector;
335
+ if (node.isolated && incCtxJS) {
336
+ incSelector = incCtxJS;
337
+ } else if (!incCtxJS) {
338
+ incSelector = '_ctx';
339
+ } else {
340
+ incSelector = '_utils.extend({}, _ctx, ' + incCtxJS + ')';
341
+ }
342
+ out += (node.ignoreMissing ? ' try {\n' : '') +
343
+ '_output += _swig.compileFile(' + incPathJS + ', {' +
344
+ 'resolveFrom: "' + node.resolveFrom + '"' +
345
+ '})(' + incSelector + ');\n' +
346
+ (node.ignoreMissing ? '} catch (e) {}\n' : '');
347
+ return;
348
+ }
349
+ if (node.type === 'With') {
350
+ // Phase 3 Session 12: scoped-context region (Twig's `{% with %}`).
351
+ // Emits an IIFE that shadows `_ctx` for the body's lexical scope;
352
+ // `_output` stays in the outer scope and is mutated via closure, so
353
+ // body writes still flow to the compiled template's output.
354
+ //
355
+ // Selector shapes:
356
+ // bare → _utils.extend({}, _ctx) (shallow copy, no leak)
357
+ // ctx → _utils.extend({}, _ctx, <ctx>) (merge)
358
+ // only → {} (isolated, no ctx)
359
+ // ctx+only → <ctx> (isolated, ctx is context)
360
+ //
361
+ // `context` is IRExpr — per-slot dispatch on object-with-.type →
362
+ // emitExpr, else verbatim string fallback preserves the userland
363
+ // setTag path for any future compile functions that hand in a raw
364
+ // JS-source fragment.
365
+ var withCtxJS;
366
+ if (node.context !== undefined) {
367
+ if (node.context && typeof node.context === 'object' && typeof node.context.type === 'string') {
368
+ withCtxJS = exports.emitExpr(node.context);
369
+ } else {
370
+ withCtxJS = node.context;
371
+ }
372
+ }
373
+ var withBodyJS = '';
374
+ utils.each(node.body, function (b) {
375
+ if (b.type === 'LegacyJS') { withBodyJS += b.js; return; }
376
+ if (b.type === 'Text' || b.type === 'Raw') {
377
+ withBodyJS += '_output += "' + escapeTextValue(b.value) + '";\n';
378
+ return;
379
+ }
380
+ });
381
+ var withSelector;
382
+ if (node.isolated) {
383
+ withSelector = (withCtxJS !== undefined) ? withCtxJS : '{}';
384
+ } else if (withCtxJS !== undefined) {
385
+ withSelector = '_utils.extend({}, _ctx, ' + withCtxJS + ')';
386
+ } else {
387
+ withSelector = '_utils.extend({}, _ctx)';
388
+ }
389
+ out += '(function (_ctx) {\n' + withBodyJS + '})(' + withSelector + ');\n';
390
+ return;
391
+ }
392
+ if (node.type === 'Output') {
393
+ // Phase 2: `expr` is typed IRExpr | IRLegacyJS (Session 14b
394
+ // Commit 9). The frontend's parseVariable falls back to LegacyJS
395
+ // for shapes the flat IROutput.filters chain can't represent
396
+ // (per-operand filter precedence, deep filters, partial consumes,
397
+ // string-valued autoescape). LegacyJS carries the complete
398
+ // `_output += …;` envelope already wrapped by the legacy
399
+ // TokenParser pass — emit verbatim. IR path emits
400
+ // `_output += <filters wrapping emitted expr>;`.
401
+ if (node.expr && node.expr.type === 'LegacyJS') {
402
+ out += node.expr.js;
403
+ return;
404
+ }
405
+ var outExprJS = exports.emitExpr(node.expr);
406
+ if (node.filters && node.filters.length) {
407
+ utils.each(node.filters, function (fc) {
408
+ var fcArgsJS = '';
409
+ if (fc.args && fc.args.length) {
410
+ var fcParts = [];
411
+ utils.each(fc.args, function (a) { fcParts.push(exports.emitExpr(a)); });
412
+ fcArgsJS = ', ' + fcParts.join(', ');
413
+ }
414
+ outExprJS = '_filters["' + fc.name + '"](' + outExprJS + fcArgsJS + ')';
415
+ });
416
+ }
417
+ out += '_output += ' + outExprJS + ';\n';
418
+ return;
419
+ }
420
+ if (node.type === 'Filter') {
421
+ // Phase 2: `args` is IRExpr[] (Session 14b Commit 6) — per-arg
422
+ // dispatch on object-with-.type → emitExpr, else verbatim string
423
+ // fallback preserves the userland setTag path (compile functions
424
+ // that still hand in raw JS-source fragments).
425
+ var bodyJS = '';
426
+ utils.each(node.body, function (b) {
427
+ if (b.type === 'LegacyJS') { bodyJS += b.js; return; }
428
+ if (b.type === 'Text' || b.type === 'Raw') {
429
+ bodyJS += '_output += "' + escapeTextValue(b.value) + '";\n';
430
+ return;
431
+ }
432
+ });
433
+ var val = '(function () {\n var _output = "";\n' + bodyJS + ' return _output;\n})()',
434
+ argsJS = '';
435
+ if (node.args && node.args.length) {
436
+ var parts = [];
437
+ utils.each(node.args, function (a) {
438
+ if (a && typeof a === 'object' && typeof a.type === 'string') {
439
+ parts.push(exports.emitExpr(a));
440
+ } else {
441
+ parts.push(a);
442
+ }
443
+ });
444
+ argsJS = ', ' + parts.join(', ');
445
+ }
446
+ out += '_output += _filters["' + node.name + '"](' + val + argsJS + ');\n';
447
+ return;
448
+ }
449
+ });
450
+
451
+ return out;
452
+ };
453
+
454
+ /**
455
+ * Emit a JS-source fragment for a single IR expression node. Round-trip
456
+ * target for the TokenParser → IRExpr migration (#T15 Session 14+): once
457
+ * the frontend produces real {@link IRExpr} values, every transitional
458
+ * `IRExpr | string` slot in the statement IR (IRFilter.args,
459
+ * IRIfBranch.test, IRFor.iterable, IRSet.target/value, IRInclude.path/
460
+ * context, IRMacro.params) is lowered to a plain string via this
461
+ * function before the statement emitter splices it into the body.
462
+ *
463
+ * The emitter enforces the CVE-2023-25345 blocklist on every {@link
464
+ * IRVarRef} path segment and every string-literal {@link IRAccess} key,
465
+ * mirroring the guards on the frontend's TokenParser + tag-parse paths.
466
+ * The frontend-side guards stay live per `.claude/security.md`; the
467
+ * duplicate is intentional defense-in-depth during the migration.
468
+ *
469
+ * `deps` is an optional injection hook:
470
+ * - `deps.dangerousProps` — override the security blocklist. Defaults
471
+ * to `require('./security').dangerousProps`.
472
+ * - `deps.throwError(msg, line, filename)` — override the throw shape.
473
+ * Defaults to `utils.throwError`, matching the seam rule for
474
+ * filename-opaque attribution (see
475
+ * .claude/architecture/multi-flavor-ir.md § Filename-awareness seam).
476
+ *
477
+ * @param {object} node IR expression node (any IRExpr shape).
478
+ * @param {object} [deps] Optional dependency overrides.
479
+ * @return {string} JS-source fragment.
480
+ */
481
+ exports.emitExpr = function (node, deps) {
482
+ return emitExpr(node, resolveDeps(deps));
483
+ };
484
+
485
+ /*!
486
+ * Resolve an optional `deps` bag into a fully populated one. @private
487
+ */
488
+ function resolveDeps(deps) {
489
+ deps = deps || {};
490
+ return {
491
+ dangerousProps: deps.dangerousProps || _security.dangerousProps,
492
+ throwError: deps.throwError || utils.throwError
493
+ };
494
+ }
495
+
496
+ /*!
497
+ * Central dispatch — pick the emitter for this IR node's `type`. @private
498
+ */
499
+ function emitExpr(node, d) {
500
+ if (!node || typeof node.type !== 'string') {
501
+ d.throwError('emitExpr: expected an IR expression node');
502
+ }
503
+ switch (node.type) {
504
+ case 'Literal': return emitLiteral(node, d);
505
+ case 'VarRef': return emitVarRef(node, d);
506
+ case 'Access': return emitAccess(node, d);
507
+ case 'BinaryOp': return emitBinaryOp(node, d);
508
+ case 'UnaryOp': return emitUnaryOp(node, d);
509
+ case 'Conditional': return emitConditional(node, d);
510
+ case 'ArrayLiteral': return emitArrayLiteral(node, d);
511
+ case 'ObjectLiteral': return emitObjectLiteral(node, d);
512
+ case 'FnCall': return emitFnCall(node, d);
513
+ case 'FilterCall': return emitFilterCall(node, d);
514
+ }
515
+ d.throwError('emitExpr: unknown IR expression type "' + node.type + '"');
516
+ }
517
+
518
+ /*!
519
+ * Fire a CVE-2023-25345 guard if `segment` resolves to a prototype-chain
520
+ * property. Attaches loc-derived line/filename when the source node
521
+ * carries them. @private
522
+ */
523
+ function checkDangerousSegment(segment, d, node) {
524
+ if (d.dangerousProps.indexOf(segment) !== -1) {
525
+ var line = (node && node.loc && node.loc.line) || undefined;
526
+ var filename = (node && node.loc && node.loc.filename) || undefined;
527
+ d.throwError('Unsafe access to "' + segment + '" is not allowed in templates (CVE-2023-25345)', line, filename);
528
+ }
529
+ }
530
+
531
+ /*!
532
+ * Emit a literal value. Strings go through JSON.stringify so embedded
533
+ * quotes / backslashes / newlines land correctly inside the compiled
534
+ * function body. @private
535
+ */
536
+ function emitLiteral(node, d) {
537
+ switch (node.kind) {
538
+ case 'string': return JSON.stringify(node.value);
539
+ case 'number': return String(node.value);
540
+ case 'bool': return node.value ? 'true' : 'false';
541
+ case 'null': return 'null';
542
+ case 'undefined': return 'undefined';
543
+ }
544
+ d.throwError('emitLiteral: unknown literal kind "' + node.kind + '"');
545
+ }
546
+
547
+ /*!
548
+ * Emit a dot-path variable reference. Byte-identical to
549
+ * TokenParser.prototype.checkMatch — any divergence breaks the
550
+ * Commit 3+ migration gates. @private
551
+ */
552
+ function emitVarRef(node, d) {
553
+ if (!utils.isArray(node.path) || node.path.length === 0) {
554
+ d.throwError('emitVarRef: path must be a non-empty array');
555
+ }
556
+ utils.each(node.path, function (segment) {
557
+ checkDangerousSegment(segment, d, node);
558
+ });
559
+ return checkMatchExpr(node.path);
560
+ }
561
+
562
+ /*!
563
+ * Replica of `TokenParser.prototype.checkMatch`. Kept as a local private
564
+ * helper rather than imported from tokenparser.js because (a) it is a
565
+ * pure function of its argument and (b) the backend must not acquire a
566
+ * runtime dependency on the TokenParser module (which is a specific
567
+ * frontend concern, not a shared-backend one). @private
568
+ */
569
+ function checkMatchExpr(match) {
570
+ var temp = match[0], result;
571
+
572
+ function checkDot(ctx) {
573
+ var c = ctx + temp,
574
+ m = match,
575
+ build = '';
576
+
577
+ build = '(typeof ' + c + ' !== "undefined" && ' + c + ' !== null';
578
+ utils.each(m, function (v, i) {
579
+ if (i === 0) {
580
+ return;
581
+ }
582
+ build += ' && ' + c + '.' + v + ' !== undefined && ' + c + '.' + v + ' !== null';
583
+ c += '.' + v;
584
+ });
585
+ build += ')';
586
+
587
+ return build;
588
+ }
589
+
590
+ function buildDot(ctx) {
591
+ return '(' + checkDot(ctx) + ' ? ' + ctx + match.join('.') + ' : "")';
592
+ }
593
+ result = '(' + checkDot('_ctx.') + ' ? ' + buildDot('_ctx.') + ' : ' + buildDot('') + ')';
594
+ return '(' + result + ' !== null ? ' + result + ' : ' + '"" )';
595
+ }
596
+
597
+ /*!
598
+ * Emit a dynamic bracket access. When the key is a string literal, guard
599
+ * it against prototype-chain pollution — mirrors the STRING-in-
600
+ * BRACKETOPEN check in TokenParser. @private
601
+ */
602
+ function emitAccess(node, d) {
603
+ if (node.key && node.key.type === 'Literal' && node.key.kind === 'string') {
604
+ checkDangerousSegment(node.key.value, d, node);
605
+ }
606
+ return emitExpr(node.object, d) + '[' + emitExpr(node.key, d) + ']';
607
+ }
608
+
609
+ /*!
610
+ * Arithmetic ops get surrounding spaces (`a + b`); logic / comparator
611
+ * ops are emitted bare (`a&&b`) to match TokenParser's LOGIC /
612
+ * COMPARATOR output shape. `in` needs trailing space so the keyword
613
+ * detokenises — `(a)in(b)` parses but `ain(b)` does not. @private
614
+ */
615
+ function isArithmeticOp(op) {
616
+ return op === '+' || op === '-' || op === '*' || op === '/' || op === '%';
617
+ }
618
+
619
+ function emitBinaryOp(node, d) {
620
+ var left = emitExpr(node.left, d),
621
+ right = emitExpr(node.right, d);
622
+ if (isArithmeticOp(node.op)) {
623
+ return left + ' ' + node.op + ' ' + right;
624
+ }
625
+ if (node.op === 'in') {
626
+ return left + ' in ' + right;
627
+ }
628
+ return left + node.op + right;
629
+ }
630
+
631
+ function emitUnaryOp(node, d) {
632
+ var operandJS = emitExpr(node.operand, d);
633
+ if (node.operand && node.operand.type === 'BinaryOp') {
634
+ operandJS = '(' + operandJS + ')';
635
+ }
636
+ return node.op + operandJS;
637
+ }
638
+
639
+ function emitConditional(node, d) {
640
+ return '(' + emitExpr(node.test, d) + ' ? ' + emitExpr(node.then, d) + ' : ' + emitExpr(node['else'], d) + ')';
641
+ }
642
+
643
+ function emitArrayLiteral(node, d) {
644
+ var elements = [];
645
+ utils.each(node.elements, function (el) {
646
+ elements.push(emitExpr(el, d));
647
+ });
648
+ return '[' + elements.join(', ') + ']';
649
+ }
650
+
651
+ function emitObjectLiteral(node, d) {
652
+ var props = [];
653
+ utils.each(node.properties, function (p) {
654
+ props.push(emitExpr(p.key, d) + ':' + emitExpr(p.value, d));
655
+ });
656
+ return '{' + props.join(', ') + '}';
657
+ }
658
+
659
+ /*!
660
+ * Emit a function / method invocation. Three callee shapes:
661
+ * 1. Single-segment VarRef (`foo(...)`) — FUNCTION-token pattern with
662
+ * the `_ctx.foo || foo || _fn` fallback ladder.
663
+ * 2. Multi-segment VarRef (`foo.bar(...)`) — method-call pattern with
664
+ * `.call(<receiver>, ...)` so `this` binds to the receiver object,
665
+ * matching TokenParser's PARENOPEN-after-VAR METHODOPEN branch.
666
+ * 3. Any other callee expression — plain `(<callee>)(args)`.
667
+ * @private
668
+ */
669
+ function emitFnCall(node, d) {
670
+ var args = [],
671
+ callee = node.callee,
672
+ name,
673
+ receiver,
674
+ argsJS;
675
+
676
+ utils.each(node.args, function (a) {
677
+ args.push(emitExpr(a, d));
678
+ });
679
+ argsJS = args.join(', ');
680
+
681
+ if (callee && callee.type === 'VarRef' && utils.isArray(callee.path)) {
682
+ utils.each(callee.path, function (segment) {
683
+ checkDangerousSegment(segment, d, callee);
684
+ });
685
+
686
+ if (callee.path.length === 1) {
687
+ name = callee.path[0];
688
+ return '((typeof _ctx.' + name + ' !== "undefined") ? _ctx.' + name +
689
+ ' : ((typeof ' + name + ' !== "undefined") ? ' + name +
690
+ ' : _fn))(' + argsJS + ')';
691
+ }
692
+
693
+ receiver = callee.path.slice(0, -1);
694
+ return '(' + checkMatchExpr(callee.path) + ' || _fn).call(' +
695
+ checkMatchExpr(receiver) +
696
+ (argsJS ? ', ' + argsJS : '') +
697
+ ')';
698
+ }
699
+
700
+ return '(' + emitExpr(callee, d) + ')(' + argsJS + ')';
701
+ }
702
+
703
+ /*!
704
+ * Emit an expression-position filter invocation —
705
+ * `_filters["<name>"](<input>[, <args>])`. Mirrors the top-level drain
706
+ * in the Output emitter, but reads its input from `node.input` (a real
707
+ * {@link IRExpr}) rather than accumulating positionally. @private
708
+ */
709
+ function emitFilterCall(node, d) {
710
+ var inputJS = emitExpr(node.input, d),
711
+ argsJS = '';
712
+ if (node.args && node.args.length) {
713
+ var parts = [];
714
+ utils.each(node.args, function (a) { parts.push(emitExpr(a, d)); });
715
+ argsJS = ', ' + parts.join(', ');
716
+ }
717
+ return '_filters["' + node.name + '"](' + inputJS + argsJS + ')';
718
+ }