@rhinostone/swig-twig 2.0.0-alpha.3
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/filters.js +845 -0
- package/lib/index.js +68 -0
- package/lib/lexer.js +479 -0
- package/lib/parser.js +670 -0
- package/lib/tags/apply.js +206 -0
- package/lib/tags/block.js +93 -0
- package/lib/tags/extends.js +87 -0
- package/lib/tags/for.js +134 -0
- package/lib/tags/from.js +217 -0
- package/lib/tags/if.js +57 -0
- package/lib/tags/import.js +170 -0
- package/lib/tags/include.js +170 -0
- package/lib/tags/index.js +35 -0
- package/lib/tags/macro.js +149 -0
- package/lib/tags/set.js +174 -0
- package/lib/tags/verbatim.js +52 -0
- package/lib/tags/with.js +133 -0
- package/lib/tokentypes.js +97 -0
- package/package.json +30 -0
package/lib/filters.js
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
var utils = require('@rhinostone/swig-core/lib/utils'),
|
|
2
|
+
dateFormatter = require('@rhinostone/swig-core/lib/dateformatter'),
|
|
3
|
+
iterateFilter = require('@rhinostone/swig-core/lib/filters').iterateFilter;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Twig filter catalog.
|
|
7
|
+
*
|
|
8
|
+
* Per-flavor map consumed by `engine.install(self, frontend)` as both
|
|
9
|
+
* the `_filters` runtime map in the compiled template function and the
|
|
10
|
+
* mutation target for `setFilter`. The `.safe = true` convention is
|
|
11
|
+
* inherited from swig-core — filters marked `.safe` suppress the
|
|
12
|
+
* autoescape `e` tail in `parseVariable`.
|
|
13
|
+
*
|
|
14
|
+
* Filter names route through `_filters["<name>"]` at runtime (bracket
|
|
15
|
+
* access on the engine's own filter map), never through the `_ctx`
|
|
16
|
+
* prototype chain, so CVE-2023-25345 guards don't apply at this layer.
|
|
17
|
+
* Filter arg expressions inherit the expression parser's existing
|
|
18
|
+
* `_dangerousProps` guards.
|
|
19
|
+
*
|
|
20
|
+
* See .claude/architecture/tags-and-filters.md § Filters and
|
|
21
|
+
* .claude/architecture/multi-flavor-ir.md § Filter catalogs stay
|
|
22
|
+
* per-flavor.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the number of items in an array, string, or object.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* {{ "Tacos"|length }}
|
|
30
|
+
* // => 5
|
|
31
|
+
*
|
|
32
|
+
* @param {*} input
|
|
33
|
+
* @return {*} The length of the input.
|
|
34
|
+
*/
|
|
35
|
+
exports.length = function (input) {
|
|
36
|
+
if (typeof input === 'object' && !utils.isArray(input)) {
|
|
37
|
+
var keys = utils.keys(input);
|
|
38
|
+
return keys.length;
|
|
39
|
+
}
|
|
40
|
+
if (input && input.hasOwnProperty('length')) {
|
|
41
|
+
return input.length;
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return the input in all lowercase letters.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* {{ "FOOBAR"|lower }}
|
|
51
|
+
* // => foobar
|
|
52
|
+
*
|
|
53
|
+
* @param {*} input
|
|
54
|
+
* @return {*} Same type as input, with strings lower-cased.
|
|
55
|
+
*/
|
|
56
|
+
exports.lower = function (input) {
|
|
57
|
+
var out = iterateFilter.apply(exports.lower, arguments);
|
|
58
|
+
if (out !== undefined) {
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
return input.toString().toLowerCase();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Return the input in all uppercase letters.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* {{ "tacos"|upper }}
|
|
69
|
+
* // => TACOS
|
|
70
|
+
*
|
|
71
|
+
* @param {*} input
|
|
72
|
+
* @return {*} Same type as input, with strings upper-cased.
|
|
73
|
+
*/
|
|
74
|
+
exports.upper = function (input) {
|
|
75
|
+
var out = iterateFilter.apply(exports.upper, arguments);
|
|
76
|
+
if (out !== undefined) {
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
return input.toString().toUpperCase();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the first item of an array, character of a string, or first value
|
|
84
|
+
* of an object.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* {{ ["a", "b", "c"]|first }}
|
|
88
|
+
* // => a
|
|
89
|
+
*
|
|
90
|
+
* @param {*} input
|
|
91
|
+
* @return {*}
|
|
92
|
+
*/
|
|
93
|
+
exports.first = function (input) {
|
|
94
|
+
if (typeof input === 'object' && !utils.isArray(input)) {
|
|
95
|
+
var keys = utils.keys(input);
|
|
96
|
+
return input[keys[0]];
|
|
97
|
+
}
|
|
98
|
+
if (typeof input === 'string') {
|
|
99
|
+
return input.substr(0, 1);
|
|
100
|
+
}
|
|
101
|
+
return input[0];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get the last item of an array, character of a string, or last value
|
|
106
|
+
* of an object.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* {{ ["a", "b", "c"]|last }}
|
|
110
|
+
* // => c
|
|
111
|
+
*
|
|
112
|
+
* @param {*} input
|
|
113
|
+
* @return {*}
|
|
114
|
+
*/
|
|
115
|
+
exports.last = function (input) {
|
|
116
|
+
if (typeof input === 'object' && !utils.isArray(input)) {
|
|
117
|
+
var keys = utils.keys(input);
|
|
118
|
+
return input[keys[keys.length - 1]];
|
|
119
|
+
}
|
|
120
|
+
if (typeof input === 'string') {
|
|
121
|
+
return input.charAt(input.length - 1);
|
|
122
|
+
}
|
|
123
|
+
return input[input.length - 1];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Join an array with a string glue.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* {{ ["foo", "bar", "baz"]|join(", ") }}
|
|
131
|
+
* // => foo, bar, baz
|
|
132
|
+
*
|
|
133
|
+
* @param {*} input
|
|
134
|
+
* @param {string} glue
|
|
135
|
+
* @return {string}
|
|
136
|
+
*/
|
|
137
|
+
exports.join = function (input, glue) {
|
|
138
|
+
if (utils.isArray(input)) {
|
|
139
|
+
return input.join(glue);
|
|
140
|
+
}
|
|
141
|
+
if (typeof input === 'object') {
|
|
142
|
+
var out = [];
|
|
143
|
+
utils.each(input, function (value) {
|
|
144
|
+
out.push(value);
|
|
145
|
+
});
|
|
146
|
+
return out.join(glue);
|
|
147
|
+
}
|
|
148
|
+
return input;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reverse an array or string. Unlike swig's `reverse`, this does NOT
|
|
153
|
+
* sort the input first — items come out in reverse input order, matching
|
|
154
|
+
* Twig semantics.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* {{ [1, 2, 3]|reverse|join(",") }}
|
|
158
|
+
* // => 3,2,1
|
|
159
|
+
*
|
|
160
|
+
* @param {array|string} input
|
|
161
|
+
* @return {array|string}
|
|
162
|
+
*/
|
|
163
|
+
exports.reverse = function (input) {
|
|
164
|
+
if (utils.isArray(input)) {
|
|
165
|
+
return utils.extend([], input).reverse();
|
|
166
|
+
}
|
|
167
|
+
if (typeof input === 'string') {
|
|
168
|
+
return input.split('').reverse().join('');
|
|
169
|
+
}
|
|
170
|
+
return input;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sort an array ascending. Returns a copy — does not mutate the input.
|
|
175
|
+
* If given an object, returns a sorted array of its keys. If given a
|
|
176
|
+
* string, sorts the characters.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* {{ [3, 1, 2]|sort|join(",") }}
|
|
180
|
+
* // => 1,2,3
|
|
181
|
+
*
|
|
182
|
+
* @param {*} input
|
|
183
|
+
* @return {*}
|
|
184
|
+
*/
|
|
185
|
+
exports.sort = function (input) {
|
|
186
|
+
if (utils.isArray(input)) {
|
|
187
|
+
return utils.extend([], input).sort();
|
|
188
|
+
}
|
|
189
|
+
if (typeof input === 'string') {
|
|
190
|
+
return input.split('').sort().join('');
|
|
191
|
+
}
|
|
192
|
+
if (typeof input === 'object') {
|
|
193
|
+
return utils.keys(input).sort();
|
|
194
|
+
}
|
|
195
|
+
return input;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Strip HTML tags from the input.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* {{ "<p>hi</p>"|striptags }}
|
|
203
|
+
* // => hi
|
|
204
|
+
*
|
|
205
|
+
* @param {*} input
|
|
206
|
+
* @return {*} Same type as input, with strings tag-stripped.
|
|
207
|
+
*/
|
|
208
|
+
exports.striptags = function (input) {
|
|
209
|
+
var out = iterateFilter.apply(exports.striptags, arguments);
|
|
210
|
+
if (out !== undefined) {
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
return input.toString().replace(/(<([^>]+)>)/ig, '');
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* URL-encode a string. If an array or object is passed, each value is
|
|
218
|
+
* URL-encoded.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* {{ "a=1&b=2"|url_encode }}
|
|
222
|
+
* // => a%3D1%26b%3D2
|
|
223
|
+
*
|
|
224
|
+
* @param {*} input
|
|
225
|
+
* @return {*}
|
|
226
|
+
*/
|
|
227
|
+
exports.url_encode = function (input) {
|
|
228
|
+
var out = iterateFilter.apply(exports.url_encode, arguments);
|
|
229
|
+
if (out !== undefined) {
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
return encodeURIComponent(input);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* JSON-encode the input.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* {{ {"a": 1}|json_encode }}
|
|
240
|
+
* // => {"a":1}
|
|
241
|
+
*
|
|
242
|
+
* @param {*} input
|
|
243
|
+
* @param {number} [indent] Indent width for pretty-printing.
|
|
244
|
+
* @return {string}
|
|
245
|
+
*/
|
|
246
|
+
exports.json_encode = function (input, indent) {
|
|
247
|
+
return JSON.stringify(input, null, indent || 0);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pass the input through untouched, bypassing autoescape.
|
|
252
|
+
*
|
|
253
|
+
* Marked `.safe = true` so the parser suppresses the trailing `e` filter
|
|
254
|
+
* injected for autoescape. Twig calls this filter `raw`; swig calls the
|
|
255
|
+
* same concept `safe` — Twig exposes only the `raw` name.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* {{ "<b>bold</b>"|raw }}
|
|
259
|
+
* // => <b>bold</b>
|
|
260
|
+
*
|
|
261
|
+
* @param {*} input
|
|
262
|
+
* @return {*}
|
|
263
|
+
*/
|
|
264
|
+
exports.raw = function (input) {
|
|
265
|
+
return input;
|
|
266
|
+
};
|
|
267
|
+
exports.raw.safe = true;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* HTML-escape (default) or JS-escape the input. `e` is a shortcut alias
|
|
271
|
+
* applied by autoescape.
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* {{ "<b>"|escape }}
|
|
275
|
+
* // => <b>
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* {{ "<b>"|escape("js") }}
|
|
279
|
+
* // => \u003Cb\u003E
|
|
280
|
+
*
|
|
281
|
+
* @param {*} input
|
|
282
|
+
* @param {string} [type='html'] Pass `'js'` for JavaScript-safe escaping.
|
|
283
|
+
* @return {string}
|
|
284
|
+
*/
|
|
285
|
+
exports.escape = function (input, type) {
|
|
286
|
+
var out = iterateFilter.apply(exports.escape, arguments),
|
|
287
|
+
inp = input,
|
|
288
|
+
i = 0,
|
|
289
|
+
code;
|
|
290
|
+
|
|
291
|
+
if (out !== undefined) {
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (typeof input !== 'string') {
|
|
296
|
+
return input;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
out = '';
|
|
300
|
+
|
|
301
|
+
switch (type) {
|
|
302
|
+
case 'js':
|
|
303
|
+
inp = inp.replace(/\\/g, '\\u005C');
|
|
304
|
+
for (i; i < inp.length; i += 1) {
|
|
305
|
+
code = inp.charCodeAt(i);
|
|
306
|
+
if (code < 32) {
|
|
307
|
+
code = code.toString(16).toUpperCase();
|
|
308
|
+
code = (code.length < 2) ? '0' + code : code;
|
|
309
|
+
out += '\\u00' + code;
|
|
310
|
+
} else {
|
|
311
|
+
out += inp[i];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return out.replace(/&/g, '\\u0026')
|
|
315
|
+
.replace(/</g, '\\u003C')
|
|
316
|
+
.replace(/>/g, '\\u003E')
|
|
317
|
+
.replace(/\'/g, '\\u0027')
|
|
318
|
+
.replace(/"/g, '\\u0022')
|
|
319
|
+
.replace(/\=/g, '\\u003D')
|
|
320
|
+
.replace(/-/g, '\\u002D')
|
|
321
|
+
.replace(/;/g, '\\u003B');
|
|
322
|
+
|
|
323
|
+
default:
|
|
324
|
+
return inp.replace(/&(?!amp;|lt;|gt;|quot;|#39;)/g, '&')
|
|
325
|
+
.replace(/</g, '<')
|
|
326
|
+
.replace(/>/g, '>')
|
|
327
|
+
.replace(/"/g, '"')
|
|
328
|
+
.replace(/'/g, ''');
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
exports.e = exports.escape;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Return the input if it is not "empty"; otherwise return the fallback.
|
|
335
|
+
*
|
|
336
|
+
* Twig's empty-value semantics match the `empty` test: returns the
|
|
337
|
+
* fallback when the input is `undefined`, `null`, `false`, the empty
|
|
338
|
+
* string `""`, an empty array, or an empty plain object. Numeric `0`
|
|
339
|
+
* and the string `"0"` are NOT considered empty and pass through
|
|
340
|
+
* unchanged. Non-empty values pass through as-is.
|
|
341
|
+
*
|
|
342
|
+
* Divergent from native swig, which has no `default` filter.
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* {{ missing|default("fallback") }}
|
|
346
|
+
* // => fallback
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* {{ ""|default("fallback") }}
|
|
350
|
+
* // => fallback
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* {{ 0|default("fallback") }}
|
|
354
|
+
* // => 0
|
|
355
|
+
*
|
|
356
|
+
* @param {*} input
|
|
357
|
+
* @param {*} fallback
|
|
358
|
+
* @return {*}
|
|
359
|
+
*/
|
|
360
|
+
/**
|
|
361
|
+
* Extract a slice of a string or array.
|
|
362
|
+
*
|
|
363
|
+
* Mirrors Twig's `slice` and PHP's `array_slice` / `substr` semantics:
|
|
364
|
+
* negative `start` counts from the end; `length` omitted or null slices
|
|
365
|
+
* to the end; negative `length` stops that many elements from the end.
|
|
366
|
+
* Non-string non-array input passes through unchanged.
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* {{ "Hello, World"|slice(7, 5) }}
|
|
370
|
+
* // => World
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* {{ [1, 2, 3, 4, 5]|slice(-2)|join(",") }}
|
|
374
|
+
* // => 4,5
|
|
375
|
+
*
|
|
376
|
+
* @param {string|array} input
|
|
377
|
+
* @param {number} start
|
|
378
|
+
* @param {number} [length]
|
|
379
|
+
* @return {string|array}
|
|
380
|
+
*/
|
|
381
|
+
exports.slice = function (input, start, length) {
|
|
382
|
+
if (input === null || input === undefined) {
|
|
383
|
+
return input;
|
|
384
|
+
}
|
|
385
|
+
var isStr = typeof input === 'string';
|
|
386
|
+
var isArr = utils.isArray(input);
|
|
387
|
+
if (!isStr && !isArr) {
|
|
388
|
+
return input;
|
|
389
|
+
}
|
|
390
|
+
var len = input.length;
|
|
391
|
+
var s = start < 0 ? Math.max(0, len + start) : Math.min(start, len);
|
|
392
|
+
var e;
|
|
393
|
+
if (length === undefined || length === null) {
|
|
394
|
+
e = len;
|
|
395
|
+
} else if (length < 0) {
|
|
396
|
+
e = Math.max(s, len + length);
|
|
397
|
+
} else {
|
|
398
|
+
e = Math.min(s + length, len);
|
|
399
|
+
}
|
|
400
|
+
return input.slice(s, e);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Split a string into an array on a delimiter.
|
|
405
|
+
*
|
|
406
|
+
* Mirrors Twig's `split` filter and PHP's `explode` / `str_split`:
|
|
407
|
+
* positive `limit` caps the number of returned pieces (last piece
|
|
408
|
+
* absorbs the remainder); negative `limit` drops that many pieces
|
|
409
|
+
* from the tail; zero or omitted `limit` splits without a cap.
|
|
410
|
+
* An empty delimiter splits by character — with a positive `limit`,
|
|
411
|
+
* each chunk is `limit` characters wide (last chunk may be shorter).
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* {{ "one,two,three"|split(",") }}
|
|
415
|
+
* // => ["one","two","three"]
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* {{ "one,two,three,four"|split(",", 3) }}
|
|
419
|
+
* // => ["one","two","three,four"]
|
|
420
|
+
*
|
|
421
|
+
* @param {string} input
|
|
422
|
+
* @param {string} delimiter
|
|
423
|
+
* @param {number} [limit]
|
|
424
|
+
* @return {string[]}
|
|
425
|
+
*/
|
|
426
|
+
exports.split = function (input, delimiter, limit) {
|
|
427
|
+
if (typeof input !== 'string') {
|
|
428
|
+
return input;
|
|
429
|
+
}
|
|
430
|
+
if (delimiter === '') {
|
|
431
|
+
if (limit === undefined || limit === null || limit <= 1) {
|
|
432
|
+
return input.split('');
|
|
433
|
+
}
|
|
434
|
+
var out = [];
|
|
435
|
+
var i = 0;
|
|
436
|
+
while (i < input.length) {
|
|
437
|
+
out.push(input.substr(i, limit));
|
|
438
|
+
i += limit;
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
if (limit === undefined || limit === null || limit === 0) {
|
|
443
|
+
return input.split(delimiter);
|
|
444
|
+
}
|
|
445
|
+
if (limit > 0) {
|
|
446
|
+
var parts = input.split(delimiter);
|
|
447
|
+
if (parts.length <= limit) {
|
|
448
|
+
return parts;
|
|
449
|
+
}
|
|
450
|
+
var head = parts.slice(0, limit - 1);
|
|
451
|
+
head.push(parts.slice(limit - 1).join(delimiter));
|
|
452
|
+
return head;
|
|
453
|
+
}
|
|
454
|
+
var all = input.split(delimiter);
|
|
455
|
+
return all.slice(0, Math.max(0, all.length + limit));
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Group an array (or object values) into chunks of `size` items. When
|
|
460
|
+
* a `fill` value is provided, the last chunk is padded to `size` with
|
|
461
|
+
* it; otherwise the tail runs shorter.
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* {{ ['a','b','c','d','e']|batch(2) }}
|
|
465
|
+
* // => [['a','b'],['c','d'],['e']]
|
|
466
|
+
*
|
|
467
|
+
* @example
|
|
468
|
+
* {{ ['a','b','c','d','e']|batch(2, '*') }}
|
|
469
|
+
* // => [['a','b'],['c','d'],['e','*']]
|
|
470
|
+
*
|
|
471
|
+
* @param {array|object} input
|
|
472
|
+
* @param {number} size
|
|
473
|
+
* @param {*} [fill]
|
|
474
|
+
* @return {array}
|
|
475
|
+
*/
|
|
476
|
+
exports.batch = function (input, size, fill) {
|
|
477
|
+
var items;
|
|
478
|
+
if (utils.isArray(input)) {
|
|
479
|
+
items = input;
|
|
480
|
+
} else if (input && typeof input === 'object') {
|
|
481
|
+
items = [];
|
|
482
|
+
utils.each(input, function (v) { items.push(v); });
|
|
483
|
+
} else {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
var n = Number(size);
|
|
487
|
+
if (!isFinite(n) || n <= 0) {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
n = Math.ceil(n);
|
|
491
|
+
var out = [];
|
|
492
|
+
var i = 0;
|
|
493
|
+
while (i < items.length) {
|
|
494
|
+
out.push(items.slice(i, i + n));
|
|
495
|
+
i += n;
|
|
496
|
+
}
|
|
497
|
+
if (fill !== undefined && out.length > 0) {
|
|
498
|
+
var last = out[out.length - 1];
|
|
499
|
+
while (last.length < n) {
|
|
500
|
+
last.push(fill);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Strip whitespace (or a custom character set) from both ends of a
|
|
508
|
+
* string. Mirrors Twig's `trim` filter and PHP's `trim` / `ltrim` /
|
|
509
|
+
* `rtrim`: passing `side` as `"left"` or `"right"` strips only the
|
|
510
|
+
* leading or trailing end; the default `"both"` strips both sides.
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* {{ " hi "|trim }}
|
|
514
|
+
* // => "hi"
|
|
515
|
+
*
|
|
516
|
+
* @example
|
|
517
|
+
* {{ "--hi--"|trim("-", "right") }}
|
|
518
|
+
* // => "--hi"
|
|
519
|
+
*
|
|
520
|
+
* @param {string} input
|
|
521
|
+
* @param {string} [chars] Characters to strip (default: whitespace).
|
|
522
|
+
* @param {string} [side='both'] `"left"`, `"right"`, or `"both"`.
|
|
523
|
+
* @return {string}
|
|
524
|
+
*/
|
|
525
|
+
exports.trim = function (input, chars, side) {
|
|
526
|
+
if (typeof input !== 'string') {
|
|
527
|
+
return input;
|
|
528
|
+
}
|
|
529
|
+
var pattern;
|
|
530
|
+
if (chars === undefined || chars === null || chars === '') {
|
|
531
|
+
pattern = '\\s';
|
|
532
|
+
} else {
|
|
533
|
+
pattern = '[' + chars.replace(/[\\\[\]\^\-]/g, '\\$&') + ']';
|
|
534
|
+
}
|
|
535
|
+
var out = input;
|
|
536
|
+
if (side !== 'right') {
|
|
537
|
+
out = out.replace(new RegExp('^' + pattern + '+'), '');
|
|
538
|
+
}
|
|
539
|
+
if (side !== 'left') {
|
|
540
|
+
out = out.replace(new RegExp(pattern + '+$'), '');
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Format a number with grouped thousands and a fixed number of decimals.
|
|
547
|
+
*
|
|
548
|
+
* Mirrors Twig's `number_format` filter and PHP's `number_format`:
|
|
549
|
+
* rounds to `decimals` places, inserts `thousand_sep` every three
|
|
550
|
+
* integer digits, and joins the fractional part with `decimal_point`.
|
|
551
|
+
* Defaults: 0 decimals, `"."` decimal point, `","` thousand separator.
|
|
552
|
+
*
|
|
553
|
+
* Non-finite input (NaN, Infinity) passes through unchanged; non-numeric
|
|
554
|
+
* input is coerced via `Number(input)` — callers expecting string
|
|
555
|
+
* passthrough should pre-check.
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* {{ 9800.333|number_format(2, ".", ",") }}
|
|
559
|
+
* // => 9,800.33
|
|
560
|
+
*
|
|
561
|
+
* @example
|
|
562
|
+
* {{ 1234567|number_format }}
|
|
563
|
+
* // => 1,234,567
|
|
564
|
+
*
|
|
565
|
+
* @param {number} input
|
|
566
|
+
* @param {number} [decimals=0]
|
|
567
|
+
* @param {string} [decimalPoint="."]
|
|
568
|
+
* @param {string} [thousandSep=","]
|
|
569
|
+
* @return {string}
|
|
570
|
+
*/
|
|
571
|
+
exports.number_format = function (input, decimals, decimalPoint, thousandSep) {
|
|
572
|
+
var num = Number(input);
|
|
573
|
+
if (!isFinite(num)) {
|
|
574
|
+
return input;
|
|
575
|
+
}
|
|
576
|
+
var d = (decimals === undefined) ? 0 : decimals;
|
|
577
|
+
var dp = (decimalPoint === undefined) ? '.' : decimalPoint;
|
|
578
|
+
var ts = (thousandSep === undefined) ? ',' : thousandSep;
|
|
579
|
+
var fixed = num.toFixed(d);
|
|
580
|
+
var parts = fixed.split('.');
|
|
581
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ts);
|
|
582
|
+
return parts.join(dp);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Replace occurrences of each key in `from` with its corresponding value.
|
|
587
|
+
*
|
|
588
|
+
* Mirrors Twig's `replace` filter (which wraps PHP's `strtr` array form):
|
|
589
|
+
* all pairs are applied simultaneously rather than sequentially, so a
|
|
590
|
+
* replacement value containing one of the keys does NOT get re-replaced
|
|
591
|
+
* on a later pass. When two keys overlap at the same position, the longer
|
|
592
|
+
* key wins (matching `strtr`'s longest-first semantics); ties are broken
|
|
593
|
+
* by insertion order.
|
|
594
|
+
*
|
|
595
|
+
* Divergent from native swig's `replace(search, replacement, flags)`
|
|
596
|
+
* which takes three string args and uses `new RegExp(...)` semantics.
|
|
597
|
+
* Twig's API takes a single object argument and does literal
|
|
598
|
+
* (non-regex) string matching.
|
|
599
|
+
*
|
|
600
|
+
* Non-string input passes through unchanged. A missing, null, or
|
|
601
|
+
* non-object `from` passes through unchanged. Empty-string keys are
|
|
602
|
+
* silently skipped (a zero-length match would loop forever).
|
|
603
|
+
*
|
|
604
|
+
* @example
|
|
605
|
+
* {{ "I like %this% and %that%"|replace({"%this%": "foo", "%that%": "bar"}) }}
|
|
606
|
+
* // => I like foo and bar
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* {{ "hello"|replace({"h": "j", "j": "k"}) }}
|
|
610
|
+
* // => jello
|
|
611
|
+
*
|
|
612
|
+
* @param {string} input
|
|
613
|
+
* @param {object} from Map of search strings to replacement strings.
|
|
614
|
+
* @return {string}
|
|
615
|
+
*/
|
|
616
|
+
exports.replace = function (input, from) {
|
|
617
|
+
if (typeof input !== 'string') {
|
|
618
|
+
return input;
|
|
619
|
+
}
|
|
620
|
+
if (!from || typeof from !== 'object' || utils.isArray(from)) {
|
|
621
|
+
return input;
|
|
622
|
+
}
|
|
623
|
+
var keys = utils.keys(from);
|
|
624
|
+
if (keys.length === 0) {
|
|
625
|
+
return input;
|
|
626
|
+
}
|
|
627
|
+
keys.sort(function (a, b) {
|
|
628
|
+
return b.length - a.length;
|
|
629
|
+
});
|
|
630
|
+
var out = '';
|
|
631
|
+
var i = 0;
|
|
632
|
+
while (i < input.length) {
|
|
633
|
+
var matched = false;
|
|
634
|
+
for (var j = 0; j < keys.length; j += 1) {
|
|
635
|
+
var key = keys[j];
|
|
636
|
+
if (key.length === 0) { continue; }
|
|
637
|
+
if (input.substring(i, i + key.length) === key) {
|
|
638
|
+
out += String(from[key]);
|
|
639
|
+
i += key.length;
|
|
640
|
+
matched = true;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (!matched) {
|
|
645
|
+
out += input.charAt(i);
|
|
646
|
+
i += 1;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
exports['default'] = function (input, fallback) {
|
|
653
|
+
if (input === undefined || input === null || input === false) {
|
|
654
|
+
return fallback;
|
|
655
|
+
}
|
|
656
|
+
if (input === '') {
|
|
657
|
+
return fallback;
|
|
658
|
+
}
|
|
659
|
+
if (utils.isArray(input) && input.length === 0) {
|
|
660
|
+
return fallback;
|
|
661
|
+
}
|
|
662
|
+
if (typeof input === 'object' && utils.keys(input).length === 0) {
|
|
663
|
+
return fallback;
|
|
664
|
+
}
|
|
665
|
+
return input;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Return the keys of an array or object as an array.
|
|
670
|
+
*
|
|
671
|
+
* For an array, returns the integer indices (`0, 1, 2, ...`). For an
|
|
672
|
+
* object, returns the own enumerable keys. For any other input (string,
|
|
673
|
+
* number, null, undefined), returns an empty array.
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
* {{ [10, 20, 30]|keys|join(",") }}
|
|
677
|
+
* // => 0,1,2
|
|
678
|
+
*
|
|
679
|
+
* @example
|
|
680
|
+
* {{ {"a": 1, "b": 2}|keys|join(",") }}
|
|
681
|
+
* // => a,b
|
|
682
|
+
*
|
|
683
|
+
* @param {*} input
|
|
684
|
+
* @return {Array}
|
|
685
|
+
*/
|
|
686
|
+
exports.keys = function (input) {
|
|
687
|
+
if (utils.isArray(input)) {
|
|
688
|
+
var out = [];
|
|
689
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
690
|
+
out.push(i);
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
if (input && typeof input === 'object') {
|
|
695
|
+
return utils.keys(input);
|
|
696
|
+
}
|
|
697
|
+
return [];
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Merge an array or object into another.
|
|
702
|
+
*
|
|
703
|
+
* When both operands are arrays, the result is the concatenation of the
|
|
704
|
+
* two (no dedup). When either operand is a plain object, the result is a
|
|
705
|
+
* shallow object merge with the right-hand operand's keys winning on
|
|
706
|
+
* collision. Non-array / non-object operands are passed through as-is.
|
|
707
|
+
*
|
|
708
|
+
* @example
|
|
709
|
+
* {{ [1, 2]|merge([3, 4])|join(",") }}
|
|
710
|
+
* // => 1,2,3,4
|
|
711
|
+
*
|
|
712
|
+
* @example
|
|
713
|
+
* {{ {"a": 1, "b": 2}|merge({"b": 99, "c": 3}) }}
|
|
714
|
+
* // => {"a":1,"b":99,"c":3}
|
|
715
|
+
*
|
|
716
|
+
* @param {*} input
|
|
717
|
+
* @param {*} other
|
|
718
|
+
* @return {*}
|
|
719
|
+
*/
|
|
720
|
+
/**
|
|
721
|
+
* Format the input string using a subset of sprintf-style placeholders.
|
|
722
|
+
*
|
|
723
|
+
* Supported directives: `%s` (string), `%d` (integer), `%f` (float),
|
|
724
|
+
* `%x` (lowercase hex), `%%` (literal `%`). Width / precision / flag
|
|
725
|
+
* modifiers (`%5d`, `%.2f`, `%-10s`, `%05d`) are not supported — they
|
|
726
|
+
* pass through as literal text. A `%d`/`%f`/`%x` directive whose
|
|
727
|
+
* argument is non-numeric emits `NaN`. An under-supplied argument list
|
|
728
|
+
* emits `undefined` for the missing slots.
|
|
729
|
+
*
|
|
730
|
+
* @example
|
|
731
|
+
* {{ "Hello, %s!"|format(name) }}
|
|
732
|
+
* // => Hello, Twig!
|
|
733
|
+
*
|
|
734
|
+
* @example
|
|
735
|
+
* {{ "%d + %d = %d"|format(1, 2, 3) }}
|
|
736
|
+
* // => 1 + 2 = 3
|
|
737
|
+
*
|
|
738
|
+
* @param {*} input
|
|
739
|
+
* @return {string}
|
|
740
|
+
*/
|
|
741
|
+
exports.format = function (input) {
|
|
742
|
+
if (typeof input !== 'string') { return input; }
|
|
743
|
+
var args = Array.prototype.slice.call(arguments, 1);
|
|
744
|
+
var idx = 0;
|
|
745
|
+
return input.replace(/%[sdfx%]/g, function (match) {
|
|
746
|
+
if (match === '%%') { return '%'; }
|
|
747
|
+
var a = args[idx];
|
|
748
|
+
idx += 1;
|
|
749
|
+
switch (match) {
|
|
750
|
+
case '%s': return String(a);
|
|
751
|
+
case '%d': return String(parseInt(a, 10));
|
|
752
|
+
case '%f': return String(parseFloat(a));
|
|
753
|
+
case '%x': return Number(a).toString(16);
|
|
754
|
+
}
|
|
755
|
+
return match;
|
|
756
|
+
});
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
exports.merge = function (input, other) {
|
|
760
|
+
if (other === undefined || other === null) {
|
|
761
|
+
return input;
|
|
762
|
+
}
|
|
763
|
+
if (utils.isArray(input) && utils.isArray(other)) {
|
|
764
|
+
return input.concat(other);
|
|
765
|
+
}
|
|
766
|
+
var out = {};
|
|
767
|
+
var k;
|
|
768
|
+
if (utils.isArray(input)) {
|
|
769
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
770
|
+
out[i] = input[i];
|
|
771
|
+
}
|
|
772
|
+
} else if (input && typeof input === 'object') {
|
|
773
|
+
for (k in input) {
|
|
774
|
+
if (input.hasOwnProperty(k)) { out[k] = input[k]; }
|
|
775
|
+
}
|
|
776
|
+
} else {
|
|
777
|
+
return input;
|
|
778
|
+
}
|
|
779
|
+
if (utils.isArray(other)) {
|
|
780
|
+
for (var j = 0; j < other.length; j += 1) {
|
|
781
|
+
out[j] = other[j];
|
|
782
|
+
}
|
|
783
|
+
} else if (typeof other === 'object') {
|
|
784
|
+
for (k in other) {
|
|
785
|
+
if (other.hasOwnProperty(k)) { out[k] = other[k]; }
|
|
786
|
+
}
|
|
787
|
+
} else {
|
|
788
|
+
return input;
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Format a date or Date-compatible string using a PHP-style format spec.
|
|
795
|
+
*
|
|
796
|
+
* Wraps the shared `@rhinostone/swig-core/lib/dateformatter`. Format
|
|
797
|
+
* tokens match PHP's `date()` — see the swig-core dateformatter for the
|
|
798
|
+
* full table (d/D/j/l/N/S/w/z, W, F/m/M/n/t, L/o/Y/y, a/A/B/g/G/h/H/i/s,
|
|
799
|
+
* O/Z, c/r/U). Backslash (`\`) escapes the next character so literal
|
|
800
|
+
* tokens can appear in the output.
|
|
801
|
+
*
|
|
802
|
+
* Twig's native `|date` accepts DateTime / DateInterval objects, a
|
|
803
|
+
* timezone string argument, and locale-aware month/day names. Those are
|
|
804
|
+
* Phase 4 concerns — today this filter supports the same surface as
|
|
805
|
+
* native swig's `date` filter: Date object or epoch-ms number input, a
|
|
806
|
+
* format string, an optional numeric `offset` in minutes from GMT, and
|
|
807
|
+
* an optional `abbr` timezone abbreviation (output-only).
|
|
808
|
+
*
|
|
809
|
+
* @example
|
|
810
|
+
* {{ now|date('Y-m-d') }}
|
|
811
|
+
* // => 2026-04-17
|
|
812
|
+
* @example
|
|
813
|
+
* {{ now|date('jS \\o\\f F') }}
|
|
814
|
+
* // => 17th of April
|
|
815
|
+
*
|
|
816
|
+
* @param {?(string|Date|number)} input
|
|
817
|
+
* @param {string} format PHP-style date format string. Escape literals with `\`.
|
|
818
|
+
* @param {number=} offset Timezone offset from GMT in minutes.
|
|
819
|
+
* @param {string=} abbr Timezone abbreviation. Output only.
|
|
820
|
+
* @return {string} Formatted date string.
|
|
821
|
+
*/
|
|
822
|
+
exports.date = function (input, format, offset, abbr) {
|
|
823
|
+
var l = format.length,
|
|
824
|
+
date = new dateFormatter.DateZ(input),
|
|
825
|
+
cur,
|
|
826
|
+
i = 0,
|
|
827
|
+
out = '';
|
|
828
|
+
|
|
829
|
+
if (offset) {
|
|
830
|
+
date.setTimezoneOffset(offset, abbr);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
for (i; i < l; i += 1) {
|
|
834
|
+
cur = format.charAt(i);
|
|
835
|
+
if (cur === '\\') {
|
|
836
|
+
i += 1;
|
|
837
|
+
out += (i < l) ? format.charAt(i) : cur;
|
|
838
|
+
} else if (dateFormatter.hasOwnProperty(cur)) {
|
|
839
|
+
out += dateFormatter[cur](date, offset, abbr);
|
|
840
|
+
} else {
|
|
841
|
+
out += cur;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return out;
|
|
845
|
+
};
|