@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.
- package/lib/async/pre-walker.js +267 -0
- package/lib/index.js +144 -1
- package/lib/lexer.js +1 -1
- package/lib/parser.js +57 -3
- package/package.json +2 -2
|
@@ -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
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
|
-
|
|
519
|
-
|
|
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.
|
|
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.
|
|
25
|
+
"@rhinostone/swig-core": "2.1.0"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|