miki-template 1.3.3 → 1.3.7
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/.eslintrc.json +16 -0
- package/.github/release-notes/v1.3.1.md +55 -55
- package/.github/release-notes/v1.3.3.md +77 -0
- package/.github/workflows/ci.yml +38 -54
- package/.github/workflows/release.yml +106 -0
- package/AGENT.md +71 -71
- package/API_REFERENCE.md +314 -314
- package/CHANGELOG.md +173 -169
- package/CODE_OF_CONDUCT.md +14 -14
- package/CONTRIBUTING.md +27 -27
- package/README.md +342 -321
- package/ROADMAP.md +40 -40
- package/benchmarks/report.json +16 -16
- package/benchmarks/run.js +49 -49
- package/benchmarks/stress.mjs +647 -647
- package/benchmarks/templates/large.dtpl +7 -7
- package/benchmarks/templates/medium.dtpl +3 -3
- package/benchmarks/templates/small.dtpl +7 -7
- package/context/component.md +109 -109
- package/context/prd.md +131 -131
- package/context/project-structure.md +33 -33
- package/dir/base.html +22 -22
- package/dir/cmpnt.html +10 -10
- package/dir/footer.html +2 -2
- package/dir/home.html +80 -80
- package/dir/index.html +80 -0
- package/dir/navbar.html +8 -8
- package/docs/README.md +18 -18
- package/docs/advanced_usage.md +71 -71
- package/docs/api.md +119 -119
- package/docs/filters.md +708 -708
- package/docs/installation.md +106 -106
- package/docs/overview.md +79 -57
- package/docs/partialdef.md +70 -70
- package/docs/security.md +27 -27
- package/docs/tags.md +673 -673
- package/docs/usage.md +646 -646
- package/eslint.config.mjs +42 -42
- package/ex.mjs +34 -32
- package/live-test/package-lock.json +915 -0
- package/live-test/package.json +9 -0
- package/live-test/server.js +14 -0
- package/live-test/views/base.html +8 -0
- package/live-test/views/child.html +7 -0
- package/live-test/views/index.html +1 -0
- package/miki-template-extension/.github/workflows/ci.yml +116 -116
- package/miki-template-extension/.vscodeignore +7 -7
- package/miki-template-extension/CHANGELOG.md +99 -99
- package/miki-template-extension/LICENSE +21 -21
- package/miki-template-extension/README.md +273 -273
- package/miki-template-extension/extension.js +1013 -1013
- package/miki-template-extension/icon.svg +10 -10
- package/miki-template-extension/package.json +280 -280
- package/miki-template-extension/snippets/miki-template.json +717 -717
- package/miki-template-extension/syntaxes/language-configuration.json +114 -114
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +355 -355
- package/miki-template-extension/tests/grammar-tests.json +162 -162
- package/miki-template-extension/tests/run-grammar-tests.js +82 -82
- package/package.json +40 -34
- package/sample-app/package-lock.json +901 -0
- package/sample-app/package.json +9 -0
- package/sample-app/server.js +14 -0
- package/sample-app/views/index.html +1 -0
- package/scripts/build-vsix.js +129 -129
- package/scripts/build-vsix.ps1 +15 -15
- package/snippets/miki-template.json +177 -177
- package/src/asyncRender.js +20 -20
- package/src/cache.js +80 -80
- package/src/context.js +126 -126
- package/src/context_processors.js +48 -48
- package/src/esm.mjs +89 -84
- package/src/filters.js +975 -975
- package/src/i18n.js +171 -171
- package/src/index.js +1112 -940
- package/src/lexer.js +114 -114
- package/src/libraries.js +371 -371
- package/src/parser.js +270 -270
- package/src/security.js +53 -53
- package/src/tags/control.js +719 -719
- package/src/tags/extra.js +154 -154
- package/src/tags/helpers.js +26 -26
- package/src/tags/i18n.js +256 -256
- package/src/tags/inheritance.js +335 -335
- package/src/tags/registry.js +18 -18
- package/src/tags/util.js +400 -400
- package/src/types.d.ts +107 -107
- package/syntaxes/language-configuration.json +26 -26
- package/syntaxes/miki-template.tmLanguage.json +146 -146
- package/tests/asyncRender.test.js +17 -17
- package/tests/base.html +6 -6
- package/tests/child.html +3 -3
- package/tests/context_processors.test.js +13 -13
- package/tests/esm.test.mjs +61 -61
- package/tests/filters.test.js +254 -254
- package/tests/finder-appdirs.test.js +19 -0
- package/tests/finder.test.js +17 -0
- package/tests/fixtures/views/nested/index.html +1 -0
- package/tests/fixtures/views/partial.html +1 -0
- package/tests/fixtures/views/sub/deepfile.html +1 -0
- package/tests/fixtures/views-appdirs/product/site/detail.html +1 -0
- package/tests/include_security.test.js +9 -9
- package/tests/integration/README.md +32 -32
- package/tests/integration/features.test.cjs +1681 -1681
- package/tests/integration/features.test.mjs +1697 -1697
- package/tests/integration/finder.esm.test.mjs +13 -0
- package/tests/integration/templates/base.miki +6 -6
- package/tests/integration/templates/child.miki +6 -6
- package/tests/integration/templates/index.html +17 -17
- package/tests/lexer.test.js +45 -45
- package/tests/parser.test.js +57 -57
- package/tests/partial.html +1 -1
- package/tests/partialdef.test.js +79 -79
- package/tests/production_checks.js +57 -57
- package/tests/security.test.js +28 -28
- package/tests/tags.test.js +233 -233
package/src/filters.js
CHANGED
|
@@ -1,975 +1,975 @@
|
|
|
1
|
-
// Filter registry and built‑in template filters.
|
|
2
|
-
|
|
3
|
-
const registry = {};
|
|
4
|
-
|
|
5
|
-
function registerFilter(name, fn) {
|
|
6
|
-
registry[name] = fn;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function getFilter(name) {
|
|
10
|
-
return registry[name];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function escapeValue(val) {
|
|
14
|
-
const { escapeHtml, markSafe } = require('./security');
|
|
15
|
-
// force=true ensures even SafeString values are re-escaped, matching
|
|
16
|
-
// Django's `{{ value|escape }}` semantics.
|
|
17
|
-
return markSafe(escapeHtml(val, true));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function markValueSafe(val) {
|
|
21
|
-
const { markSafe } = require('./security');
|
|
22
|
-
return markSafe(val);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// --- Text Filters ---
|
|
26
|
-
registerFilter('upper', (val) => {
|
|
27
|
-
return String(val === null || val === undefined ? '' : val).toUpperCase();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
registerFilter('lower', (val) => {
|
|
31
|
-
return String(val === null || val === undefined ? '' : val).toLowerCase();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
registerFilter('title', (val) => {
|
|
35
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
36
|
-
return str.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
registerFilter('capfirst', (val) => {
|
|
40
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
41
|
-
if (!str) return '';
|
|
42
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
registerFilter('truncatewords', (val, arg) => {
|
|
46
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
47
|
-
const count = parseInt(arg, 10);
|
|
48
|
-
if (isNaN(count) || count <= 0) return str;
|
|
49
|
-
const words = str.split(/\s+/).filter(Boolean);
|
|
50
|
-
if (words.length <= count) return str;
|
|
51
|
-
return words.slice(0, count).join(' ') + ' ...';
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
registerFilter('truncatechars', (val, arg) => {
|
|
55
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
56
|
-
const count = parseInt(arg, 10);
|
|
57
|
-
if (isNaN(count) || count <= 0) return str;
|
|
58
|
-
if (str.length <= count) return str;
|
|
59
|
-
return str.slice(0, count - 3) + '...';
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
registerFilter('wordcount', (val) => {
|
|
63
|
-
const str = String(val === null || val === undefined ? '' : val).trim();
|
|
64
|
-
if (!str) return 0;
|
|
65
|
-
return str.split(/\s+/).length;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
registerFilter('linebreaks', (val) => {
|
|
69
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
70
|
-
if (!str) return '';
|
|
71
|
-
const paragraphs = str.split(/\n{2,}/);
|
|
72
|
-
const formatted = paragraphs
|
|
73
|
-
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
|
74
|
-
.join('');
|
|
75
|
-
return markValueSafe(formatted);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
registerFilter('linebreaksbr', (val) => {
|
|
79
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
80
|
-
return markValueSafe(str.replace(/\n/g, '<br>'));
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
registerFilter('striptags', (val) => {
|
|
84
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
85
|
-
return str.replace(/<\/?[^>]+>/g, '');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
registerFilter('slugify', (val) => {
|
|
89
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
90
|
-
return str
|
|
91
|
-
.normalize('NFD')
|
|
92
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
93
|
-
.toLowerCase()
|
|
94
|
-
.replace(/[^a-z0-9\s-]/g, '')
|
|
95
|
-
.trim()
|
|
96
|
-
.replace(/\s+/g, '-')
|
|
97
|
-
.replace(/-+/g, '-');
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
registerFilter('length_is', (val, arg) => {
|
|
101
|
-
const length = (val && typeof val.length === 'number') ? val.length : 0;
|
|
102
|
-
const expected = parseInt(arg, 10);
|
|
103
|
-
if (isNaN(expected)) return false;
|
|
104
|
-
return length === expected;
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// --- HTML Filters ---
|
|
108
|
-
registerFilter('safe', (val) => {
|
|
109
|
-
return markValueSafe(val);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
registerFilter('escape', (val) => {
|
|
113
|
-
return escapeValue(val);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// --- List Filters ---
|
|
117
|
-
registerFilter('length', (val) => {
|
|
118
|
-
if (val === null || val === undefined) return 0;
|
|
119
|
-
if (typeof val.length === 'number') return val.length;
|
|
120
|
-
if (val instanceof Set || val instanceof Map) return val.size;
|
|
121
|
-
if (typeof val === 'object') return Object.keys(val).length;
|
|
122
|
-
return 0;
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
registerFilter('join', (val, arg) => {
|
|
126
|
-
if (!Array.isArray(val)) return val;
|
|
127
|
-
const separator = arg === undefined ? '' : String(arg);
|
|
128
|
-
return val.join(separator);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
registerFilter('slice', (val, arg) => {
|
|
132
|
-
if (!val || typeof val.slice !== 'function') return val;
|
|
133
|
-
const parts = arg.split(':');
|
|
134
|
-
if (parts.length === 1) {
|
|
135
|
-
const idx = parseInt(parts[0], 10);
|
|
136
|
-
return isNaN(idx) ? val : val.slice(idx, idx + 1);
|
|
137
|
-
}
|
|
138
|
-
const start = parts[0] === '' ? undefined : parseInt(parts[0], 10);
|
|
139
|
-
const end = parts[1] === '' ? undefined : parseInt(parts[1], 10);
|
|
140
|
-
return val.slice(start, end);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
registerFilter('dictsort', (val, arg) => {
|
|
144
|
-
if (!Array.isArray(val) || !arg) return val;
|
|
145
|
-
return [...val].sort((a, b) => {
|
|
146
|
-
const valA = a && typeof a === 'object' ? a[arg] : undefined;
|
|
147
|
-
const valB = b && typeof b === 'object' ? b[arg] : undefined;
|
|
148
|
-
if (valA === undefined && valB === undefined) return 0;
|
|
149
|
-
if (valA === undefined) return 1;
|
|
150
|
-
if (valB === undefined) return -1;
|
|
151
|
-
if (valA < valB) return -1;
|
|
152
|
-
if (valA > valB) return 1;
|
|
153
|
-
return 0;
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
registerFilter('dictsortreversed', (val, arg) => {
|
|
158
|
-
if (!Array.isArray(val) || !arg) return val;
|
|
159
|
-
return [...val].sort((a, b) => {
|
|
160
|
-
const valA = a && typeof a === 'object' ? a[arg] : undefined;
|
|
161
|
-
const valB = b && typeof b === 'object' ? b[arg] : undefined;
|
|
162
|
-
if (valA === undefined && valB === undefined) return 0;
|
|
163
|
-
if (valA === undefined) return -1;
|
|
164
|
-
if (valB === undefined) return 1;
|
|
165
|
-
if (valA < valB) return 1;
|
|
166
|
-
if (valA > valB) return -1;
|
|
167
|
-
return 0;
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// --- Date/Time Filters ---
|
|
172
|
-
const { format: formatDate, parseISO } = require('date-fns');
|
|
173
|
-
|
|
174
|
-
registerFilter('date_format', (val, pattern) => {
|
|
175
|
-
let date = val;
|
|
176
|
-
if (typeof val === 'string') {
|
|
177
|
-
date = parseISO(val);
|
|
178
|
-
} else if (!(date instanceof Date)) {
|
|
179
|
-
date = new Date(val);
|
|
180
|
-
}
|
|
181
|
-
if (isNaN(date.getTime())) return '';
|
|
182
|
-
const fmt = pattern || 'yyyy-MM-dd\'T\'HH:mm:ssxxx';
|
|
183
|
-
try {
|
|
184
|
-
return formatDate(date, fmt);
|
|
185
|
-
} catch {
|
|
186
|
-
return '';
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
registerFilter('strftime', (val, pattern) => {
|
|
191
|
-
let date = val;
|
|
192
|
-
if (typeof val === 'string') {
|
|
193
|
-
date = parseISO(val);
|
|
194
|
-
} else if (!(date instanceof Date)) {
|
|
195
|
-
date = new Date(val);
|
|
196
|
-
}
|
|
197
|
-
if (isNaN(date.getTime())) return '';
|
|
198
|
-
const fmt = pattern || 'PPpp';
|
|
199
|
-
try {
|
|
200
|
-
return formatDate(date, fmt);
|
|
201
|
-
} catch {
|
|
202
|
-
return '';
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
registerFilter('date', (val, arg) => {
|
|
207
|
-
let date = val;
|
|
208
|
-
if (!(date instanceof Date)) {
|
|
209
|
-
date = new Date(val);
|
|
210
|
-
}
|
|
211
|
-
if (isNaN(date.getTime())) return '';
|
|
212
|
-
const formatStr = arg || 'Y-m-d';
|
|
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
|
-
];
|
|
246
|
-
let output = '';
|
|
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
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return output;
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
registerFilter('time', (val, arg) => {
|
|
267
|
-
let date = val;
|
|
268
|
-
if (!(date instanceof Date)) {
|
|
269
|
-
date = new Date(val);
|
|
270
|
-
}
|
|
271
|
-
if (isNaN(date.getTime())) return '';
|
|
272
|
-
const formatStr = arg || 'H:i';
|
|
273
|
-
return getFilter('date')(date, formatStr);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
registerFilter('timesince', (val, arg) => {
|
|
277
|
-
const d1 = new Date(val);
|
|
278
|
-
const d2 = arg ? new Date(arg) : new Date();
|
|
279
|
-
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return '';
|
|
280
|
-
const diffMs = Math.max(0, d2 - d1);
|
|
281
|
-
const seconds = Math.floor(diffMs / 1000);
|
|
282
|
-
const diffMins = Math.floor(seconds / 60);
|
|
283
|
-
if (diffMins < 1) return '0 minutes';
|
|
284
|
-
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
|
285
|
-
const diffHours = Math.floor(diffMins / 60);
|
|
286
|
-
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
287
|
-
const diffDays = Math.floor(diffHours / 24);
|
|
288
|
-
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
registerFilter('timeuntil', (val, arg) => {
|
|
292
|
-
const d1 = new Date(val);
|
|
293
|
-
const d2 = arg ? new Date(arg) : new Date();
|
|
294
|
-
const diffMs = Math.max(0, d1.getTime() - d2.getTime());
|
|
295
|
-
if (diffMs <= 0) return '0 minutes';
|
|
296
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
297
|
-
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
|
298
|
-
const diffHours = Math.floor(diffMins / 60);
|
|
299
|
-
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
300
|
-
const diffDays = Math.floor(diffHours / 24);
|
|
301
|
-
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// --- Numeric Filters ---
|
|
305
|
-
registerFilter('add', (val, arg) => {
|
|
306
|
-
const numVal = Number(val);
|
|
307
|
-
const numArg = Number(arg);
|
|
308
|
-
if (!isNaN(numVal) && !isNaN(numArg)) {
|
|
309
|
-
return numVal + numArg;
|
|
310
|
-
}
|
|
311
|
-
if (Array.isArray(val) && Array.isArray(arg)) {
|
|
312
|
-
return val.concat(arg);
|
|
313
|
-
}
|
|
314
|
-
return String(val) + String(arg);
|
|
315
|
-
});
|
|
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
|
-
|
|
335
|
-
registerFilter('divisibleby', (val, arg) => {
|
|
336
|
-
const numVal = Number(val);
|
|
337
|
-
const numArg = Number(arg);
|
|
338
|
-
if (isNaN(numVal) || isNaN(numArg) || numArg === 0) return false;
|
|
339
|
-
return numVal % numArg === 0;
|
|
340
|
-
});
|
|
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
|
-
|
|
349
|
-
registerFilter('floatformat', (val, arg) => {
|
|
350
|
-
const num = Number(val);
|
|
351
|
-
if (isNaN(num)) return '';
|
|
352
|
-
if (arg === undefined || arg === null) {
|
|
353
|
-
return num.toFixed(1);
|
|
354
|
-
}
|
|
355
|
-
const decimals = parseInt(arg, 10);
|
|
356
|
-
if (isNaN(decimals)) return '';
|
|
357
|
-
if (decimals === -1) {
|
|
358
|
-
return num.toFixed(0);
|
|
359
|
-
}
|
|
360
|
-
return num.toFixed(Math.max(0, decimals));
|
|
361
|
-
});
|
|
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
|
-
|
|
375
|
-
// --- Default Filters ---
|
|
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);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
registerFilter('default_if_none', (val, arg, ctx) => {
|
|
386
|
-
if (val !== null && val !== undefined) return val;
|
|
387
|
-
return renderFallbackTemplate(arg, ctx);
|
|
388
|
-
});
|
|
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
|
-
|
|
418
|
-
registerFilter('firstof', (...args) => {
|
|
419
|
-
for (const arg of args) {
|
|
420
|
-
if (arg !== null && arg !== undefined && arg !== '') {
|
|
421
|
-
return arg;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
return '';
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
// --- Misc Filters ---
|
|
428
|
-
registerFilter('pluralize', (val, arg) => {
|
|
429
|
-
const suffixes = (arg || 's').split(',');
|
|
430
|
-
let count = val;
|
|
431
|
-
if (Array.isArray(val) || (val && typeof val === 'object' && 'length' in val)) {
|
|
432
|
-
count = val.length;
|
|
433
|
-
} else if (!isNaN(Number(val))) {
|
|
434
|
-
count = Number(val);
|
|
435
|
-
} else {
|
|
436
|
-
count = 1;
|
|
437
|
-
}
|
|
438
|
-
if (suffixes.length === 1) {
|
|
439
|
-
return count === 1 ? '' : suffixes[0];
|
|
440
|
-
}
|
|
441
|
-
return count === 1 ? suffixes[0] : suffixes[1];
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
registerFilter('yesno', (val, arg) => {
|
|
445
|
-
const mappings = (arg || 'yes,no,maybe').split(',');
|
|
446
|
-
const yes = mappings[0] || 'yes';
|
|
447
|
-
const no = mappings[1] || 'no';
|
|
448
|
-
const maybe = mappings[2] || 'maybe';
|
|
449
|
-
if (val === null || val === undefined) return maybe;
|
|
450
|
-
return val ? yes : no;
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
registerFilter('filesizeformat', (val) => {
|
|
454
|
-
const bytes = Number(val);
|
|
455
|
-
if (isNaN(bytes) || bytes < 0) return '0 bytes';
|
|
456
|
-
if (bytes === 0) return '0 bytes';
|
|
457
|
-
const k = 1024;
|
|
458
|
-
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
459
|
-
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
460
|
-
const num = bytes / Math.pow(k, i);
|
|
461
|
-
return `${num.toFixed(num % 1 === 0 ? 0 : 1)} ${sizes[i]}`;
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
registerFilter('urlencode', (val, arg) => {
|
|
465
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
466
|
-
if (arg === undefined || arg === null || arg === '') {
|
|
467
|
-
return encodeURIComponent(str).replace(/%20/g, '+');
|
|
468
|
-
}
|
|
469
|
-
if (arg === 'utf-8' || arg === 'utf8') {
|
|
470
|
-
return encodeURIComponent(str);
|
|
471
|
-
}
|
|
472
|
-
if (arg === 'query' || arg === 'raw') {
|
|
473
|
-
return encodeURIComponent(str).replace(/%20/g, '+');
|
|
474
|
-
}
|
|
475
|
-
if (arg === 'path') {
|
|
476
|
-
return str.split('/').map(seg => encodeURIComponent(seg)).join('/');
|
|
477
|
-
}
|
|
478
|
-
return encodeURIComponent(str);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
registerFilter('escapeuri', (val) => {
|
|
482
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
483
|
-
return encodeURI(str);
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
registerFilter('stringformat', (val, arg) => {
|
|
487
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
488
|
-
const fmt = String(arg === null || arg === undefined ? '%s' : arg);
|
|
489
|
-
let result = '';
|
|
490
|
-
let i = 0;
|
|
491
|
-
while (i < fmt.length) {
|
|
492
|
-
if (fmt[i] === '%') {
|
|
493
|
-
// Try to match a full format specifier with optional width/precision
|
|
494
|
-
const numMatch = fmt.slice(i).match(/^%(?:\d+)?(?:\.\d+)?([sdifFeExXo%])/);
|
|
495
|
-
if (numMatch) {
|
|
496
|
-
const spec = numMatch[1];
|
|
497
|
-
if (spec === 's') {
|
|
498
|
-
result += str;
|
|
499
|
-
} else if (spec === 'd' || spec === 'i') {
|
|
500
|
-
result += String(parseInt(str, 10));
|
|
501
|
-
} else if (spec === 'f' || spec === 'F') {
|
|
502
|
-
const precMatch = numMatch[0].match(/\.(\d+)/);
|
|
503
|
-
const precision = precMatch ? parseInt(precMatch[1], 10) : 6;
|
|
504
|
-
const widthMatch = numMatch[0].match(/%(\d+)/);
|
|
505
|
-
const width = widthMatch ? parseInt(widthMatch[1], 10) : null;
|
|
506
|
-
const num = parseFloat(str);
|
|
507
|
-
let formatted = num.toFixed(precision);
|
|
508
|
-
if (width && formatted.length < width) {
|
|
509
|
-
formatted = ' '.repeat(width - formatted.length) + formatted;
|
|
510
|
-
}
|
|
511
|
-
if (spec === 'F') formatted = formatted.toUpperCase();
|
|
512
|
-
result += formatted;
|
|
513
|
-
} else if (spec === 'e') {
|
|
514
|
-
result += parseFloat(str).toExponential();
|
|
515
|
-
} else if (spec === 'E') {
|
|
516
|
-
result += parseFloat(str).toExponential().toUpperCase();
|
|
517
|
-
} else if (spec === 'x') {
|
|
518
|
-
result += parseInt(str, 10).toString(16);
|
|
519
|
-
} else if (spec === 'X') {
|
|
520
|
-
result += parseInt(str, 10).toString(16).toUpperCase();
|
|
521
|
-
} else if (spec === 'o') {
|
|
522
|
-
result += parseInt(str, 10).toString(8);
|
|
523
|
-
} else if (spec === '%') {
|
|
524
|
-
result += '%';
|
|
525
|
-
}
|
|
526
|
-
i += numMatch[0].length;
|
|
527
|
-
} else {
|
|
528
|
-
result += fmt[i];
|
|
529
|
-
i++;
|
|
530
|
-
}
|
|
531
|
-
} else {
|
|
532
|
-
result += fmt[i];
|
|
533
|
-
i++;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
return result;
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
registerFilter('cut', (val, arg) => {
|
|
540
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
541
|
-
if (arg === undefined || arg === null) return str;
|
|
542
|
-
return str.split(String(arg)).join('');
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
registerFilter('addslashes', (val) => {
|
|
546
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
547
|
-
return str.replace(/['"\\]/g, c => '\\' + c);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
registerFilter('removetags', (val, arg) => {
|
|
551
|
-
const str = String(val === null || val === undefined ? '' : val);
|
|
552
|
-
if (!arg) return str;
|
|
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);
|
|
555
|
-
let result = str;
|
|
556
|
-
for (const tag of tags) {
|
|
557
|
-
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
558
|
-
result = result.replace(new RegExp(`<\\/${escaped}>`, 'gi'), '');
|
|
559
|
-
result = result.replace(new RegExp(`<${escaped}\\b[^>]*>`, 'gi'), '');
|
|
560
|
-
result = result.replace(new RegExp(`<${escaped}>`, 'gi'), '');
|
|
561
|
-
}
|
|
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);
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* `trans` filter — translates a string using the i18n registry.
|
|
570
|
-
* Requires the optional i18n module to have translations registered.
|
|
571
|
-
* Falls back to the original value if no translation is found.
|
|
572
|
-
*
|
|
573
|
-
* {{ "Hello, world!"|trans }}
|
|
574
|
-
* {{ greeting|trans:"Hello, %s!" }}
|
|
575
|
-
*/
|
|
576
|
-
registerFilter('trans', (val, arg) => {
|
|
577
|
-
const i18n = require('./i18n');
|
|
578
|
-
const key = arg && arg.length > 0 ? arg : String(val);
|
|
579
|
-
const isSafeValue = val && val.constructor && val.constructor.name === 'SafeString';
|
|
580
|
-
const result = i18n.lookup(key);
|
|
581
|
-
if (isSafeValue) {
|
|
582
|
-
const { markSafe } = require('./security');
|
|
583
|
-
return markSafe(String(result));
|
|
584
|
-
}
|
|
585
|
-
return String(result);
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* `regroup` filter — groups a list of objects by a common attribute.
|
|
590
|
-
* Returns an array of { grouper, list } objects suitable for iteration.
|
|
591
|
-
*
|
|
592
|
-
* {% for group in items|regroup:"category" %}
|
|
593
|
-
* <h3>{{ group.grouper }}</h3>
|
|
594
|
-
* {% for item in group.list %}
|
|
595
|
-
* <p>{{ item.name }}</p>
|
|
596
|
-
* {% endfor %}
|
|
597
|
-
* {% endfor %}
|
|
598
|
-
*/
|
|
599
|
-
registerFilter('regroup', (val, arg) => {
|
|
600
|
-
if (!Array.isArray(val)) return [];
|
|
601
|
-
const key = String(arg || '');
|
|
602
|
-
const groups = new Map();
|
|
603
|
-
for (const item of val) {
|
|
604
|
-
const grouper = item && typeof item === 'object' ? (item[key] !== undefined ? item[key] : null) : null;
|
|
605
|
-
const grouperKey = grouper === null ? '__null__' : String(grouper);
|
|
606
|
-
if (!groups.has(grouperKey)) {
|
|
607
|
-
groups.set(grouperKey, { grouper, list: [] });
|
|
608
|
-
}
|
|
609
|
-
groups.get(grouperKey).list.push(item);
|
|
610
|
-
}
|
|
611
|
-
return Array.from(groups.values());
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* `strftime` filter — formats a Date using `date-fns` format strings.
|
|
616
|
-
* Supports all `date-fns` format tokens (pp, yyyy, MM, dd, HH, mm, ss, etc.)
|
|
617
|
-
*
|
|
618
|
-
* {{ now|strftime:"PPpp" }} → "Aug 31, 2026 at 10:24 PM"
|
|
619
|
-
* {{ now|strftime:"yyyy-MM-dd" }} → "2026-08-31"
|
|
620
|
-
* {{ now|strftime:"HH:mm:ss" }} → "22:24:56"
|
|
621
|
-
*/
|
|
622
|
-
registerFilter('strftime', (val, arg) => {
|
|
623
|
-
const d = new Date(val);
|
|
624
|
-
if (isNaN(d.getTime())) return String(val);
|
|
625
|
-
const fmt = String(arg === null || arg === undefined ? 'yyyy-MM-dd' : arg);
|
|
626
|
-
try {
|
|
627
|
-
const { format } = require('date-fns');
|
|
628
|
-
return format(d, fmt);
|
|
629
|
-
} catch {
|
|
630
|
-
// If date-fns is not available or format is invalid, fallback
|
|
631
|
-
return d.toISOString();
|
|
632
|
-
}
|
|
633
|
-
});
|
|
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
|
-
|
|
974
|
-
module.exports = { registerFilter, getFilter };
|
|
975
|
-
|
|
1
|
+
// Filter registry and built‑in template filters.
|
|
2
|
+
|
|
3
|
+
const registry = {};
|
|
4
|
+
|
|
5
|
+
function registerFilter(name, fn) {
|
|
6
|
+
registry[name] = fn;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getFilter(name) {
|
|
10
|
+
return registry[name];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escapeValue(val) {
|
|
14
|
+
const { escapeHtml, markSafe } = require('./security');
|
|
15
|
+
// force=true ensures even SafeString values are re-escaped, matching
|
|
16
|
+
// Django's `{{ value|escape }}` semantics.
|
|
17
|
+
return markSafe(escapeHtml(val, true));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function markValueSafe(val) {
|
|
21
|
+
const { markSafe } = require('./security');
|
|
22
|
+
return markSafe(val);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- Text Filters ---
|
|
26
|
+
registerFilter('upper', (val) => {
|
|
27
|
+
return String(val === null || val === undefined ? '' : val).toUpperCase();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
registerFilter('lower', (val) => {
|
|
31
|
+
return String(val === null || val === undefined ? '' : val).toLowerCase();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
registerFilter('title', (val) => {
|
|
35
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
36
|
+
return str.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
registerFilter('capfirst', (val) => {
|
|
40
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
41
|
+
if (!str) return '';
|
|
42
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
registerFilter('truncatewords', (val, arg) => {
|
|
46
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
47
|
+
const count = parseInt(arg, 10);
|
|
48
|
+
if (isNaN(count) || count <= 0) return str;
|
|
49
|
+
const words = str.split(/\s+/).filter(Boolean);
|
|
50
|
+
if (words.length <= count) return str;
|
|
51
|
+
return words.slice(0, count).join(' ') + ' ...';
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
registerFilter('truncatechars', (val, arg) => {
|
|
55
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
56
|
+
const count = parseInt(arg, 10);
|
|
57
|
+
if (isNaN(count) || count <= 0) return str;
|
|
58
|
+
if (str.length <= count) return str;
|
|
59
|
+
return str.slice(0, count - 3) + '...';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
registerFilter('wordcount', (val) => {
|
|
63
|
+
const str = String(val === null || val === undefined ? '' : val).trim();
|
|
64
|
+
if (!str) return 0;
|
|
65
|
+
return str.split(/\s+/).length;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
registerFilter('linebreaks', (val) => {
|
|
69
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
70
|
+
if (!str) return '';
|
|
71
|
+
const paragraphs = str.split(/\n{2,}/);
|
|
72
|
+
const formatted = paragraphs
|
|
73
|
+
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
|
74
|
+
.join('');
|
|
75
|
+
return markValueSafe(formatted);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
registerFilter('linebreaksbr', (val) => {
|
|
79
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
80
|
+
return markValueSafe(str.replace(/\n/g, '<br>'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
registerFilter('striptags', (val) => {
|
|
84
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
85
|
+
return str.replace(/<\/?[^>]+>/g, '');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
registerFilter('slugify', (val) => {
|
|
89
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
90
|
+
return str
|
|
91
|
+
.normalize('NFD')
|
|
92
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
95
|
+
.trim()
|
|
96
|
+
.replace(/\s+/g, '-')
|
|
97
|
+
.replace(/-+/g, '-');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
registerFilter('length_is', (val, arg) => {
|
|
101
|
+
const length = (val && typeof val.length === 'number') ? val.length : 0;
|
|
102
|
+
const expected = parseInt(arg, 10);
|
|
103
|
+
if (isNaN(expected)) return false;
|
|
104
|
+
return length === expected;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// --- HTML Filters ---
|
|
108
|
+
registerFilter('safe', (val) => {
|
|
109
|
+
return markValueSafe(val);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
registerFilter('escape', (val) => {
|
|
113
|
+
return escapeValue(val);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// --- List Filters ---
|
|
117
|
+
registerFilter('length', (val) => {
|
|
118
|
+
if (val === null || val === undefined) return 0;
|
|
119
|
+
if (typeof val.length === 'number') return val.length;
|
|
120
|
+
if (val instanceof Set || val instanceof Map) return val.size;
|
|
121
|
+
if (typeof val === 'object') return Object.keys(val).length;
|
|
122
|
+
return 0;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
registerFilter('join', (val, arg) => {
|
|
126
|
+
if (!Array.isArray(val)) return val;
|
|
127
|
+
const separator = arg === undefined ? '' : String(arg);
|
|
128
|
+
return val.join(separator);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
registerFilter('slice', (val, arg) => {
|
|
132
|
+
if (!val || typeof val.slice !== 'function') return val;
|
|
133
|
+
const parts = arg.split(':');
|
|
134
|
+
if (parts.length === 1) {
|
|
135
|
+
const idx = parseInt(parts[0], 10);
|
|
136
|
+
return isNaN(idx) ? val : val.slice(idx, idx + 1);
|
|
137
|
+
}
|
|
138
|
+
const start = parts[0] === '' ? undefined : parseInt(parts[0], 10);
|
|
139
|
+
const end = parts[1] === '' ? undefined : parseInt(parts[1], 10);
|
|
140
|
+
return val.slice(start, end);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
registerFilter('dictsort', (val, arg) => {
|
|
144
|
+
if (!Array.isArray(val) || !arg) return val;
|
|
145
|
+
return [...val].sort((a, b) => {
|
|
146
|
+
const valA = a && typeof a === 'object' ? a[arg] : undefined;
|
|
147
|
+
const valB = b && typeof b === 'object' ? b[arg] : undefined;
|
|
148
|
+
if (valA === undefined && valB === undefined) return 0;
|
|
149
|
+
if (valA === undefined) return 1;
|
|
150
|
+
if (valB === undefined) return -1;
|
|
151
|
+
if (valA < valB) return -1;
|
|
152
|
+
if (valA > valB) return 1;
|
|
153
|
+
return 0;
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
registerFilter('dictsortreversed', (val, arg) => {
|
|
158
|
+
if (!Array.isArray(val) || !arg) return val;
|
|
159
|
+
return [...val].sort((a, b) => {
|
|
160
|
+
const valA = a && typeof a === 'object' ? a[arg] : undefined;
|
|
161
|
+
const valB = b && typeof b === 'object' ? b[arg] : undefined;
|
|
162
|
+
if (valA === undefined && valB === undefined) return 0;
|
|
163
|
+
if (valA === undefined) return -1;
|
|
164
|
+
if (valB === undefined) return 1;
|
|
165
|
+
if (valA < valB) return 1;
|
|
166
|
+
if (valA > valB) return -1;
|
|
167
|
+
return 0;
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- Date/Time Filters ---
|
|
172
|
+
const { format: formatDate, parseISO } = require('date-fns');
|
|
173
|
+
|
|
174
|
+
registerFilter('date_format', (val, pattern) => {
|
|
175
|
+
let date = val;
|
|
176
|
+
if (typeof val === 'string') {
|
|
177
|
+
date = parseISO(val);
|
|
178
|
+
} else if (!(date instanceof Date)) {
|
|
179
|
+
date = new Date(val);
|
|
180
|
+
}
|
|
181
|
+
if (isNaN(date.getTime())) return '';
|
|
182
|
+
const fmt = pattern || 'yyyy-MM-dd\'T\'HH:mm:ssxxx';
|
|
183
|
+
try {
|
|
184
|
+
return formatDate(date, fmt);
|
|
185
|
+
} catch {
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
registerFilter('strftime', (val, pattern) => {
|
|
191
|
+
let date = val;
|
|
192
|
+
if (typeof val === 'string') {
|
|
193
|
+
date = parseISO(val);
|
|
194
|
+
} else if (!(date instanceof Date)) {
|
|
195
|
+
date = new Date(val);
|
|
196
|
+
}
|
|
197
|
+
if (isNaN(date.getTime())) return '';
|
|
198
|
+
const fmt = pattern || 'PPpp';
|
|
199
|
+
try {
|
|
200
|
+
return formatDate(date, fmt);
|
|
201
|
+
} catch {
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
registerFilter('date', (val, arg) => {
|
|
207
|
+
let date = val;
|
|
208
|
+
if (!(date instanceof Date)) {
|
|
209
|
+
date = new Date(val);
|
|
210
|
+
}
|
|
211
|
+
if (isNaN(date.getTime())) return '';
|
|
212
|
+
const formatStr = arg || 'Y-m-d';
|
|
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
|
+
];
|
|
246
|
+
let output = '';
|
|
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
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return output;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
registerFilter('time', (val, arg) => {
|
|
267
|
+
let date = val;
|
|
268
|
+
if (!(date instanceof Date)) {
|
|
269
|
+
date = new Date(val);
|
|
270
|
+
}
|
|
271
|
+
if (isNaN(date.getTime())) return '';
|
|
272
|
+
const formatStr = arg || 'H:i';
|
|
273
|
+
return getFilter('date')(date, formatStr);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
registerFilter('timesince', (val, arg) => {
|
|
277
|
+
const d1 = new Date(val);
|
|
278
|
+
const d2 = arg ? new Date(arg) : new Date();
|
|
279
|
+
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return '';
|
|
280
|
+
const diffMs = Math.max(0, d2 - d1);
|
|
281
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
282
|
+
const diffMins = Math.floor(seconds / 60);
|
|
283
|
+
if (diffMins < 1) return '0 minutes';
|
|
284
|
+
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
|
285
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
286
|
+
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
287
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
288
|
+
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
registerFilter('timeuntil', (val, arg) => {
|
|
292
|
+
const d1 = new Date(val);
|
|
293
|
+
const d2 = arg ? new Date(arg) : new Date();
|
|
294
|
+
const diffMs = Math.max(0, d1.getTime() - d2.getTime());
|
|
295
|
+
if (diffMs <= 0) return '0 minutes';
|
|
296
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
297
|
+
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
|
298
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
299
|
+
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
300
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
301
|
+
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// --- Numeric Filters ---
|
|
305
|
+
registerFilter('add', (val, arg) => {
|
|
306
|
+
const numVal = Number(val);
|
|
307
|
+
const numArg = Number(arg);
|
|
308
|
+
if (!isNaN(numVal) && !isNaN(numArg)) {
|
|
309
|
+
return numVal + numArg;
|
|
310
|
+
}
|
|
311
|
+
if (Array.isArray(val) && Array.isArray(arg)) {
|
|
312
|
+
return val.concat(arg);
|
|
313
|
+
}
|
|
314
|
+
return String(val) + String(arg);
|
|
315
|
+
});
|
|
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
|
+
|
|
335
|
+
registerFilter('divisibleby', (val, arg) => {
|
|
336
|
+
const numVal = Number(val);
|
|
337
|
+
const numArg = Number(arg);
|
|
338
|
+
if (isNaN(numVal) || isNaN(numArg) || numArg === 0) return false;
|
|
339
|
+
return numVal % numArg === 0;
|
|
340
|
+
});
|
|
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
|
+
|
|
349
|
+
registerFilter('floatformat', (val, arg) => {
|
|
350
|
+
const num = Number(val);
|
|
351
|
+
if (isNaN(num)) return '';
|
|
352
|
+
if (arg === undefined || arg === null) {
|
|
353
|
+
return num.toFixed(1);
|
|
354
|
+
}
|
|
355
|
+
const decimals = parseInt(arg, 10);
|
|
356
|
+
if (isNaN(decimals)) return '';
|
|
357
|
+
if (decimals === -1) {
|
|
358
|
+
return num.toFixed(0);
|
|
359
|
+
}
|
|
360
|
+
return num.toFixed(Math.max(0, decimals));
|
|
361
|
+
});
|
|
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
|
+
|
|
375
|
+
// --- Default Filters ---
|
|
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);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
registerFilter('default_if_none', (val, arg, ctx) => {
|
|
386
|
+
if (val !== null && val !== undefined) return val;
|
|
387
|
+
return renderFallbackTemplate(arg, ctx);
|
|
388
|
+
});
|
|
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
|
+
|
|
418
|
+
registerFilter('firstof', (...args) => {
|
|
419
|
+
for (const arg of args) {
|
|
420
|
+
if (arg !== null && arg !== undefined && arg !== '') {
|
|
421
|
+
return arg;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return '';
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// --- Misc Filters ---
|
|
428
|
+
registerFilter('pluralize', (val, arg) => {
|
|
429
|
+
const suffixes = (arg || 's').split(',');
|
|
430
|
+
let count = val;
|
|
431
|
+
if (Array.isArray(val) || (val && typeof val === 'object' && 'length' in val)) {
|
|
432
|
+
count = val.length;
|
|
433
|
+
} else if (!isNaN(Number(val))) {
|
|
434
|
+
count = Number(val);
|
|
435
|
+
} else {
|
|
436
|
+
count = 1;
|
|
437
|
+
}
|
|
438
|
+
if (suffixes.length === 1) {
|
|
439
|
+
return count === 1 ? '' : suffixes[0];
|
|
440
|
+
}
|
|
441
|
+
return count === 1 ? suffixes[0] : suffixes[1];
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
registerFilter('yesno', (val, arg) => {
|
|
445
|
+
const mappings = (arg || 'yes,no,maybe').split(',');
|
|
446
|
+
const yes = mappings[0] || 'yes';
|
|
447
|
+
const no = mappings[1] || 'no';
|
|
448
|
+
const maybe = mappings[2] || 'maybe';
|
|
449
|
+
if (val === null || val === undefined) return maybe;
|
|
450
|
+
return val ? yes : no;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
registerFilter('filesizeformat', (val) => {
|
|
454
|
+
const bytes = Number(val);
|
|
455
|
+
if (isNaN(bytes) || bytes < 0) return '0 bytes';
|
|
456
|
+
if (bytes === 0) return '0 bytes';
|
|
457
|
+
const k = 1024;
|
|
458
|
+
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
459
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
460
|
+
const num = bytes / Math.pow(k, i);
|
|
461
|
+
return `${num.toFixed(num % 1 === 0 ? 0 : 1)} ${sizes[i]}`;
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
registerFilter('urlencode', (val, arg) => {
|
|
465
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
466
|
+
if (arg === undefined || arg === null || arg === '') {
|
|
467
|
+
return encodeURIComponent(str).replace(/%20/g, '+');
|
|
468
|
+
}
|
|
469
|
+
if (arg === 'utf-8' || arg === 'utf8') {
|
|
470
|
+
return encodeURIComponent(str);
|
|
471
|
+
}
|
|
472
|
+
if (arg === 'query' || arg === 'raw') {
|
|
473
|
+
return encodeURIComponent(str).replace(/%20/g, '+');
|
|
474
|
+
}
|
|
475
|
+
if (arg === 'path') {
|
|
476
|
+
return str.split('/').map(seg => encodeURIComponent(seg)).join('/');
|
|
477
|
+
}
|
|
478
|
+
return encodeURIComponent(str);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
registerFilter('escapeuri', (val) => {
|
|
482
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
483
|
+
return encodeURI(str);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
registerFilter('stringformat', (val, arg) => {
|
|
487
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
488
|
+
const fmt = String(arg === null || arg === undefined ? '%s' : arg);
|
|
489
|
+
let result = '';
|
|
490
|
+
let i = 0;
|
|
491
|
+
while (i < fmt.length) {
|
|
492
|
+
if (fmt[i] === '%') {
|
|
493
|
+
// Try to match a full format specifier with optional width/precision
|
|
494
|
+
const numMatch = fmt.slice(i).match(/^%(?:\d+)?(?:\.\d+)?([sdifFeExXo%])/);
|
|
495
|
+
if (numMatch) {
|
|
496
|
+
const spec = numMatch[1];
|
|
497
|
+
if (spec === 's') {
|
|
498
|
+
result += str;
|
|
499
|
+
} else if (spec === 'd' || spec === 'i') {
|
|
500
|
+
result += String(parseInt(str, 10));
|
|
501
|
+
} else if (spec === 'f' || spec === 'F') {
|
|
502
|
+
const precMatch = numMatch[0].match(/\.(\d+)/);
|
|
503
|
+
const precision = precMatch ? parseInt(precMatch[1], 10) : 6;
|
|
504
|
+
const widthMatch = numMatch[0].match(/%(\d+)/);
|
|
505
|
+
const width = widthMatch ? parseInt(widthMatch[1], 10) : null;
|
|
506
|
+
const num = parseFloat(str);
|
|
507
|
+
let formatted = num.toFixed(precision);
|
|
508
|
+
if (width && formatted.length < width) {
|
|
509
|
+
formatted = ' '.repeat(width - formatted.length) + formatted;
|
|
510
|
+
}
|
|
511
|
+
if (spec === 'F') formatted = formatted.toUpperCase();
|
|
512
|
+
result += formatted;
|
|
513
|
+
} else if (spec === 'e') {
|
|
514
|
+
result += parseFloat(str).toExponential();
|
|
515
|
+
} else if (spec === 'E') {
|
|
516
|
+
result += parseFloat(str).toExponential().toUpperCase();
|
|
517
|
+
} else if (spec === 'x') {
|
|
518
|
+
result += parseInt(str, 10).toString(16);
|
|
519
|
+
} else if (spec === 'X') {
|
|
520
|
+
result += parseInt(str, 10).toString(16).toUpperCase();
|
|
521
|
+
} else if (spec === 'o') {
|
|
522
|
+
result += parseInt(str, 10).toString(8);
|
|
523
|
+
} else if (spec === '%') {
|
|
524
|
+
result += '%';
|
|
525
|
+
}
|
|
526
|
+
i += numMatch[0].length;
|
|
527
|
+
} else {
|
|
528
|
+
result += fmt[i];
|
|
529
|
+
i++;
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
result += fmt[i];
|
|
533
|
+
i++;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
registerFilter('cut', (val, arg) => {
|
|
540
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
541
|
+
if (arg === undefined || arg === null) return str;
|
|
542
|
+
return str.split(String(arg)).join('');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
registerFilter('addslashes', (val) => {
|
|
546
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
547
|
+
return str.replace(/['"\\]/g, c => '\\' + c);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
registerFilter('removetags', (val, arg) => {
|
|
551
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
552
|
+
if (!arg) return str;
|
|
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);
|
|
555
|
+
let result = str;
|
|
556
|
+
for (const tag of tags) {
|
|
557
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
558
|
+
result = result.replace(new RegExp(`<\\/${escaped}>`, 'gi'), '');
|
|
559
|
+
result = result.replace(new RegExp(`<${escaped}\\b[^>]*>`, 'gi'), '');
|
|
560
|
+
result = result.replace(new RegExp(`<${escaped}>`, 'gi'), '');
|
|
561
|
+
}
|
|
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);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* `trans` filter — translates a string using the i18n registry.
|
|
570
|
+
* Requires the optional i18n module to have translations registered.
|
|
571
|
+
* Falls back to the original value if no translation is found.
|
|
572
|
+
*
|
|
573
|
+
* {{ "Hello, world!"|trans }}
|
|
574
|
+
* {{ greeting|trans:"Hello, %s!" }}
|
|
575
|
+
*/
|
|
576
|
+
registerFilter('trans', (val, arg) => {
|
|
577
|
+
const i18n = require('./i18n');
|
|
578
|
+
const key = arg && arg.length > 0 ? arg : String(val);
|
|
579
|
+
const isSafeValue = val && val.constructor && val.constructor.name === 'SafeString';
|
|
580
|
+
const result = i18n.lookup(key);
|
|
581
|
+
if (isSafeValue) {
|
|
582
|
+
const { markSafe } = require('./security');
|
|
583
|
+
return markSafe(String(result));
|
|
584
|
+
}
|
|
585
|
+
return String(result);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* `regroup` filter — groups a list of objects by a common attribute.
|
|
590
|
+
* Returns an array of { grouper, list } objects suitable for iteration.
|
|
591
|
+
*
|
|
592
|
+
* {% for group in items|regroup:"category" %}
|
|
593
|
+
* <h3>{{ group.grouper }}</h3>
|
|
594
|
+
* {% for item in group.list %}
|
|
595
|
+
* <p>{{ item.name }}</p>
|
|
596
|
+
* {% endfor %}
|
|
597
|
+
* {% endfor %}
|
|
598
|
+
*/
|
|
599
|
+
registerFilter('regroup', (val, arg) => {
|
|
600
|
+
if (!Array.isArray(val)) return [];
|
|
601
|
+
const key = String(arg || '');
|
|
602
|
+
const groups = new Map();
|
|
603
|
+
for (const item of val) {
|
|
604
|
+
const grouper = item && typeof item === 'object' ? (item[key] !== undefined ? item[key] : null) : null;
|
|
605
|
+
const grouperKey = grouper === null ? '__null__' : String(grouper);
|
|
606
|
+
if (!groups.has(grouperKey)) {
|
|
607
|
+
groups.set(grouperKey, { grouper, list: [] });
|
|
608
|
+
}
|
|
609
|
+
groups.get(grouperKey).list.push(item);
|
|
610
|
+
}
|
|
611
|
+
return Array.from(groups.values());
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* `strftime` filter — formats a Date using `date-fns` format strings.
|
|
616
|
+
* Supports all `date-fns` format tokens (pp, yyyy, MM, dd, HH, mm, ss, etc.)
|
|
617
|
+
*
|
|
618
|
+
* {{ now|strftime:"PPpp" }} → "Aug 31, 2026 at 10:24 PM"
|
|
619
|
+
* {{ now|strftime:"yyyy-MM-dd" }} → "2026-08-31"
|
|
620
|
+
* {{ now|strftime:"HH:mm:ss" }} → "22:24:56"
|
|
621
|
+
*/
|
|
622
|
+
registerFilter('strftime', (val, arg) => {
|
|
623
|
+
const d = new Date(val);
|
|
624
|
+
if (isNaN(d.getTime())) return String(val);
|
|
625
|
+
const fmt = String(arg === null || arg === undefined ? 'yyyy-MM-dd' : arg);
|
|
626
|
+
try {
|
|
627
|
+
const { format } = require('date-fns');
|
|
628
|
+
return format(d, fmt);
|
|
629
|
+
} catch {
|
|
630
|
+
// If date-fns is not available or format is invalid, fallback
|
|
631
|
+
return d.toISOString();
|
|
632
|
+
}
|
|
633
|
+
});
|
|
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
|
+
|
|
974
|
+
module.exports = { registerFilter, getFilter };
|
|
975
|
+
|