@rhinostone/swig 2.0.1 → 2.1.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.
@@ -0,0 +1,279 @@
1
+ var utils = require('../utils');
2
+
3
+ /*!
4
+ * Makes a string safe for a regular expression. Mirrors lib/parser.js.
5
+ * @private
6
+ */
7
+ function escapeRegExp(str) {
8
+ return str.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&');
9
+ }
10
+
11
+ /*!
12
+ * Build the splitter regex from the controls trio. Mirrors the regex that
13
+ * parser.parse() builds at parse-time so the pre-walker chunks the same
14
+ * way the real parser would.
15
+ * @private
16
+ */
17
+ function buildSplitter(controls) {
18
+ var anyChar = '[\\s\\S]*?',
19
+ varOpen = escapeRegExp(controls.varControls[0]),
20
+ varClose = escapeRegExp(controls.varControls[1]),
21
+ tagOpen = escapeRegExp(controls.tagControls[0]),
22
+ tagClose = escapeRegExp(controls.tagControls[1]),
23
+ cmtOpen = escapeRegExp(controls.cmtControls[0]),
24
+ cmtClose = escapeRegExp(controls.cmtControls[1]);
25
+ return new RegExp(
26
+ '(' +
27
+ tagOpen + anyChar + tagClose + '|' +
28
+ varOpen + anyChar + varClose + '|' +
29
+ cmtOpen + anyChar + cmtClose +
30
+ ')'
31
+ );
32
+ }
33
+
34
+ /*!
35
+ * Strip tag controls and optional whitespace-control markers from a tag
36
+ * chunk, returning the trimmed tag body (e.g. `extends "x.html"`).
37
+ * @private
38
+ */
39
+ function stripTagBody(chunk, tagOpen, tagClose) {
40
+ var body = chunk.substr(tagOpen.length, chunk.length - tagOpen.length - tagClose.length);
41
+ if (body.charAt(0) === '-') {
42
+ body = body.substr(1);
43
+ }
44
+ if (body.charAt(body.length - 1) === '-') {
45
+ body = body.substr(0, body.length - 1);
46
+ }
47
+ return body.replace(/^\s+|\s+$/g, '');
48
+ }
49
+
50
+ /**
51
+ * Scan template source for static `{% extends|include|import|from "..." %}`
52
+ * targets. Pure function; performs no I/O.
53
+ *
54
+ * The scanner mirrors the real parser's chunk-splitter so it agrees on
55
+ * chunk boundaries even under non-default control characters. Dynamic
56
+ * paths (`{% extends parent_var %}`) and tag bodies whose first token
57
+ * isn't a string literal are silently skipped — they remain on the sync
58
+ * path, which throws appropriately at parse time.
59
+ *
60
+ * @example
61
+ * preWalker.scan('{% extends "layout.html" %}{% include "x" %}', {
62
+ * varControls: ['{{', '}}'],
63
+ * tagControls: ['{%', '%}'],
64
+ * cmtControls: ['{#', '#}'],
65
+ * rawTag: 'raw',
66
+ * keywords: ['extends', 'include', 'import']
67
+ * });
68
+ * // => [
69
+ * // { kind: 'extends', path: 'layout.html' },
70
+ * // { kind: 'include', path: 'x' }
71
+ * // ]
72
+ *
73
+ * @param {string} source
74
+ * @param {object} opts
75
+ * @param {array} opts.varControls e.g. <code>['{{', '}}']</code>.
76
+ * @param {array} opts.tagControls e.g. <code>['{%', '%}']</code>.
77
+ * @param {array} opts.cmtControls e.g. <code>['{#', '#}']</code>.
78
+ * @param {string} opts.rawTag Tag name that opens verbatim regions
79
+ * (<code>raw</code> for native swig).
80
+ * @param {array} opts.keywords Keywords whose first quoted argument is
81
+ * a template path. Native swig:
82
+ * <code>['extends', 'include', 'import']</code>.
83
+ * @return {array} List of <code>{ kind, path }</code> entries.
84
+ */
85
+ exports.scan = function (source, opts) {
86
+ source = source.replace(/\r\n/g, '\n');
87
+
88
+ var splitter = buildSplitter(opts),
89
+ tagOpen = opts.tagControls[0],
90
+ tagClose = opts.tagControls[1],
91
+ rawTag = opts.rawTag,
92
+ endRawTag = 'end' + rawTag,
93
+ keywordRegex = new RegExp(
94
+ '^(' + opts.keywords.join('|') + ')\\s+["\\\']([^"\\\']+)["\\\']'
95
+ ),
96
+ chunks = source.split(splitter),
97
+ results = [],
98
+ inRaw = false,
99
+ i,
100
+ chunk,
101
+ body,
102
+ name,
103
+ m;
104
+
105
+ for (i = 0; i < chunks.length; i += 1) {
106
+ chunk = chunks[i];
107
+ if (typeof chunk !== 'string' || !chunk) {
108
+ continue;
109
+ }
110
+
111
+ if (!utils.startsWith(chunk, tagOpen) || !utils.endsWith(chunk, tagClose)) {
112
+ continue;
113
+ }
114
+
115
+ body = stripTagBody(chunk, tagOpen, tagClose);
116
+ name = body.split(/\s+/)[0];
117
+
118
+ if (name === rawTag) {
119
+ inRaw = true;
120
+ continue;
121
+ }
122
+ if (name === endRawTag) {
123
+ inRaw = false;
124
+ continue;
125
+ }
126
+ if (inRaw) {
127
+ continue;
128
+ }
129
+
130
+ m = keywordRegex.exec(body);
131
+ if (m) {
132
+ results.push({ kind: m[1], path: m[2] });
133
+ }
134
+ }
135
+
136
+ return results;
137
+ };
138
+
139
+ /**
140
+ * Walk the dependency graph asynchronously starting from <var>entryPath</var>.
141
+ *
142
+ * Repeatedly loads, scans, and resolves child template paths in parallel
143
+ * via the user's async loader, until the dep graph closes. Returns a
144
+ * Promise resolving to a populated <code>{ resolvedPath: source }</code>
145
+ * map suitable for backing a memory loader.
146
+ *
147
+ * Cycles in the graph are tolerated — once a path is in the map or
148
+ * pending, subsequent enqueue requests are dropped. The synchronous
149
+ * renderer's existing circular-extends guard handles cycles at parse
150
+ * time on the second pass.
151
+ *
152
+ * @example
153
+ * preWalker.walk('/abs/entry.html', userLoader, scanOpts).then(function (memMap) {
154
+ * // memMap = { '/abs/entry.html': '...', '/abs/layout.html': '...', ... }
155
+ * });
156
+ *
157
+ * @param {string} entryPath Resolved path of the entry template.
158
+ * @param {object} loader User loader. Must expose:
159
+ * <code>resolve(to, from)</code> (sync, returns
160
+ * string) and
161
+ * <code>load(id, cb)</code> (async, calls
162
+ * <code>cb(err, source)</code>).
163
+ * @param {object} scanOpts Pass-through to {@link scan}.
164
+ * @return {Promise} Resolves to the populated memory map.
165
+ */
166
+ exports.walk = function (entryPath, loader, scanOpts) {
167
+ var memMap = {};
168
+ var pending = {};
169
+
170
+ return new Promise(function (resolve, reject) {
171
+ var inFlight = 0;
172
+ var queue = [];
173
+ var hasError = false;
174
+
175
+ function enqueue(path) {
176
+ if (memMap.hasOwnProperty(path) || pending[path]) {
177
+ return;
178
+ }
179
+ pending[path] = true;
180
+ queue.push(path);
181
+ }
182
+
183
+ function drain() {
184
+ while (queue.length > 0 && !hasError) {
185
+ var path = queue.shift();
186
+ inFlight += 1;
187
+ startLoad(path);
188
+ }
189
+ if (inFlight === 0 && !hasError && queue.length === 0) {
190
+ resolve(memMap);
191
+ }
192
+ }
193
+
194
+ function startLoad(resolvedPath) {
195
+ loader.load(resolvedPath, function (err, src) {
196
+ if (hasError) {
197
+ return;
198
+ }
199
+ if (err) {
200
+ hasError = true;
201
+ reject(err);
202
+ return;
203
+ }
204
+ if (typeof src !== 'string') {
205
+ hasError = true;
206
+ reject(new Error('Async loader returned non-string source for "' + resolvedPath + '"'));
207
+ return;
208
+ }
209
+ memMap[resolvedPath] = src;
210
+
211
+ var targets;
212
+ try {
213
+ targets = exports.scan(src, scanOpts);
214
+ } catch (e) {
215
+ hasError = true;
216
+ reject(e);
217
+ return;
218
+ }
219
+
220
+ var i, resolvedChild;
221
+ for (i = 0; i < targets.length; i += 1) {
222
+ try {
223
+ resolvedChild = loader.resolve(targets[i].path, resolvedPath);
224
+ } catch (e) {
225
+ hasError = true;
226
+ reject(e);
227
+ return;
228
+ }
229
+ enqueue(resolvedChild);
230
+ }
231
+
232
+ inFlight -= 1;
233
+ drain();
234
+ });
235
+ }
236
+
237
+ enqueue(entryPath);
238
+ drain();
239
+ });
240
+ };
241
+
242
+ /**
243
+ * Build a sync memory wrapper around a pre-populated
244
+ * <code>{ resolvedPath: source }</code> map. Delegates <code>resolve</code>
245
+ * to the user loader so cache keys match what the pre-walker produced.
246
+ *
247
+ * @example
248
+ * var mem = preWalker.makeMemoryWrapper(userLoader, memMap);
249
+ * swig.options.loader = mem;
250
+ * swig.renderFile('/abs/entry.html', locals); // sync, hits memMap
251
+ *
252
+ * @param {object} userLoader Original async loader (used for resolve).
253
+ * @param {object} memMap Pre-populated source map.
254
+ * @return {object} A loader exposing <code>resolve</code> and
255
+ * <code>load</code>.
256
+ */
257
+ exports.makeMemoryWrapper = function (userLoader, memMap) {
258
+ return {
259
+ resolve: function (to, from) {
260
+ return userLoader.resolve(to, from);
261
+ },
262
+ load: function (id, cb) {
263
+ var src = memMap[id];
264
+ if (typeof src !== 'string') {
265
+ var err = new Error('Pre-walked map missing path: "' + id + '"');
266
+ if (cb) {
267
+ cb(err);
268
+ return;
269
+ }
270
+ throw err;
271
+ }
272
+ if (cb) {
273
+ cb(null, src);
274
+ return;
275
+ }
276
+ return src;
277
+ }
278
+ };
279
+ };
package/lib/swig.js CHANGED
@@ -4,16 +4,17 @@ var utils = require('./utils'),
4
4
  parser = require('./parser'),
5
5
  dateformatter = require('./dateformatter'),
6
6
  loaders = require('./loaders'),
7
+ preWalker = require('./async/pre-walker'),
7
8
  engine = require('@rhinostone/swig-core/lib/engine');
8
9
 
9
10
  /**
10
11
  * Swig version number as a string.
11
12
  * @example
12
- * if (swig.version === "2.0.1") { ... }
13
+ * if (swig.version === "2.1.0") { ... }
13
14
  *
14
15
  * @type {String}
15
16
  */
16
- exports.version = "2.0.1";
17
+ exports.version = "2.1.0";
17
18
 
18
19
  /**
19
20
  * Swig Options Object. This object can be passed to many of the API-level Swig methods to control various aspects of the engine. All keys are optional.
@@ -177,6 +178,147 @@ exports.Swig = function (opts) {
177
178
  utils.throwError(err, null, options.filename);
178
179
  }
179
180
  });
181
+
182
+ var self = this;
183
+
184
+ function buildScanOpts() {
185
+ return {
186
+ varControls: self.options.varControls,
187
+ tagControls: self.options.tagControls,
188
+ cmtControls: self.options.cmtControls,
189
+ rawTag: 'raw',
190
+ keywords: ['extends', 'include', 'import']
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Render a template file asynchronously, supporting async loaders.
196
+ *
197
+ * Pre-walks <var>extends</var> / <var>include</var> / <var>import</var>
198
+ * targets in parallel via the user loader, populates an in-memory map,
199
+ * then runs the existing sync render pipeline against the populated map.
200
+ * Dynamic paths (e.g. <code>{% extends parent_var %}</code>) are not
201
+ * pre-resolved and will throw at render time as they would on the sync
202
+ * path.
203
+ *
204
+ * @example
205
+ * swig.setDefaults({ loader: myAsyncLoader });
206
+ * swig.renderFileAsync('page.html', { name: 'world' }, function (err, output) {
207
+ * if (err) { return done(err); }
208
+ * res.end(output);
209
+ * });
210
+ *
211
+ * @param {string} pathName Template path; resolved via the active loader.
212
+ * @param {object} [locals] Locals to render with.
213
+ * @param {Function} cb Node-style callback <code>(err, output)</code>.
214
+ * @return {undefined}
215
+ */
216
+ this.renderFileAsync = function (pathName, locals, cb) {
217
+ if (typeof locals === 'function') {
218
+ cb = locals;
219
+ locals = undefined;
220
+ }
221
+
222
+ var loader = self.options.loader;
223
+ var entry;
224
+
225
+ try {
226
+ entry = loader.resolve(pathName);
227
+ } catch (e) {
228
+ cb(e);
229
+ return;
230
+ }
231
+
232
+ preWalker.walk(entry, loader, buildScanOpts()).then(function (memMap) {
233
+ var memWrapper = preWalker.makeMemoryWrapper(loader, memMap);
234
+ var origLoader = self.options.loader;
235
+ self.options.loader = memWrapper;
236
+ var output, error;
237
+ try {
238
+ output = self.renderFile(entry, locals);
239
+ } catch (e) {
240
+ error = e;
241
+ }
242
+ self.options.loader = origLoader;
243
+ if (error) {
244
+ cb(error);
245
+ return;
246
+ }
247
+ cb(null, output);
248
+ }, function (err) {
249
+ cb(err);
250
+ });
251
+ };
252
+
253
+ /**
254
+ * Compile a template file asynchronously, supporting async loaders.
255
+ *
256
+ * Same pre-walk / memory-wrapper / sync-pipeline shape as
257
+ * {@link Swig#renderFileAsync}. Returns the compiled function (via
258
+ * <var>cb</var>) that takes a locals object and yields a rendered
259
+ * string. The returned function captures the pre-walked memory map and
260
+ * temporarily swaps the loader on each call, so subsequent runtime
261
+ * <var>include</var>s resolve correctly without re-running the pre-walk.
262
+ *
263
+ * @example
264
+ * swig.compileFileAsync('page.html', {}, function (err, fn) {
265
+ * if (err) { return done(err); }
266
+ * res.end(fn({ name: 'world' }));
267
+ * });
268
+ *
269
+ * @param {string} pathName Template path.
270
+ * @param {object} [options] Compilation options.
271
+ * @param {Function} cb Node-style callback <code>(err, fn)</code>.
272
+ * @return {undefined}
273
+ */
274
+ this.compileFileAsync = function (pathName, options, cb) {
275
+ if (typeof options === 'function') {
276
+ cb = options;
277
+ options = {};
278
+ }
279
+
280
+ var loader = self.options.loader;
281
+ var entry;
282
+
283
+ try {
284
+ entry = loader.resolve(pathName);
285
+ } catch (e) {
286
+ cb(e);
287
+ return;
288
+ }
289
+
290
+ preWalker.walk(entry, loader, buildScanOpts()).then(function (memMap) {
291
+ var memWrapper = preWalker.makeMemoryWrapper(loader, memMap);
292
+ var origLoader = self.options.loader;
293
+ self.options.loader = memWrapper;
294
+ var compiled, error;
295
+ try {
296
+ compiled = self.compileFile(entry, options);
297
+ } catch (e) {
298
+ error = e;
299
+ }
300
+ self.options.loader = origLoader;
301
+ if (error) {
302
+ cb(error);
303
+ return;
304
+ }
305
+ var wrapped = function (locals) {
306
+ var origInner = self.options.loader;
307
+ self.options.loader = memWrapper;
308
+ try {
309
+ var output = compiled(locals);
310
+ self.options.loader = origInner;
311
+ return output;
312
+ } catch (e) {
313
+ self.options.loader = origInner;
314
+ throw e;
315
+ }
316
+ };
317
+ cb(null, wrapped);
318
+ }, function (err) {
319
+ cb(err);
320
+ });
321
+ };
180
322
  };
181
323
 
182
324
  /*!
@@ -190,8 +332,10 @@ exports.parseFile = defaultInstance.parseFile;
190
332
  exports.precompile = defaultInstance.precompile;
191
333
  exports.compile = defaultInstance.compile;
192
334
  exports.compileFile = defaultInstance.compileFile;
335
+ exports.compileFileAsync = defaultInstance.compileFileAsync;
193
336
  exports.render = defaultInstance.render;
194
337
  exports.renderFile = defaultInstance.renderFile;
338
+ exports.renderFileAsync = defaultInstance.renderFileAsync;
195
339
  exports.run = defaultInstance.run;
196
340
  exports.invalidateCache = defaultInstance.invalidateCache;
197
341
  exports.loaders = loaders;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinostone/swig",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A simple, powerful, and extendable templating engine for node.js and browsers, similar to Django, Jinja2, and Twig.",
5
5
  "keywords": [
6
6
  "template",
@@ -21,7 +21,7 @@
21
21
  "Rhinostone <contact@gina.io>"
22
22
  ],
23
23
  "dependencies": {
24
- "@rhinostone/swig-core": "2.0.1",
24
+ "@rhinostone/swig-core": "2.1.0",
25
25
  "terser": "^5.46.1",
26
26
  "yargs": "^17.7.2"
27
27
  },