@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 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
+ * // => &lt;b&gt;
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, '&amp;')
325
+ .replace(/</g, '&lt;')
326
+ .replace(/>/g, '&gt;')
327
+ .replace(/"/g, '&quot;')
328
+ .replace(/'/g, '&#39;');
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
+ };