@rhinostone/swig-jinja2 2.5.0

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 ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * @rhinostone/swig-jinja2 — Jinja2 frontend for the @rhinostone/swig family.
3
+ *
4
+ * End-to-end render wiring (Path A): the package exposes a Jinja2
5
+ * constructor + default instance via `engine.install(self, frontend)` from
6
+ * @rhinostone/swig-core, so callers can `render(source, locals)` /
7
+ * `renderFile(path, locals, cb)` directly against Jinja2 syntax.
8
+ */
9
+
10
+ var utils = require('@rhinostone/swig-core/lib/utils'),
11
+ engine = require('@rhinostone/swig-core/lib/engine'),
12
+ loaders = require('@rhinostone/swig-core/lib/loaders'),
13
+ dateformatter = require('@rhinostone/swig-core/lib/dateformatter'),
14
+ parser = require('./parser'),
15
+ _tags = require('./tags'),
16
+ _filters = require('./filters'),
17
+ _tests = require('./tests'),
18
+ preWalker = require('./async/pre-walker');
19
+
20
+ exports.name = 'jinja2';
21
+
22
+ /**
23
+ * Expression-level parser — Pratt-style recursive descent that consumes
24
+ * Jinja2 lexer tokens and returns swig-core IRExpr nodes, plus the
25
+ * top-level `parse(swig, source, opts, tags, filters)` splitter.
26
+ *
27
+ * @type {object}
28
+ */
29
+ exports.parser = parser;
30
+
31
+ /**
32
+ * Built-in Jinja2 tag registry.
33
+ *
34
+ * @type {object}
35
+ */
36
+ exports.tags = _tags;
37
+
38
+ /**
39
+ * Built-in Jinja2 filter catalog.
40
+ *
41
+ * @type {object}
42
+ */
43
+ exports.filters = _filters;
44
+
45
+ /**
46
+ * Template loaders re-exported from swig-core.
47
+ *
48
+ * @type {object}
49
+ */
50
+ exports.loaders = loaders;
51
+
52
+ var defaultOptions = {
53
+ autoescape: true,
54
+ varControls: ['{{', '}}'],
55
+ tagControls: ['{%', '%}'],
56
+ cmtControls: ['{#', '#}'],
57
+ locals: {},
58
+ cache: 'memory',
59
+ loader: loaders.fs()
60
+ },
61
+ defaultInstance;
62
+
63
+ /**
64
+ * Validate the Jinja2 options object.
65
+ *
66
+ * @param {?object} options Jinja2 options object.
67
+ * @return {undefined} Throws on malformed input.
68
+ * @private
69
+ */
70
+ function validateOptions(options) {
71
+ if (!options) {
72
+ return;
73
+ }
74
+
75
+ utils.each(['varControls', 'tagControls', 'cmtControls'], function (key) {
76
+ if (!options.hasOwnProperty(key)) {
77
+ return;
78
+ }
79
+ if (!utils.isArray(options[key]) || options[key].length !== 2) {
80
+ throw new Error('Option "' + key + '" must be an array containing 2 different control strings.');
81
+ }
82
+ if (options[key][0] === options[key][1]) {
83
+ throw new Error('Option "' + key + '" open and close controls must not be the same.');
84
+ }
85
+ utils.each(options[key], function (a, i) {
86
+ if (a.length < 2) {
87
+ throw new Error('Option "' + key + '" ' + ((i) ? 'open ' : 'close ') + 'control must be at least 2 characters. Saw "' + a + '" instead.');
88
+ }
89
+ });
90
+ });
91
+
92
+ if (options.hasOwnProperty('cache')) {
93
+ if (options.cache && options.cache !== 'memory') {
94
+ if (!options.cache.get || !options.cache.set) {
95
+ throw new Error('Invalid cache option ' + JSON.stringify(options.cache) + ' found. Expected "memory" or { get: function (key) { ... }, set: function (key, value) { ... } }.');
96
+ }
97
+ }
98
+ }
99
+ if (options.hasOwnProperty('loader')) {
100
+ if (options.loader) {
101
+ if (!options.loader.load || !options.loader.resolve) {
102
+ throw new Error('Invalid loader option ' + JSON.stringify(options.loader) + ' found. Expected { load: function (pathname, cb) { ... }, resolve: function (to, from) { ... } }.');
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Set defaults for the base and all new Jinja2 environments.
110
+ *
111
+ * @param {object} [options={}] Jinja2 options object.
112
+ * @return {undefined}
113
+ */
114
+ exports.setDefaults = function (options) {
115
+ validateOptions(options);
116
+ defaultInstance.options = utils.extend(defaultInstance.options, options);
117
+ };
118
+
119
+ /**
120
+ * Set the default TimeZone offset for date formatting via the date filter.
121
+ * Mutates the shared dateformatter's tzOffset — affects every frontend
122
+ * (native swig + swig-jinja2) because both consume the same module instance.
123
+ *
124
+ * @param {number} offset Offset from GMT, in minutes (west of GMT).
125
+ * @return {undefined}
126
+ */
127
+ exports.setDefaultTZOffset = function (offset) {
128
+ dateformatter.tzOffset = offset;
129
+ };
130
+
131
+ /**
132
+ * Create a new, separate Jinja2 compile/render environment.
133
+ *
134
+ * @example
135
+ * var jinja2 = require('@rhinostone/swig-jinja2');
136
+ * var myjinja2 = new jinja2.Jinja2({ autoescape: false });
137
+ * myjinja2.render('Hello {{ name }}', { locals: { name: 'world' }});
138
+ *
139
+ * @param {object} [opts={}] Jinja2 options object.
140
+ * @return {object} New Jinja2 environment.
141
+ */
142
+ exports.Jinja2 = function (opts) {
143
+ var self = this;
144
+ validateOptions(opts);
145
+ this.options = utils.extend({}, defaultOptions, opts || {});
146
+ this.cache = {};
147
+ this.extensions = {};
148
+
149
+ engine.install(this, {
150
+ parser: parser,
151
+ tags: _tags,
152
+ filters: _filters,
153
+ validateOptions: validateOptions,
154
+ onCompileError: function (err, options) {
155
+ utils.throwError(err, null, options.filename);
156
+ }
157
+ });
158
+
159
+ // Register Jinja2 `is <name>` runtime helpers as `_ext._test_<name>`.
160
+ // setExtension attaches them to this instance's `extensions` map, so a
161
+ // consumer can override any test per-instance with their own
162
+ // `setExtension('_test_<name>', fn)` after construction.
163
+ utils.each(_tests, function (fn, name) {
164
+ self.setExtension('_test_' + name, fn);
165
+ });
166
+
167
+ function buildScanOpts() {
168
+ return {
169
+ varControls: self.options.varControls,
170
+ tagControls: self.options.tagControls,
171
+ cmtControls: self.options.cmtControls,
172
+ rawTag: 'raw',
173
+ keywords: ['extends', 'include', 'import', 'from']
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Render a Jinja2 template file asynchronously, supporting async loaders.
179
+ *
180
+ * Pre-walks <code>extends</code> / <code>include</code> /
181
+ * <code>import</code> / <code>from</code> targets in parallel via the
182
+ * user loader, populates an in-memory map, then runs the existing sync
183
+ * render pipeline against the populated map. Dynamic paths
184
+ * (<code>{% extends parent_var %}</code>) are not pre-resolved and will
185
+ * throw at render time as they would on the sync path.
186
+ *
187
+ * @deprecated since 2.5.0 — use {@link Jinja2#renderFile} with a loader
188
+ * that sets <code>loader.async === true</code>. The async-codegen
189
+ * dispatch handles dynamic include paths the pre-walker cannot. This
190
+ * method will be removed in 3.0.
191
+ *
192
+ * @example
193
+ * jinja2.setDefaults({ loader: myAsyncLoader });
194
+ * jinja2.renderFileAsync('page.html', { name: 'world' }, function (err, output) {
195
+ * if (err) { return done(err); }
196
+ * res.end(output);
197
+ * });
198
+ *
199
+ * @param {string} pathName Template path; resolved via the active loader.
200
+ * @param {object} [locals] Locals to render with.
201
+ * @param {Function} cb Node-style callback <code>(err, output)</code>.
202
+ * @return {undefined}
203
+ */
204
+ this.renderFileAsync = function (pathName, locals, cb) {
205
+ if (typeof locals === 'function') {
206
+ cb = locals;
207
+ locals = undefined;
208
+ }
209
+
210
+ var loader = self.options.loader;
211
+ var entry;
212
+
213
+ try {
214
+ entry = loader.resolve(pathName);
215
+ } catch (e) {
216
+ cb(e);
217
+ return;
218
+ }
219
+
220
+ preWalker.walk(entry, loader, buildScanOpts()).then(function (memMap) {
221
+ var memWrapper = preWalker.makeMemoryWrapper(loader, memMap);
222
+ var origLoader = self.options.loader;
223
+ self.options.loader = memWrapper;
224
+ var output, error;
225
+ try {
226
+ output = self.renderFile(entry, locals);
227
+ } catch (e) {
228
+ error = e;
229
+ }
230
+ self.options.loader = origLoader;
231
+ if (error) {
232
+ cb(error);
233
+ return;
234
+ }
235
+ cb(null, output);
236
+ }, function (err) {
237
+ cb(err);
238
+ });
239
+ };
240
+
241
+ /**
242
+ * Compile a Jinja2 template file asynchronously, supporting async loaders.
243
+ *
244
+ * Same pre-walk / memory-wrapper / sync-pipeline shape as
245
+ * {@link Jinja2#renderFileAsync}. Returns the compiled function (via
246
+ * <var>cb</var>) that takes a locals object and yields a rendered
247
+ * string. The returned function captures the pre-walked memory map and
248
+ * temporarily swaps the loader on each call, so subsequent runtime
249
+ * <code>include</code>s resolve correctly without re-running the
250
+ * pre-walk.
251
+ *
252
+ * @deprecated since 2.5.0 — use {@link Jinja2#compileFile} with
253
+ * <code>options.codegenMode === 'async'</code> on a loader that sets
254
+ * <code>loader.async === true</code>. The returned compiled function
255
+ * yields a <code>Promise&lt;{output, exports}&gt;</code> instead of a
256
+ * string. This method will be removed in 3.0.
257
+ *
258
+ * @example
259
+ * jinja2.compileFileAsync('page.html', {}, function (err, fn) {
260
+ * if (err) { return done(err); }
261
+ * res.end(fn({ name: 'world' }));
262
+ * });
263
+ *
264
+ * @param {string} pathName Template path.
265
+ * @param {object} [options] Compilation options.
266
+ * @param {Function} cb Node-style callback <code>(err, fn)</code>.
267
+ * @return {undefined}
268
+ */
269
+ this.compileFileAsync = function (pathName, options, cb) {
270
+ if (typeof options === 'function') {
271
+ cb = options;
272
+ options = {};
273
+ }
274
+
275
+ var loader = self.options.loader;
276
+ var entry;
277
+
278
+ try {
279
+ entry = loader.resolve(pathName);
280
+ } catch (e) {
281
+ cb(e);
282
+ return;
283
+ }
284
+
285
+ preWalker.walk(entry, loader, buildScanOpts()).then(function (memMap) {
286
+ var memWrapper = preWalker.makeMemoryWrapper(loader, memMap);
287
+ var origLoader = self.options.loader;
288
+ self.options.loader = memWrapper;
289
+ var compiled, error;
290
+ try {
291
+ compiled = self.compileFile(entry, options);
292
+ } catch (e) {
293
+ error = e;
294
+ }
295
+ self.options.loader = origLoader;
296
+ if (error) {
297
+ cb(error);
298
+ return;
299
+ }
300
+ var wrapped = function (locals) {
301
+ var origInner = self.options.loader;
302
+ self.options.loader = memWrapper;
303
+ try {
304
+ var output = compiled(locals);
305
+ self.options.loader = origInner;
306
+ return output;
307
+ } catch (e) {
308
+ self.options.loader = origInner;
309
+ throw e;
310
+ }
311
+ };
312
+ cb(null, wrapped);
313
+ }, function (err) {
314
+ cb(err);
315
+ });
316
+ };
317
+ };
318
+
319
+ /*!
320
+ * Export methods publicly via the default instance.
321
+ */
322
+ defaultInstance = new exports.Jinja2();
323
+ exports.setFilter = defaultInstance.setFilter;
324
+ exports.setTag = defaultInstance.setTag;
325
+ exports.setExtension = defaultInstance.setExtension;
326
+ exports.parseFile = defaultInstance.parseFile;
327
+ exports.precompile = defaultInstance.precompile;
328
+ exports.compile = defaultInstance.compile;
329
+ exports.compileFile = defaultInstance.compileFile;
330
+ exports.compileFileAsync = defaultInstance.compileFileAsync;
331
+ exports.render = defaultInstance.render;
332
+ exports.renderFile = defaultInstance.renderFile;
333
+ exports.renderFileAsync = defaultInstance.renderFileAsync;
334
+ exports.run = defaultInstance.run;
335
+ exports.invalidateCache = defaultInstance.invalidateCache;
336
+
337
+ /**
338
+ * Express 3/4 compatibility alias.
339
+ *
340
+ * @example
341
+ * app.engine('jinja2', require('@rhinostone/swig-jinja2').__express);
342
+ * app.set('view engine', 'jinja2');
343
+ */
344
+ exports.__express = defaultInstance.renderFile;
package/lib/lexer.js ADDED
@@ -0,0 +1,305 @@
1
+ var utils = require('@rhinostone/swig-core/lib/utils');
2
+ var TYPES = require('./tokentypes');
3
+
4
+ /**
5
+ * A Jinja2 lexer token.
6
+ *
7
+ * @typedef {object} LexerToken
8
+ * @property {string} match The string that was matched (post-replace).
9
+ * @property {number} type Jinja2 token type enum value.
10
+ * @property {number} length Length of the input chunk consumed.
11
+ */
12
+
13
+ /*!
14
+ * Jinja2 lexer rule table — the swig-shared token subset.
15
+ *
16
+ * The Jinja2-only operators (`~` concat, `**` power, `//` floor-division,
17
+ * `is` / `is not` tests) land in subsequent commits; until then they fall
18
+ * through to the unknown-token throw (fail-close). Jinja2 has no range
19
+ * (`..`), null-coalescing (`??`), ternary (`?:`), or `#{}` string-
20
+ * interpolation operators, so this table will never grow those rules.
21
+ *
22
+ * Rule ordering constraints worth the call-out:
23
+ *
24
+ * - FILTER / FILTEREMPTY / FUNCTION above VAR — `|name(` and `name(`
25
+ * must be consumed as filter / function tokens before VAR's
26
+ * `^[a-zA-Z_$]\w*` pattern gobbles the bare identifier.
27
+ * - LOGIC (`and` / `or`), NOT (`not`), BOOL (`true` / `false`), and the
28
+ * COMPARATOR `in\s` keyword above VAR — same reason: bake the keyword
29
+ * sequence into the operator token rather than emit an identifier.
30
+ *
31
+ * Rules are tried in order; first match wins. Patterns are anchored at
32
+ * start-of-string because the consumer slices `str` before each dispatch.
33
+ */
34
+ var rules = [
35
+ {
36
+ type: TYPES.WHITESPACE,
37
+ regex: [
38
+ /^\s+/
39
+ ]
40
+ },
41
+ {
42
+ type: TYPES.STRING,
43
+ regex: [
44
+ /^""/,
45
+ /^".*?[^\\]"/,
46
+ /^''/,
47
+ /^'.*?[^\\]'/
48
+ ]
49
+ },
50
+ {
51
+ type: TYPES.FILTER,
52
+ regex: [
53
+ /^\|\s*(\w+)\(/
54
+ ],
55
+ idx: 1
56
+ },
57
+ {
58
+ type: TYPES.FILTEREMPTY,
59
+ regex: [
60
+ /^\|\s*(\w+)/
61
+ ],
62
+ idx: 1
63
+ },
64
+ {
65
+ type: TYPES.FUNCTIONEMPTY,
66
+ regex: [
67
+ /^\s*(\w+)\(\)/
68
+ ],
69
+ idx: 1
70
+ },
71
+ {
72
+ type: TYPES.FUNCTION,
73
+ regex: [
74
+ /^\s*(\w+)\(/
75
+ ],
76
+ idx: 1
77
+ },
78
+ {
79
+ type: TYPES.PARENOPEN,
80
+ regex: [
81
+ /^\(/
82
+ ]
83
+ },
84
+ {
85
+ type: TYPES.PARENCLOSE,
86
+ regex: [
87
+ /^\)/
88
+ ]
89
+ },
90
+ {
91
+ type: TYPES.COMMA,
92
+ regex: [
93
+ /^,/
94
+ ]
95
+ },
96
+ {
97
+ type: TYPES.LOGIC,
98
+ regex: [
99
+ /^(&&|\|\|)\s*/,
100
+ /^(and|or)\s+/
101
+ ],
102
+ idx: 1,
103
+ replace: {
104
+ 'and': '&&',
105
+ 'or': '||'
106
+ }
107
+ },
108
+ {
109
+ type: TYPES.COMPARATOR,
110
+ regex: [
111
+ /^(===|==|\!==|\!=|<=|<|>=|>|in\s)\s*/
112
+ ],
113
+ idx: 1
114
+ },
115
+ {
116
+ type: TYPES.ASSIGNMENT,
117
+ regex: [
118
+ /^(=|\+=|-=|\*=|\/=)/
119
+ ]
120
+ },
121
+ {
122
+ type: TYPES.NOT,
123
+ regex: [
124
+ /^\!\s*/,
125
+ /^not\s+/
126
+ ],
127
+ replace: {
128
+ 'not': '!'
129
+ }
130
+ },
131
+ {
132
+ type: TYPES.BOOL,
133
+ regex: [
134
+ /^(true|false)\s+/,
135
+ /^(true|false)$/
136
+ ],
137
+ idx: 1
138
+ },
139
+ {
140
+ // ISNOT above IS above VAR — the `is` keyword would otherwise be
141
+ // gobbled by VAR's `^[a-zA-Z_$]\w*` pattern. ISNOT above IS because
142
+ // `is not` must be consumed as a single token, not IS + NOT. The `\b`
143
+ // word boundary keeps identifiers like `island` from matching.
144
+ type: TYPES.ISNOT,
145
+ regex: [
146
+ /^is\s+not\b/
147
+ ]
148
+ },
149
+ {
150
+ type: TYPES.IS,
151
+ regex: [
152
+ /^is\b/
153
+ ]
154
+ },
155
+ {
156
+ // The dotted-path interior segment uses `\w+` (not `\w*`) so a future
157
+ // operator whose first char is `.` cannot be absorbed as a zero-width
158
+ // interior segment. Jinja2 has no `..` range today, but the tighter
159
+ // form is the defensive default and matches the Twig sibling lexer.
160
+ type: TYPES.VAR,
161
+ regex: [
162
+ /^[a-zA-Z_$]\w*((\.\$?\w+)+)?/,
163
+ /^[a-zA-Z_$]\w*/
164
+ ]
165
+ },
166
+ {
167
+ type: TYPES.BRACKETOPEN,
168
+ regex: [
169
+ /^\[/
170
+ ]
171
+ },
172
+ {
173
+ type: TYPES.BRACKETCLOSE,
174
+ regex: [
175
+ /^\]/
176
+ ]
177
+ },
178
+ {
179
+ type: TYPES.CURLYOPEN,
180
+ regex: [
181
+ /^\{/
182
+ ]
183
+ },
184
+ {
185
+ type: TYPES.COLON,
186
+ regex: [
187
+ /^\:/
188
+ ]
189
+ },
190
+ {
191
+ type: TYPES.CURLYCLOSE,
192
+ regex: [
193
+ /^\}/
194
+ ]
195
+ },
196
+ {
197
+ type: TYPES.DOTKEY,
198
+ regex: [
199
+ /^\.(\w+)/
200
+ ],
201
+ idx: 1
202
+ },
203
+ {
204
+ type: TYPES.NUMBER,
205
+ regex: [
206
+ /^\d+(\.\d+)?/
207
+ ]
208
+ },
209
+ {
210
+ type: TYPES.TILDE,
211
+ regex: [
212
+ /^~/
213
+ ]
214
+ },
215
+ {
216
+ // Above OPERATOR so `**` wins over a bare `*`; first-match-wins would
217
+ // otherwise lex `**` as two OPERATOR tokens.
218
+ type: TYPES.POWER,
219
+ regex: [
220
+ /^\*\*/
221
+ ]
222
+ },
223
+ {
224
+ // Above OPERATOR so `//` wins over a bare `/`. Note JS has no
225
+ // floor-division operator (`a // b` is a comment), so the parser
226
+ // lowers FLOORDIV to Math.floor(a / b) rather than emitting `//`.
227
+ type: TYPES.FLOORDIV,
228
+ regex: [
229
+ /^\/\//
230
+ ]
231
+ },
232
+ {
233
+ type: TYPES.OPERATOR,
234
+ regex: [
235
+ /^(\+|\-|\/|\*|%)/
236
+ ]
237
+ }
238
+ ];
239
+
240
+ exports.types = TYPES;
241
+
242
+ /**
243
+ * Match the next token at the start of `str`.
244
+ *
245
+ * Throws via utils.throwError when no rule matches — including every
246
+ * Jinja2-only operator until its rule lands. The throw is opaque (no
247
+ * line / file info); the Jinja2 frontend's onCompileError callback
248
+ * attaches filename + line per the swig-core / frontend seam rule.
249
+ *
250
+ * @param {string} str Input slice starting at the unconsumed offset.
251
+ * @return {LexerToken} Matched token.
252
+ * @throws {Error} When no rule matches.
253
+ * @private
254
+ */
255
+ function reader(str) {
256
+ var matched;
257
+
258
+ utils.some(rules, function (rule) {
259
+ return utils.some(rule.regex, function (regex) {
260
+ var match = str.match(regex),
261
+ normalized;
262
+
263
+ if (!match) {
264
+ return;
265
+ }
266
+
267
+ normalized = match[rule.idx || 0].replace(/\s*$/, '');
268
+ normalized = (rule.hasOwnProperty('replace') && rule.replace.hasOwnProperty(normalized)) ? rule.replace[normalized] : normalized;
269
+
270
+ matched = {
271
+ match: normalized,
272
+ type: rule.type,
273
+ length: match[0].length
274
+ };
275
+ return true;
276
+ });
277
+ });
278
+
279
+ if (!matched) {
280
+ utils.throwError('Unexpected token "' + str.charAt(0) + '" in Jinja2 expression');
281
+ }
282
+
283
+ return matched;
284
+ }
285
+
286
+ /**
287
+ * Tokenize a Jinja2 expression string.
288
+ *
289
+ * @param {string} str Expression source (the contents of
290
+ * `{{ … }}` or `{% … %}` minus the
291
+ * control delimiters and tag name).
292
+ * @return {Array.<LexerToken>} Sequence of matched tokens.
293
+ * @throws {Error} On the first unrecognised character.
294
+ */
295
+ exports.read = function (str) {
296
+ var offset = 0,
297
+ tokens = [],
298
+ match;
299
+ while (offset < str.length) {
300
+ match = reader(str.substring(offset));
301
+ offset += match.length;
302
+ tokens.push(match);
303
+ }
304
+ return tokens;
305
+ };