@rhinostone/swig-twig 2.0.0-alpha.3 → 2.0.0-alpha.5
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/index.js +183 -28
- package/lib/parser.js +21 -5
- package/lib/tests/index.js +109 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,66 +1,221 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @rhinostone/swig-twig — Twig frontend for the @rhinostone/swig family.
|
|
3
3
|
*
|
|
4
|
-
* Phase 3
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* parse time rather than registering through the runtime setTag
|
|
9
|
-
* extension point.
|
|
4
|
+
* Phase 3 Session 17: end-to-end render wiring (Path A). The package now
|
|
5
|
+
* exposes a Twig constructor + default instance via `engine.install(self,
|
|
6
|
+
* frontend)` from @rhinostone/swig-core, so callers can `render(source,
|
|
7
|
+
* locals)` / `renderFile(path, locals, cb)` directly against Twig syntax.
|
|
10
8
|
*
|
|
11
|
-
* See .claude/architecture/multi-flavor-ir.md § Phase 3 for the
|
|
12
|
-
*
|
|
9
|
+
* See .claude/architecture/multi-flavor-ir.md § Phase 3 for the per-flavor
|
|
10
|
+
* split decision.
|
|
13
11
|
*/
|
|
14
12
|
|
|
13
|
+
var utils = require('@rhinostone/swig-core/lib/utils'),
|
|
14
|
+
engine = require('@rhinostone/swig-core/lib/engine'),
|
|
15
|
+
loaders = require('@rhinostone/swig-core/lib/loaders'),
|
|
16
|
+
dateformatter = require('@rhinostone/swig-core/lib/dateformatter'),
|
|
17
|
+
parser = require('./parser'),
|
|
18
|
+
_tags = require('./tags'),
|
|
19
|
+
_filters = require('./filters'),
|
|
20
|
+
_tests = require('./tests');
|
|
21
|
+
|
|
15
22
|
exports.name = 'twig';
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Expression-level parser — Pratt-style recursive descent that consumes
|
|
19
26
|
* Twig lexer tokens and returns swig-core IRExpr nodes.
|
|
20
27
|
*
|
|
21
|
-
* Exposed here so callers can import it from the package entry-point;
|
|
22
|
-
* NOT wired into parse(source) yet (that still throws).
|
|
23
|
-
*
|
|
24
28
|
* @type {object}
|
|
25
29
|
*/
|
|
26
|
-
exports.parser =
|
|
30
|
+
exports.parser = parser;
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
|
-
* Built-in Twig tag registry.
|
|
33
|
+
* Built-in Twig tag registry.
|
|
30
34
|
*
|
|
31
35
|
* @type {object}
|
|
32
36
|
*/
|
|
33
|
-
exports.tags =
|
|
37
|
+
exports.tags = _tags;
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
* Built-in Twig filter catalog.
|
|
40
|
+
* Built-in Twig filter catalog.
|
|
37
41
|
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
* @type {object}
|
|
43
|
+
*/
|
|
44
|
+
exports.filters = _filters;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Template loaders re-exported from swig-core.
|
|
42
48
|
*
|
|
43
49
|
* @type {object}
|
|
44
50
|
*/
|
|
45
|
-
exports.
|
|
51
|
+
exports.loaders = loaders;
|
|
52
|
+
|
|
53
|
+
var defaultOptions = {
|
|
54
|
+
autoescape: true,
|
|
55
|
+
varControls: ['{{', '}}'],
|
|
56
|
+
tagControls: ['{%', '%}'],
|
|
57
|
+
cmtControls: ['{#', '#}'],
|
|
58
|
+
locals: {},
|
|
59
|
+
cache: 'memory',
|
|
60
|
+
loader: loaders.fs()
|
|
61
|
+
},
|
|
62
|
+
defaultInstance;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate the Twig options object.
|
|
66
|
+
*
|
|
67
|
+
* @param {?object} options Twig options object.
|
|
68
|
+
* @return {undefined} Throws on malformed input.
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
function validateOptions(options) {
|
|
72
|
+
if (!options) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
utils.each(['varControls', 'tagControls', 'cmtControls'], function (key) {
|
|
77
|
+
if (!options.hasOwnProperty(key)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!utils.isArray(options[key]) || options[key].length !== 2) {
|
|
81
|
+
throw new Error('Option "' + key + '" must be an array containing 2 different control strings.');
|
|
82
|
+
}
|
|
83
|
+
if (options[key][0] === options[key][1]) {
|
|
84
|
+
throw new Error('Option "' + key + '" open and close controls must not be the same.');
|
|
85
|
+
}
|
|
86
|
+
utils.each(options[key], function (a, i) {
|
|
87
|
+
if (a.length < 2) {
|
|
88
|
+
throw new Error('Option "' + key + '" ' + ((i) ? 'open ' : 'close ') + 'control must be at least 2 characters. Saw "' + a + '" instead.');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (options.hasOwnProperty('cache')) {
|
|
94
|
+
if (options.cache && options.cache !== 'memory') {
|
|
95
|
+
if (!options.cache.get || !options.cache.set) {
|
|
96
|
+
throw new Error('Invalid cache option ' + JSON.stringify(options.cache) + ' found. Expected "memory" or { get: function (key) { ... }, set: function (key, value) { ... } }.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (options.hasOwnProperty('loader')) {
|
|
101
|
+
if (options.loader) {
|
|
102
|
+
if (!options.loader.load || !options.loader.resolve) {
|
|
103
|
+
throw new Error('Invalid loader option ' + JSON.stringify(options.loader) + ' found. Expected { load: function (pathname, cb) { ... }, resolve: function (to, from) { ... } }.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Set defaults for the base and all new Twig environments.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} [options={}] Twig options object.
|
|
113
|
+
* @return {undefined}
|
|
114
|
+
*/
|
|
115
|
+
exports.setDefaults = function (options) {
|
|
116
|
+
validateOptions(options);
|
|
117
|
+
defaultInstance.options = utils.extend(defaultInstance.options, options);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Set the default TimeZone offset for date formatting via the date filter.
|
|
122
|
+
* Mutates the shared dateformatter's tzOffset — affects every frontend
|
|
123
|
+
* (native swig + swig-twig) because both consume the same module instance.
|
|
124
|
+
*
|
|
125
|
+
* @param {number} offset Offset from GMT, in minutes (west of GMT).
|
|
126
|
+
* @return {undefined}
|
|
127
|
+
*/
|
|
128
|
+
exports.setDefaultTZOffset = function (offset) {
|
|
129
|
+
dateformatter.tzOffset = offset;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a new, separate Twig compile/render environment.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* var twig = require('@rhinostone/swig-twig');
|
|
137
|
+
* var mytwig = new twig.Twig({ autoescape: false });
|
|
138
|
+
* mytwig.render('Hello {{ name }}', { locals: { name: 'world' }});
|
|
139
|
+
*
|
|
140
|
+
* @param {object} [opts={}] Twig options object.
|
|
141
|
+
* @return {object} New Twig environment.
|
|
142
|
+
*/
|
|
143
|
+
exports.Twig = function (opts) {
|
|
144
|
+
var self = this;
|
|
145
|
+
validateOptions(opts);
|
|
146
|
+
this.options = utils.extend({}, defaultOptions, opts || {});
|
|
147
|
+
this.cache = {};
|
|
148
|
+
this.extensions = {};
|
|
149
|
+
|
|
150
|
+
engine.install(this, {
|
|
151
|
+
parser: parser,
|
|
152
|
+
tags: _tags,
|
|
153
|
+
filters: _filters,
|
|
154
|
+
validateOptions: validateOptions,
|
|
155
|
+
onCompileError: function (err, options) {
|
|
156
|
+
utils.throwError(err, null, options.filename);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Register Twig `is <name>` runtime helpers as `_ext._test_<name>`. The
|
|
161
|
+
// parser lowers `foo is odd` to a two-segment VarRef + FnCall pointing
|
|
162
|
+
// at this slot, so the helpers must be installed on every Twig
|
|
163
|
+
// instance (including the default one). `setExtension` attaches to the
|
|
164
|
+
// per-instance `extensions` map — consumers can still override a test
|
|
165
|
+
// with their own `setExtension('_test_<name>', fn)` after construction.
|
|
166
|
+
utils.each(_tests, function (fn, name) {
|
|
167
|
+
self.setExtension('_test_' + name, fn);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/*!
|
|
172
|
+
* Export methods publicly via the default instance.
|
|
173
|
+
*/
|
|
174
|
+
defaultInstance = new exports.Twig();
|
|
175
|
+
exports.setFilter = defaultInstance.setFilter;
|
|
176
|
+
exports.setTag = defaultInstance.setTag;
|
|
177
|
+
exports.setExtension = defaultInstance.setExtension;
|
|
178
|
+
exports.parseFile = defaultInstance.parseFile;
|
|
179
|
+
exports.precompile = defaultInstance.precompile;
|
|
180
|
+
exports.compile = defaultInstance.compile;
|
|
181
|
+
exports.compileFile = defaultInstance.compileFile;
|
|
182
|
+
exports.render = defaultInstance.render;
|
|
183
|
+
exports.renderFile = defaultInstance.renderFile;
|
|
184
|
+
exports.run = defaultInstance.run;
|
|
185
|
+
exports.invalidateCache = defaultInstance.invalidateCache;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Express 3/4 compatibility alias.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* app.engine('twig', require('@rhinostone/swig-twig').__express);
|
|
192
|
+
* app.set('view engine', 'twig');
|
|
193
|
+
*/
|
|
194
|
+
exports.__express = defaultInstance.renderFile;
|
|
195
|
+
|
|
196
|
+
var _parseDeprecationWarned = false;
|
|
46
197
|
|
|
47
198
|
/**
|
|
48
199
|
* Parse a Twig source string into the parse-tree shape consumed by
|
|
49
200
|
* swig-core's `engine.compile`: `{ name, parent, tokens, blocks }`.
|
|
50
201
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
54
|
-
*
|
|
55
|
-
*
|
|
202
|
+
* @deprecated since 2.0.0-alpha.4 — use `new exports.Twig(opts)` and the
|
|
203
|
+
* per-instance `precompile` / `compile` / `render` surface installed by
|
|
204
|
+
* `engine.install`. Slated for removal in `2.0.0` stable. The full-instance
|
|
205
|
+
* path uses closure-captured tag/filter maps and honors `setFilter` /
|
|
206
|
+
* `setTag` overrides; this Path B wrapper does not.
|
|
56
207
|
*
|
|
57
208
|
* @param {string} source Twig template source.
|
|
58
|
-
* @param {object} [options] Per-call
|
|
59
|
-
* (`autoescape`, `varControls`, `tagControls`,
|
|
60
|
-
* `cmtControls`, `filename`, `tags`, `filters`).
|
|
209
|
+
* @param {object} [options] Per-call options.
|
|
61
210
|
* @return {object} `{ name, parent, tokens, blocks }`.
|
|
62
211
|
*/
|
|
63
212
|
exports.parse = function (source, options) {
|
|
213
|
+
if (!_parseDeprecationWarned) {
|
|
214
|
+
_parseDeprecationWarned = true;
|
|
215
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
216
|
+
console.warn('[@rhinostone/swig-twig] exports.parse is deprecated and will be removed in 2.0.0. Use `new twig.Twig(opts)` and the per-instance precompile/compile/render API instead.');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
64
219
|
options = options || {};
|
|
65
220
|
var tags = options.tags || exports.tags;
|
|
66
221
|
var filters = options.filters || exports.filters;
|
package/lib/parser.js
CHANGED
|
@@ -302,11 +302,11 @@ exports.parseExpr = function (tokens, filters, _posOut) {
|
|
|
302
302
|
var m;
|
|
303
303
|
switch (tok.type) {
|
|
304
304
|
case _t.STRING:
|
|
305
|
-
return ir.literal('string', unquoteString(tok.match));
|
|
305
|
+
return parsePostfix(ir.literal('string', unquoteString(tok.match)));
|
|
306
306
|
case _t.NUMBER:
|
|
307
|
-
return ir.literal('number', parseFloat(tok.match));
|
|
307
|
+
return parsePostfix(ir.literal('number', parseFloat(tok.match)));
|
|
308
308
|
case _t.BOOL:
|
|
309
|
-
return ir.literal('bool', tok.match === 'true');
|
|
309
|
+
return parsePostfix(ir.literal('bool', tok.match === 'true'));
|
|
310
310
|
case _t.NOT:
|
|
311
311
|
return ir.unaryOp('!', parseUnary());
|
|
312
312
|
case _t.OPERATOR:
|
|
@@ -371,13 +371,29 @@ exports.parseExpr = function (tokens, filters, _posOut) {
|
|
|
371
371
|
// parses as `(foo is defined) and bar`.
|
|
372
372
|
if (info.op === 'is' || info.op === 'is not') {
|
|
373
373
|
var test = parseTest();
|
|
374
|
-
var testCall
|
|
374
|
+
var testCall;
|
|
375
|
+
// VarRef subjects coerce null/undefined to "" through checkMatchExpr
|
|
376
|
+
// in emitVarRef, which loses the defined/undefined signal that
|
|
377
|
+
// `is defined` / `is null` need. Route both through IRVarRefExists
|
|
378
|
+
// (same shape as the `??` fallback from C3) so a path that is
|
|
379
|
+
// actually undefined or null yields the right boolean instead of
|
|
380
|
+
// being compared against the coerced "" placeholder. Non-VarRef
|
|
381
|
+
// subjects (Literal, BinaryOp, FnCall, FilterCall) evaluate to a
|
|
382
|
+
// concrete value with no coercion, so they fall through to the
|
|
383
|
+
// generic `_ext._test_<name>` helper path registered by the engine.
|
|
384
|
+
if (test.args.length === 0 && left.type === 'VarRef' && test.name === 'defined') {
|
|
385
|
+
testCall = ir.varRefExists(left.path, left.loc);
|
|
386
|
+
} else if (test.args.length === 0 && left.type === 'VarRef' && test.name === 'null') {
|
|
387
|
+
testCall = ir.unaryOp('!', ir.varRefExists(left.path, left.loc));
|
|
388
|
+
} else {
|
|
389
|
+
testCall = ir.fnCall(ir.varRef(['_ext', '_test_' + test.name]), [left].concat(test.args));
|
|
390
|
+
}
|
|
375
391
|
left = info.op === 'is not' ? ir.unaryOp('!', testCall) : testCall;
|
|
376
392
|
continue;
|
|
377
393
|
}
|
|
378
394
|
var right = parseExpression(info.prec + 1);
|
|
379
395
|
if (info.op === '..') {
|
|
380
|
-
left = ir.fnCall(ir.varRef(['
|
|
396
|
+
left = ir.fnCall(ir.varRef(['_utils', 'range']), [left, right]);
|
|
381
397
|
} else {
|
|
382
398
|
left = ir.binaryOp(info.op, left, right);
|
|
383
399
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rhinostone/swig-twig — built-in test runtime helpers.
|
|
3
|
+
*
|
|
4
|
+
* Twig `is <name>` / `is not <name>` expressions lower to
|
|
5
|
+
* `_ext._test_<name>(subject, ...args)` at the IR layer. The Twig
|
|
6
|
+
* constructor registers each export here via `self.setExtension('_test_'
|
|
7
|
+
* + name, fn)`, which installs the helper onto the per-instance
|
|
8
|
+
* `_swig.extensions` map — so Path A (`new Twig().render(...)`) honors
|
|
9
|
+
* per-instance overrides without leaking cross-instance.
|
|
10
|
+
*
|
|
11
|
+
* Two tests (`defined`, `null`) are additionally special-cased in the
|
|
12
|
+
* Twig parser when the subject is a VarRef with no args: both route
|
|
13
|
+
* through IRVarRefExists to preserve the defined/undefined signal that
|
|
14
|
+
* `emitVarRef` coerces to "". The helpers below still run for non-VarRef
|
|
15
|
+
* subjects (literals, BinaryOp, FnCall) where the coercion isn't in
|
|
16
|
+
* play. See packages/swig-twig/lib/parser.js `parseExpression` IS/ISNOT
|
|
17
|
+
* branch for the special-case logic.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function isNumber(v) {
|
|
21
|
+
return typeof v === 'number' && !isNaN(v);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `foo is defined` — true when the subject is not `undefined`. The
|
|
26
|
+
* VarRef-subject path bypasses this helper and uses IRVarRefExists.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} v
|
|
29
|
+
* @return {boolean}
|
|
30
|
+
*/
|
|
31
|
+
exports['defined'] = function (v) {
|
|
32
|
+
return typeof v !== 'undefined';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `foo is null` — true when the subject is `null` or `undefined`. The
|
|
37
|
+
* VarRef-subject path bypasses this helper and uses `!IRVarRefExists`.
|
|
38
|
+
*
|
|
39
|
+
* @param {*} v
|
|
40
|
+
* @return {boolean}
|
|
41
|
+
*/
|
|
42
|
+
exports['null'] = function (v) {
|
|
43
|
+
return v === null || typeof v === 'undefined';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `foo is empty` — true for `null`, `undefined`, `""`, empty arrays,
|
|
48
|
+
* and objects with no own-enumerable keys.
|
|
49
|
+
*
|
|
50
|
+
* @param {*} v
|
|
51
|
+
* @return {boolean}
|
|
52
|
+
*/
|
|
53
|
+
exports['empty'] = function (v) {
|
|
54
|
+
if (v === null || typeof v === 'undefined') { return true; }
|
|
55
|
+
if (typeof v === 'string') { return v.length === 0; }
|
|
56
|
+
if (Object.prototype.toString.call(v) === '[object Array]') { return v.length === 0; }
|
|
57
|
+
if (typeof v === 'object') {
|
|
58
|
+
for (var k in v) {
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(v, k)) { return false; }
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `foo is iterable` — true for arrays and non-null objects (mirrors
|
|
68
|
+
* Twig's rule that dicts iterate by key).
|
|
69
|
+
*
|
|
70
|
+
* @param {*} v
|
|
71
|
+
* @return {boolean}
|
|
72
|
+
*/
|
|
73
|
+
exports['iterable'] = function (v) {
|
|
74
|
+
if (v === null || typeof v === 'undefined') { return false; }
|
|
75
|
+
if (Object.prototype.toString.call(v) === '[object Array]') { return true; }
|
|
76
|
+
return typeof v === 'object';
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `n is odd` — true for numbers whose remainder mod 2 is non-zero.
|
|
81
|
+
*
|
|
82
|
+
* @param {number} v
|
|
83
|
+
* @return {boolean}
|
|
84
|
+
*/
|
|
85
|
+
exports['odd'] = function (v) {
|
|
86
|
+
return isNumber(v) && v % 2 !== 0;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `n is even` — true for numbers whose remainder mod 2 is zero.
|
|
91
|
+
*
|
|
92
|
+
* @param {number} v
|
|
93
|
+
* @return {boolean}
|
|
94
|
+
*/
|
|
95
|
+
exports['even'] = function (v) {
|
|
96
|
+
return isNumber(v) && v % 2 === 0;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `n is divisibleby(m)` — true when `m` is a non-zero number and `n % m
|
|
101
|
+
* === 0`. Twig's canonical name for the test.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} v
|
|
104
|
+
* @param {number} n
|
|
105
|
+
* @return {boolean}
|
|
106
|
+
*/
|
|
107
|
+
exports['divisibleby'] = function (v, n) {
|
|
108
|
+
return isNumber(v) && isNumber(n) && n !== 0 && v % n === 0;
|
|
109
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhinostone/swig-twig",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.5",
|
|
4
4
|
"description": "Twig frontend for the @rhinostone/swig-core template engine. Phase 3 of the multi-flavor architecture (see @rhinostone/swig #T16).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"template",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"node": ">=12"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@rhinostone/swig-core": "2.0.0-alpha.
|
|
25
|
+
"@rhinostone/swig-core": "2.0.0-alpha.5"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|