@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,149 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 10 — Twig `{% macro %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig macro syntax:
|
|
5
|
+
*
|
|
6
|
+
* {% macro name() %}…{% endmacro %}
|
|
7
|
+
* {% macro name(a, b, c) %}…{% endmacro %}
|
|
8
|
+
*
|
|
9
|
+
* Defines a reusable function bound to `_ctx.<name>`. Backend emits the
|
|
10
|
+
* full IIFE (`_utils.extend` snapshot, shadow-delete of param names from
|
|
11
|
+
* `_ctx`, body, restore) — see backend.js:236. Tag ships only semantic
|
|
12
|
+
* IR: name, params (`IRMacroParam[]`), body.
|
|
13
|
+
*
|
|
14
|
+
* Param names and the macro name are bare identifiers — dotted paths
|
|
15
|
+
* (`foo.bar`) and CVE-2023-25345 prototype-chain names (`__proto__`,
|
|
16
|
+
* `constructor`, `prototype`) are rejected at parse time. Lexer-folded
|
|
17
|
+
* dotted-path bail per `.claude/conventions.md § Lexer-folded-path bail`:
|
|
18
|
+
* single-name binding slots reject any `.` in the match before the
|
|
19
|
+
* `_dangerousProps` check.
|
|
20
|
+
*
|
|
21
|
+
* Twig kwargs (`{% macro foo(a=1, b="x") %}`) are deferred — Phase 4 with
|
|
22
|
+
* the rest of the Twig-specific surface.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
26
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
27
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
28
|
+
|
|
29
|
+
var lexer = require('../lexer');
|
|
30
|
+
var _t = require('../tokentypes');
|
|
31
|
+
|
|
32
|
+
exports.ends = true;
|
|
33
|
+
exports.block = true;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse the `{% macro %}` tag body. Extracts the macro name and the
|
|
37
|
+
* optional comma-separated parameter list. Both name and params are
|
|
38
|
+
* validated against the bare-identifier rule and the CVE-2023-25345
|
|
39
|
+
* `_dangerousProps` blocklist.
|
|
40
|
+
*
|
|
41
|
+
* Accepts both shapes:
|
|
42
|
+
* `name` + FUNCTION/FUNCTIONEMPTY (Twig idiomatic)
|
|
43
|
+
* `name(a, b)` lexed as FUNCTION token whose `match` is the name
|
|
44
|
+
*
|
|
45
|
+
* Stashes `[name, {name: p1}, {name: p2}, ...]` on `token.args`. Compile
|
|
46
|
+
* lifts the name off the head and passes the remaining param objects
|
|
47
|
+
* straight to `ir.macro` — the backend handles the IIFE.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} str Tag body.
|
|
50
|
+
* @param {number} line Source line of the opening `{%`.
|
|
51
|
+
* @param {object} parser The Twig parser module (unused — macro body is
|
|
52
|
+
* lexed locally).
|
|
53
|
+
* @param {object} types Twig lexer token-type enum.
|
|
54
|
+
* @param {Array} stack Open-tag stack (unused — parser.js manages push).
|
|
55
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
56
|
+
* @param {object} swig Swig instance (unused).
|
|
57
|
+
* @param {object} token In-progress TagToken. `token.args` gets the
|
|
58
|
+
* macro name + IRMacroParam objects.
|
|
59
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
60
|
+
*/
|
|
61
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
62
|
+
var tokens = lexer.read(utils.strip(str));
|
|
63
|
+
var pos = 0;
|
|
64
|
+
|
|
65
|
+
function peek() {
|
|
66
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
67
|
+
return pos < tokens.length ? tokens[pos] : null;
|
|
68
|
+
}
|
|
69
|
+
function consume() {
|
|
70
|
+
var t = peek();
|
|
71
|
+
if (t) { pos += 1; }
|
|
72
|
+
return t;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkName(name, role) {
|
|
76
|
+
if (name.indexOf('.') !== -1) {
|
|
77
|
+
utils.throwError(role + ' "' + name + '" must be a bare identifier in "macro" tag', line, opts.filename);
|
|
78
|
+
}
|
|
79
|
+
if (_dangerousProps.indexOf(name) !== -1) {
|
|
80
|
+
utils.throwError('Unsafe ' + role.toLowerCase() + ' "' + name + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var head = consume();
|
|
85
|
+
if (!head) {
|
|
86
|
+
utils.throwError('Expected macro name in "macro" tag', line, opts.filename);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var name;
|
|
90
|
+
var params = [];
|
|
91
|
+
|
|
92
|
+
if (head.type === types.FUNCTIONEMPTY) {
|
|
93
|
+
name = head.match;
|
|
94
|
+
checkName(name, 'Macro name');
|
|
95
|
+
} else if (head.type === types.FUNCTION) {
|
|
96
|
+
name = head.match;
|
|
97
|
+
checkName(name, 'Macro name');
|
|
98
|
+
var first = true;
|
|
99
|
+
while (true) {
|
|
100
|
+
var tk = peek();
|
|
101
|
+
if (!tk) {
|
|
102
|
+
utils.throwError('Unclosed parameter list in "macro" tag', line, opts.filename);
|
|
103
|
+
}
|
|
104
|
+
if (tk.type === types.PARENCLOSE) {
|
|
105
|
+
consume();
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (!first) {
|
|
109
|
+
if (tk.type !== types.COMMA) {
|
|
110
|
+
utils.throwError('Expected "," between parameters in "macro" tag', line, opts.filename);
|
|
111
|
+
}
|
|
112
|
+
consume();
|
|
113
|
+
}
|
|
114
|
+
first = false;
|
|
115
|
+
var pTok = consume();
|
|
116
|
+
if (!pTok || pTok.type !== types.VAR) {
|
|
117
|
+
utils.throwError('Expected parameter name in "macro" tag', line, opts.filename);
|
|
118
|
+
}
|
|
119
|
+
checkName(pTok.match, 'Parameter');
|
|
120
|
+
params.push(ir.macroParam(pTok.match));
|
|
121
|
+
}
|
|
122
|
+
} else if (head.type === types.VAR) {
|
|
123
|
+
name = head.match;
|
|
124
|
+
checkName(name, 'Macro name');
|
|
125
|
+
} else {
|
|
126
|
+
utils.throwError('Expected macro name in "macro" tag', line, opts.filename);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (peek()) {
|
|
130
|
+
utils.throwError('Unexpected token "' + peek().match + '" after macro signature in "macro" tag', line, opts.filename);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
token.args = [name].concat(params);
|
|
134
|
+
return true;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Emit an IRMacro node. Backend's `Macro` branch owns the `_ctx.<name>
|
|
139
|
+
* = function(...) { … }` IIFE + `_utils.extend` snapshot + shadow-delete
|
|
140
|
+
* of param names from `_ctx`.
|
|
141
|
+
*
|
|
142
|
+
* @return {object} IRMacro node.
|
|
143
|
+
*/
|
|
144
|
+
exports.compile = function (compiler, args, content, parents, options, blockName) {
|
|
145
|
+
var name = args[0];
|
|
146
|
+
var params = args.slice(1);
|
|
147
|
+
var bodyJS = compiler(content, parents, options, blockName);
|
|
148
|
+
return ir.macro(name, params, [ir.legacyJS(bodyJS)]);
|
|
149
|
+
};
|
package/lib/tags/set.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 7 — Twig `{% set %}` tag.
|
|
3
|
+
* Phase 3 Session 11 — extended with body-capture form.
|
|
4
|
+
*
|
|
5
|
+
* Twig `set` has two forms:
|
|
6
|
+
*
|
|
7
|
+
* Inline: {% set <lhs> <op> <rhs> %}
|
|
8
|
+
* Body: {% set <lhs> %}…{% endset %}
|
|
9
|
+
*
|
|
10
|
+
* lhs — a bare identifier or a pure-dot path (`foo`, `foo.bar.baz`).
|
|
11
|
+
* Bracket LHS (`foo[bar]`) is rejected at parse time — the
|
|
12
|
+
* bracket-lvalue contract is a cross-flavor design call and
|
|
13
|
+
* is deferred.
|
|
14
|
+
* op — any valid JS assignment operator (`=`, `+=`, `-=`, `*=`, `/=`).
|
|
15
|
+
* Inline form only.
|
|
16
|
+
* rhs — any Twig expression; parsed via `parser.parseExpr`.
|
|
17
|
+
* Inline form only.
|
|
18
|
+
*
|
|
19
|
+
* Inline form emits an IRSet node on `token.irExpr`:
|
|
20
|
+
*
|
|
21
|
+
* ir.set(ir.varRef(['foo', 'bar']), '=', <IRExpr value>)
|
|
22
|
+
*
|
|
23
|
+
* Body form captures the rendered content as a string via an IIFE and
|
|
24
|
+
* assigns it to the target. No dedicated IR factory — emits an
|
|
25
|
+
* IRLegacyJS fragment because the capture is a JS plumbing shape
|
|
26
|
+
* (IIFE over `_output`) that doesn't need its own IR surface.
|
|
27
|
+
*
|
|
28
|
+
* Static `exports.ends = true` is the default so the token's `ends`
|
|
29
|
+
* slot starts truthy — the body form keeps it, the inline form flips
|
|
30
|
+
* `token.ends = false` at parse time so the splitter does NOT push
|
|
31
|
+
* the inline-form token onto the open-tag stack.
|
|
32
|
+
*
|
|
33
|
+
* CVE-2023-25345 checkpoints apply twice — here on the LHS path
|
|
34
|
+
* segments, and again in the backend `checkDangerousSegment` walk —
|
|
35
|
+
* per the duplication invariant in .claude/security.md § _dangerousProps
|
|
36
|
+
* is duplicated across layers.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
40
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
41
|
+
var _dangerousProps = require('@rhinostone/swig-core/lib/security').dangerousProps;
|
|
42
|
+
|
|
43
|
+
var lexer = require('../lexer');
|
|
44
|
+
var _t = require('../tokentypes');
|
|
45
|
+
|
|
46
|
+
exports.ends = true;
|
|
47
|
+
exports.block = true;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse the `{% set %}` tag body and attach the appropriate IR to the
|
|
51
|
+
* token. Inline form sets `token.irExpr` and flips `token.ends = false`;
|
|
52
|
+
* body form leaves `token.ends = true` and stashes the target path on
|
|
53
|
+
* `token.args` for the compile step to pick up.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} str Tag body (everything between `{%` and `%}`, tag name stripped).
|
|
56
|
+
* @param {number} line Source line of the opening `{%`.
|
|
57
|
+
* @param {object} parser The Twig parser module (exposes `parseExpr`).
|
|
58
|
+
* @param {object} types Twig lexer token-type enum.
|
|
59
|
+
* @param {Array} stack Open-tag stack (unused — parser.js manages the push).
|
|
60
|
+
* @param {object} opts Per-call options (honors `opts.filename` for filename-aware throws).
|
|
61
|
+
* @param {object} swig Swig instance (unused).
|
|
62
|
+
* @param {object} token In-progress TagToken. Body form: `token.args` gets the path;
|
|
63
|
+
* `token.ends` stays true. Inline form: `token.irExpr` gets
|
|
64
|
+
* the IRSet; `token.ends` flips to false.
|
|
65
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
66
|
+
*/
|
|
67
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
68
|
+
var tokens = lexer.read(utils.strip(str));
|
|
69
|
+
var pos = 0;
|
|
70
|
+
|
|
71
|
+
function peek() {
|
|
72
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
73
|
+
return pos < tokens.length ? tokens[pos] : null;
|
|
74
|
+
}
|
|
75
|
+
function consume() {
|
|
76
|
+
var t = peek();
|
|
77
|
+
if (t) { pos += 1; }
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
var lhsTok = consume();
|
|
82
|
+
if (!lhsTok || lhsTok.type !== types.VAR) {
|
|
83
|
+
utils.throwError('Expected variable name in "set" tag', line, opts.filename);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var path = lhsTok.match.split('.');
|
|
87
|
+
utils.each(path, function (segment) {
|
|
88
|
+
if (_dangerousProps.indexOf(segment) !== -1) {
|
|
89
|
+
utils.throwError('Unsafe assignment to "' + segment + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// DOTKEY tail — the Twig lexer already folds dotted paths into the
|
|
94
|
+
// VAR match, but a defensive DOTKEY consumer here keeps this tag
|
|
95
|
+
// robust if a future lexer tightening splits `foo.bar` into VAR + DOTKEY.
|
|
96
|
+
while (peek() && peek().type === types.DOTKEY) {
|
|
97
|
+
var dk = consume();
|
|
98
|
+
if (_dangerousProps.indexOf(dk.match) !== -1) {
|
|
99
|
+
utils.throwError('Unsafe assignment to "' + dk.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
|
|
100
|
+
}
|
|
101
|
+
path.push(dk.match);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var next = peek();
|
|
105
|
+
if (next && next.type === types.BRACKETOPEN) {
|
|
106
|
+
utils.throwError('Bracket-notation assignment is not supported in "set" (use dot-path notation)', line, opts.filename);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!next) {
|
|
110
|
+
// Body-capture form — no more tokens after the LHS. Keep
|
|
111
|
+
// `token.ends = true` (the default from `exports.ends`) so the
|
|
112
|
+
// splitter pushes the token onto the open-tag stack and waits
|
|
113
|
+
// for a matching `{% endset %}`. Stash the path on `token.args`
|
|
114
|
+
// for the compile handler to consume.
|
|
115
|
+
token.args = path;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Inline form — flip `token.ends = false` so the splitter does NOT
|
|
120
|
+
// push the token onto the open-tag stack.
|
|
121
|
+
token.ends = false;
|
|
122
|
+
|
|
123
|
+
var opTok = consume();
|
|
124
|
+
if (opTok.type !== types.ASSIGNMENT) {
|
|
125
|
+
utils.throwError('Expected assignment operator in "set" tag', line, opts.filename);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Skip leading whitespace between `=` and the RHS expression so the
|
|
129
|
+
// RHS slice starts at the first meaningful token. parseExpr tolerates
|
|
130
|
+
// leading whitespace internally, but trimming here keeps the slice
|
|
131
|
+
// shape predictable for future callers.
|
|
132
|
+
while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
|
|
133
|
+
|
|
134
|
+
var rhsTokens = tokens.slice(pos);
|
|
135
|
+
if (!rhsTokens.length) {
|
|
136
|
+
utils.throwError('Expected expression after assignment in "set" tag', line, opts.filename);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var value = parser.parseExpr(rhsTokens);
|
|
140
|
+
token.irExpr = ir.set(ir.varRef(path), opTok.match, value);
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Emit the IR for either the inline-assignment or body-capture form.
|
|
146
|
+
* Inline form returns the pre-built IRSet via `token.irExpr`. Body form
|
|
147
|
+
* compiles the captured content and wraps it in an IIFE assigned to
|
|
148
|
+
* the target.
|
|
149
|
+
*
|
|
150
|
+
* @param {Function} compiler Backend walker (recurses into `content`).
|
|
151
|
+
* @param {Array} args Target path segments (body form only).
|
|
152
|
+
* @param {Array} content Child tokens captured between `{% set %}`
|
|
153
|
+
* and `{% endset %}` (body form only).
|
|
154
|
+
* @param {Array} parents Parent template chain (passed through).
|
|
155
|
+
* @param {object} options Compile options (passed through).
|
|
156
|
+
* @param {?string} blockName Enclosing block name (passed through).
|
|
157
|
+
* @param {object} token The tag token. `token.irExpr` is set
|
|
158
|
+
* for inline form only.
|
|
159
|
+
* @return {object} IRSet (inline) or IRLegacyJS (body).
|
|
160
|
+
*/
|
|
161
|
+
exports.compile = function (compiler, args, content, parents, options, blockName, token) {
|
|
162
|
+
if (token.irExpr) {
|
|
163
|
+
return token.irExpr;
|
|
164
|
+
}
|
|
165
|
+
var path = args;
|
|
166
|
+
var bodyJS = compiler(content, parents, options, blockName);
|
|
167
|
+
return ir.legacyJS(
|
|
168
|
+
'_ctx.' + path.join('.') + ' = (function () {\n' +
|
|
169
|
+
' var _output = "";\n' +
|
|
170
|
+
bodyJS +
|
|
171
|
+
' return _output;\n' +
|
|
172
|
+
'})();\n'
|
|
173
|
+
);
|
|
174
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 11 — Twig `{% verbatim %}…{% endverbatim %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Preserves arbitrary template-like content as literal output. Inside
|
|
5
|
+
* a verbatim block, `{{ … }}`, `{% … %}` (other than `{% endverbatim %}`),
|
|
6
|
+
* and `{# … #}` are NOT parsed — the splitter in `parser.js` flips an
|
|
7
|
+
* `inVerbatim` flag that bypasses the variable/tag/comment branches and
|
|
8
|
+
* wraps each chunk as `ir.text`, so the content array handed to this
|
|
9
|
+
* tag's compile is already a list of IRText nodes.
|
|
10
|
+
*
|
|
11
|
+
* Takes no arguments. Extra tokens after `verbatim` are rejected at
|
|
12
|
+
* parse time with a filename-aware throw.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reject any tokens after the `verbatim` keyword. The splitter owns
|
|
19
|
+
* all content-capture behaviour via its `inVerbatim` flag, so this
|
|
20
|
+
* handler only has to validate the tag's own argument list (which
|
|
21
|
+
* must be empty).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} str Tag body (everything after `verbatim`).
|
|
24
|
+
* @param {number} line Source line of the opening `{%`.
|
|
25
|
+
* @param {object} parser The Twig parser module (unused).
|
|
26
|
+
* @param {object} types Twig lexer token-type enum (unused).
|
|
27
|
+
* @param {Array} stack Open-tag stack (parser.js manages the push).
|
|
28
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
29
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
30
|
+
*/
|
|
31
|
+
exports.parse = function (str, line, parser, types, stack, opts) {
|
|
32
|
+
var stripped = utils.strip(str || '');
|
|
33
|
+
if (stripped.length > 0) {
|
|
34
|
+
utils.throwError('Unexpected token "' + stripped + '" after "verbatim"', line, opts.filename);
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Return the captured content array unchanged. Each item is already
|
|
41
|
+
* an IRText node (or another pre-built IR node that the backend will
|
|
42
|
+
* splice through), so the backend's emit loop can iterate and emit
|
|
43
|
+
* without any further wrapping.
|
|
44
|
+
*
|
|
45
|
+
* @return {Array} Content node list.
|
|
46
|
+
*/
|
|
47
|
+
exports.compile = function (compiler, args, content) {
|
|
48
|
+
return content;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
exports.ends = true;
|
|
52
|
+
exports.block = false;
|
package/lib/tags/with.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Phase 3 Session 12 — Twig `{% with %}` tag.
|
|
3
|
+
*
|
|
4
|
+
* Twig scoped-context region:
|
|
5
|
+
*
|
|
6
|
+
* {% with %}…{% endwith %} (shallow copy of _ctx)
|
|
7
|
+
* {% with <ctx> %}…{% endwith %} (merge ctx into _ctx)
|
|
8
|
+
* {% with <ctx> only %}…{% endwith %} (isolated, ctx is context)
|
|
9
|
+
* {% with only %}…{% endwith %} (isolated, empty context)
|
|
10
|
+
*
|
|
11
|
+
* The context expression (when present) is lowered through
|
|
12
|
+
* `parser.parseExpr`, so object literals, variable references,
|
|
13
|
+
* conditionals, function calls — any Twig expression — all route
|
|
14
|
+
* through the same path.
|
|
15
|
+
*
|
|
16
|
+
* The `only` keyword is recognised as a bare VAR token at top-level
|
|
17
|
+
* paren/bracket/curly/function depth. Depth tracking prevents a nested
|
|
18
|
+
* `only` inside the context expression (unlikely but possible) from
|
|
19
|
+
* being mistaken for the keyword.
|
|
20
|
+
*
|
|
21
|
+
* The tag emits an `IRWith` node. The backend's `With` branch
|
|
22
|
+
* (packages/swig-core/lib/backend.js) owns the IIFE scaffolding that
|
|
23
|
+
* shadows `_ctx` for the body's lexical scope while letting `_output`
|
|
24
|
+
* stay in the outer scope via closure capture.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
var ir = require('@rhinostone/swig-core/lib/ir');
|
|
28
|
+
var utils = require('@rhinostone/swig-core/lib/utils');
|
|
29
|
+
|
|
30
|
+
var lexer = require('../lexer');
|
|
31
|
+
var _t = require('../tokentypes');
|
|
32
|
+
|
|
33
|
+
exports.ends = true;
|
|
34
|
+
exports.block = false;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the `{% with %}` tag body. Extracts the optional context
|
|
38
|
+
* expression and the optional `only` keyword marker, lowers the
|
|
39
|
+
* context slice through `parser.parseExpr`, and stashes the result on
|
|
40
|
+
* `token.irExpr` along with the `isolated` flag on `token.args`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} str Tag body.
|
|
43
|
+
* @param {number} line Source line of the opening `{%`.
|
|
44
|
+
* @param {object} parser The Twig parser module (exposes `parseExpr`).
|
|
45
|
+
* @param {object} types Twig lexer token-type enum.
|
|
46
|
+
* @param {Array} stack Open-tag stack (parser.js manages the push).
|
|
47
|
+
* @param {object} opts Per-call options (honors `opts.filename`).
|
|
48
|
+
* @param {object} swig Swig instance (unused).
|
|
49
|
+
* @param {object} token In-progress TagToken.
|
|
50
|
+
* @return {boolean} Always `true` on success. Throws otherwise.
|
|
51
|
+
*/
|
|
52
|
+
exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
|
|
53
|
+
var tokens = lexer.read(utils.strip(str));
|
|
54
|
+
|
|
55
|
+
var depth = 0;
|
|
56
|
+
var onlyIdx = -1;
|
|
57
|
+
var i, tk;
|
|
58
|
+
|
|
59
|
+
for (i = 0; i < tokens.length; i += 1) {
|
|
60
|
+
tk = tokens[i];
|
|
61
|
+
if (tk.type === types.PARENOPEN || tk.type === types.BRACKETOPEN ||
|
|
62
|
+
tk.type === types.CURLYOPEN || tk.type === types.FUNCTION) {
|
|
63
|
+
depth += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (tk.type === types.PARENCLOSE || tk.type === types.BRACKETCLOSE ||
|
|
67
|
+
tk.type === types.CURLYCLOSE) {
|
|
68
|
+
depth -= 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (depth !== 0) { continue; }
|
|
72
|
+
if (tk.type !== types.VAR) { continue; }
|
|
73
|
+
|
|
74
|
+
if (tk.match === 'only' && onlyIdx === -1) {
|
|
75
|
+
onlyIdx = i;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var ctxEnd = (onlyIdx !== -1) ? onlyIdx : tokens.length;
|
|
80
|
+
var ctxTokens = sliceTrim(tokens, 0, ctxEnd, types);
|
|
81
|
+
|
|
82
|
+
var ctxExpr;
|
|
83
|
+
if (ctxTokens.length) {
|
|
84
|
+
ctxExpr = parser.parseExpr(ctxTokens);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Trailing tokens after `only` are not allowed — `{% with ctx only extra %}`
|
|
88
|
+
// is ambiguous (is `extra` a second context slot? a stray keyword?).
|
|
89
|
+
if (onlyIdx !== -1) {
|
|
90
|
+
var tail = sliceTrim(tokens, onlyIdx + 1, tokens.length, types);
|
|
91
|
+
if (tail.length) {
|
|
92
|
+
utils.throwError('Unexpected tokens after "only" in "with" tag', line, opts.filename);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
token.args = [!!(onlyIdx !== -1)];
|
|
97
|
+
token.irExpr = ctxExpr;
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Strip WHITESPACE tokens from both ends of a slice range, returning a
|
|
103
|
+
* plain array. Parser.parseExpr skips whitespace in the interior, but
|
|
104
|
+
* leading/trailing whitespace produces a zero-length effective slice
|
|
105
|
+
* that parseExpr cannot classify; the explicit trim keeps the empty-
|
|
106
|
+
* context detection (`ctxTokens.length === 0`) honest.
|
|
107
|
+
*
|
|
108
|
+
* @param {object[]} tokens Token stream.
|
|
109
|
+
* @param {number} start Inclusive start index.
|
|
110
|
+
* @param {number} end Exclusive end index.
|
|
111
|
+
* @param {object} types Twig lexer token-type enum.
|
|
112
|
+
* @return {object[]} Trimmed slice.
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
function sliceTrim(tokens, start, end, types) {
|
|
116
|
+
while (start < end && tokens[start].type === types.WHITESPACE) { start += 1; }
|
|
117
|
+
while (end > start && tokens[end - 1].type === types.WHITESPACE) { end -= 1; }
|
|
118
|
+
return tokens.slice(start, end);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Emit an IRWith node carrying the optional context IRExpr, the
|
|
123
|
+
* `isolated` flag, and the recursively-compiled body wrapped in
|
|
124
|
+
* IRLegacyJS. The backend's `With` branch owns the IIFE-shadow of
|
|
125
|
+
* `_ctx` for the body's lexical scope.
|
|
126
|
+
*
|
|
127
|
+
* @return {object} IRWith node.
|
|
128
|
+
*/
|
|
129
|
+
exports.compile = function (compiler, args, content, parents, options, blockName, token) {
|
|
130
|
+
var isolated = !!args[0];
|
|
131
|
+
var bodyJS = compiler(content, parents, options, blockName);
|
|
132
|
+
return ir.withStmt(token.irExpr, isolated, [ir.legacyJS(bodyJS)]);
|
|
133
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twig lexer token type enum — the contract between the Twig lexer and
|
|
3
|
+
* the Twig parser in @rhinostone/swig-twig.
|
|
4
|
+
*
|
|
5
|
+
* Numeric IDs in the shared range (0–25, 100) mirror
|
|
6
|
+
* @rhinostone/swig-core/lib/tokentypes by design: Twig and native Swig
|
|
7
|
+
* lower to the same swig-core IR, and aligning the IDs keeps shared
|
|
8
|
+
* consumers (e.g. backend.compile splice-through paths, CVE-2023-25345
|
|
9
|
+
* `_dangerousProps` enforcement) flavor-agnostic. The Twig parser is
|
|
10
|
+
* its own module — it does not inherit from swig-core's TokenParser —
|
|
11
|
+
* but the cognitive overhead of re-mapping IDs across flavors is not
|
|
12
|
+
* worth the freedom.
|
|
13
|
+
*
|
|
14
|
+
* Twig-only IDs (30–37) are reserved here so Session 3 can add lexer
|
|
15
|
+
* rules without renumbering. Keeping the layout stable up front avoids
|
|
16
|
+
* silent ID collisions across in-flight flavor work.
|
|
17
|
+
*
|
|
18
|
+
* See .claude/architecture/multi-flavor-ir.md § Phase 3 for the
|
|
19
|
+
* per-flavor split decision.
|
|
20
|
+
*
|
|
21
|
+
* @readonly
|
|
22
|
+
* @enum {number}
|
|
23
|
+
*/
|
|
24
|
+
module.exports = {
|
|
25
|
+
/** Whitespace */
|
|
26
|
+
WHITESPACE: 0,
|
|
27
|
+
/** Plain string literal */
|
|
28
|
+
STRING: 1,
|
|
29
|
+
/** Variable filter call with arguments — `|name(...)` */
|
|
30
|
+
FILTER: 2,
|
|
31
|
+
/** Variable filter call with no arguments — `|name` */
|
|
32
|
+
FILTEREMPTY: 3,
|
|
33
|
+
/** Function call with arguments — `name(...)` */
|
|
34
|
+
FUNCTION: 4,
|
|
35
|
+
/** Function call with no arguments — `name()` */
|
|
36
|
+
FUNCTIONEMPTY: 5,
|
|
37
|
+
/** Open parenthesis */
|
|
38
|
+
PARENOPEN: 6,
|
|
39
|
+
/** Close parenthesis */
|
|
40
|
+
PARENCLOSE: 7,
|
|
41
|
+
/** Comma */
|
|
42
|
+
COMMA: 8,
|
|
43
|
+
/** Variable identifier */
|
|
44
|
+
VAR: 9,
|
|
45
|
+
/** Numeric literal */
|
|
46
|
+
NUMBER: 10,
|
|
47
|
+
/** Math operator (+, -, *, /, %) */
|
|
48
|
+
OPERATOR: 11,
|
|
49
|
+
/** Open square bracket */
|
|
50
|
+
BRACKETOPEN: 12,
|
|
51
|
+
/** Close square bracket */
|
|
52
|
+
BRACKETCLOSE: 13,
|
|
53
|
+
/** Dot-key accessor — `.key` */
|
|
54
|
+
DOTKEY: 14,
|
|
55
|
+
/** Open square bracket at the start of an array literal */
|
|
56
|
+
ARRAYOPEN: 15,
|
|
57
|
+
/** Open curly brace */
|
|
58
|
+
CURLYOPEN: 17,
|
|
59
|
+
/** Close curly brace */
|
|
60
|
+
CURLYCLOSE: 18,
|
|
61
|
+
/** Colon — object literal key/value separator */
|
|
62
|
+
COLON: 19,
|
|
63
|
+
/** JavaScript-valid comparator (==, !=, <=, etc.) */
|
|
64
|
+
COMPARATOR: 20,
|
|
65
|
+
/** Boolean logic (`and`, `or`, `&&`, `||`) */
|
|
66
|
+
LOGIC: 21,
|
|
67
|
+
/** Boolean negation (`not`, `!`) */
|
|
68
|
+
NOT: 22,
|
|
69
|
+
/** Boolean literal (`true`, `false`) */
|
|
70
|
+
BOOL: 23,
|
|
71
|
+
/** Variable assignment (`=`, `+=`, `-=`, `*=`, `/=`) */
|
|
72
|
+
ASSIGNMENT: 24,
|
|
73
|
+
/** Method call open — internal */
|
|
74
|
+
METHODOPEN: 25,
|
|
75
|
+
|
|
76
|
+
/* ---- Twig-only token IDs (reserved; rules land in Session 3+) ---- */
|
|
77
|
+
|
|
78
|
+
/** Twig string-concatenation operator — `~` */
|
|
79
|
+
TILDE: 30,
|
|
80
|
+
/** Twig range operator — `..` */
|
|
81
|
+
RANGE: 31,
|
|
82
|
+
/** Twig test operator — `is` */
|
|
83
|
+
IS: 32,
|
|
84
|
+
/** Twig negated test operator — `is not` */
|
|
85
|
+
ISNOT: 33,
|
|
86
|
+
/** Twig shorthand ternary — `?:` */
|
|
87
|
+
QMARK: 34,
|
|
88
|
+
/** Twig null-coalescing operator — `??` */
|
|
89
|
+
NULLCOALESCE: 35,
|
|
90
|
+
/** Twig string-interpolation open — `#{` inside double-quoted strings */
|
|
91
|
+
INTERP_OPEN: 36,
|
|
92
|
+
/** Twig string-interpolation close — `}` matching `#{` */
|
|
93
|
+
INTERP_CLOSE: 37,
|
|
94
|
+
|
|
95
|
+
/** Unknown token */
|
|
96
|
+
UNKNOWN: 100
|
|
97
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rhinostone/swig-twig",
|
|
3
|
+
"version": "2.0.0-alpha.3",
|
|
4
|
+
"description": "Twig frontend for the @rhinostone/swig-core template engine. Phase 3 of the multi-flavor architecture (see @rhinostone/swig #T16).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"template",
|
|
7
|
+
"templating",
|
|
8
|
+
"twig",
|
|
9
|
+
"swig",
|
|
10
|
+
"swig-twig",
|
|
11
|
+
"frontend"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/gina-io/swig.git",
|
|
16
|
+
"directory": "packages/swig-twig"
|
|
17
|
+
},
|
|
18
|
+
"author": "Rhinostone <contact@gina.io>",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"main": "lib/index.js",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=12"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@rhinostone/swig-core": "2.0.0-alpha.3"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|