miki-template 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +54 -0
- package/AGENT.md +71 -0
- package/API_REFERENCE.md +314 -0
- package/CHANGELOG.md +97 -0
- package/CODE_OF_CONDUCT.md +14 -0
- package/CONTRIBUTING.md +27 -0
- package/README.md +304 -0
- package/ROADMAP.md +40 -0
- package/benchmarks/report.json +17 -0
- package/benchmarks/run.js +49 -0
- package/benchmarks/templates/large.dtpl +7 -0
- package/benchmarks/templates/medium.dtpl +3 -0
- package/benchmarks/templates/small.dtpl +7 -0
- package/context/component.md +109 -0
- package/context/prd.md +131 -0
- package/context/project-structure.md +33 -0
- package/docs/README.md +18 -0
- package/docs/advanced_usage.md +71 -0
- package/docs/api.md +102 -0
- package/docs/filters.md +540 -0
- package/docs/installation.md +106 -0
- package/docs/overview.md +57 -0
- package/docs/partialdef.md +41 -0
- package/docs/security.md +27 -0
- package/docs/tags.md +610 -0
- package/docs/usage.md +599 -0
- package/eslint.config.mjs +34 -0
- package/miki-template-1.2.0.vsix +0 -0
- package/miki-template-extension/LICENSE +21 -0
- package/miki-template-extension/README.md +82 -0
- package/miki-template-extension/icon.png +0 -0
- package/miki-template-extension/icon.svg +10 -0
- package/miki-template-extension/package.json +46 -0
- package/miki-template-extension/snippets/miki-template.json +177 -0
- package/miki-template-extension/syntaxes/language-configuration.json +26 -0
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +146 -0
- package/package.json +31 -0
- package/snippets/miki-template.json +177 -0
- package/src/asyncRender.js +21 -0
- package/src/cache.js +41 -0
- package/src/context.js +122 -0
- package/src/context_processors.js +41 -0
- package/src/esm.mjs +72 -0
- package/src/filters.js +527 -0
- package/src/i18n.js +171 -0
- package/src/index.js +454 -0
- package/src/lexer.js +92 -0
- package/src/libraries.js +240 -0
- package/src/parser.js +250 -0
- package/src/security.js +51 -0
- package/src/tags/control.js +591 -0
- package/src/tags/helpers.js +27 -0
- package/src/tags/i18n.js +230 -0
- package/src/tags/inheritance.js +216 -0
- package/src/tags/registry.js +18 -0
- package/src/tags/util.js +322 -0
- package/src/types.d.ts +107 -0
- package/syntaxes/language-configuration.json +26 -0
- package/syntaxes/miki-template.tmLanguage.json +146 -0
- package/tests/asyncRender.test.js +17 -0
- package/tests/base.html +6 -0
- package/tests/child.html +3 -0
- package/tests/context_processors.test.js +13 -0
- package/tests/esm.test.mjs +26 -0
- package/tests/filters.test.js +99 -0
- package/tests/include_security.test.js +9 -0
- package/tests/lexer.test.js +45 -0
- package/tests/parser.test.js +55 -0
- package/tests/partial.html +1 -0
- package/tests/partialdef.test.js +40 -0
- package/tests/production_checks.js +57 -0
- package/tests/security.test.js +28 -0
- package/tests/tags.test.js +203 -0
package/src/filters.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
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
|
+
return markSafe(escapeHtml(val));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function markValueSafe(val) {
|
|
19
|
+
const { markSafe } = require('./security');
|
|
20
|
+
return markSafe(val);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- Text Filters ---
|
|
24
|
+
registerFilter('upper', (val) => {
|
|
25
|
+
return String(val === null || val === undefined ? '' : val).toUpperCase();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
registerFilter('lower', (val) => {
|
|
29
|
+
return String(val === null || val === undefined ? '' : val).toLowerCase();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
registerFilter('title', (val) => {
|
|
33
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
34
|
+
return str.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
registerFilter('capfirst', (val) => {
|
|
38
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
39
|
+
if (!str) return '';
|
|
40
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
registerFilter('truncatewords', (val, arg) => {
|
|
44
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
45
|
+
const count = parseInt(arg, 10);
|
|
46
|
+
if (isNaN(count) || count <= 0) return str;
|
|
47
|
+
const words = str.split(/\s+/).filter(Boolean);
|
|
48
|
+
if (words.length <= count) return str;
|
|
49
|
+
return words.slice(0, count).join(' ') + ' ...';
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
registerFilter('truncatechars', (val, arg) => {
|
|
53
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
54
|
+
const count = parseInt(arg, 10);
|
|
55
|
+
if (isNaN(count) || count <= 0) return str;
|
|
56
|
+
if (str.length <= count) return str;
|
|
57
|
+
return str.slice(0, count - 3) + '...';
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
registerFilter('wordcount', (val) => {
|
|
61
|
+
const str = String(val === null || val === undefined ? '' : val).trim();
|
|
62
|
+
if (!str) return 0;
|
|
63
|
+
return str.split(/\s+/).length;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
registerFilter('linebreaks', (val) => {
|
|
67
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
68
|
+
if (!str) return '';
|
|
69
|
+
const paragraphs = str.split(/\n{2,}/);
|
|
70
|
+
const formatted = paragraphs
|
|
71
|
+
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
|
72
|
+
.join('');
|
|
73
|
+
return markValueSafe(formatted);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
registerFilter('linebreaksbr', (val) => {
|
|
77
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
78
|
+
return markValueSafe(str.replace(/\n/g, '<br>'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
registerFilter('striptags', (val) => {
|
|
82
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
83
|
+
return str.replace(/<\/?[^>]+>/g, '');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
registerFilter('slugify', (val) => {
|
|
87
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
88
|
+
return str
|
|
89
|
+
.normalize('NFD')
|
|
90
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
93
|
+
.trim()
|
|
94
|
+
.replace(/\s+/g, '-')
|
|
95
|
+
.replace(/-+/g, '-');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
registerFilter('length_is', (val, arg) => {
|
|
99
|
+
const length = (val && typeof val.length === 'number') ? val.length : 0;
|
|
100
|
+
const expected = parseInt(arg, 10);
|
|
101
|
+
if (isNaN(expected)) return false;
|
|
102
|
+
return length === expected;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// --- HTML Filters ---
|
|
106
|
+
registerFilter('safe', (val) => {
|
|
107
|
+
return markValueSafe(val);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
registerFilter('escape', (val) => {
|
|
111
|
+
return escapeValue(val);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// --- List Filters ---
|
|
115
|
+
registerFilter('length', (val) => {
|
|
116
|
+
if (val === null || val === undefined) return 0;
|
|
117
|
+
if (typeof val.length === 'number') return val.length;
|
|
118
|
+
if (val instanceof Set || val instanceof Map) return val.size;
|
|
119
|
+
if (typeof val === 'object') return Object.keys(val).length;
|
|
120
|
+
return 0;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
registerFilter('join', (val, arg) => {
|
|
124
|
+
if (!Array.isArray(val)) return val;
|
|
125
|
+
const separator = arg === undefined ? '' : String(arg);
|
|
126
|
+
return val.join(separator);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
registerFilter('slice', (val, arg) => {
|
|
130
|
+
if (!val || typeof val.slice !== 'function') return val;
|
|
131
|
+
const parts = arg.split(':');
|
|
132
|
+
if (parts.length === 1) {
|
|
133
|
+
const idx = parseInt(parts[0], 10);
|
|
134
|
+
return isNaN(idx) ? val : val.slice(idx, idx + 1);
|
|
135
|
+
}
|
|
136
|
+
const start = parts[0] === '' ? undefined : parseInt(parts[0], 10);
|
|
137
|
+
const end = parts[1] === '' ? undefined : parseInt(parts[1], 10);
|
|
138
|
+
return val.slice(start, end);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
registerFilter('dictsort', (val, arg) => {
|
|
142
|
+
if (!Array.isArray(val) || !arg) return val;
|
|
143
|
+
return [...val].sort((a, b) => {
|
|
144
|
+
const valA = a && typeof a === 'object' ? a[arg] : undefined;
|
|
145
|
+
const valB = b && typeof b === 'object' ? b[arg] : undefined;
|
|
146
|
+
if (valA === undefined && valB === undefined) return 0;
|
|
147
|
+
if (valA === undefined) return 1;
|
|
148
|
+
if (valB === undefined) return -1;
|
|
149
|
+
if (valA < valB) return -1;
|
|
150
|
+
if (valA > valB) return 1;
|
|
151
|
+
return 0;
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
registerFilter('dictsortreversed', (val, arg) => {
|
|
156
|
+
if (!Array.isArray(val) || !arg) return val;
|
|
157
|
+
return [...val].sort((a, b) => {
|
|
158
|
+
const valA = a && typeof a === 'object' ? a[arg] : undefined;
|
|
159
|
+
const valB = b && typeof b === 'object' ? b[arg] : undefined;
|
|
160
|
+
if (valA === undefined && valB === undefined) return 0;
|
|
161
|
+
if (valA === undefined) return -1;
|
|
162
|
+
if (valB === undefined) return 1;
|
|
163
|
+
if (valA < valB) return 1;
|
|
164
|
+
if (valA > valB) return -1;
|
|
165
|
+
return 0;
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// --- Date/Time Filters ---
|
|
170
|
+
const { format: formatDate, parseISO } = require('date-fns');
|
|
171
|
+
|
|
172
|
+
registerFilter('date_format', (val, pattern) => {
|
|
173
|
+
let date = val;
|
|
174
|
+
if (typeof val === 'string') {
|
|
175
|
+
date = parseISO(val);
|
|
176
|
+
} else if (!(date instanceof Date)) {
|
|
177
|
+
date = new Date(val);
|
|
178
|
+
}
|
|
179
|
+
if (isNaN(date.getTime())) return '';
|
|
180
|
+
const fmt = pattern || 'yyyy-MM-dd\'T\'HH:mm:ssxxx';
|
|
181
|
+
try {
|
|
182
|
+
return formatDate(date, fmt);
|
|
183
|
+
} catch {
|
|
184
|
+
return '';
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
registerFilter('strftime', (val, pattern) => {
|
|
189
|
+
let date = val;
|
|
190
|
+
if (typeof val === 'string') {
|
|
191
|
+
date = parseISO(val);
|
|
192
|
+
} else if (!(date instanceof Date)) {
|
|
193
|
+
date = new Date(val);
|
|
194
|
+
}
|
|
195
|
+
if (isNaN(date.getTime())) return '';
|
|
196
|
+
const fmt = pattern || 'PPpp';
|
|
197
|
+
try {
|
|
198
|
+
return formatDate(date, fmt);
|
|
199
|
+
} catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
registerFilter('date', (val, arg) => {
|
|
205
|
+
let date = val;
|
|
206
|
+
if (!(date instanceof Date)) {
|
|
207
|
+
date = new Date(val);
|
|
208
|
+
}
|
|
209
|
+
if (isNaN(date.getTime())) return '';
|
|
210
|
+
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
|
+
};
|
|
224
|
+
let output = '';
|
|
225
|
+
for (let i = 0; i < formatStr.length; i++) {
|
|
226
|
+
const ch = formatStr[i];
|
|
227
|
+
output += ch in mapper ? mapper[ch]() : ch;
|
|
228
|
+
}
|
|
229
|
+
return output;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
registerFilter('time', (val, arg) => {
|
|
233
|
+
let date = val;
|
|
234
|
+
if (!(date instanceof Date)) {
|
|
235
|
+
date = new Date(val);
|
|
236
|
+
}
|
|
237
|
+
if (isNaN(date.getTime())) return '';
|
|
238
|
+
const formatStr = arg || 'H:i';
|
|
239
|
+
return getFilter('date')(date, formatStr);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
registerFilter('timesince', (val, arg) => {
|
|
243
|
+
const d1 = new Date(val);
|
|
244
|
+
const d2 = arg ? new Date(arg) : new Date();
|
|
245
|
+
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return '';
|
|
246
|
+
const diffMs = Math.max(0, d2 - d1);
|
|
247
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
248
|
+
const diffMins = Math.floor(seconds / 60);
|
|
249
|
+
if (diffMins < 1) return '0 minutes';
|
|
250
|
+
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
|
251
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
252
|
+
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
253
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
254
|
+
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
registerFilter('timeuntil', (val, arg) => {
|
|
258
|
+
const d1 = new Date(val);
|
|
259
|
+
const d2 = arg ? new Date(arg) : new Date();
|
|
260
|
+
const diffMs = Math.max(0, d1.getTime() - d2.getTime());
|
|
261
|
+
if (diffMs <= 0) return '0 minutes';
|
|
262
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
263
|
+
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
|
264
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
265
|
+
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
266
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
267
|
+
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// --- Numeric Filters ---
|
|
271
|
+
registerFilter('add', (val, arg) => {
|
|
272
|
+
const numVal = Number(val);
|
|
273
|
+
const numArg = Number(arg);
|
|
274
|
+
if (!isNaN(numVal) && !isNaN(numArg)) {
|
|
275
|
+
return numVal + numArg;
|
|
276
|
+
}
|
|
277
|
+
if (Array.isArray(val) && Array.isArray(arg)) {
|
|
278
|
+
return val.concat(arg);
|
|
279
|
+
}
|
|
280
|
+
return String(val) + String(arg);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
registerFilter('divisibleby', (val, arg) => {
|
|
284
|
+
const numVal = Number(val);
|
|
285
|
+
const numArg = Number(arg);
|
|
286
|
+
if (isNaN(numVal) || isNaN(numArg) || numArg === 0) return false;
|
|
287
|
+
return numVal % numArg === 0;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
registerFilter('floatformat', (val, arg) => {
|
|
291
|
+
const num = Number(val);
|
|
292
|
+
if (isNaN(num)) return '';
|
|
293
|
+
if (arg === undefined || arg === null) {
|
|
294
|
+
return num.toFixed(1);
|
|
295
|
+
}
|
|
296
|
+
const decimals = parseInt(arg, 10);
|
|
297
|
+
if (isNaN(decimals)) return '';
|
|
298
|
+
if (decimals === -1) {
|
|
299
|
+
return num.toFixed(0);
|
|
300
|
+
}
|
|
301
|
+
return num.toFixed(Math.max(0, decimals));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// --- Default Filters ---
|
|
305
|
+
registerFilter('default', (val, arg) => {
|
|
306
|
+
return val === null || val === undefined || val === '' ? arg : val;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
registerFilter('default_if_none', (val, arg) => {
|
|
310
|
+
return (val === null || val === undefined) ? arg : val;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
registerFilter('firstof', (...args) => {
|
|
314
|
+
for (const arg of args) {
|
|
315
|
+
if (arg !== null && arg !== undefined && arg !== '') {
|
|
316
|
+
return arg;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return '';
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// --- Misc Filters ---
|
|
323
|
+
registerFilter('pluralize', (val, arg) => {
|
|
324
|
+
const suffixes = (arg || 's').split(',');
|
|
325
|
+
let count = val;
|
|
326
|
+
if (Array.isArray(val) || (val && typeof val === 'object' && 'length' in val)) {
|
|
327
|
+
count = val.length;
|
|
328
|
+
} else if (!isNaN(Number(val))) {
|
|
329
|
+
count = Number(val);
|
|
330
|
+
} else {
|
|
331
|
+
count = 1;
|
|
332
|
+
}
|
|
333
|
+
if (suffixes.length === 1) {
|
|
334
|
+
return count === 1 ? '' : suffixes[0];
|
|
335
|
+
}
|
|
336
|
+
return count === 1 ? suffixes[0] : suffixes[1];
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
registerFilter('yesno', (val, arg) => {
|
|
340
|
+
const mappings = (arg || 'yes,no,maybe').split(',');
|
|
341
|
+
const yes = mappings[0] || 'yes';
|
|
342
|
+
const no = mappings[1] || 'no';
|
|
343
|
+
const maybe = mappings[2] || 'maybe';
|
|
344
|
+
if (val === null || val === undefined) return maybe;
|
|
345
|
+
return val ? yes : no;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
registerFilter('filesizeformat', (val) => {
|
|
349
|
+
const bytes = Number(val);
|
|
350
|
+
if (isNaN(bytes) || bytes < 0) return '0 bytes';
|
|
351
|
+
if (bytes === 0) return '0 bytes';
|
|
352
|
+
const k = 1024;
|
|
353
|
+
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
354
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
355
|
+
const num = bytes / Math.pow(k, i);
|
|
356
|
+
return `${num.toFixed(num % 1 === 0 ? 0 : 1)} ${sizes[i]}`;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
registerFilter('urlencode', (val, arg) => {
|
|
360
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
361
|
+
if (arg === undefined || arg === null || arg === '') {
|
|
362
|
+
return encodeURIComponent(str).replace(/%20/g, '+');
|
|
363
|
+
}
|
|
364
|
+
if (arg === 'utf-8' || arg === 'utf8') {
|
|
365
|
+
return encodeURIComponent(str);
|
|
366
|
+
}
|
|
367
|
+
if (arg === 'query' || arg === 'raw') {
|
|
368
|
+
return encodeURIComponent(str).replace(/%20/g, '+');
|
|
369
|
+
}
|
|
370
|
+
if (arg === 'path') {
|
|
371
|
+
return str.split('/').map(seg => encodeURIComponent(seg)).join('/');
|
|
372
|
+
}
|
|
373
|
+
return encodeURIComponent(str);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
registerFilter('escapeuri', (val) => {
|
|
377
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
378
|
+
return encodeURI(str);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
registerFilter('stringformat', (val, arg) => {
|
|
382
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
383
|
+
const fmt = String(arg === null || arg === undefined ? '%s' : arg);
|
|
384
|
+
let result = '';
|
|
385
|
+
let i = 0;
|
|
386
|
+
while (i < fmt.length) {
|
|
387
|
+
if (fmt[i] === '%') {
|
|
388
|
+
// Try to match a full format specifier with optional width/precision
|
|
389
|
+
const numMatch = fmt.slice(i).match(/^%(?:\d+)?(?:\.\d+)?([sdifFeExXo%])/);
|
|
390
|
+
if (numMatch) {
|
|
391
|
+
const spec = numMatch[1];
|
|
392
|
+
if (spec === 's') {
|
|
393
|
+
result += str;
|
|
394
|
+
} else if (spec === 'd' || spec === 'i') {
|
|
395
|
+
result += String(parseInt(str, 10));
|
|
396
|
+
} else if (spec === 'f' || spec === 'F') {
|
|
397
|
+
const precMatch = numMatch[0].match(/\.(\d+)/);
|
|
398
|
+
const precision = precMatch ? parseInt(precMatch[1], 10) : 6;
|
|
399
|
+
const widthMatch = numMatch[0].match(/%(\d+)/);
|
|
400
|
+
const width = widthMatch ? parseInt(widthMatch[1], 10) : null;
|
|
401
|
+
const num = parseFloat(str);
|
|
402
|
+
let formatted = num.toFixed(precision);
|
|
403
|
+
if (width && formatted.length < width) {
|
|
404
|
+
formatted = ' '.repeat(width - formatted.length) + formatted;
|
|
405
|
+
}
|
|
406
|
+
if (spec === 'F') formatted = formatted.toUpperCase();
|
|
407
|
+
result += formatted;
|
|
408
|
+
} else if (spec === 'e') {
|
|
409
|
+
result += parseFloat(str).toExponential();
|
|
410
|
+
} else if (spec === 'E') {
|
|
411
|
+
result += parseFloat(str).toExponential().toUpperCase();
|
|
412
|
+
} else if (spec === 'x') {
|
|
413
|
+
result += parseInt(str, 10).toString(16);
|
|
414
|
+
} else if (spec === 'X') {
|
|
415
|
+
result += parseInt(str, 10).toString(16).toUpperCase();
|
|
416
|
+
} else if (spec === 'o') {
|
|
417
|
+
result += parseInt(str, 10).toString(8);
|
|
418
|
+
} else if (spec === '%') {
|
|
419
|
+
result += '%';
|
|
420
|
+
}
|
|
421
|
+
i += numMatch[0].length;
|
|
422
|
+
} else {
|
|
423
|
+
result += fmt[i];
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
result += fmt[i];
|
|
428
|
+
i++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
registerFilter('cut', (val, arg) => {
|
|
435
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
436
|
+
if (arg === undefined || arg === null) return str;
|
|
437
|
+
return str.split(String(arg)).join('');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
registerFilter('addslashes', (val) => {
|
|
441
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
442
|
+
return str.replace(/['"\\]/g, c => '\\' + c);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
registerFilter('removetags', (val, arg) => {
|
|
446
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
447
|
+
if (!arg) return str;
|
|
448
|
+
const tags = arg.split(',').map(t => t.trim()).filter(Boolean);
|
|
449
|
+
let result = str;
|
|
450
|
+
for (const tag of tags) {
|
|
451
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
452
|
+
result = result.replace(new RegExp(`<\\/${escaped}>`, 'gi'), '');
|
|
453
|
+
result = result.replace(new RegExp(`<${escaped}[^>]*>`, 'gi'), '');
|
|
454
|
+
result = result.replace(new RegExp(`<${escaped}>`, 'gi'), '');
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* `trans` filter — translates a string using the i18n registry.
|
|
461
|
+
* Requires the optional i18n module to have translations registered.
|
|
462
|
+
* Falls back to the original value if no translation is found.
|
|
463
|
+
*
|
|
464
|
+
* {{ "Hello, world!"|trans }}
|
|
465
|
+
* {{ greeting|trans:"Hello, %s!" }}
|
|
466
|
+
*/
|
|
467
|
+
registerFilter('trans', (val, arg) => {
|
|
468
|
+
const i18n = require('./i18n');
|
|
469
|
+
const key = arg && arg.length > 0 ? arg : String(val);
|
|
470
|
+
const isSafeValue = val && val.constructor && val.constructor.name === 'SafeString';
|
|
471
|
+
const result = i18n.lookup(key);
|
|
472
|
+
if (isSafeValue) {
|
|
473
|
+
const { markSafe } = require('./security');
|
|
474
|
+
return markSafe(String(result));
|
|
475
|
+
}
|
|
476
|
+
return String(result);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* `regroup` filter — groups a list of objects by a common attribute.
|
|
481
|
+
* Returns an array of { grouper, list } objects suitable for iteration.
|
|
482
|
+
*
|
|
483
|
+
* {% for group in items|regroup:"category" %}
|
|
484
|
+
* <h3>{{ group.grouper }}</h3>
|
|
485
|
+
* {% for item in group.list %}
|
|
486
|
+
* <p>{{ item.name }}</p>
|
|
487
|
+
* {% endfor %}
|
|
488
|
+
* {% endfor %}
|
|
489
|
+
*/
|
|
490
|
+
registerFilter('regroup', (val, arg) => {
|
|
491
|
+
if (!Array.isArray(val)) return [];
|
|
492
|
+
const key = String(arg || '');
|
|
493
|
+
const groups = new Map();
|
|
494
|
+
for (const item of val) {
|
|
495
|
+
const grouper = item && typeof item === 'object' ? (item[key] !== undefined ? item[key] : null) : null;
|
|
496
|
+
const grouperKey = grouper === null ? '__null__' : String(grouper);
|
|
497
|
+
if (!groups.has(grouperKey)) {
|
|
498
|
+
groups.set(grouperKey, { grouper, list: [] });
|
|
499
|
+
}
|
|
500
|
+
groups.get(grouperKey).list.push(item);
|
|
501
|
+
}
|
|
502
|
+
return Array.from(groups.values());
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* `strftime` filter — formats a Date using `date-fns` format strings.
|
|
507
|
+
* Supports all `date-fns` format tokens (pp, yyyy, MM, dd, HH, mm, ss, etc.)
|
|
508
|
+
*
|
|
509
|
+
* {{ now|strftime:"PPpp" }} → "Aug 31, 2026 at 10:24 PM"
|
|
510
|
+
* {{ now|strftime:"yyyy-MM-dd" }} → "2026-08-31"
|
|
511
|
+
* {{ now|strftime:"HH:mm:ss" }} → "22:24:56"
|
|
512
|
+
*/
|
|
513
|
+
registerFilter('strftime', (val, arg) => {
|
|
514
|
+
const d = new Date(val);
|
|
515
|
+
if (isNaN(d.getTime())) return String(val);
|
|
516
|
+
const fmt = String(arg === null || arg === undefined ? 'yyyy-MM-dd' : arg);
|
|
517
|
+
try {
|
|
518
|
+
const { format } = require('date-fns');
|
|
519
|
+
return format(d, fmt);
|
|
520
|
+
} catch {
|
|
521
|
+
// If date-fns is not available or format is invalid, fallback
|
|
522
|
+
return d.toISOString();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
module.exports = { registerFilter, getFilter };
|
|
527
|
+
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n (Internationalization) support for miki-template.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Translations registry: registerTranslation(lang, messages)
|
|
6
|
+
* - `trans` filter: {{ key|trans:"fallback" }}
|
|
7
|
+
* - `{% trans "key" %}` tag
|
|
8
|
+
* - `{% blocktrans %}...{% endblocktrans %}` tag (supports {% with %}, {% plural %})
|
|
9
|
+
* - `{% language "xx" %}` tag (switches current language for block)
|
|
10
|
+
* - Active language getter/setter
|
|
11
|
+
* - Plural rule support
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const translations = new Map(); // Map<lang, Map<key, string | {one:string, other:string}>>
|
|
15
|
+
let activeLanguage = 'en';
|
|
16
|
+
let fallbackLanguage = 'en';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register a set of translations for a language.
|
|
20
|
+
*
|
|
21
|
+
* registerTranslation('fr', {
|
|
22
|
+
* "Hello, world!": "Bonjour, le monde !",
|
|
23
|
+
* "%d item": { one: "%d élément", other: "%d éléments" }
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
function registerTranslation(lang, messages) {
|
|
27
|
+
if (!translations.has(lang)) {
|
|
28
|
+
translations.set(lang, new Map());
|
|
29
|
+
}
|
|
30
|
+
const map = translations.get(lang);
|
|
31
|
+
for (const [k, v] of Object.entries(messages)) {
|
|
32
|
+
map.set(k, v);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Unregister a language or a specific key. */
|
|
37
|
+
function unregisterTranslation(lang, key) {
|
|
38
|
+
if (!lang) {
|
|
39
|
+
translations.clear();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (key) {
|
|
43
|
+
const m = translations.get(lang);
|
|
44
|
+
if (m) m.delete(key);
|
|
45
|
+
} else {
|
|
46
|
+
translations.delete(lang);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Set the active language for all subsequent renders. */
|
|
51
|
+
function setLanguage(lang) {
|
|
52
|
+
activeLanguage = lang;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Get the currently active language. */
|
|
56
|
+
function getLanguage() {
|
|
57
|
+
return activeLanguage;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Set the fallback language used when a key is missing. */
|
|
61
|
+
function setFallbackLanguage(lang) {
|
|
62
|
+
fallbackLanguage = lang;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Get the fallback language. */
|
|
66
|
+
function getFallbackLanguage() {
|
|
67
|
+
return fallbackLanguage;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List all registered languages. */
|
|
71
|
+
function getAvailableLanguages() {
|
|
72
|
+
return Array.from(translations.keys());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Look up a translation key.
|
|
77
|
+
* Returns the active language's translation, falling back to fallback, or the key itself.
|
|
78
|
+
*/
|
|
79
|
+
function lookup(key, params = {}, count) {
|
|
80
|
+
const lookupIn = (lang) => {
|
|
81
|
+
const m = translations.get(lang);
|
|
82
|
+
if (!m) return undefined;
|
|
83
|
+
return m.get(key);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let result = lookupIn(activeLanguage);
|
|
87
|
+
if (result === undefined) {
|
|
88
|
+
result = lookupIn(fallbackLanguage);
|
|
89
|
+
}
|
|
90
|
+
if (result === undefined) {
|
|
91
|
+
return key; // No translation, return the key
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle plural forms
|
|
95
|
+
if (typeof result === 'object' && result !== null) {
|
|
96
|
+
if (typeof count === 'number') {
|
|
97
|
+
if (count === 1 && result.one !== undefined) {
|
|
98
|
+
result = result.one;
|
|
99
|
+
} else if (count !== 1 && result.other !== undefined) {
|
|
100
|
+
result = result.other;
|
|
101
|
+
} else if (result.other !== undefined) {
|
|
102
|
+
result = result.other;
|
|
103
|
+
} else {
|
|
104
|
+
result = key;
|
|
105
|
+
}
|
|
106
|
+
} else if (result.other !== undefined) {
|
|
107
|
+
result = result.other;
|
|
108
|
+
} else if (result.one !== undefined) {
|
|
109
|
+
result = result.one;
|
|
110
|
+
} else {
|
|
111
|
+
result = key;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Interpolate %name% and %s style placeholders
|
|
116
|
+
if (typeof result === 'string' && params && Object.keys(params).length > 0) {
|
|
117
|
+
result = interpolate(result, params, count);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Interpolate placeholders. Supports:
|
|
124
|
+
* - %s, %d, %f, %.Nf (Python-style positional via args)
|
|
125
|
+
* - %name% (named placeholders)
|
|
126
|
+
* - {name} (Django-style named placeholders)
|
|
127
|
+
*/
|
|
128
|
+
function interpolate(template, params, count) {
|
|
129
|
+
let out = template;
|
|
130
|
+
|
|
131
|
+
// {name} style — Django
|
|
132
|
+
out = out.replace(/\{(\w+)\}/g, (m, name) => {
|
|
133
|
+
if (name === 'count' && typeof count === 'number') return String(count);
|
|
134
|
+
if (Object.prototype.hasOwnProperty.call(params, name)) {
|
|
135
|
+
return String(params[name]);
|
|
136
|
+
}
|
|
137
|
+
return m;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// %name% style
|
|
141
|
+
out = out.replace(/%(\w+)%/g, (m, name) => {
|
|
142
|
+
if (name === 'count' && typeof count === 'number') return String(count);
|
|
143
|
+
if (Object.prototype.hasOwnProperty.call(params, name)) {
|
|
144
|
+
return String(params[name]);
|
|
145
|
+
}
|
|
146
|
+
return m;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Positional: if there's a single non-object value in params use it for %s
|
|
150
|
+
if (Object.keys(params).length > 0) {
|
|
151
|
+
const firstScalar = Object.values(params).find(v => typeof v === 'string' || typeof v === 'number');
|
|
152
|
+
if (firstScalar !== undefined) {
|
|
153
|
+
out = out.replace(/%s/g, String(firstScalar));
|
|
154
|
+
out = out.replace(/%d/g, String(parseInt(firstScalar, 10) || 0));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
registerTranslation,
|
|
162
|
+
unregisterTranslation,
|
|
163
|
+
setLanguage,
|
|
164
|
+
getLanguage,
|
|
165
|
+
setFallbackLanguage,
|
|
166
|
+
getFallbackLanguage,
|
|
167
|
+
getAvailableLanguages,
|
|
168
|
+
lookup,
|
|
169
|
+
interpolate,
|
|
170
|
+
translations
|
|
171
|
+
};
|