@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
package/lib/tags/from.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 11 — Twig `{% from "file" import a, b as c %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Selective macro import syntax — binds a named subset of an imported
|
|
5
|
+
* template's macros into the current context, optionally renaming each
|
|
6
|
+
* via `as <alias>`:
|
|
7
|
+
*
|
|
8
|
+
* {% from "forms.twig" import input %}
|
|
9
|
+
* {% from "forms.twig" import input, textarea %}
|
|
10
|
+
* {% from "forms.twig" import input as field, textarea %}
|
|
11
|
+
*
|
|
12
|
+
* Differs from `{% import %}` in that each entry lands at top-level
|
|
13
|
+
* `_ctx.<alias-or-name>` rather than inside a shared namespace object.
|
|
14
|
+
* Macros not listed in the `import` clause are not surfaced.
|
|
15
|
+
*
|
|
16
|
+
* Stays on `IRLegacyJS` per the same flavor-invariant test that keeps
|
|
17
|
+
* `{% import %}` on IRLegacyJS — the regex-surgery rewrite of a compiled
|
|
18
|
+
* macro body (`_ctx\.<origName>` → `_ctx\.<aliasName>`) is swig-specific
|
|
19
|
+
* coupling on the exact JS source shape the Macro IR emits. When the
|
|
20
|
+
* macro-name → alias rewrite moves into the IR emitter itself, this tag
|
|
21
|
+
* collapses to a dedicated `IRFromImport` node.
|
|
22
|
+
*
|
|
23
|
+
* Dynamic paths (`{% from dynPath import foo %}`) are rejected at parse
|
|
24
|
+
* time — same rationale as `{% import %}` + `{% extends %}`.
|
|
25
|
+
*
|
|
26
|
+
* Every requested macro name and every alias is a bare identifier —
|
|
27
|
+
* dotted paths are rejected per the lexer-folded-path bail pattern, and
|
|
28
|
+
* CVE-2023-25345 prototype-chain names are rejected on both slots before
|
|
29
|
+
* the assignment lands on `_ctx`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
33
|
+
var backend = require('@rhinostone/swig-core/lib/backend');
|
|
34
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
35
|
+
|
|
36
|
+
var lexer = require('../lexer');
|
|
37
|
+
|
|
38
|
+
exports.ends = false;
|
|
39
|
+
exports.block = true;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse the `{% from %}` tag body. Extracts the STRING literal path,
|
|
43
|
+
* the `import` keyword, and the comma-separated entry list (each entry
|
|
44
|
+
* is `<name>` or `<name> as <alias>`); validates every name and alias
|
|
45
|
+
* against the bare-identifier rule and the CVE-2023-25345
|
|
46
|
+
* `_dangerousProps` blocklist.
|
|
47
|
+
*
|
|
48
|
+
* Walks the imported template's token list (via `swig.parseFile`) and
|
|
49
|
+
* for each requested macro, invokes its `compile` to get the IRMacro
|
|
50
|
+
* node and renders that node to JS through `backend.compile`. A
|
|
51
|
+
* macro requested by name but not found in the imported template
|
|
52
|
+
* raises a filename-aware throw. The resulting
|
|
53
|
+
* `[{compiled, origName, aliasName}, ...]` list is stashed on
|
|
54
|
+
* `token.args` for the compile step to rewrite.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} str Tag body.
|
|
57
|
+
* @param {number} line Source line of the opening `{%`.
|
|
58
|
+
* @param {object} parser The Twig parser module (unused — body is
|
|
59
|
+
* lexed locally).
|
|
60
|
+
* @param {object} types Twig lexer token-type enum.
|
|
61
|
+
* @param {Array} stack Open-tag stack (unused — from has no body).
|
|
62
|
+
* @param {object} opts Per-call options. Honors `opts.filename` for
|
|
63
|
+
* `resolveFrom` + filename-aware throws.
|
|
64
|
+
* @param {object} swig Swig instance. Must expose `parseFile`.
|
|
65
|
+
* @param {object} token In-progress TagToken. `token.args` gets the
|
|
66
|
+
* `[{compiled, origName, aliasName}, ...]` list.
|
|
67
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
68
|
+
*/
|
|
69
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
70
|
+
var tokens = lexer.read(utils.strip(str));
|
|
71
|
+
var pos = 0;
|
|
72
|
+
|
|
73
|
+
function peek() {
|
|
74
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
75
|
+
return pos < tokens.length ? tokens[pos] : null;
|
|
76
|
+
}
|
|
77
|
+
function consume() {
|
|
78
|
+
var t = peek();
|
|
79
|
+
if (t) { pos += 1; }
|
|
80
|
+
return t;
|
|
81
|
+
}
|
|
82
|
+
function guardName(name, role) {
|
|
83
|
+
if (name.indexOf('.') !== -1) {
|
|
84
|
+
utils.throwError(role + ' "' + name + '" must be a bare identifier in "from" tag', line, opts.filename);
|
|
85
|
+
}
|
|
86
|
+
if (_dangerousProps.indexOf(name) !== -1) {
|
|
87
|
+
utils.throwError('Unsafe ' + role.toLowerCase() + ' "' + name + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
var pathTok = consume();
|
|
92
|
+
if (!pathTok) {
|
|
93
|
+
utils.throwError('Expected template path in "from" tag', line, opts.filename);
|
|
94
|
+
}
|
|
95
|
+
if (pathTok.type !== types.STRING) {
|
|
96
|
+
utils.throwError('Dynamic "from" is not supported — path must be a string literal', line, opts.filename);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var importTok = consume();
|
|
100
|
+
if (!importTok || importTok.type !== types.VAR || importTok.match !== 'import') {
|
|
101
|
+
utils.throwError('Expected "import" keyword after path in "from" tag', line, opts.filename);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Collect requested macro entries — each is `<name>` or
|
|
105
|
+
// `<name> as <alias>`. At least one entry is required.
|
|
106
|
+
var entries = [];
|
|
107
|
+
while (true) {
|
|
108
|
+
var nameTok = consume();
|
|
109
|
+
if (!nameTok || nameTok.type !== types.VAR) {
|
|
110
|
+
utils.throwError('Expected macro name in "from" tag', line, opts.filename);
|
|
111
|
+
}
|
|
112
|
+
guardName(nameTok.match, 'Macro name');
|
|
113
|
+
|
|
114
|
+
var origName = nameTok.match;
|
|
115
|
+
var aliasName = origName;
|
|
116
|
+
|
|
117
|
+
var next = peek();
|
|
118
|
+
if (next && next.type === types.VAR && next.match === 'as') {
|
|
119
|
+
consume();
|
|
120
|
+
var aliasTok = consume();
|
|
121
|
+
if (!aliasTok || aliasTok.type !== types.VAR) {
|
|
122
|
+
utils.throwError('Expected alias after "as" in "from" tag', line, opts.filename);
|
|
123
|
+
}
|
|
124
|
+
guardName(aliasTok.match, 'Import alias');
|
|
125
|
+
aliasName = aliasTok.match;
|
|
126
|
+
next = peek();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
entries.push({ origName: origName, aliasName: aliasName });
|
|
130
|
+
|
|
131
|
+
if (!next) { break; }
|
|
132
|
+
if (next.type !== types.COMMA) {
|
|
133
|
+
utils.throwError('Unexpected token "' + next.match + '" in "from" tag import list', line, opts.filename);
|
|
134
|
+
}
|
|
135
|
+
consume();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!swig || typeof swig.parseFile !== 'function') {
|
|
139
|
+
utils.throwError('"from" tag requires an engine context with a loader', line, opts.filename);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var path = pathTok.match.replace(/^['"]|['"]$/g, '');
|
|
143
|
+
var parseOpts = { resolveFrom: opts.filename };
|
|
144
|
+
var compileOpts = utils.extend({}, opts, parseOpts);
|
|
145
|
+
var parsed = swig.parseFile(path, parseOpts);
|
|
146
|
+
|
|
147
|
+
// Index the imported template's macros by name so we can look up
|
|
148
|
+
// each requested entry once. Raises a filename-aware throw if an
|
|
149
|
+
// entry names a macro that doesn't exist in the imported template.
|
|
150
|
+
var macroIndex = {};
|
|
151
|
+
utils.each(parsed.tokens, function (tk) {
|
|
152
|
+
if (!tk || tk.name !== 'macro' || typeof tk.compile !== 'function') {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
macroIndex[tk.args[0]] = tk;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
var resolved = [];
|
|
159
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
160
|
+
var entry = entries[i];
|
|
161
|
+
var macroTok = macroIndex[entry.origName];
|
|
162
|
+
if (!macroTok) {
|
|
163
|
+
utils.throwError('Macro "' + entry.origName + '" not found in "' + path + '"', line, opts.filename);
|
|
164
|
+
}
|
|
165
|
+
var macroIR = macroTok.compile(backend.compile, macroTok.args, macroTok.content, [], compileOpts);
|
|
166
|
+
var compiled = backend.compile([macroIR], [], compileOpts) + '\n';
|
|
167
|
+
resolved.push({
|
|
168
|
+
compiled: compiled,
|
|
169
|
+
origName: entry.origName,
|
|
170
|
+
aliasName: entry.aliasName
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
token.args = resolved;
|
|
175
|
+
return true;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Emit the selective-import rewrite. For each imported entry, rewrites
|
|
180
|
+
* every occurrence of `_ctx.<origName>` in the compiled macro body to
|
|
181
|
+
* `_ctx.<aliasName>`, including sibling-macro references to any other
|
|
182
|
+
* imported name. The `(?!<allOrigNames>)` negative lookahead matches
|
|
183
|
+
* `lib/tags/import.js` behaviour — guards against the edge case where
|
|
184
|
+
* a rewritten `_ctx.<aliasName>` fragment would itself be re-matched by
|
|
185
|
+
* a later replacement whose `origName` happens to be a prefix of the
|
|
186
|
+
* alias tail.
|
|
187
|
+
*
|
|
188
|
+
* Sibling references to a macro that was NOT in the import list are
|
|
189
|
+
* left as-is — those expand to `_ctx.<origName>` lookups at render time
|
|
190
|
+
* and will either read a user-provided context value or evaluate to
|
|
191
|
+
* `undefined`, matching Twig's "unimported macros are not available"
|
|
192
|
+
* semantic.
|
|
193
|
+
*
|
|
194
|
+
* @return {string} JS source that assigns every imported macro into
|
|
195
|
+
* `_ctx.<aliasName>`. Backend lifts it into
|
|
196
|
+
* `IRLegacyJS`.
|
|
197
|
+
*/
|
|
198
|
+
exports.compile = function (compiler, args) {
|
|
199
|
+
var allOrigNames = utils.map(args, function (arg) { return arg.origName; }).join('|');
|
|
200
|
+
var replacements = utils.map(args, function (arg) {
|
|
201
|
+
return {
|
|
202
|
+
ex: new RegExp('_ctx\\.' + arg.origName + '(\\W)(?!' + allOrigNames + ')', 'g'),
|
|
203
|
+
re: '_ctx.' + arg.aliasName + '$1'
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
var out = ' var _output = "";\n';
|
|
208
|
+
utils.each(args, function (arg) {
|
|
209
|
+
var c = arg.compiled;
|
|
210
|
+
utils.each(replacements, function (re) {
|
|
211
|
+
c = c.replace(re.ex, re.re);
|
|
212
|
+
});
|
|
213
|
+
out += c;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return out;
|
|
217
|
+
};
|
package/lib/tags/if.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 7 — Twig `{% if %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig conditional: `{% if <expr> %}…{% endif %}`. The test expression
|
|
5
|
+
* is parsed via `parser.parseExpr` and attached to `token.irExpr`; the
|
|
6
|
+
* tag's body content is captured via the parser's open-tag stack
|
|
7
|
+
* mechanism (parser.js sets `ends: true` so subsequent tokens append to
|
|
8
|
+
* `token.content` until the matching `{% endif %}` arrives).
|
|
9
|
+
*
|
|
10
|
+
* Session 7 ships a single-branch shape — `{% else %}` / `{% elseif %}`
|
|
11
|
+
* are deferred to a later session. The compile path emits one
|
|
12
|
+
* IRIfBranch carrying the test IRExpr and the recursively-compiled
|
|
13
|
+
* body wrapped in IRLegacyJS.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
17
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
18
|
+
|
|
19
|
+
var lexer = require('../lexer');
|
|
20
|
+
|
|
21
|
+
exports.ends = true;
|
|
22
|
+
exports.block = false;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse the `{% if %}` tag body and attach the test IRExpr to
|
|
26
|
+
* `token.irExpr`.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} str Tag body (everything between `{%` and `%}`, tag name stripped).
|
|
29
|
+
* @param {number} line Source line of the opening `{%`.
|
|
30
|
+
* @param {object} parser The Twig parser module (exposes `parseExpr`).
|
|
31
|
+
* @param {object} types Twig lexer token-type enum.
|
|
32
|
+
* @param {Array} stack Open-tag stack (managed by parser.js).
|
|
33
|
+
* @param {object} opts Per-call options (honors `opts.filename` for filename-aware throws).
|
|
34
|
+
* @param {object} swig Swig instance (unused).
|
|
35
|
+
* @param {object} token In-progress TagToken. `token.irExpr` is set to the test IRExpr.
|
|
36
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
37
|
+
*/
|
|
38
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
39
|
+
var tokens = lexer.read(utils.strip(str));
|
|
40
|
+
if (!tokens.length) {
|
|
41
|
+
utils.throwError('Expected conditional expression in "if" tag', line, opts.filename);
|
|
42
|
+
}
|
|
43
|
+
token.irExpr = parser.parseExpr(tokens);
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recursively compile the body content and wrap it as a single-branch
|
|
49
|
+
* IRIf node. The backend's `If` walker emits the JS `if (<test>) { … }`
|
|
50
|
+
* envelope.
|
|
51
|
+
*
|
|
52
|
+
* @return {object} IRIf node.
|
|
53
|
+
*/
|
|
54
|
+
exports.compile = function (compiler, args, content, parents, options, blockName, token) {
|
|
55
|
+
var bodyJS = compiler(content, parents, options, blockName);
|
|
56
|
+
return ir.ifStmt([ir.ifBranch(token.irExpr, [ir.legacyJS(bodyJS)])]);
|
|
57
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 10 — Twig `{% import %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig import syntax:
|
|
5
|
+
*
|
|
6
|
+
* {% import "partial.twig" as form %}
|
|
7
|
+
*
|
|
8
|
+
* Loads a template and imports every `{% macro %}` it defines into a
|
|
9
|
+
* namespace bound to `_ctx.<alias>`. Each imported macro is rendered
|
|
10
|
+
* to JS via `backend.compile`; the compile step performs regex surgery
|
|
11
|
+
* on that rendered JS to rewrite `_ctx.<macroName>` →
|
|
12
|
+
* `_ctx.<alias>.<macroName>`, including sibling-macro references
|
|
13
|
+
* (the `(?!<allMacros>)` negative lookahead lifted from the native
|
|
14
|
+
* `lib/tags/import.js`).
|
|
15
|
+
*
|
|
16
|
+
* The regex surgery is swig-specific coupling on the exact JS source
|
|
17
|
+
* shape a Macro IR emits — it fails the flavor-invariant test and
|
|
18
|
+
* stays on `IRLegacyJS`. The tag returns a JS source string from
|
|
19
|
+
* `compile`; the backend lifts it into `IRLegacyJS` at emit time. When
|
|
20
|
+
* the macro-name → namespace rewrite moves into the emitter itself, the
|
|
21
|
+
* tag collapses to a dedicated `IRImport` node.
|
|
22
|
+
*
|
|
23
|
+
* Dynamic paths (`{% import dyn as ns %}`) and the plural
|
|
24
|
+
* `{% from "file" import a, b %}` shorthand are deferred to a later
|
|
25
|
+
* Twig-specific session.
|
|
26
|
+
*
|
|
27
|
+
* The alias is a bare identifier — dotted paths are rejected at parse
|
|
28
|
+
* time, and CVE-2023-25345 prototype-chain names are rejected before
|
|
29
|
+
* the namespace assignment. Lexer-folded-path bail per
|
|
30
|
+
* `.claude/conventions.md § Lexer-folded-path bail`: single-name
|
|
31
|
+
* binding slots reject any `.` in the match before the
|
|
32
|
+
* `_dangerousProps` check.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
36
|
+
var backend = require('@rhinostone/swig-core/lib/backend');
|
|
37
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
38
|
+
|
|
39
|
+
var lexer = require('../lexer');
|
|
40
|
+
var _t = require('../tokentypes');
|
|
41
|
+
|
|
42
|
+
exports.ends = false;
|
|
43
|
+
exports.block = true;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse the `{% import %}` tag body. Extracts the STRING literal path,
|
|
47
|
+
* the `as` keyword, and the bare-identifier alias; validates the alias
|
|
48
|
+
* against the bare-identifier rule and the CVE-2023-25345
|
|
49
|
+
* `_dangerousProps` blocklist.
|
|
50
|
+
*
|
|
51
|
+
* Walks the imported template's token list (via `swig.parseFile`) for
|
|
52
|
+
* `{% macro %}` tokens; for each macro, invokes its `compile` to get
|
|
53
|
+
* the IRMacro node and renders that node to JS through
|
|
54
|
+
* `backend.compile`. The resulting `{compiled, name}` objects + the
|
|
55
|
+
* alias string are stashed on `token.args` — `exports.compile` pops
|
|
56
|
+
* the alias off the tail and performs the namespace-prefix rewrite on
|
|
57
|
+
* each macro's compiled JS.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} str Tag body.
|
|
60
|
+
* @param {number} line Source line of the opening `{%`.
|
|
61
|
+
* @param {object} parser The Twig parser module (unused — the body is
|
|
62
|
+
* lexed locally).
|
|
63
|
+
* @param {object} types Twig lexer token-type enum.
|
|
64
|
+
* @param {Array} stack Open-tag stack (unused — import has no body).
|
|
65
|
+
* @param {object} opts Per-call options. Honors `opts.filename` for
|
|
66
|
+
* `resolveFrom` + filename-aware throws.
|
|
67
|
+
* @param {object} swig Swig instance. Must expose `parseFile`.
|
|
68
|
+
* @param {object} token In-progress TagToken. `token.args` gets the
|
|
69
|
+
* `[{compiled, name}, ..., alias]` list.
|
|
70
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
71
|
+
*/
|
|
72
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
73
|
+
var tokens = lexer.read(utils.strip(str));
|
|
74
|
+
var pos = 0;
|
|
75
|
+
|
|
76
|
+
function peek() {
|
|
77
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
78
|
+
return pos < tokens.length ? tokens[pos] : null;
|
|
79
|
+
}
|
|
80
|
+
function consume() {
|
|
81
|
+
var t = peek();
|
|
82
|
+
if (t) { pos += 1; }
|
|
83
|
+
return t;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var pathTok = consume();
|
|
87
|
+
if (!pathTok) {
|
|
88
|
+
utils.throwError('Expected template path in "import" tag', line, opts.filename);
|
|
89
|
+
}
|
|
90
|
+
if (pathTok.type !== types.STRING) {
|
|
91
|
+
utils.throwError('Dynamic "import" is not supported — path must be a string literal', line, opts.filename);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var asTok = consume();
|
|
95
|
+
if (!asTok || asTok.type !== types.VAR || asTok.match !== 'as') {
|
|
96
|
+
utils.throwError('Expected "as" keyword after path in "import" tag', line, opts.filename);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var aliasTok = consume();
|
|
100
|
+
if (!aliasTok || aliasTok.type !== types.VAR) {
|
|
101
|
+
utils.throwError('Expected namespace alias after "as" in "import" tag', line, opts.filename);
|
|
102
|
+
}
|
|
103
|
+
if (aliasTok.match.indexOf('.') !== -1) {
|
|
104
|
+
utils.throwError('Import alias "' + aliasTok.match + '" must be a bare identifier in "import" tag', line, opts.filename);
|
|
105
|
+
}
|
|
106
|
+
if (_dangerousProps.indexOf(aliasTok.match) !== -1) {
|
|
107
|
+
utils.throwError('Unsafe import alias "' + aliasTok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (peek()) {
|
|
111
|
+
utils.throwError('Unexpected token "' + peek().match + '" after alias in "import" tag', line, opts.filename);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!swig || typeof swig.parseFile !== 'function') {
|
|
115
|
+
utils.throwError('"import" tag requires an engine context with a loader', line, opts.filename);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
var path = pathTok.match.replace(/^['"]|['"]$/g, '');
|
|
119
|
+
var parseOpts = { resolveFrom: opts.filename };
|
|
120
|
+
var compileOpts = utils.extend({}, opts, parseOpts);
|
|
121
|
+
var parsed = swig.parseFile(path, parseOpts);
|
|
122
|
+
var macros = [];
|
|
123
|
+
|
|
124
|
+
utils.each(parsed.tokens, function (tk) {
|
|
125
|
+
if (!tk || tk.name !== 'macro' || typeof tk.compile !== 'function') {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
var macroName = tk.args[0];
|
|
129
|
+
var macroIR = tk.compile(backend.compile, tk.args, tk.content, [], compileOpts);
|
|
130
|
+
var compiled = backend.compile([macroIR], [], compileOpts) + '\n';
|
|
131
|
+
macros.push({ compiled: compiled, name: macroName });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
token.args = macros.concat([aliasTok.match]);
|
|
135
|
+
return true;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Emit the namespace-prefix rewrite. Pops the alias off the tail of
|
|
140
|
+
* `args`, builds a `_ctx.<name>(\\W)(?!<allMacros>)` regex for each
|
|
141
|
+
* imported macro, and rewrites every occurrence in each macro's
|
|
142
|
+
* compiled JS to `_ctx.<alias>.<name>`. Concatenates the rewritten
|
|
143
|
+
* bodies after the `_ctx.<alias> = {};` namespace-init line and
|
|
144
|
+
* returns the result as a JS source string (the backend lifts it into
|
|
145
|
+
* `IRLegacyJS`).
|
|
146
|
+
*
|
|
147
|
+
* @return {string} JS source that initialises `_ctx.<alias>` and
|
|
148
|
+
* assigns every imported macro into it.
|
|
149
|
+
*/
|
|
150
|
+
exports.compile = function (compiler, args) {
|
|
151
|
+
var ctx = args.pop();
|
|
152
|
+
var allMacros = utils.map(args, function (arg) { return arg.name; }).join('|');
|
|
153
|
+
var out = '_ctx.' + ctx + ' = {};\n var _output = "";\n';
|
|
154
|
+
var replacements = utils.map(args, function (arg) {
|
|
155
|
+
return {
|
|
156
|
+
ex: new RegExp('_ctx\\.' + arg.name + '(\\W)(?!' + allMacros + ')', 'g'),
|
|
157
|
+
re: '_ctx.' + ctx + '.' + arg.name + '$1'
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
utils.each(args, function (arg) {
|
|
162
|
+
var c = arg.compiled;
|
|
163
|
+
utils.each(replacements, function (re) {
|
|
164
|
+
c = c.replace(re.ex, re.re);
|
|
165
|
+
});
|
|
166
|
+
out += c;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return out;
|
|
170
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 10 — Twig `{% include %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig include syntax:
|
|
5
|
+
*
|
|
6
|
+
* {% include "partial.twig" %}
|
|
7
|
+
* {% include "partial.twig" with ctx %}
|
|
8
|
+
* {% include "partial.twig" with ctx only %}
|
|
9
|
+
* {% include "partial.twig" ignore missing %}
|
|
10
|
+
* {% include "partial.twig" with ctx only ignore missing %}
|
|
11
|
+
* {% include dynamicPath %}
|
|
12
|
+
*
|
|
13
|
+
* Path is lowered through `parser.parseExpr`, so STRING literals, VAR
|
|
14
|
+
* references, member access, conditionals, and any other Twig
|
|
15
|
+
* expression all route through the same path. The context expression
|
|
16
|
+
* (after `with`) is likewise parsed via `parseExpr` — object literals,
|
|
17
|
+
* function results, ternaries all work.
|
|
18
|
+
*
|
|
19
|
+
* Three Twig-only keywords are recognised via a depth-tracked scan over
|
|
20
|
+
* the lexed token stream: `with`, `only`, `ignore missing`. Each is a
|
|
21
|
+
* bare VAR token at top-level paren/bracket/curly depth — nested
|
|
22
|
+
* occurrences inside expressions are left alone. `only` requires a
|
|
23
|
+
* preceding `with`; `ignore` must be followed by `missing`.
|
|
24
|
+
*
|
|
25
|
+
* The tag emits an `IRInclude` node. The backend's `Include` branch
|
|
26
|
+
* (packages/swig-core/lib/backend.js:310) owns the
|
|
27
|
+
* `_swig.compileFile(...)` + `resolveFrom` plumbing and the optional
|
|
28
|
+
* `try { ... } catch {}` wrapper that collapses missing-file errors to
|
|
29
|
+
* the empty string when `ignoreMissing` is set.
|
|
30
|
+
*
|
|
31
|
+
* `resolveFrom` is the template's own filename (backslash-escaped so
|
|
32
|
+
* Windows paths round-trip through the JSON-literal emit). The native
|
|
33
|
+
* parser's splitter passes it in as a trailing arg; Twig's
|
|
34
|
+
* tag-dispatch shape carries it via `opts.filename`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
38
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
39
|
+
|
|
40
|
+
var lexer = require('../lexer');
|
|
41
|
+
var _t = require('../tokentypes');
|
|
42
|
+
|
|
43
|
+
exports.ends = false;
|
|
44
|
+
exports.block = false;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse the `{% include %}` tag body. Extracts the path expression and
|
|
48
|
+
* the optional `with <ctx>` / `only` / `ignore missing` keyword
|
|
49
|
+
* markers, then lowers each expression slice through
|
|
50
|
+
* `parser.parseExpr`. The resulting IR is attached to `token.irExpr`.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} str Tag body.
|
|
53
|
+
* @param {number} line Source line of the opening `{%`.
|
|
54
|
+
* @param {object} parser The Twig parser module (exposes `parseExpr`).
|
|
55
|
+
* @param {object} types Twig lexer token-type enum.
|
|
56
|
+
* @param {Array} stack Open-tag stack (unused — include has no body).
|
|
57
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
58
|
+
* @param {object} swig Swig instance (unused — backend owns load).
|
|
59
|
+
* @param {object} token In-progress TagToken. `token.irExpr` gets
|
|
60
|
+
* the IRInclude node.
|
|
61
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
62
|
+
*/
|
|
63
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
64
|
+
var tokens = lexer.read(utils.strip(str));
|
|
65
|
+
|
|
66
|
+
var depth = 0;
|
|
67
|
+
var withIdx = -1;
|
|
68
|
+
var onlyIdx = -1;
|
|
69
|
+
var ignoreIdx = -1;
|
|
70
|
+
var missingIdx = -1;
|
|
71
|
+
var i, tk;
|
|
72
|
+
|
|
73
|
+
for (i = 0; i < tokens.length; i += 1) {
|
|
74
|
+
tk = tokens[i];
|
|
75
|
+
if (tk.type === types.PARENOPEN || tk.type === types.BRACKETOPEN ||
|
|
76
|
+
tk.type === types.CURLYOPEN || tk.type === types.FUNCTION) {
|
|
77
|
+
depth += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (tk.type === types.PARENCLOSE || tk.type === types.BRACKETCLOSE ||
|
|
81
|
+
tk.type === types.CURLYCLOSE) {
|
|
82
|
+
depth -= 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (depth !== 0) { continue; }
|
|
86
|
+
if (tk.type !== types.VAR) { continue; }
|
|
87
|
+
|
|
88
|
+
if (tk.match === 'with' && withIdx === -1 && missingIdx === -1) {
|
|
89
|
+
withIdx = i;
|
|
90
|
+
} else if (tk.match === 'only' && onlyIdx === -1) {
|
|
91
|
+
if (withIdx === -1) {
|
|
92
|
+
utils.throwError('"only" keyword in "include" tag requires a preceding "with"', line, opts.filename);
|
|
93
|
+
}
|
|
94
|
+
onlyIdx = i;
|
|
95
|
+
} else if (tk.match === 'ignore' && ignoreIdx === -1) {
|
|
96
|
+
ignoreIdx = i;
|
|
97
|
+
} else if (tk.match === 'missing' && ignoreIdx !== -1 && missingIdx === -1) {
|
|
98
|
+
missingIdx = i;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ignoreIdx !== -1 && missingIdx === -1) {
|
|
103
|
+
utils.throwError('"ignore" keyword in "include" tag must be followed by "missing"', line, opts.filename);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
var pathEnd = tokens.length;
|
|
107
|
+
if (withIdx !== -1 && withIdx < pathEnd) { pathEnd = withIdx; }
|
|
108
|
+
if (ignoreIdx !== -1 && ignoreIdx < pathEnd) { pathEnd = ignoreIdx; }
|
|
109
|
+
|
|
110
|
+
var pathTokens = sliceTrim(tokens, 0, pathEnd, types);
|
|
111
|
+
if (!pathTokens.length) {
|
|
112
|
+
utils.throwError('Expected template path in "include" tag', line, opts.filename);
|
|
113
|
+
}
|
|
114
|
+
var pathExpr = parser.parseExpr(pathTokens);
|
|
115
|
+
|
|
116
|
+
var ctxExpr;
|
|
117
|
+
if (withIdx !== -1) {
|
|
118
|
+
var ctxEnd = tokens.length;
|
|
119
|
+
if (onlyIdx !== -1 && onlyIdx > withIdx && onlyIdx < ctxEnd) { ctxEnd = onlyIdx; }
|
|
120
|
+
if (ignoreIdx !== -1 && ignoreIdx > withIdx && ignoreIdx < ctxEnd) { ctxEnd = ignoreIdx; }
|
|
121
|
+
var ctxTokens = sliceTrim(tokens, withIdx + 1, ctxEnd, types);
|
|
122
|
+
if (!ctxTokens.length) {
|
|
123
|
+
utils.throwError('Expected context expression after "with" in "include" tag', line, opts.filename);
|
|
124
|
+
}
|
|
125
|
+
ctxExpr = parser.parseExpr(ctxTokens);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var resolveFrom = (opts.filename || '').replace(/\\/g, '\\\\');
|
|
129
|
+
|
|
130
|
+
token.irExpr = ir.include(
|
|
131
|
+
pathExpr,
|
|
132
|
+
ctxExpr,
|
|
133
|
+
onlyIdx !== -1,
|
|
134
|
+
ignoreIdx !== -1,
|
|
135
|
+
resolveFrom
|
|
136
|
+
);
|
|
137
|
+
return true;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Strip WHITESPACE tokens from both ends of a slice range, returning a
|
|
142
|
+
* plain array. The rest of parser.parseExpr skips whitespace itself, so
|
|
143
|
+
* interior whitespace is harmless — but leading/trailing whitespace can
|
|
144
|
+
* produce a zero-length slice that parseExpr treats as "empty
|
|
145
|
+
* expression" without the explicit empty-check here.
|
|
146
|
+
*
|
|
147
|
+
* @param {object[]} tokens Token stream.
|
|
148
|
+
* @param {number} start Inclusive start index.
|
|
149
|
+
* @param {number} end Exclusive end index.
|
|
150
|
+
* @param {object} types Twig lexer token-type enum.
|
|
151
|
+
* @return {object[]} Trimmed slice.
|
|
152
|
+
* @private
|
|
153
|
+
*/
|
|
154
|
+
function sliceTrim(tokens, start, end, types) {
|
|
155
|
+
while (start < end && tokens[start].type === types.WHITESPACE) { start += 1; }
|
|
156
|
+
while (end > start && tokens[end - 1].type === types.WHITESPACE) { end -= 1; }
|
|
157
|
+
return tokens.slice(start, end);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Return the pre-built IRInclude node for the backend's splice-through
|
|
162
|
+
* path. The backend's `Include` branch owns all plumbing (path +
|
|
163
|
+
* context emission, isolated-vs-merged selector, resolveFrom, optional
|
|
164
|
+
* try/catch for ignoreMissing).
|
|
165
|
+
*
|
|
166
|
+
* @return {object} IRInclude node from `token.irExpr`.
|
|
167
|
+
*/
|
|
168
|
+
exports.compile = function (compiler, args, content, parents, options, blockName, token) {
|
|
169
|
+
return token.irExpr;
|
|
170
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 7 — Twig per-flavor tag registry.
|
|
3
|
+
*
|
|
4
|
+
* Each tag exports `{ parse, compile, ends, block }` with a Twig-tailored
|
|
5
|
+
* shape:
|
|
6
|
+
*
|
|
7
|
+
* parse(str, line, parser, types, stack, opts, swig, token) → boolean
|
|
8
|
+
*
|
|
9
|
+
* The 8th `token` argument is the in-progress TagToken. Tag implementations
|
|
10
|
+
* call `parser.parseExpr(lexer.read(str), filters)` directly and attach the
|
|
11
|
+
* resulting IRExpr to `token.irExpr`, then return true. This avoids the
|
|
12
|
+
* native-swig `parser.on(types.X, fn)` callback indirection — Twig tags own
|
|
13
|
+
* their own arg-parsing path.
|
|
14
|
+
*
|
|
15
|
+
* Session 7 begins with an empty registry; subsequent commits within the
|
|
16
|
+
* session add `set` (assignment) and `if` (flow control) to validate the
|
|
17
|
+
* per-flavor shape. Future sessions add `for`, `block`, `extends`,
|
|
18
|
+
* `include`, `import`, `macro`, `apply`, `verbatim`, `with`,
|
|
19
|
+
* `from … import`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
'set': require('./set'),
|
|
24
|
+
'if': require('./if'),
|
|
25
|
+
'for': require('./for'),
|
|
26
|
+
'block': require('./block'),
|
|
27
|
+
'extends': require('./extends'),
|
|
28
|
+
'include': require('./include'),
|
|
29
|
+
'macro': require('./macro'),
|
|
30
|
+
'import': require('./import'),
|
|
31
|
+
'verbatim': require('./verbatim'),
|
|
32
|
+
'apply': require('./apply'),
|
|
33
|
+
'from': require('./from'),
|
|
34
|
+
'with': require('./with')
|
|
35
|
+
};
|