@rhinostone/swig-twig 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/filters.js +845 -0
- package/lib/index.js +68 -0
- package/lib/lexer.js +479 -0
- package/lib/parser.js +670 -0
- package/lib/tags/apply.js +206 -0
- package/lib/tags/block.js +93 -0
- package/lib/tags/extends.js +87 -0
- package/lib/tags/for.js +134 -0
- package/lib/tags/from.js +217 -0
- package/lib/tags/if.js +57 -0
- package/lib/tags/import.js +170 -0
- package/lib/tags/include.js +170 -0
- package/lib/tags/index.js +35 -0
- package/lib/tags/macro.js +149 -0
- package/lib/tags/set.js +174 -0
- package/lib/tags/verbatim.js +52 -0
- package/lib/tags/with.js +133 -0
- package/lib/tokentypes.js +97 -0
- package/package.json +30 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 11 — Twig `{% apply filter %}…{% endapply %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig apply syntax — pipe the captured body through one or more filters,
|
|
5
|
+
* left-to-right:
|
|
6
|
+
*
|
|
7
|
+
* {% apply upper %}hello{% endapply %}
|
|
8
|
+
* {% apply upper|trim %} hi {% endapply %}
|
|
9
|
+
* {% apply replace({'a': 'b'}) %}banana{% endapply %}
|
|
10
|
+
* {% apply replace({'a': 'b'})|upper %}banana{% endapply %}
|
|
11
|
+
*
|
|
12
|
+
* Emits `IRLegacyJS` rather than `IRFilter` — `IRFilter` is single-filter
|
|
13
|
+
* only (backend wraps the body in one `_filters[name](...)` call), and
|
|
14
|
+
* chains require nested `_filters["f3"](_filters["f2"](_filters["f1"](...),
|
|
15
|
+
* ...), ...)`. Keeping the chain-emission in the tag avoids growing a new
|
|
16
|
+
* IR node for what is a JS plumbing shape; consistent with `{% set %}`'s
|
|
17
|
+
* body-capture form which also emits an IIFE via `IRLegacyJS`.
|
|
18
|
+
*
|
|
19
|
+
* CVE-2023-25345 checkpoint applies to each filter name — prototype-chain
|
|
20
|
+
* names (`__proto__`, `constructor`, `prototype`) are rejected at parse
|
|
21
|
+
* time. Filter argument expressions go through `parser.parseExpr` and
|
|
22
|
+
* inherit the `_dangerousProps` guards the expression parser already
|
|
23
|
+
* applies to VAR / DOTKEY / STRING-in-bracket / FUNCTION callee.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
27
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
28
|
+
var backend = require('@rhinostone/swig-core/lib/backend');
|
|
29
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
30
|
+
|
|
31
|
+
var lexer = require('../lexer');
|
|
32
|
+
var _t = require('../tokentypes');
|
|
33
|
+
|
|
34
|
+
exports.ends = true;
|
|
35
|
+
exports.block = false;
|
|
36
|
+
|
|
37
|
+
/*!
|
|
38
|
+
* Depth-tracked COMMA-split over the token stream starting at `start`
|
|
39
|
+
* (which should be the position immediately after the FUNCTION or FILTER
|
|
40
|
+
* token's implicit open paren). Returns `{ slices, end }` where `end` is
|
|
41
|
+
* the index of the balancing PARENCLOSE (one past the last consumed arg
|
|
42
|
+
* token). Throws via `utils.throwError` on unclosed paren.
|
|
43
|
+
*
|
|
44
|
+
* Pattern mirrors `lib/tags/filter.js:lowerExpr` — paren / bracket /
|
|
45
|
+
* curly / function all bump depth; PARENCLOSE / BRACKETCLOSE /
|
|
46
|
+
* CURLYCLOSE drop it; COMMA at depth 1 is a top-level separator.
|
|
47
|
+
* @private
|
|
48
|
+
*/
|
|
49
|
+
function sliceCallArgs(tokens, start, line, filename) {
|
|
50
|
+
var depth = 1,
|
|
51
|
+
argStart = start,
|
|
52
|
+
slices = [],
|
|
53
|
+
j;
|
|
54
|
+
for (j = start; j < tokens.length; j += 1) {
|
|
55
|
+
var tk = tokens[j];
|
|
56
|
+
if (tk.type === _t.PARENOPEN || tk.type === _t.FUNCTION ||
|
|
57
|
+
tk.type === _t.BRACKETOPEN || tk.type === _t.CURLYOPEN) {
|
|
58
|
+
depth += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (tk.type === _t.PARENCLOSE || tk.type === _t.BRACKETCLOSE ||
|
|
62
|
+
tk.type === _t.CURLYCLOSE) {
|
|
63
|
+
depth -= 1;
|
|
64
|
+
if (depth === 0) {
|
|
65
|
+
if (j > argStart) {
|
|
66
|
+
slices.push(tokens.slice(argStart, j));
|
|
67
|
+
}
|
|
68
|
+
return { slices: slices, end: j + 1 };
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (tk.type === _t.COMMA && depth === 1) {
|
|
73
|
+
if (j > argStart) {
|
|
74
|
+
slices.push(tokens.slice(argStart, j));
|
|
75
|
+
}
|
|
76
|
+
argStart = j + 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
utils.throwError('Unclosed argument list in "apply" tag', line, filename);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse the `{% apply filter %}` tag body. Extracts the filter chain
|
|
84
|
+
* and validates each filter name against the CVE-2023-25345 blocklist.
|
|
85
|
+
*
|
|
86
|
+
* Stashes `[{name, args: IRExpr[]}, {name, args: IRExpr[]}, ...]` on
|
|
87
|
+
* `token.args`. `args: []` means the filter takes no arguments.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} str Tag body.
|
|
90
|
+
* @param {number} line Source line of the opening `{%`.
|
|
91
|
+
* @param {object} parser The Twig parser module (exposes `parseExpr`).
|
|
92
|
+
* @param {object} types Twig lexer token-type enum.
|
|
93
|
+
* @param {Array} stack Open-tag stack (parser.js manages push).
|
|
94
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
95
|
+
* @param {object} swig Swig instance (unused).
|
|
96
|
+
* @param {object} token In-progress TagToken. `token.args` gets the
|
|
97
|
+
* filter chain descriptor array.
|
|
98
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
99
|
+
*/
|
|
100
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
101
|
+
var tokens = lexer.read(utils.strip(str));
|
|
102
|
+
var pos = 0;
|
|
103
|
+
|
|
104
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
105
|
+
|
|
106
|
+
if (pos >= tokens.length) {
|
|
107
|
+
utils.throwError('Expected filter name in "apply" tag', line, opts.filename);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkName(name) {
|
|
111
|
+
if (_dangerousProps.indexOf(name) !== -1) {
|
|
112
|
+
utils.throwError('Unsafe filter name "' + name + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var chain = [];
|
|
117
|
+
|
|
118
|
+
// Head filter — VAR (bare name), FUNCTIONEMPTY (`name()`), or FUNCTION
|
|
119
|
+
// (`name(args...)`). Subsequent filters must come in via FILTER /
|
|
120
|
+
// FILTEREMPTY — a trailing bare VAR / FUNCTION would be a syntax error.
|
|
121
|
+
var head = tokens[pos];
|
|
122
|
+
if (head.type === types.VAR) {
|
|
123
|
+
checkName(head.match);
|
|
124
|
+
chain.push({ name: head.match, args: [] });
|
|
125
|
+
pos += 1;
|
|
126
|
+
} else if (head.type === types.FUNCTIONEMPTY) {
|
|
127
|
+
checkName(head.match);
|
|
128
|
+
chain.push({ name: head.match, args: [] });
|
|
129
|
+
pos += 1;
|
|
130
|
+
} else if (head.type === types.FUNCTION) {
|
|
131
|
+
checkName(head.match);
|
|
132
|
+
var result = sliceCallArgs(tokens, pos + 1, line, opts.filename);
|
|
133
|
+
var exprs = [];
|
|
134
|
+
for (var i = 0; i < result.slices.length; i += 1) {
|
|
135
|
+
exprs.push(parser.parseExpr(result.slices[i]));
|
|
136
|
+
}
|
|
137
|
+
chain.push({ name: head.match, args: exprs });
|
|
138
|
+
pos = result.end;
|
|
139
|
+
} else {
|
|
140
|
+
utils.throwError('Expected filter name in "apply" tag', line, opts.filename);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Chain tail — each FILTER or FILTEREMPTY appends another filter call.
|
|
144
|
+
while (pos < tokens.length) {
|
|
145
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
146
|
+
if (pos >= tokens.length) { break; }
|
|
147
|
+
|
|
148
|
+
var tk = tokens[pos];
|
|
149
|
+
if (tk.type === types.FILTEREMPTY) {
|
|
150
|
+
checkName(tk.match);
|
|
151
|
+
chain.push({ name: tk.match, args: [] });
|
|
152
|
+
pos += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (tk.type === types.FILTER) {
|
|
156
|
+
checkName(tk.match);
|
|
157
|
+
var r = sliceCallArgs(tokens, pos + 1, line, opts.filename);
|
|
158
|
+
var e = [];
|
|
159
|
+
for (var k = 0; k < r.slices.length; k += 1) {
|
|
160
|
+
e.push(parser.parseExpr(r.slices[k]));
|
|
161
|
+
}
|
|
162
|
+
chain.push({ name: tk.match, args: e });
|
|
163
|
+
pos = r.end;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
utils.throwError('Unexpected token "' + tk.match + '" in "apply" tag filter chain', line, opts.filename);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
token.args = chain;
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Emit an `IRLegacyJS` node that captures the body into a local
|
|
175
|
+
* `_output` via an IIFE and folds the filter chain left-to-right into
|
|
176
|
+
* nested `_filters["<name>"](input, ...args)` calls.
|
|
177
|
+
*
|
|
178
|
+
* For `{% apply upper|trim %}body{% endapply %}` this produces roughly:
|
|
179
|
+
*
|
|
180
|
+
* _output += _filters["trim"](_filters["upper"](
|
|
181
|
+
* (function () { var _output = ""; <bodyJS> return _output; })()
|
|
182
|
+
* ));
|
|
183
|
+
*
|
|
184
|
+
* @return {object} IRLegacyJS node.
|
|
185
|
+
*/
|
|
186
|
+
exports.compile = function (compiler, args, content, parents, options, blockName) {
|
|
187
|
+
var chain = args;
|
|
188
|
+
var bodyJS = compiler(content, parents, options, blockName);
|
|
189
|
+
var input = '(function () {\n var _output = "";\n' + bodyJS + ' return _output;\n})()';
|
|
190
|
+
|
|
191
|
+
var expr = input;
|
|
192
|
+
for (var i = 0; i < chain.length; i += 1) {
|
|
193
|
+
var entry = chain[i];
|
|
194
|
+
var argsJS = '';
|
|
195
|
+
if (entry.args && entry.args.length) {
|
|
196
|
+
var parts = [];
|
|
197
|
+
for (var j = 0; j < entry.args.length; j += 1) {
|
|
198
|
+
parts.push(backend.emitExpr(entry.args[j]));
|
|
199
|
+
}
|
|
200
|
+
argsJS = ', ' + parts.join(', ');
|
|
201
|
+
}
|
|
202
|
+
expr = '_filters["' + entry.name + '"](' + expr + argsJS + ')';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return ir.legacyJS('_output += ' + expr + ';\n');
|
|
206
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 9 — Twig `{% block %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Named override point for template inheritance:
|
|
5
|
+
*
|
|
6
|
+
* {% block <name> %}…{% endblock %}
|
|
7
|
+
*
|
|
8
|
+
* The block name must be a bare identifier (dotted paths rejected) and
|
|
9
|
+
* passes the CVE-2023-25345 `_dangerousProps` guard. The parser
|
|
10
|
+
* captures the block in `template.blocks[name]` when it appears at the
|
|
11
|
+
* top level (see `packages/swig-twig/lib/parser.js` — the block-keying
|
|
12
|
+
* branch is triggered by `token.block && !stack.length`).
|
|
13
|
+
*
|
|
14
|
+
* Compile emits an `IRBlock` node with the body wrapped in IRLegacyJS.
|
|
15
|
+
* The backend's `Block` branch emits the body verbatim — block-override
|
|
16
|
+
* resolution happens at parse time via `engine.remapBlocks` /
|
|
17
|
+
* `importNonBlocks`, which substitutes the child's block content into
|
|
18
|
+
* the parent's token tree before backend emission.
|
|
19
|
+
*
|
|
20
|
+
* Native hardening gap flagged: `lib/tags/block.js` uses
|
|
21
|
+
* `parser.on('*')` and does NOT guard the block name against
|
|
22
|
+
* `_dangerousProps`. A `{% block __proto__ %}` in native Swig would
|
|
23
|
+
* key the blocks map by that name; the override path does not currently
|
|
24
|
+
* reach the prototype chain but the cross-layer invariant is to guard
|
|
25
|
+
* anyway. See .claude/architecture/multi-flavor-ir.md § Phase 3 —
|
|
26
|
+
* Session 9 native hardening follow-up.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
30
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
31
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
32
|
+
|
|
33
|
+
var lexer = require('../lexer');
|
|
34
|
+
var _t = require('../tokentypes');
|
|
35
|
+
|
|
36
|
+
exports.ends = true;
|
|
37
|
+
exports.block = true;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse the `{% block %}` tag body. Extracts the bare-identifier name,
|
|
41
|
+
* validates it against `_dangerousProps` and the dotted-path rule, and
|
|
42
|
+
* stashes it on `token.args` so the parser's top-level block-keying
|
|
43
|
+
* branch can pick it up via `token.args.join('')`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} str Tag body.
|
|
46
|
+
* @param {number} line Source line of the opening `{%`.
|
|
47
|
+
* @param {object} parser The Twig parser module (unused here — block
|
|
48
|
+
* names are plain identifiers).
|
|
49
|
+
* @param {object} types Twig lexer token-type enum.
|
|
50
|
+
* @param {Array} stack Open-tag stack (parser.js manages the push).
|
|
51
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
52
|
+
* @param {object} swig Swig instance (unused).
|
|
53
|
+
* @param {object} token In-progress TagToken. `token.args` gets the
|
|
54
|
+
* block name as its single element.
|
|
55
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
56
|
+
*/
|
|
57
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
58
|
+
var tokens = lexer.read(utils.strip(str));
|
|
59
|
+
var pos = 0;
|
|
60
|
+
|
|
61
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
62
|
+
var nameTok = pos < tokens.length ? tokens[pos] : null;
|
|
63
|
+
if (!nameTok || nameTok.type !== types.VAR) {
|
|
64
|
+
utils.throwError('Expected block name in "block" tag', line, opts.filename);
|
|
65
|
+
}
|
|
66
|
+
if (nameTok.match.indexOf('.') !== -1) {
|
|
67
|
+
utils.throwError('Block name "' + nameTok.match + '" must be a bare identifier', line, opts.filename);
|
|
68
|
+
}
|
|
69
|
+
if (_dangerousProps.indexOf(nameTok.match) !== -1) {
|
|
70
|
+
utils.throwError('Unsafe block name "' + nameTok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pos += 1;
|
|
74
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
75
|
+
if (pos < tokens.length) {
|
|
76
|
+
utils.throwError('Unexpected token "' + tokens[pos].match + '" after block name', line, opts.filename);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
token.args = [nameTok.match];
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Emit an IRBlock node. Body is the recursively-compiled content
|
|
85
|
+
* wrapped in IRLegacyJS. Mirrors the native `lib/tags/block.js` compile
|
|
86
|
+
* shape so the backend's `Block` branch treats both frontends the same.
|
|
87
|
+
*
|
|
88
|
+
* @return {object} IRBlock node.
|
|
89
|
+
*/
|
|
90
|
+
exports.compile = function (compiler, args, content, parents, options) {
|
|
91
|
+
var name = args.join('');
|
|
92
|
+
return ir.block(name, [ir.legacyJS(compiler(content, parents, options, name))]);
|
|
93
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 9 — Twig `{% extends %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Declares a parent template for inheritance:
|
|
5
|
+
*
|
|
6
|
+
* {% extends "layout.twig" %}
|
|
7
|
+
*
|
|
8
|
+
* Twig supports both static string paths (handled here) and dynamic
|
|
9
|
+
* expressions (`{% extends some_var %}`, `{% extends a ? b : c %}`). This
|
|
10
|
+
* session rejects dynamic extends at parse time — the engine's parent-
|
|
11
|
+
* chain resolution (`engine.getParents` + `remapBlocks` +
|
|
12
|
+
* `importNonBlocks`) walks the chain statically at compile time, so a
|
|
13
|
+
* runtime-valued parent cannot be resolved without reworking the engine.
|
|
14
|
+
* Dynamic extends is tracked for a later session; the rejection is
|
|
15
|
+
* deliberate, not an oversight.
|
|
16
|
+
*
|
|
17
|
+
* The parser's splitter reads `token.args[0]` and stashes it on
|
|
18
|
+
* `template.parent` (see `packages/swig-twig/lib/parser.js` line 609).
|
|
19
|
+
* This tag must therefore push the *unquoted* path as the single
|
|
20
|
+
* `token.args` element.
|
|
21
|
+
*
|
|
22
|
+
* Compile emits nothing — `extends.compile` returns undefined. The
|
|
23
|
+
* backend's emit loop skips undefined returns. Extends is a parse-time
|
|
24
|
+
* declaration carried via `template.parent` metadata; no runtime code
|
|
25
|
+
* is generated for the `{% extends %}` tag itself.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
29
|
+
|
|
30
|
+
var lexer = require('../lexer');
|
|
31
|
+
var _t = require('../tokentypes');
|
|
32
|
+
|
|
33
|
+
exports.ends = false;
|
|
34
|
+
exports.block = true;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the `{% extends %}` tag body. Extracts the STRING literal path,
|
|
38
|
+
* strips surrounding quotes, and stashes the result as `token.args[0]`
|
|
39
|
+
* for the parser's splitter to pick up.
|
|
40
|
+
*
|
|
41
|
+
* Rejects anything other than a single STRING token — dynamic extends
|
|
42
|
+
* (VAR, FUNCTION, expressions) is not supported in this session.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} str Tag body.
|
|
45
|
+
* @param {number} line Source line of the opening `{%`.
|
|
46
|
+
* @param {object} parser The Twig parser module (unused — path is a
|
|
47
|
+
* bare string literal).
|
|
48
|
+
* @param {object} types Twig lexer token-type enum.
|
|
49
|
+
* @param {Array} stack Open-tag stack (unused — extends has no body).
|
|
50
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
51
|
+
* @param {object} swig Swig instance (unused).
|
|
52
|
+
* @param {object} token In-progress TagToken. `token.args` gets the
|
|
53
|
+
* unquoted parent path as its single element.
|
|
54
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
55
|
+
*/
|
|
56
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
57
|
+
var tokens = lexer.read(utils.strip(str));
|
|
58
|
+
var pos = 0;
|
|
59
|
+
|
|
60
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
61
|
+
var pathTok = pos < tokens.length ? tokens[pos] : null;
|
|
62
|
+
if (!pathTok) {
|
|
63
|
+
utils.throwError('Expected parent template path in "extends" tag', line, opts.filename);
|
|
64
|
+
}
|
|
65
|
+
if (pathTok.type !== types.STRING) {
|
|
66
|
+
utils.throwError('Dynamic "extends" is not supported — parent path must be a string literal', line, opts.filename);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pos += 1;
|
|
70
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
71
|
+
if (pos < tokens.length) {
|
|
72
|
+
utils.throwError('Unexpected token "' + tokens[pos].match + '" after parent path in "extends" tag', line, opts.filename);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
token.args = [pathTok.match.replace(/^['"]|['"]$/g, '')];
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* No-op compile. Extends is a parse-time declaration — the parent path
|
|
81
|
+
* lives on `template.parent` (set by the parser's splitter), which the
|
|
82
|
+
* engine's `getParents` reads during compile. The `{% extends %}` tag
|
|
83
|
+
* itself emits no runtime code.
|
|
84
|
+
*
|
|
85
|
+
* @return {undefined}
|
|
86
|
+
*/
|
|
87
|
+
exports.compile = function () {};
|
package/lib/tags/for.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 8 — Twig `{% for %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig iteration:
|
|
5
|
+
* {% for <val> in <iterable> %}…{% endfor %}
|
|
6
|
+
* {% for <key>, <val> in <iterable> %}…{% endfor %}
|
|
7
|
+
*
|
|
8
|
+
* Loop variable names must be bare identifiers — dotted paths
|
|
9
|
+
* (`foo.bar`) are rejected at parse time (not valid Twig loop-var
|
|
10
|
+
* syntax; and accepting them would let malformed templates silently
|
|
11
|
+
* through). The CVE-2023-25345 `_dangerousProps` guard runs on every
|
|
12
|
+
* bound name (key and val).
|
|
13
|
+
*
|
|
14
|
+
* The iterable is lowered through `parser.parseExpr`, so filter chains
|
|
15
|
+
* (`list|sort`), BinaryOps (`a + b`), function calls, and ternaries
|
|
16
|
+
* route through the same path as any other Twig expression — no
|
|
17
|
+
* tag-local bail conditions. The resulting IRExpr is attached to
|
|
18
|
+
* `token.irExpr`.
|
|
19
|
+
*
|
|
20
|
+
* The backend's `For` branch (packages/swig-core/lib/backend.js:187)
|
|
21
|
+
* owns the full IIFE scaffolding: `_utils.each`, `_ctx.loop.*`
|
|
22
|
+
* bookkeeping (first/last/index/index0/revindex/revindex0/length/key),
|
|
23
|
+
* and the `Math.random()`-based loopcache identifier that keeps nested
|
|
24
|
+
* loops from clobbering each other's `_ctx.loop` state (gh-433). The
|
|
25
|
+
* tag ships only semantic IR — (val, key, iterable, body).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
29
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
30
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
31
|
+
|
|
32
|
+
var lexer = require('../lexer');
|
|
33
|
+
var _t = require('../tokentypes');
|
|
34
|
+
|
|
35
|
+
exports.ends = true;
|
|
36
|
+
exports.block = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse the `{% for %}` tag body. Extracts the binding names (val or
|
|
40
|
+
* key+val), validates them against `_dangerousProps` and the bare-
|
|
41
|
+
* identifier rule, then lowers the iterable expression through
|
|
42
|
+
* `parser.parseExpr`. Names are stashed on `token.args` (`[val]` or
|
|
43
|
+
* `[key, val]`); the iterable IR is stashed on `token.irExpr`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} str Tag body.
|
|
46
|
+
* @param {number} line Source line of the opening `{%`.
|
|
47
|
+
* @param {object} parser The Twig parser module (exposes `parseExpr`).
|
|
48
|
+
* @param {object} types Twig lexer token-type enum.
|
|
49
|
+
* @param {Array} stack Open-tag stack (parser.js manages the push
|
|
50
|
+
* after parse returns).
|
|
51
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
52
|
+
* @param {object} swig Swig instance (unused).
|
|
53
|
+
* @param {object} token In-progress TagToken.
|
|
54
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
55
|
+
*/
|
|
56
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
57
|
+
var tokens = lexer.read(utils.strip(str));
|
|
58
|
+
var pos = 0;
|
|
59
|
+
|
|
60
|
+
function peek() {
|
|
61
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
62
|
+
return pos < tokens.length ? tokens[pos] : null;
|
|
63
|
+
}
|
|
64
|
+
function consume() {
|
|
65
|
+
var t = peek();
|
|
66
|
+
if (t) { pos += 1; }
|
|
67
|
+
return t;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function takeName() {
|
|
71
|
+
var tok = consume();
|
|
72
|
+
if (!tok || tok.type !== types.VAR) {
|
|
73
|
+
utils.throwError('Expected loop variable in "for" tag', line, opts.filename);
|
|
74
|
+
}
|
|
75
|
+
if (tok.match.indexOf('.') !== -1) {
|
|
76
|
+
utils.throwError('Loop variable "' + tok.match + '" must be a bare identifier in "for" tag', line, opts.filename);
|
|
77
|
+
}
|
|
78
|
+
if (_dangerousProps.indexOf(tok.match) !== -1) {
|
|
79
|
+
utils.throwError('Unsafe loop variable "' + tok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
80
|
+
}
|
|
81
|
+
return tok.match;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var first = takeName();
|
|
85
|
+
var val = first;
|
|
86
|
+
var key;
|
|
87
|
+
|
|
88
|
+
if (peek() && peek().type === types.COMMA) {
|
|
89
|
+
consume();
|
|
90
|
+
key = first;
|
|
91
|
+
val = takeName();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// NB: the Twig lexer's COMPARATOR rule is `^(=== | ... | in\s)` — the
|
|
95
|
+
// trailing `\s` is required, so `{% for x in %}` (nothing after `in`)
|
|
96
|
+
// lexes `in` as a VAR instead of a COMPARATOR. Match on the literal
|
|
97
|
+
// string so the user-facing error stays "Expected iterable" for that
|
|
98
|
+
// shape rather than "Expected in".
|
|
99
|
+
var inTok = consume();
|
|
100
|
+
if (!inTok || inTok.match !== 'in' || (inTok.type !== types.COMPARATOR && inTok.type !== types.VAR)) {
|
|
101
|
+
utils.throwError('Expected "in" in "for" tag', line, opts.filename);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
105
|
+
|
|
106
|
+
var iterableTokens = tokens.slice(pos);
|
|
107
|
+
if (!iterableTokens.length) {
|
|
108
|
+
utils.throwError('Expected iterable after "in" in "for" tag', line, opts.filename);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
token.args = key !== undefined ? [key, val] : [val];
|
|
112
|
+
token.irExpr = parser.parseExpr(iterableTokens);
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Emit an IRFor node. The backend's `For` branch owns the loopcache +
|
|
118
|
+
* `_utils.each` scaffolding — this returns only (val, iterable, body,
|
|
119
|
+
* key). Body is the recursively-compiled content wrapped in IRLegacyJS.
|
|
120
|
+
*
|
|
121
|
+
* @return {object} IRFor node.
|
|
122
|
+
*/
|
|
123
|
+
exports.compile = function (compiler, args, content, parents, options, blockName, token) {
|
|
124
|
+
var val, key;
|
|
125
|
+
if (args.length === 2) {
|
|
126
|
+
key = args[0];
|
|
127
|
+
val = args[1];
|
|
128
|
+
} else {
|
|
129
|
+
val = args[0];
|
|
130
|
+
key = '__k';
|
|
131
|
+
}
|
|
132
|
+
var bodyJS = compiler(content, parents, options, blockName);
|
|
133
|
+
return ir.forStmt(val, token.irExpr, [ir.legacyJS(bodyJS)], key);
|
|
134
|
+
};
|