@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 +718 -0
- package/lib/cache.js +75 -0
- package/lib/dateformatter.js +201 -0
- package/lib/engine.js +414 -0
- package/lib/filters.js +43 -0
- package/lib/index.js +21 -0
- package/lib/ir.js +873 -0
- package/lib/loaders/filesystem.js +59 -0
- package/lib/loaders/index.js +53 -0
- package/lib/loaders/memory.js +63 -0
- package/lib/security.js +25 -0
- package/lib/tokenparser.js +920 -0
- package/lib/tokentypes.js +78 -0
- package/lib/utils.js +184 -0
- package/package.json +26 -0
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
|
+
}
|