@rhinostone/swig-twig 2.0.0 → 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,267 @@
1
+ var utils = require('@rhinostone/swig-core/lib/utils');
2
+
3
+ /*!
4
+ * Makes a string safe for a regular expression. Mirrors swig-twig's parser.
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 the
13
+ * Twig parser builds at parse-time so the pre-walker chunks the same way
14
+ * 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. <code>extends "x.html"</code>).
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 Twig template source for static <code>{% extends|include|import|from "..." %}</code>
52
+ * targets. Pure function; performs no I/O.
53
+ *
54
+ * The scanner mirrors the Twig parser's chunk-splitter so it agrees on
55
+ * chunk boundaries even under non-default control characters. Dynamic
56
+ * paths (<code>{% extends parent_var %}</code>) and tag bodies whose
57
+ * first token isn't a string literal are silently skipped — they remain
58
+ * on the sync path, which throws appropriately at parse time.
59
+ *
60
+ * @example
61
+ * preWalker.scan('{% from "macros.html" import foo %}', {
62
+ * varControls: ['{{', '}}'],
63
+ * tagControls: ['{%', '%}'],
64
+ * cmtControls: ['{#', '#}'],
65
+ * rawTag: 'verbatim',
66
+ * keywords: ['extends', 'include', 'import', 'from']
67
+ * });
68
+ * // => [{ kind: 'from', path: 'macros.html' }]
69
+ *
70
+ * @param {string} source
71
+ * @param {object} opts
72
+ * @param {array} opts.varControls
73
+ * @param {array} opts.tagControls
74
+ * @param {array} opts.cmtControls
75
+ * @param {string} opts.rawTag Tag name that opens verbatim regions
76
+ * (<code>verbatim</code> for Twig).
77
+ * @param {array} opts.keywords Keywords whose first quoted argument
78
+ * is a template path. Twig:
79
+ * <code>['extends', 'include', 'import',
80
+ * 'from']</code>.
81
+ * @return {array} List of <code>{ kind, path }</code> entries.
82
+ */
83
+ exports.scan = function (source, opts) {
84
+ source = source.replace(/\r\n/g, '\n');
85
+
86
+ var splitter = buildSplitter(opts),
87
+ tagOpen = opts.tagControls[0],
88
+ tagClose = opts.tagControls[1],
89
+ rawTag = opts.rawTag,
90
+ endRawTag = 'end' + rawTag,
91
+ keywordRegex = new RegExp(
92
+ '^(' + opts.keywords.join('|') + ')\\s+["\\\']([^"\\\']+)["\\\']'
93
+ ),
94
+ chunks = source.split(splitter),
95
+ results = [],
96
+ inRaw = false,
97
+ i,
98
+ chunk,
99
+ body,
100
+ name,
101
+ m;
102
+
103
+ for (i = 0; i < chunks.length; i += 1) {
104
+ chunk = chunks[i];
105
+ if (typeof chunk !== 'string' || !chunk) {
106
+ continue;
107
+ }
108
+
109
+ if (!utils.startsWith(chunk, tagOpen) || !utils.endsWith(chunk, tagClose)) {
110
+ continue;
111
+ }
112
+
113
+ body = stripTagBody(chunk, tagOpen, tagClose);
114
+ name = body.split(/\s+/)[0];
115
+
116
+ if (name === rawTag) {
117
+ inRaw = true;
118
+ continue;
119
+ }
120
+ if (name === endRawTag) {
121
+ inRaw = false;
122
+ continue;
123
+ }
124
+ if (inRaw) {
125
+ continue;
126
+ }
127
+
128
+ m = keywordRegex.exec(body);
129
+ if (m) {
130
+ results.push({ kind: m[1], path: m[2] });
131
+ }
132
+ }
133
+
134
+ return results;
135
+ };
136
+
137
+ /**
138
+ * Walk the dependency graph asynchronously starting from <var>entryPath</var>.
139
+ *
140
+ * Repeatedly loads, scans, and resolves child template paths in parallel
141
+ * via the user's async loader, until the dep graph closes. Returns a
142
+ * Promise resolving to a populated <code>{ resolvedPath: source }</code>
143
+ * map suitable for backing a memory loader.
144
+ *
145
+ * Cycles in the graph are tolerated — once a path is in the map or
146
+ * pending, subsequent enqueue requests are dropped. The synchronous
147
+ * renderer's existing circular-extends guard handles cycles at parse
148
+ * time on the second pass.
149
+ *
150
+ * @param {string} entryPath Resolved path of the entry template.
151
+ * @param {object} loader User loader. Must expose:
152
+ * <code>resolve(to, from)</code> (sync, returns
153
+ * string) and
154
+ * <code>load(id, cb)</code> (async, calls
155
+ * <code>cb(err, source)</code>).
156
+ * @param {object} scanOpts Pass-through to {@link scan}.
157
+ * @return {Promise} Resolves to the populated memory map.
158
+ */
159
+ exports.walk = function (entryPath, loader, scanOpts) {
160
+ var memMap = {};
161
+ var pending = {};
162
+
163
+ return new Promise(function (resolve, reject) {
164
+ var inFlight = 0;
165
+ var queue = [];
166
+ var hasError = false;
167
+
168
+ function enqueue(path) {
169
+ if (memMap.hasOwnProperty(path) || pending[path]) {
170
+ return;
171
+ }
172
+ pending[path] = true;
173
+ queue.push(path);
174
+ }
175
+
176
+ function drain() {
177
+ while (queue.length > 0 && !hasError) {
178
+ var path = queue.shift();
179
+ inFlight += 1;
180
+ startLoad(path);
181
+ }
182
+ if (inFlight === 0 && !hasError && queue.length === 0) {
183
+ resolve(memMap);
184
+ }
185
+ }
186
+
187
+ function startLoad(resolvedPath) {
188
+ loader.load(resolvedPath, function (err, src) {
189
+ if (hasError) {
190
+ return;
191
+ }
192
+ if (err) {
193
+ hasError = true;
194
+ reject(err);
195
+ return;
196
+ }
197
+ if (typeof src !== 'string') {
198
+ hasError = true;
199
+ reject(new Error('Async loader returned non-string source for "' + resolvedPath + '"'));
200
+ return;
201
+ }
202
+ memMap[resolvedPath] = src;
203
+
204
+ var targets;
205
+ try {
206
+ targets = exports.scan(src, scanOpts);
207
+ } catch (e) {
208
+ hasError = true;
209
+ reject(e);
210
+ return;
211
+ }
212
+
213
+ var i, resolvedChild;
214
+ for (i = 0; i < targets.length; i += 1) {
215
+ try {
216
+ resolvedChild = loader.resolve(targets[i].path, resolvedPath);
217
+ } catch (e) {
218
+ hasError = true;
219
+ reject(e);
220
+ return;
221
+ }
222
+ enqueue(resolvedChild);
223
+ }
224
+
225
+ inFlight -= 1;
226
+ drain();
227
+ });
228
+ }
229
+
230
+ enqueue(entryPath);
231
+ drain();
232
+ });
233
+ };
234
+
235
+ /**
236
+ * Build a sync memory wrapper around a pre-populated
237
+ * <code>{ resolvedPath: source }</code> map. Delegates <code>resolve</code>
238
+ * to the user loader so cache keys match what the pre-walker produced.
239
+ *
240
+ * @param {object} userLoader Original async loader (used for resolve).
241
+ * @param {object} memMap Pre-populated source map.
242
+ * @return {object} A loader exposing <code>resolve</code> and
243
+ * <code>load</code>.
244
+ */
245
+ exports.makeMemoryWrapper = function (userLoader, memMap) {
246
+ return {
247
+ resolve: function (to, from) {
248
+ return userLoader.resolve(to, from);
249
+ },
250
+ load: function (id, cb) {
251
+ var src = memMap[id];
252
+ if (typeof src !== 'string') {
253
+ var err = new Error('Pre-walked map missing path: "' + id + '"');
254
+ if (cb) {
255
+ cb(err);
256
+ return;
257
+ }
258
+ throw err;
259
+ }
260
+ if (cb) {
261
+ cb(null, src);
262
+ return;
263
+ }
264
+ return src;
265
+ }
266
+ };
267
+ };
package/lib/index.js CHANGED
@@ -14,7 +14,8 @@ var utils = require('@rhinostone/swig-core/lib/utils'),
14
14
  parser = require('./parser'),
15
15
  _tags = require('./tags'),
16
16
  _filters = require('./filters'),
17
- _tests = require('./tests');
17
+ _tests = require('./tests'),
18
+ preWalker = require('./async/pre-walker');
18
19
 
19
20
  exports.name = 'twig';
20
21
 
@@ -163,6 +164,146 @@ exports.Twig = function (opts) {
163
164
  utils.each(_tests, function (fn, name) {
164
165
  self.setExtension('_test_' + name, fn);
165
166
  });
167
+
168
+ function buildScanOpts() {
169
+ return {
170
+ varControls: self.options.varControls,
171
+ tagControls: self.options.tagControls,
172
+ cmtControls: self.options.cmtControls,
173
+ rawTag: 'verbatim',
174
+ keywords: ['extends', 'include', 'import', 'from']
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Render a Twig template file asynchronously, supporting async loaders.
180
+ *
181
+ * Pre-walks <code>extends</code> / <code>include</code> /
182
+ * <code>import</code> / <code>from</code> targets in parallel via the
183
+ * user loader, populates an in-memory map, then runs the existing sync
184
+ * render pipeline against the populated map. Dynamic paths
185
+ * (<code>{% extends parent_var %}</code>) are not pre-resolved and will
186
+ * throw at render time as they would on the sync path.
187
+ *
188
+ * @example
189
+ * twig.setDefaults({ loader: myAsyncLoader });
190
+ * twig.renderFileAsync('page.twig', { name: 'world' }, function (err, output) {
191
+ * if (err) { return done(err); }
192
+ * res.end(output);
193
+ * });
194
+ *
195
+ * @param {string} pathName Template path; resolved via the active loader.
196
+ * @param {object} [locals] Locals to render with.
197
+ * @param {Function} cb Node-style callback <code>(err, output)</code>.
198
+ * @return {undefined}
199
+ */
200
+ this.renderFileAsync = function (pathName, locals, cb) {
201
+ if (typeof locals === 'function') {
202
+ cb = locals;
203
+ locals = undefined;
204
+ }
205
+
206
+ var loader = self.options.loader;
207
+ var entry;
208
+
209
+ try {
210
+ entry = loader.resolve(pathName);
211
+ } catch (e) {
212
+ cb(e);
213
+ return;
214
+ }
215
+
216
+ preWalker.walk(entry, loader, buildScanOpts()).then(function (memMap) {
217
+ var memWrapper = preWalker.makeMemoryWrapper(loader, memMap);
218
+ var origLoader = self.options.loader;
219
+ self.options.loader = memWrapper;
220
+ var output, error;
221
+ try {
222
+ output = self.renderFile(entry, locals);
223
+ } catch (e) {
224
+ error = e;
225
+ }
226
+ self.options.loader = origLoader;
227
+ if (error) {
228
+ cb(error);
229
+ return;
230
+ }
231
+ cb(null, output);
232
+ }, function (err) {
233
+ cb(err);
234
+ });
235
+ };
236
+
237
+ /**
238
+ * Compile a Twig template file asynchronously, supporting async loaders.
239
+ *
240
+ * Same pre-walk / memory-wrapper / sync-pipeline shape as
241
+ * {@link Twig#renderFileAsync}. Returns the compiled function (via
242
+ * <var>cb</var>) that takes a locals object and yields a rendered
243
+ * string. The returned function captures the pre-walked memory map and
244
+ * temporarily swaps the loader on each call, so subsequent runtime
245
+ * <code>include</code>s resolve correctly without re-running the
246
+ * pre-walk.
247
+ *
248
+ * @example
249
+ * twig.compileFileAsync('page.twig', {}, function (err, fn) {
250
+ * if (err) { return done(err); }
251
+ * res.end(fn({ name: 'world' }));
252
+ * });
253
+ *
254
+ * @param {string} pathName Template path.
255
+ * @param {object} [options] Compilation options.
256
+ * @param {Function} cb Node-style callback <code>(err, fn)</code>.
257
+ * @return {undefined}
258
+ */
259
+ this.compileFileAsync = function (pathName, options, cb) {
260
+ if (typeof options === 'function') {
261
+ cb = options;
262
+ options = {};
263
+ }
264
+
265
+ var loader = self.options.loader;
266
+ var entry;
267
+
268
+ try {
269
+ entry = loader.resolve(pathName);
270
+ } catch (e) {
271
+ cb(e);
272
+ return;
273
+ }
274
+
275
+ preWalker.walk(entry, loader, buildScanOpts()).then(function (memMap) {
276
+ var memWrapper = preWalker.makeMemoryWrapper(loader, memMap);
277
+ var origLoader = self.options.loader;
278
+ self.options.loader = memWrapper;
279
+ var compiled, error;
280
+ try {
281
+ compiled = self.compileFile(entry, options);
282
+ } catch (e) {
283
+ error = e;
284
+ }
285
+ self.options.loader = origLoader;
286
+ if (error) {
287
+ cb(error);
288
+ return;
289
+ }
290
+ var wrapped = function (locals) {
291
+ var origInner = self.options.loader;
292
+ self.options.loader = memWrapper;
293
+ try {
294
+ var output = compiled(locals);
295
+ self.options.loader = origInner;
296
+ return output;
297
+ } catch (e) {
298
+ self.options.loader = origInner;
299
+ throw e;
300
+ }
301
+ };
302
+ cb(null, wrapped);
303
+ }, function (err) {
304
+ cb(err);
305
+ });
306
+ };
166
307
  };
167
308
 
168
309
  /*!
@@ -176,8 +317,10 @@ exports.parseFile = defaultInstance.parseFile;
176
317
  exports.precompile = defaultInstance.precompile;
177
318
  exports.compile = defaultInstance.compile;
178
319
  exports.compileFile = defaultInstance.compileFile;
320
+ exports.compileFileAsync = defaultInstance.compileFileAsync;
179
321
  exports.render = defaultInstance.render;
180
322
  exports.renderFile = defaultInstance.renderFile;
323
+ exports.renderFileAsync = defaultInstance.renderFileAsync;
181
324
  exports.run = defaultInstance.run;
182
325
  exports.invalidateCache = defaultInstance.invalidateCache;
183
326
 
package/lib/lexer.js CHANGED
@@ -221,7 +221,7 @@ var rules = [
221
221
  {
222
222
  type: TYPES.NUMBER,
223
223
  regex: [
224
- /^[+\-]?\d+(\.\d+)?/
224
+ /^\d+(\.\d+)?/
225
225
  ]
226
226
  },
227
227
  {
package/lib/parser.js CHANGED
@@ -515,8 +515,16 @@ exports.parse = function (swig, source, opts, tags, filters) {
515
515
  escapeRegExp(cmtOpen) + anyChar + escapeRegExp(cmtClose) +
516
516
  ')'
517
517
  );
518
- var tagStrip = new RegExp('^' + escapeRegExp(tagOpen) + '\\s*|\\s*' + escapeRegExp(tagClose) + '$', 'g');
519
- var varStrip = new RegExp('^' + escapeRegExp(varOpen) + '\\s*|\\s*' + escapeRegExp(varClose) + '$', 'g');
518
+ // Twig/Jinja2 whitespace-control. `{{- -}}` / `{%- -%}` strip
519
+ // surrounding whitespace; the `-?` lives only adjacent to the open /
520
+ // close marker (post-#T23 shape — drop the inner `-?` after `\s*` so
521
+ // `{{ -5 }}` doesn't have its expression-`-` eaten as a strip marker).
522
+ var tagStrip = new RegExp('^' + escapeRegExp(tagOpen) + '-?\\s*|\\s*-?' + escapeRegExp(tagClose) + '$', 'g');
523
+ var varStrip = new RegExp('^' + escapeRegExp(varOpen) + '-?\\s*|\\s*-?' + escapeRegExp(varClose) + '$', 'g');
524
+ var tagStripBefore = new RegExp('^' + escapeRegExp(tagOpen) + '-');
525
+ var tagStripAfter = new RegExp('-' + escapeRegExp(tagClose) + '$');
526
+ var varStripBefore = new RegExp('^' + escapeRegExp(varOpen) + '-');
527
+ var varStripAfter = new RegExp('-' + escapeRegExp(varClose) + '$');
520
528
 
521
529
  var line = 1;
522
530
  var stack = [];
@@ -524,6 +532,29 @@ exports.parse = function (swig, source, opts, tags, filters) {
524
532
  var tokens = [];
525
533
  var blocks = {};
526
534
  var inVerbatim = false;
535
+ // Carries `-}}` / `-%}` strip-after intent across the chunk boundary.
536
+ // Consumed by the next text chunk (leading whitespace stripped, flag
537
+ // reset). Mirrors native lib/parser.js's closure-scoped `stripNext`.
538
+ var stripNext = false;
539
+
540
+ /**
541
+ * If the previous token is a Text IR node, strip its trailing
542
+ * whitespace in-place. No-op for non-Text tokens.
543
+ *
544
+ * Mirrors lib/parser.js's stripPrevToken — same one-level-deep
545
+ * limitation: a `{%- endif %}` only strips the trailing whitespace of
546
+ * the last child of the immediately enclosing tag, not deeper.
547
+ *
548
+ * @param {object} token IR node (typed), possibly a Text node.
549
+ * @return {object} Same node; mutated when `type === 'Text'`.
550
+ * @private
551
+ */
552
+ function stripPrevToken(token) {
553
+ if (token && token.type === 'Text' && typeof token.value === 'string') {
554
+ token.value = token.value.replace(/\s*$/, '');
555
+ }
556
+ return token;
557
+ }
527
558
 
528
559
  /**
529
560
  * Build an IROutput node for a `{{ … }}` chunk.
@@ -628,13 +659,17 @@ exports.parse = function (swig, source, opts, tags, filters) {
628
659
  }
629
660
 
630
661
  utils.each(source.split(splitter), function (chunk) {
631
- var token, lines;
662
+ var token, lines, stripPrev, prevToken, prevChildToken;
632
663
 
633
664
  if (!chunk) { return; }
634
665
 
635
666
  if (!inVerbatim && utils.startsWith(chunk, varOpen) && utils.endsWith(chunk, varClose)) {
667
+ stripPrev = varStripBefore.test(chunk);
668
+ stripNext = varStripAfter.test(chunk);
636
669
  token = parseVariable(chunk.replace(varStrip, ''), line);
637
670
  } else if (utils.startsWith(chunk, tagOpen) && utils.endsWith(chunk, tagClose)) {
671
+ stripPrev = tagStripBefore.test(chunk);
672
+ stripNext = tagStripAfter.test(chunk);
638
673
  token = parseTag(chunk.replace(tagStrip, ''), line);
639
674
  if (token) {
640
675
  if (token.name === 'extends') {
@@ -654,9 +689,28 @@ exports.parse = function (swig, source, opts, tags, filters) {
654
689
  line += lines ? lines.length : 0;
655
690
  return;
656
691
  } else {
692
+ if (stripNext) {
693
+ chunk = chunk.replace(/^\s*/, '');
694
+ stripNext = false;
695
+ }
657
696
  token = ir.text(chunk);
658
697
  }
659
698
 
699
+ // `{{-` / `{%-` strips the previous text chunk's trailing whitespace.
700
+ // Mirrors lib/parser.js: pop tokens.last; if it's a Text node strip
701
+ // it directly, else if it carries `.content` (a tag with body) drill
702
+ // one level into its last child. One-level-deep — matches native.
703
+ if (stripPrev && tokens.length) {
704
+ prevToken = tokens.pop();
705
+ if (prevToken && prevToken.type === 'Text') {
706
+ prevToken = stripPrevToken(prevToken);
707
+ } else if (prevToken && prevToken.content && prevToken.content.length) {
708
+ prevChildToken = stripPrevToken(prevToken.content.pop());
709
+ prevToken.content.push(prevChildToken);
710
+ }
711
+ tokens.push(prevToken);
712
+ }
713
+
660
714
  if (token) {
661
715
  if (stack.length) {
662
716
  stack[stack.length - 1].content.push(token);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinostone/swig-twig",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Twig-syntax frontend for the @rhinostone/swig-core template engine. Part of the @rhinostone/swig multi-flavor family.",
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"
25
+ "@rhinostone/swig-core": "2.1.0"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"