miki-template 1.2.0 → 1.3.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.
Files changed (60) hide show
  1. package/.github/release-notes/v1.3.1.md +55 -0
  2. package/CHANGELOG.md +72 -0
  3. package/README.md +43 -26
  4. package/assets/banner.png +0 -0
  5. package/benchmarks/stress.mjs +647 -0
  6. package/dir/base.html +23 -0
  7. package/dir/cmpnt.html +11 -0
  8. package/dir/footer.html +3 -0
  9. package/dir/home.html +80 -0
  10. package/dir/navbar.html +9 -0
  11. package/docs/api.md +20 -3
  12. package/docs/filters.md +301 -133
  13. package/docs/partialdef.md +30 -1
  14. package/docs/tags.md +63 -0
  15. package/docs/usage.md +50 -3
  16. package/eslint.config.mjs +9 -1
  17. package/ex.mjs +33 -0
  18. package/miki-template-extension/.github/workflows/ci.yml +116 -0
  19. package/miki-template-extension/.vscodeignore +7 -0
  20. package/miki-template-extension/CHANGELOG.md +99 -0
  21. package/miki-template-extension/README.md +244 -53
  22. package/miki-template-extension/extension.js +1013 -0
  23. package/miki-template-extension/icon.png +0 -0
  24. package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
  25. package/miki-template-extension/package.json +244 -10
  26. package/miki-template-extension/snippets/miki-template.json +612 -72
  27. package/miki-template-extension/syntaxes/language-configuration.json +101 -13
  28. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +270 -61
  29. package/miki-template-extension/tests/grammar-tests.json +162 -0
  30. package/miki-template-extension/tests/run-grammar-tests.js +82 -0
  31. package/package.json +7 -4
  32. package/scripts/build-vsix.js +129 -0
  33. package/scripts/build-vsix.ps1 +15 -0
  34. package/src/cache.js +41 -2
  35. package/src/context.js +9 -5
  36. package/src/context_processors.js +9 -2
  37. package/src/esm.mjs +12 -0
  38. package/src/filters.js +472 -24
  39. package/src/index.js +571 -85
  40. package/src/lexer.js +76 -54
  41. package/src/libraries.js +134 -3
  42. package/src/parser.js +22 -2
  43. package/src/security.js +4 -2
  44. package/src/tags/control.js +150 -21
  45. package/src/tags/extra.js +154 -0
  46. package/src/tags/i18n.js +49 -23
  47. package/src/tags/inheritance.js +142 -23
  48. package/src/tags/util.js +102 -24
  49. package/tests/esm.test.mjs +37 -2
  50. package/tests/filters.test.js +155 -0
  51. package/tests/integration/README.md +32 -0
  52. package/tests/integration/features.test.cjs +1681 -0
  53. package/tests/integration/features.test.mjs +1697 -0
  54. package/tests/integration/templates/base.miki +6 -0
  55. package/tests/integration/templates/child.miki +6 -0
  56. package/tests/integration/templates/index.html +17 -0
  57. package/tests/parser.test.js +5 -3
  58. package/tests/partialdef.test.js +40 -1
  59. package/tests/tags.test.js +30 -0
  60. package/miki-template-1.2.0.vsix +0 -0
package/src/filters.js CHANGED
@@ -12,7 +12,9 @@ function getFilter(name) {
12
12
 
13
13
  function escapeValue(val) {
14
14
  const { escapeHtml, markSafe } = require('./security');
15
- return markSafe(escapeHtml(val));
15
+ // force=true ensures even SafeString values are re-escaped, matching
16
+ // Django's `{{ value|escape }}` semantics.
17
+ return markSafe(escapeHtml(val, true));
16
18
  }
17
19
 
18
20
  function markValueSafe(val) {
@@ -208,23 +210,55 @@ registerFilter('date', (val, arg) => {
208
210
  }
209
211
  if (isNaN(date.getTime())) return '';
210
212
  const formatStr = arg || 'Y-m-d';
211
- const mapper = {
212
- d: () => String(date.getDate()).padStart(2, '0'),
213
- j: () => String(date.getDate()),
214
- m: () => String(date.getMonth() + 1).padStart(2, '0'),
215
- n: () => String(date.getMonth() + 1),
216
- Y: () => String(date.getFullYear()),
217
- y: () => String(date.getFullYear()).slice(-2),
218
- H: () => String(date.getHours()).padStart(2, '0'),
219
- i: () => String(date.getMinutes()).padStart(2, '0'),
220
- s: () => String(date.getSeconds()).padStart(2, '0'),
221
- F: () => date.toLocaleString('default', { month: 'long' }),
222
- M: () => date.toLocaleString('default', { month: 'short' })
223
- };
213
+ // Multi-character tokens are matched longest-first so that "yyyy" is
214
+ // one token and not four "y" tokens. Conventions:
215
+ // yyyy 4-digit year yy 2-digit year
216
+ // MM padded month number M month number (no pad)
217
+ // mm padded minutes m month number (Django-style alias)
218
+ // dd padded day d day number (no pad)
219
+ // HH padded 24h hour H hour (no pad)
220
+ // ii padded minutes i minutes (no pad)
221
+ // ss padded seconds s seconds (no pad)
222
+ // F long month name D short day name
223
+ const tokenMap = [
224
+ ['yyyy', () => String(date.getFullYear()).padStart(4, '0')],
225
+ ['yy', () => String(date.getFullYear()).slice(-2).padStart(2, '0')],
226
+ ['MM', () => String(date.getMonth() + 1).padStart(2, '0')],
227
+ ['dd', () => String(date.getDate()).padStart(2, '0')],
228
+ ['HH', () => String(date.getHours()).padStart(2, '0')],
229
+ ['mm', () => String(date.getMinutes()).padStart(2, '0')],
230
+ ['ii', () => String(date.getMinutes()).padStart(2, '0')],
231
+ ['ss', () => String(date.getSeconds()).padStart(2, '0')],
232
+ ['F', () => date.toLocaleString('default', { month: 'long' })],
233
+ ['D', () => date.toLocaleString('default', { weekday: 'short' })],
234
+ ['M', () => String(date.getMonth() + 1)],
235
+ ['Y', () => String(date.getFullYear())],
236
+ ['y', () => String(date.getFullYear()).slice(-2)],
237
+ ['m', () => String(date.getMonth() + 1)],
238
+ ['d', () => String(date.getDate())],
239
+ ['j', () => String(date.getDate())],
240
+ ['n', () => String(date.getMonth() + 1)],
241
+ ['H', () => String(date.getHours())],
242
+ ['i', () => String(date.getMinutes())],
243
+ ['s', () => String(date.getSeconds())],
244
+ ['G', () => String(date.getHours())],
245
+ ];
224
246
  let output = '';
225
- for (let i = 0; i < formatStr.length; i++) {
226
- const ch = formatStr[i];
227
- output += ch in mapper ? mapper[ch]() : ch;
247
+ let i = 0;
248
+ while (i < formatStr.length) {
249
+ let matched = false;
250
+ for (const [tok, fn] of tokenMap) {
251
+ if (formatStr.startsWith(tok, i)) {
252
+ output += fn();
253
+ i += tok.length;
254
+ matched = true;
255
+ break;
256
+ }
257
+ }
258
+ if (!matched) {
259
+ output += formatStr[i];
260
+ i++;
261
+ }
228
262
  }
229
263
  return output;
230
264
  });
@@ -280,6 +314,24 @@ registerFilter('add', (val, arg) => {
280
314
  return String(val) + String(arg);
281
315
  });
282
316
 
317
+ registerFilter('sub', (val, arg) => {
318
+ const numVal = Number(val);
319
+ const numArg = Number(arg);
320
+ if (!isNaN(numVal) && !isNaN(numArg)) {
321
+ return numVal - numArg;
322
+ }
323
+ return String(val);
324
+ });
325
+
326
+ registerFilter('mult', (val, arg) => {
327
+ const numVal = Number(val);
328
+ const numArg = Number(arg);
329
+ if (!isNaN(numVal) && !isNaN(numArg)) {
330
+ return numVal * numArg;
331
+ }
332
+ return String(val);
333
+ });
334
+
283
335
  registerFilter('divisibleby', (val, arg) => {
284
336
  const numVal = Number(val);
285
337
  const numArg = Number(arg);
@@ -287,6 +339,13 @@ registerFilter('divisibleby', (val, arg) => {
287
339
  return numVal % numArg === 0;
288
340
  });
289
341
 
342
+ registerFilter('mod', (val, arg) => {
343
+ const numVal = Number(val);
344
+ const numArg = Number(arg);
345
+ if (isNaN(numVal) || isNaN(numArg) || numArg === 0) return 0;
346
+ return numVal % numArg;
347
+ });
348
+
290
349
  registerFilter('floatformat', (val, arg) => {
291
350
  const num = Number(val);
292
351
  if (isNaN(num)) return '';
@@ -301,15 +360,61 @@ registerFilter('floatformat', (val, arg) => {
301
360
  return num.toFixed(Math.max(0, decimals));
302
361
  });
303
362
 
363
+ registerFilter('square', (val) => {
364
+ const num = Number(val);
365
+ if (isNaN(num)) return 0;
366
+ return num * num;
367
+ });
368
+
369
+ registerFilter('sqrt', (val) => {
370
+ const num = Number(val);
371
+ if (isNaN(num) || num < 0) return 0;
372
+ return Math.sqrt(num);
373
+ });
374
+
304
375
  // --- Default Filters ---
305
- registerFilter('default', (val, arg) => {
306
- return val === null || val === undefined || val === '' ? arg : val;
376
+ // When the fallback contains template syntax ({% ... %} or {{ ... }}),
377
+ // it is rendered against the current context. This matches Django's
378
+ // behavior where `{{ x|default:"{% lorem 1 %}" }}` produces lorem text
379
+ // when x is empty.
380
+ registerFilter('default', (val, arg, ctx) => {
381
+ if (val !== null && val !== undefined && val !== '') return val;
382
+ return renderFallbackTemplate(arg, ctx);
307
383
  });
308
384
 
309
- registerFilter('default_if_none', (val, arg) => {
310
- return (val === null || val === undefined) ? arg : val;
385
+ registerFilter('default_if_none', (val, arg, ctx) => {
386
+ if (val !== null && val !== undefined) return val;
387
+ return renderFallbackTemplate(arg, ctx);
311
388
  });
312
389
 
390
+ /**
391
+ * If `arg` looks like a mini-template (contains `{% ... %}` or
392
+ * `{{ ... }}`), compile and render it. Otherwise return it as-is.
393
+ */
394
+ function renderFallbackTemplate(arg, ctx) {
395
+ if (typeof arg !== 'string') return arg;
396
+ if (!/\{[{%]/.test(arg)) return arg;
397
+ try {
398
+ // Lazy require to avoid circular dependency with index.js
399
+ const { render } = require('./index');
400
+ // Merge all active scopes (top-most wins) into a plain object
401
+ let merged = {};
402
+ if (ctx && Array.isArray(ctx.scopes)) {
403
+ // scopes[0] is the topmost (most local). Iterate from bottom
404
+ // (root) to top so topmost values win.
405
+ for (let i = ctx.scopes.length - 1; i >= 0; i--) {
406
+ const s = ctx.scopes[i];
407
+ if (s && typeof s === 'object' && !s.__forloop && !s.__block) {
408
+ Object.assign(merged, s);
409
+ }
410
+ }
411
+ }
412
+ return render(arg, merged);
413
+ } catch {
414
+ return arg;
415
+ }
416
+ }
417
+
313
418
  registerFilter('firstof', (...args) => {
314
419
  for (const arg of args) {
315
420
  if (arg !== null && arg !== undefined && arg !== '') {
@@ -445,15 +550,19 @@ registerFilter('addslashes', (val) => {
445
550
  registerFilter('removetags', (val, arg) => {
446
551
  const str = String(val === null || val === undefined ? '' : val);
447
552
  if (!arg) return str;
448
- const tags = arg.split(',').map(t => t.trim()).filter(Boolean);
553
+ // Django's removetags accepts space-separated tag names (and tolerates commas).
554
+ const tags = String(arg).split(/[\s,]+/).map(t => t.trim()).filter(Boolean);
449
555
  let result = str;
450
556
  for (const tag of tags) {
451
557
  const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
452
558
  result = result.replace(new RegExp(`<\\/${escaped}>`, 'gi'), '');
453
- result = result.replace(new RegExp(`<${escaped}[^>]*>`, 'gi'), '');
559
+ result = result.replace(new RegExp(`<${escaped}\\b[^>]*>`, 'gi'), '');
454
560
  result = result.replace(new RegExp(`<${escaped}>`, 'gi'), '');
455
561
  }
456
- return result;
562
+ // The result is intentionally safe (we just produced raw HTML by
563
+ // removing tags) and must not be re-escaped.
564
+ const { markSafe } = require('./security');
565
+ return markSafe(result);
457
566
  });
458
567
 
459
568
  /**
@@ -523,5 +632,344 @@ registerFilter('strftime', (val, arg) => {
523
632
  }
524
633
  });
525
634
 
635
+ // --- Math Filters ---
636
+ registerFilter('sub', (val, arg) => {
637
+ const numVal = Number(val);
638
+ const numArg = Number(arg);
639
+ if (!isNaN(numVal) && !isNaN(numArg)) {
640
+ return numVal - numArg;
641
+ }
642
+ return String(val);
643
+ });
644
+
645
+ registerFilter('mult', (val, arg) => {
646
+ const numVal = Number(val);
647
+ const numArg = Number(arg);
648
+ if (!isNaN(numVal) && !isNaN(numArg)) {
649
+ return numVal * numArg;
650
+ }
651
+ return String(val);
652
+ });
653
+
654
+ registerFilter('square', (val) => {
655
+ const num = Number(val);
656
+ if (isNaN(num)) return 0;
657
+ return num * num;
658
+ });
659
+
660
+ registerFilter('sqrt', (val) => {
661
+ const num = Number(val);
662
+ if (isNaN(num) || num < 0) return 0;
663
+ return Math.sqrt(num);
664
+ });
665
+
666
+ registerFilter('mod', (val, arg) => {
667
+ const numVal = Number(val);
668
+ const numArg = Number(arg);
669
+ if (isNaN(numVal) || isNaN(numArg) || numArg === 0) return 0;
670
+ return numVal % numArg;
671
+ });
672
+
673
+ // --- Data Formatting Filters ---
674
+ registerFilter('currency', (val, arg) => {
675
+ const num = Number(val);
676
+ if (isNaN(num)) return '';
677
+ const symbol = arg || '$';
678
+ const formatted = num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
679
+ return symbol + formatted;
680
+ });
681
+
682
+ registerFilter('phone_number', (val) => {
683
+ const str = String(val === null || val === undefined ? '' : val).replace(/\D/g, '');
684
+ if (str.length === 10) {
685
+ return `(${str.slice(0, 3)}) ${str.slice(3, 6)}-${str.slice(6)}`;
686
+ }
687
+ if (str.length === 11 && str.startsWith('1')) {
688
+ return `+1 (${str.slice(1, 4)}) ${str.slice(4, 7)}-${str.slice(7)}`;
689
+ }
690
+ return String(val);
691
+ });
692
+
693
+ registerFilter('email', (val) => {
694
+ const str = String(val === null || val === undefined ? '' : val).trim();
695
+ if (!str) return '';
696
+ return `mailto:${str}`;
697
+ });
698
+
699
+ registerFilter('url', (val) => {
700
+ let str = String(val === null || val === undefined ? '' : val).trim();
701
+ if (!str) return '';
702
+ if (!/^https?:\/\//i.test(str)) {
703
+ str = 'https://' + str;
704
+ }
705
+ return str;
706
+ });
707
+
708
+ registerFilter('mask', (val, arg) => {
709
+ const str = String(val === null || val === undefined ? '' : val);
710
+ const char = arg || '*';
711
+ const visible = 4;
712
+ if (str.length <= visible) return str;
713
+ return char.repeat(str.length - visible) + str.slice(-visible);
714
+ });
715
+
716
+ registerFilter('whatsapp_link', (val, arg) => {
717
+ let str = String(val === null || val === undefined ? '' : val).trim();
718
+ if (!str) return '';
719
+ const digits = str.replace(/\D/g, '');
720
+ const message = arg || '';
721
+ const url = new URL('https://wa.me/' + digits);
722
+ if (message) {
723
+ url.searchParams.set('text', message);
724
+ }
725
+ return url.toString();
726
+ });
727
+
728
+ // --- Math Filters ---
729
+ registerFilter('abs', (val) => {
730
+ const num = Number(val);
731
+ return isNaN(num) ? 0 : Math.abs(num);
732
+ });
733
+
734
+ registerFilter('round', (val, arg) => {
735
+ const num = Number(val);
736
+ if (isNaN(num)) return 0;
737
+ const decimals = arg !== undefined ? parseInt(arg, 10) : 0;
738
+ if (isNaN(decimals)) return Math.round(num);
739
+ const factor = Math.pow(10, decimals);
740
+ return Math.round(num * factor) / factor;
741
+ });
742
+
743
+ registerFilter('floor', (val) => {
744
+ const num = Number(val);
745
+ return isNaN(num) ? 0 : Math.floor(num);
746
+ });
747
+
748
+ registerFilter('ceil', (val) => {
749
+ const num = Number(val);
750
+ return isNaN(num) ? 0 : Math.ceil(num);
751
+ });
752
+
753
+ registerFilter('min', (val, arg) => {
754
+ if (Array.isArray(val)) return Math.min(...val);
755
+ const num = Number(val);
756
+ const numArg = Number(arg);
757
+ if (!isNaN(num) && !isNaN(numArg)) return Math.min(num, numArg);
758
+ return num;
759
+ });
760
+
761
+ registerFilter('max', (val, arg) => {
762
+ if (Array.isArray(val)) return Math.max(...val);
763
+ const num = Number(val);
764
+ const numArg = Number(arg);
765
+ if (!isNaN(num) && !isNaN(numArg)) return Math.max(num, numArg);
766
+ return num;
767
+ });
768
+
769
+ registerFilter('sum', (val) => {
770
+ if (!Array.isArray(val)) return Number(val) || 0;
771
+ return val.reduce((acc, item) => acc + (Number(item) || 0), 0);
772
+ });
773
+
774
+ registerFilter('average', (val) => {
775
+ if (!Array.isArray(val)) return Number(val) || 0;
776
+ if (val.length === 0) return 0;
777
+ return val.reduce((acc, item) => acc + (Number(item) || 0), 0) / val.length;
778
+ });
779
+
780
+ // --- String / Array Manipulation Filters ---
781
+ registerFilter('reverse', (val) => {
782
+ if (Array.isArray(val)) return val.slice().reverse();
783
+ const str = String(val === null || val === undefined ? '' : val);
784
+ return str.split('').reverse().join('');
785
+ });
786
+
787
+ registerFilter('sort', (val) => {
788
+ if (!Array.isArray(val)) return val;
789
+ return [...val].sort((a, b) => {
790
+ if (a === b) return 0;
791
+ if (a === null || a === undefined) return 1;
792
+ if (b === null || b === undefined) return -1;
793
+ if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b);
794
+ return a < b ? -1 : 1;
795
+ });
796
+ });
797
+
798
+ registerFilter('unique', (val) => {
799
+ if (!Array.isArray(val)) return val;
800
+ return [...new Set(val)];
801
+ });
802
+
803
+ registerFilter('random', (val) => {
804
+ if (!Array.isArray(val) || val.length === 0) return '';
805
+ return val[Math.floor(Math.random() * val.length)];
806
+ });
807
+
808
+ registerFilter('split', (val, arg) => {
809
+ const str = String(val === null || val === undefined ? '' : val);
810
+ const sep = arg || ' ';
811
+ return str.split(sep);
812
+ });
813
+
814
+ registerFilter('replace', (val, arg) => {
815
+ const str = String(val === null || val === undefined ? '' : val);
816
+ if (!arg) return str;
817
+ const parts = String(arg).split(',');
818
+ if (parts.length >= 2) {
819
+ const oldStr = parts[0].trim();
820
+ const newStr = parts.slice(1).join(',').trim();
821
+ return str.split(oldStr).join(newStr);
822
+ }
823
+ return str;
824
+ });
825
+
826
+ // --- Encoding Filters ---
827
+ registerFilter('base64_encode', (val) => {
828
+ const str = String(val === null || val === undefined ? '' : val);
829
+ return Buffer.from(str).toString('base64');
830
+ });
831
+
832
+ registerFilter('base64_decode', (val) => {
833
+ const str = String(val === null || val === undefined ? '' : val);
834
+ try {
835
+ const decoded = Buffer.from(str, 'base64').toString('utf8');
836
+ const reEncoded = Buffer.from(decoded).toString('base64');
837
+ if (reEncoded.replace(/=+$/, '') === str.replace(/=+$/, '')) {
838
+ return decoded;
839
+ }
840
+ return String(val);
841
+ } catch {
842
+ return String(val);
843
+ }
844
+ });
845
+
846
+ // --- Text Formatting Filters ---
847
+ registerFilter('urlize', (val) => {
848
+ const str = String(val === null || val === undefined ? '' : val);
849
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
850
+ return str.replace(urlRegex, '<a href="$1">$1</a>');
851
+ });
852
+
853
+ registerFilter('json', (val) => {
854
+ const { markSafe } = require('./security');
855
+ try {
856
+ return markSafe(JSON.stringify(val));
857
+ } catch {
858
+ return String(val);
859
+ }
860
+ });
861
+
862
+ registerFilter('truncatechars_html', (val, arg) => {
863
+ const str = String(val === null || val === undefined ? '' : val);
864
+ const count = parseInt(arg, 10);
865
+ if (isNaN(count) || count <= 0) return str;
866
+ const { markSafe } = require('./security');
867
+ if (str.length <= count) return markSafe(str);
868
+ let currentLen = 0;
869
+ let result = '';
870
+ let inTag = false;
871
+ for (let i = 0; i < str.length && currentLen < count; i++) {
872
+ const ch = str[i];
873
+ if (ch === '<') inTag = true;
874
+ if (inTag) {
875
+ result += ch;
876
+ if (ch === '>') inTag = false;
877
+ continue;
878
+ }
879
+ currentLen++;
880
+ result += ch;
881
+ }
882
+ if (!inTag && currentLen >= count) {
883
+ result += '...';
884
+ }
885
+ return markSafe(result);
886
+ });
887
+
888
+ // --- Time Filters ---
889
+ registerFilter('time_diff', (val, arg) => {
890
+ const d1 = new Date(val);
891
+ const d2 = arg ? new Date(arg) : new Date();
892
+ if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return '';
893
+ const diffMs = Math.max(0, Math.abs(d2 - d1));
894
+ const seconds = Math.floor(diffMs / 1000);
895
+ const diffMins = Math.floor(seconds / 60);
896
+ if (diffMins < 1) return '0 minutes';
897
+ if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
898
+ const diffHours = Math.floor(diffMins / 60);
899
+ if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
900
+ const diffDays = Math.floor(diffHours / 24);
901
+ return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
902
+ });
903
+
904
+ registerFilter('ago', (val) => {
905
+ const d = new Date(val);
906
+ if (isNaN(d.getTime())) return '';
907
+ const now = new Date();
908
+ const diffMs = Math.max(0, now - d);
909
+ const seconds = Math.floor(diffMs / 1000);
910
+ const diffMins = Math.floor(seconds / 60);
911
+ if (diffMins < 1) return 'just now';
912
+ if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
913
+ const diffHours = Math.floor(diffMins / 60);
914
+ if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
915
+ const diffDays = Math.floor(diffHours / 24);
916
+ if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
917
+ const diffWeeks = Math.floor(diffDays / 7);
918
+ if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks !== 1 ? 's' : ''} ago`;
919
+ const diffMonths = Math.floor(diffDays / 30);
920
+ if (diffMonths < 12) return `${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`;
921
+ const diffYears = Math.floor(diffDays / 365);
922
+ return `${diffYears} year${diffYears !== 1 ? 's' : ''} ago`;
923
+ });
924
+
925
+ registerFilter('until', (val) => {
926
+ const d = new Date(val);
927
+ if (isNaN(d.getTime())) return '';
928
+ const now = new Date();
929
+ const diffMs = Math.max(0, d - now);
930
+ const seconds = Math.floor(diffMs / 1000);
931
+ const diffMins = Math.floor(seconds / 60);
932
+ if (diffMins < 1) return 'now';
933
+ if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
934
+ const diffHours = Math.floor(diffMins / 60);
935
+ if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
936
+ const diffDays = Math.floor(diffHours / 24);
937
+ return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
938
+ });
939
+
940
+ // --- Data Formatting Filters ---
941
+ registerFilter('credit_card', (val) => {
942
+ const str = String(val === null || val === undefined ? '' : val).replace(/\D/g, '');
943
+ if (str.length < 13 || str.length > 19) return String(val);
944
+ const parts = [];
945
+ for (let i = 0; i < str.length; i += 4) {
946
+ parts.push(str.slice(i, i + 4));
947
+ }
948
+ return parts.join('-');
949
+ });
950
+
951
+ registerFilter('ssn', (val) => {
952
+ const str = String(val === null || val === undefined ? '' : val).replace(/\D/g, '');
953
+ if (str.length !== 9) return String(val);
954
+ return `${str.slice(0, 3)}-${str.slice(3, 5)}-${str.slice(5)}`;
955
+ });
956
+
957
+ registerFilter('ip_address', (val) => {
958
+ const str = String(val === null || val === undefined ? '' : val).replace(/\D/g, '');
959
+ if (str.length !== 10 && str.length !== 12) return String(val);
960
+ if (str.length === 12) {
961
+ return `${str.slice(0, 4)}.${str.slice(4, 8)}.${str.slice(8, 10)}.${str.slice(10)}`;
962
+ }
963
+ return `${str.slice(0, 3)}.${str.slice(3, 6)}.${str.slice(6, 9)}.${str.slice(9)}`;
964
+ });
965
+
966
+ registerFilter('uuid', () => {
967
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
968
+ const r = (Math.random() * 16) | 0;
969
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
970
+ return v.toString(16);
971
+ });
972
+ });
973
+
526
974
  module.exports = { registerFilter, getFilter };
527
975