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