@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.
@@ -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
+ };