@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 CHANGED
@@ -1,66 +1,221 @@
1
1
  /**
2
2
  * @rhinostone/swig-twig — Twig frontend for the @rhinostone/swig family.
3
3
  *
4
- * Phase 3 scaffold. Subsequent commits add the Twig lexer + parser
5
- * (source IR), the Twig filter parity catalog, and the per-flavor
6
- * tag set. Source-to-IR lowering targets the swig-core IR schema
7
- * defined in @rhinostone/swig-core/lib/ir; built-in Twig tags lower at
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
- * per-flavor split decision and migration sequence.
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 = require('./parser');
30
+ exports.parser = parser;
27
31
 
28
32
  /**
29
- * Built-in Twig tag registry. See `./tags/index.js` for the per-tag shape.
33
+ * Built-in Twig tag registry.
30
34
  *
31
35
  * @type {object}
32
36
  */
33
- exports.tags = require('./tags');
37
+ exports.tags = _tags;
34
38
 
35
39
  /**
36
- * Built-in Twig filter catalog. See `./filters.js` for the per-filter shape.
40
+ * Built-in Twig filter catalog.
37
41
  *
38
- * Shipped as the Twig frontend's `_filters` runtime map and `setFilter`
39
- * mutation target via `engine.install(self, frontend)`. Filters marked
40
- * `.safe = true` suppress the autoescape `e` tail injected by
41
- * `parseVariable`. Only `raw` carries `.safe = true` this session.
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.filters = require('./filters');
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
- * Convenience wrapper around `exports.parser.parse(swig, source, options,
52
- * tags, filters)` defaults `tags` to the built-in Twig registry and
53
- * `filters` to an empty map. Callers wiring Twig as a frontend through
54
- * `engine.install(self, frontend)` should call `exports.parser.parse`
55
- * directly so the engine's own filter and tag maps flow through.
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 frontend options
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 = ir.fnCall(ir.varRef(['_test_' + test.name]), [left].concat(test.args));
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(['_range']), [left, right]);
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",
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.3"
25
+ "@rhinostone/swig-core": "2.0.0-alpha.5"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"