miki-template 2.3.3 → 2.3.4
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/README.md +2 -0
- package/package.json +1 -1
- package/src/codegen.js +50 -2
- package/src/filters.js +243 -4
- package/src/libraries.js +1 -1
- package/src/tags/control.js +36 -1
- package/src/tags/i18n.js +72 -0
- package/src/tags/util.js +151 -2
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ you can register your own custom filters and filters in miki templates.
|
|
|
19
19
|
- **Smart template discovery**: Stop hardcoding view paths. The engine searches `templates/`, nested app directories, and custom folder names automatically — just like Django. `setupExpress()` expands your views roots so templates live where they make sense.
|
|
20
20
|
- **One-line Express integration**: `miki.setupExpress(app, { extension: 'html', views: dir })` — wires the engine, views directory, and a `res.render` shim that makes `res.render('home#card', ...)` Just Work for HTMX-style partial responses. **No boilerplate, no extra middleware.**
|
|
21
21
|
- **Full Syntax Parity**: Supports variables, dotted lookups, filters (`|`), and block tags (`{% %}`). **All tag arguments now support filter chains** — e.g. `{% set x = " hello "|trim|upper %}`, `{% for i in "5,1,2"|split:","|sort %}`, `{% with greeting="hello"|upper %}`.
|
|
22
|
+
- **Extended Tag Set**: Includes `{% filter %}`, `{% verbatim %}`, `{% resetcycle %}`, `{% endfirstof %}`, `{% translate %}`, and `{% blocktranslate %}` for full vscode-django-support parity.
|
|
23
|
+
- **Extended Filter Set**: 25 additional Django contrib filters: `center`, `escapejs`, `first`, `fix_ampersands`, `force_escape`, `get_digit`, `intcomma`, `intword`, `iriencode`, `last`, `linenumbers`, `ljust`, `make_list`, `naturalday`, `ordinal`, `phone2numeric`, `pprint`, `rjust`, `safeseq`, `STATIC_PREFIX`, `truncatewords_html`, `unordered_list`, `urlizetrunc`, `wordwrap`, `apnumber`.
|
|
22
24
|
- **Template Inheritance**: Multi-level inheritance with `extends`, block overrides, and `{{ block.super }}` support.
|
|
23
25
|
- **Built-in libraries**: `humanize`, `cache`, and `lorem` ship pre-activated. `{% lorem 5 p %}` works without `{% load lorem %}`.
|
|
24
26
|
- **ESM & CommonJS**: Works seamlessly with both `import` and `require` syntax.
|
package/package.json
CHANGED
package/src/codegen.js
CHANGED
|
@@ -362,6 +362,35 @@ function genNode(node, stmts, level, buf = 'out', loopVarMap = {}) {
|
|
|
362
362
|
stmts.push(pad(`${buf} += _firstof(_ctx, ${js(node.args)});`, level));
|
|
363
363
|
return;
|
|
364
364
|
|
|
365
|
+
case 'EndFirstofNode':
|
|
366
|
+
return;
|
|
367
|
+
|
|
368
|
+
case 'FilterNode': {
|
|
369
|
+
stmts.push(pad('{', level));
|
|
370
|
+
stmts.push(pad('let _filterBuf = \'\';', level + 1));
|
|
371
|
+
genNodes(node.body, stmts, level + 1, '_filterBuf', loopVarMap);
|
|
372
|
+
stmts.push(pad(`${buf} += _applyFilter(_ctx, ${js(node.filterName)}, _filterBuf, ${js(node.filterArg)});`, level));
|
|
373
|
+
stmts.push(pad('}', level));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case 'VerbatimNode': {
|
|
378
|
+
stmts.push(pad('{', level));
|
|
379
|
+
stmts.push(pad('let _verbatimBuf = \'\';', level + 1));
|
|
380
|
+
for (const n of node.body) {
|
|
381
|
+
if (n.constructor.name === 'TextNode' && n.content) {
|
|
382
|
+
stmts.push(pad(`_verbatimBuf += ${js(n.content)};`, level + 1));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
stmts.push(pad(`${buf} += _verbatimBuf;`, level));
|
|
386
|
+
stmts.push(pad('}', level));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
case 'ResetCycleNode':
|
|
391
|
+
stmts.push(pad(`_resetCycle(_ctx, ${js(node.key)});`, level));
|
|
392
|
+
return;
|
|
393
|
+
|
|
365
394
|
case 'PartialDefNode': {
|
|
366
395
|
stmts.push(pad(`_registerPartial(_ctx, ${js(node.name)}, _partials[${node._partialId}]);`, level));
|
|
367
396
|
if (node.inline) {
|
|
@@ -635,7 +664,8 @@ function buildCode(nodes) {
|
|
|
635
664
|
'_csp_nonce', '_loadLibs', '_debug', '_now', '_set', '_ifchanged',
|
|
636
665
|
'_resolveVal', '_helperCall', '_missingFilter', '_normalizeFor',
|
|
637
666
|
'_trans', '_blocktrans', '_registerPartial', '_extends', '_block',
|
|
638
|
-
'_language', '_fallback', '_SafeString', '_astNodes', '_partials', '_languages', '_in'
|
|
667
|
+
'_language', '_fallback', '_SafeString', '_astNodes', '_partials', '_languages', '_in',
|
|
668
|
+
'_applyFilter', '_resetCycle'
|
|
639
669
|
];
|
|
640
670
|
|
|
641
671
|
const src = `"use strict"; return function(${args.join(',')}) { ${body} }`;
|
|
@@ -852,6 +882,22 @@ function firstofHelper(ctx, args) {
|
|
|
852
882
|
return '';
|
|
853
883
|
}
|
|
854
884
|
|
|
885
|
+
function applyFilterHelper(ctx, filterName, raw, filterArg) {
|
|
886
|
+
const { getFilter } = require('./filters');
|
|
887
|
+
const fn = getFilter(filterName);
|
|
888
|
+
if (!fn) throw new Error(`Unknown filter: '${filterName}'`);
|
|
889
|
+
return fn(raw, filterArg, ctx);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function resetCycleHelper(ctx, key) {
|
|
893
|
+
if (!ctx.cycleStates) ctx.cycleStates = new Map();
|
|
894
|
+
if (key) {
|
|
895
|
+
ctx.cycleStates.set(key, 0);
|
|
896
|
+
} else {
|
|
897
|
+
ctx.cycleStates.clear();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
855
901
|
function partialHelper(ctx, name, extra) {
|
|
856
902
|
const partial = ctx.getPartial(name);
|
|
857
903
|
if (!partial) throw new Error(`Partial '${name}' not found`);
|
|
@@ -1019,7 +1065,9 @@ function generateCode(nodes) {
|
|
|
1019
1065
|
flat,
|
|
1020
1066
|
partialsList,
|
|
1021
1067
|
languagesList,
|
|
1022
|
-
inHelper
|
|
1068
|
+
inHelper,
|
|
1069
|
+
applyFilterHelper,
|
|
1070
|
+
resetCycleHelper
|
|
1023
1071
|
);
|
|
1024
1072
|
};
|
|
1025
1073
|
}
|
package/src/filters.js
CHANGED
|
@@ -301,6 +301,11 @@ registerFilter('timeuntil', (val, arg) => {
|
|
|
301
301
|
return `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
+
registerFilter('timeutil', (val, arg) => {
|
|
305
|
+
const fn = getFilter('timeuntil');
|
|
306
|
+
return fn(val, arg);
|
|
307
|
+
});
|
|
308
|
+
|
|
304
309
|
// --- Numeric Filters ---
|
|
305
310
|
registerFilter('add', (val, arg) => {
|
|
306
311
|
const numVal = Number(val);
|
|
@@ -786,15 +791,42 @@ registerFilter('reverse', (val) => {
|
|
|
786
791
|
|
|
787
792
|
registerFilter('sort', (val) => {
|
|
788
793
|
if (!Array.isArray(val)) return val;
|
|
794
|
+
|
|
789
795
|
return [...val].sort((a, b) => {
|
|
796
|
+
// Handle equality
|
|
790
797
|
if (a === b) return 0;
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (
|
|
794
|
-
|
|
798
|
+
|
|
799
|
+
// Handle null/undefined consistently
|
|
800
|
+
if (a == null && b == null) return 0;
|
|
801
|
+
if (a == null) return 1;
|
|
802
|
+
if (b == null) return -1;
|
|
803
|
+
|
|
804
|
+
// Try numeric comparison
|
|
805
|
+
const numA = parseFloat(a);
|
|
806
|
+
const numB = parseFloat(b);
|
|
807
|
+
const isNumA = !Number.isNaN(numA);
|
|
808
|
+
const isNumB = !Number.isNaN(numB);
|
|
809
|
+
|
|
810
|
+
if (isNumA && isNumB) {
|
|
811
|
+
return numA - numB;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Handle booleans explicitly
|
|
815
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
816
|
+
return (a === b) ? 0 : (a ? 1 : -1);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Handle strings with locale-aware comparison
|
|
820
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
821
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Fallback: convert to string and compare
|
|
825
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
|
795
826
|
});
|
|
796
827
|
});
|
|
797
828
|
|
|
829
|
+
|
|
798
830
|
registerFilter('unique', (val) => {
|
|
799
831
|
if (!Array.isArray(val)) return val;
|
|
800
832
|
return [...new Set(val)];
|
|
@@ -987,5 +1019,212 @@ registerFilter('range', (val, arg) => {
|
|
|
987
1019
|
return out;
|
|
988
1020
|
});
|
|
989
1021
|
|
|
1022
|
+
// --- Django contrib filters ---
|
|
1023
|
+
|
|
1024
|
+
registerFilter('center', (val, arg) => {
|
|
1025
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1026
|
+
const width = parseInt(arg, 10);
|
|
1027
|
+
if (Number.isNaN(width) || width <= 0) return str;
|
|
1028
|
+
const len = str.length;
|
|
1029
|
+
if (len >= width) return str;
|
|
1030
|
+
const pad = width - len;
|
|
1031
|
+
const left = Math.floor(pad / 2);
|
|
1032
|
+
const right = pad - left;
|
|
1033
|
+
return ' '.repeat(left) + str + ' '.repeat(right);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
registerFilter('escapejs', (val) => {
|
|
1037
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1038
|
+
return str.replace(/\\/g, '\\\\')
|
|
1039
|
+
.replace(/'/g, '\\\'')
|
|
1040
|
+
.replace(/"/g, '\\"')
|
|
1041
|
+
.replace(/\n/g, '\\n')
|
|
1042
|
+
.replace(/\r/g, '\\r')
|
|
1043
|
+
.replace(/\t/g, '\\t')
|
|
1044
|
+
.replace(/</g, '\\x3C')
|
|
1045
|
+
.replace(/>/g, '\\x3E')
|
|
1046
|
+
.replace(/&/g, '\\x26');
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
registerFilter('first', (val) => {
|
|
1050
|
+
if (Array.isArray(val) && val.length > 0) return val[0];
|
|
1051
|
+
if (typeof val === 'string' && val.length > 0) return val[0];
|
|
1052
|
+
return '';
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
registerFilter('fix_ampersands', (val) => {
|
|
1056
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1057
|
+
return str.replace(/&/g, '&').replace(/&#/g, '&#');
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
registerFilter('force_escape', (val) => {
|
|
1061
|
+
const { escapeHtml } = require('./security');
|
|
1062
|
+
return escapeHtml(String(val === null || val === undefined ? '' : val), true);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
registerFilter('get_digit', (val, arg) => {
|
|
1066
|
+
const str = String(Math.abs(Number(val)));
|
|
1067
|
+
const idx = parseInt(arg, 10);
|
|
1068
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= str.length) return '';
|
|
1069
|
+
return str[str.length - 1 - idx];
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
registerFilter('intcomma', (val) => {
|
|
1073
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1074
|
+
const num = parseInt(str, 10);
|
|
1075
|
+
if (Number.isNaN(num)) return str;
|
|
1076
|
+
return num.toLocaleString('en-US');
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
registerFilter('intword', (val) => {
|
|
1080
|
+
const num = parseInt(val, 10);
|
|
1081
|
+
if (Number.isNaN(num)) return '';
|
|
1082
|
+
if (num >= 1000000) {
|
|
1083
|
+
const w = (num / 1000000).toFixed(1).replace(/\.0$/, '');
|
|
1084
|
+
return w + ' million';
|
|
1085
|
+
}
|
|
1086
|
+
if (num >= 1000) {
|
|
1087
|
+
const w = (num / 1000).toFixed(1).replace(/\.0$/, '');
|
|
1088
|
+
return w + ' thousand';
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return String(num);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
registerFilter('iriencode', (val) => {
|
|
1095
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1096
|
+
return encodeURIComponent(str);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
registerFilter('last', (val) => {
|
|
1100
|
+
if (Array.isArray(val) && val.length > 0) return val[val.length - 1];
|
|
1101
|
+
if (typeof val === 'string' && val.length > 0) return val[val.length - 1];
|
|
1102
|
+
return '';
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
registerFilter('linenumbers', (val) => {
|
|
1106
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1107
|
+
const lines = str.split('\n');
|
|
1108
|
+
return lines.map((line, i) => `${i + 1}. ${line}`).join('\n');
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
registerFilter('ljust', (val, arg) => {
|
|
1112
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1113
|
+
const width = parseInt(arg, 10);
|
|
1114
|
+
if (Number.isNaN(width) || width <= 0) return str;
|
|
1115
|
+
return str.padEnd(width, ' ');
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
registerFilter('make_list', (val) => {
|
|
1119
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1120
|
+
return str.split('');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
registerFilter('naturalday', (val) => {
|
|
1124
|
+
const date = new Date(val);
|
|
1125
|
+
if (Number.isNaN(date.getTime())) return String(val);
|
|
1126
|
+
const now = new Date();
|
|
1127
|
+
const diff = Math.floor((now.setHours(0, 0, 0, 0) - date.setHours(0, 0, 0, 0)) / 86400000);
|
|
1128
|
+
if (diff === 0) return 'today';
|
|
1129
|
+
if (diff === 1) return 'yesterday';
|
|
1130
|
+
if (diff === -1) return 'tomorrow';
|
|
1131
|
+
return date.toLocaleDateString();
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
registerFilter('ordinal', (val) => {
|
|
1135
|
+
const num = parseInt(val, 10);
|
|
1136
|
+
if (Number.isNaN(num)) return String(val);
|
|
1137
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
1138
|
+
const v = num % 100;
|
|
1139
|
+
return num + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
registerFilter('phone2numeric', (val) => {
|
|
1143
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1144
|
+
const map = { a: '2', b: '2', c: '2', d: '3', e: '3', f: '3', g: '4', h: '4', i: '4', j: '5', k: '5', l: '5', m: '6', n: '6', o: '6', p: '7', q: '7', r: '7', s: '7', t: '8', u: '8', v: '8', w: '9', x: '9', y: '9', z: '9' };
|
|
1145
|
+
return str.toLowerCase().replace(/[a-z]/g, (c) => map[c] || c);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
registerFilter('pprint', (val) => {
|
|
1149
|
+
return JSON.stringify(val, null, 2);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
registerFilter('rjust', (val, arg) => {
|
|
1153
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1154
|
+
const width = parseInt(arg, 10);
|
|
1155
|
+
if (Number.isNaN(width) || width <= 0) return str;
|
|
1156
|
+
return str.padStart(width, ' ');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
registerFilter('safeseq', (val) => {
|
|
1160
|
+
const { markSafe } = require('./security');
|
|
1161
|
+
if (Array.isArray(val)) return val.map(markSafe);
|
|
1162
|
+
return val;
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
registerFilter('STATIC_PREFIX', (val) => {
|
|
1166
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1167
|
+
return '/static/' + str.replace(/^\/+/, '');
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
registerFilter('truncatewords_html', (val, arg) => {
|
|
1171
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1172
|
+
const count = parseInt(arg, 10);
|
|
1173
|
+
if (Number.isNaN(count) || count <= 0) return '';
|
|
1174
|
+
const words = str.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean);
|
|
1175
|
+
if (words.length <= count) return str;
|
|
1176
|
+
const plain = words.slice(0, count).join(' ');
|
|
1177
|
+
return plain + '...';
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
registerFilter('unordered_list', (val) => {
|
|
1181
|
+
if (!Array.isArray(val)) return String(val);
|
|
1182
|
+
const items = val.map(item => {
|
|
1183
|
+
if (Array.isArray(item)) {
|
|
1184
|
+
const nested = item.map(sub => `<li>${String(sub)}</li>`).join('');
|
|
1185
|
+
return `<ul>${nested}</ul>`;
|
|
1186
|
+
}
|
|
1187
|
+
return `<li>${String(item)}</li>`;
|
|
1188
|
+
}).join('');
|
|
1189
|
+
return `<ul>${items}</ul>`;
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
registerFilter('urlizetrunc', (val, arg) => {
|
|
1193
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1194
|
+
const maxlen = arg !== undefined && arg !== null ? parseInt(arg, 10) : 30;
|
|
1195
|
+
const urlRe = /(https?:\/\/[^\s]+)/g;
|
|
1196
|
+
return str.replace(urlRe, (url) => {
|
|
1197
|
+
const display = url.length > maxlen ? url.slice(0, maxlen) + '...' : url;
|
|
1198
|
+
return `<a href="${url}" rel="nofollow">${display}</a>`;
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
registerFilter('wordwrap', (val, arg) => {
|
|
1203
|
+
const str = String(val === null || val === undefined ? '' : val);
|
|
1204
|
+
const width = parseInt(arg, 10);
|
|
1205
|
+
if (Number.isNaN(width) || width <= 0) return str;
|
|
1206
|
+
const words = str.split(/\s+/);
|
|
1207
|
+
const lines = [];
|
|
1208
|
+
let line = '';
|
|
1209
|
+
for (const word of words) {
|
|
1210
|
+
if ((line + ' ' + word).trim().length > width) {
|
|
1211
|
+
if (line) lines.push(line);
|
|
1212
|
+
line = word;
|
|
1213
|
+
} else {
|
|
1214
|
+
line = (line + ' ' + word).trim();
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (line) lines.push(line);
|
|
1218
|
+
return lines.join('\n');
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
registerFilter('apnumber', (val) => {
|
|
1222
|
+
const num = parseInt(val, 10);
|
|
1223
|
+
if (Number.isNaN(num)) return String(val);
|
|
1224
|
+
const map = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'];
|
|
1225
|
+
if (num <= 20) return map[num] || String(num);
|
|
1226
|
+
return String(num);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
990
1229
|
module.exports = { registerFilter, getFilter };
|
|
991
1230
|
|
package/src/libraries.js
CHANGED
|
@@ -142,7 +142,7 @@ registerLibrary('humanize', {
|
|
|
142
142
|
apnumber: (val) => {
|
|
143
143
|
const n = parseInt(val, 10);
|
|
144
144
|
const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
|
|
145
|
-
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
|
|
145
|
+
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'];
|
|
146
146
|
if (!isNaN(n) && n >= 0 && n < words.length) return words[n];
|
|
147
147
|
return String(val);
|
|
148
148
|
},
|
package/src/tags/control.js
CHANGED
|
@@ -333,6 +333,12 @@ class FirstofNode {
|
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
class EndFirstofNode {
|
|
337
|
+
render(_context) {
|
|
338
|
+
return '';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
336
342
|
class CommentNode {
|
|
337
343
|
constructor(body) {
|
|
338
344
|
this.body = body;
|
|
@@ -758,12 +764,39 @@ function parseFirstof(tagContent, _parser) {
|
|
|
758
764
|
return new FirstofNode(args);
|
|
759
765
|
}
|
|
760
766
|
|
|
767
|
+
class ResetCycleNode {
|
|
768
|
+
constructor(key) {
|
|
769
|
+
this.key = key ? key.trim() : null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
render(context) {
|
|
773
|
+
if (!context.cycleStates) context.cycleStates = new Map();
|
|
774
|
+
if (this.key) {
|
|
775
|
+
context.cycleStates.set(this.key, 0);
|
|
776
|
+
} else {
|
|
777
|
+
context.cycleStates.clear();
|
|
778
|
+
}
|
|
779
|
+
return '';
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function parseResetCycle(tagContent, _parser) {
|
|
784
|
+
const key = tagContent.slice(11).trim() || null;
|
|
785
|
+
return new ResetCycleNode(key);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function parseEndFirstof(_tagContent, _parser) {
|
|
789
|
+
return new EndFirstofNode();
|
|
790
|
+
}
|
|
791
|
+
|
|
761
792
|
module.exports = {
|
|
762
793
|
IfNode,
|
|
763
794
|
ForNode,
|
|
764
795
|
WithNode,
|
|
765
796
|
CycleNode,
|
|
766
797
|
FirstofNode,
|
|
798
|
+
EndFirstofNode,
|
|
799
|
+
ResetCycleNode,
|
|
767
800
|
CommentNode,
|
|
768
801
|
AutoescapeNode,
|
|
769
802
|
PartialDefNode,
|
|
@@ -779,6 +812,8 @@ module.exports = {
|
|
|
779
812
|
comment: parseComment,
|
|
780
813
|
partialdef: parsePartialDef,
|
|
781
814
|
partial: parsePartial,
|
|
782
|
-
firstof: parseFirstof
|
|
815
|
+
firstof: parseFirstof,
|
|
816
|
+
endfirstof: parseEndFirstof,
|
|
817
|
+
resetcycle: parseResetCycle
|
|
783
818
|
}
|
|
784
819
|
};
|
package/src/tags/i18n.js
CHANGED
|
@@ -119,6 +119,76 @@ function parseTrans(tagContent, _parser) {
|
|
|
119
119
|
return new TransNode(key, Object.keys(args).length ? args : null);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function parseTranslate(tagContent, _parser) {
|
|
123
|
+
const trimmed = tagContent.slice(9).trim(); // strip "translate"
|
|
124
|
+
const tokens = trimmed.match(/(?:"[^"]*"|'[^']*'|\S+)/g) || [];
|
|
125
|
+
|
|
126
|
+
let keyIdx = 0;
|
|
127
|
+
if (tokens[0] && tokens[0] === 'context') {
|
|
128
|
+
keyIdx = 2;
|
|
129
|
+
}
|
|
130
|
+
const key = tokens[keyIdx] || '';
|
|
131
|
+
const args = {};
|
|
132
|
+
for (let i = keyIdx + 1; i < tokens.length; i++) {
|
|
133
|
+
const eq = tokens[i].indexOf('=');
|
|
134
|
+
if (eq > 0) {
|
|
135
|
+
const name = tokens[i].slice(0, eq);
|
|
136
|
+
let val = tokens[i].slice(eq + 1);
|
|
137
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith('\'') && val.endsWith('\''))) {
|
|
138
|
+
val = val.slice(1, -1);
|
|
139
|
+
}
|
|
140
|
+
args[name] = val;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return new TransNode(key, Object.keys(args).length ? args : null);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseBlockTranslate(tagContent, parser) {
|
|
147
|
+
const body = parser.parse(['endblocktranslate']);
|
|
148
|
+
const next = parser.peek();
|
|
149
|
+
if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endblocktranslate') {
|
|
150
|
+
parser.advance();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const withMappings = [];
|
|
154
|
+
const pluralMappings = [];
|
|
155
|
+
const afterTrans = tagContent.replace(/^blocktranslate\s*/, '').trim();
|
|
156
|
+
const tokens = afterTrans.match(/(?:"[^"]*"|'[^']*'|\S+)/g) || [];
|
|
157
|
+
let i = 0;
|
|
158
|
+
if (tokens[i] === 'context') i += 2;
|
|
159
|
+
while (i < tokens.length) {
|
|
160
|
+
const tok = tokens[i];
|
|
161
|
+
if (tok === 'with') {
|
|
162
|
+
i++;
|
|
163
|
+
while (i < tokens.length && tokens[i].indexOf('=') > 0) {
|
|
164
|
+
const pair = tokens[i];
|
|
165
|
+
const eq = pair.indexOf('=');
|
|
166
|
+
const name = pair.slice(0, eq);
|
|
167
|
+
let val = pair.slice(eq + 1);
|
|
168
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith('\'') && val.endsWith('\''))) {
|
|
169
|
+
val = val.slice(1, -1);
|
|
170
|
+
}
|
|
171
|
+
withMappings.push({ name, valPath: val });
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
} else if (tok === 'count') {
|
|
175
|
+
i++;
|
|
176
|
+
const countVal = tokens[i] || '1';
|
|
177
|
+
pluralMappings.push({ name: 'count', valPath: countVal });
|
|
178
|
+
i++;
|
|
179
|
+
} else {
|
|
180
|
+
i++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return new BlockTransNode(
|
|
185
|
+
extractTextAndVars(body),
|
|
186
|
+
withMappings.length ? withMappings : extractWithMappings(body),
|
|
187
|
+
pluralMappings,
|
|
188
|
+
body
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
122
192
|
function parseLanguage(tagContent, parser) {
|
|
123
193
|
const lang = tagContent.slice(9).trim(); // strip "language"
|
|
124
194
|
const body = parser.parse(['endlanguage']);
|
|
@@ -238,8 +308,10 @@ module.exports = {
|
|
|
238
308
|
PluralMappingNode,
|
|
239
309
|
parsers: {
|
|
240
310
|
trans: parseTrans,
|
|
311
|
+
translate: parseTranslate,
|
|
241
312
|
language: parseLanguage,
|
|
242
313
|
blocktrans: parseBlockTrans,
|
|
314
|
+
blocktranslate: parseBlockTranslate,
|
|
243
315
|
plural: parsePlural
|
|
244
316
|
}
|
|
245
317
|
};
|
package/src/tags/util.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Utility template tags: static, url, regroup, spaceless.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
const { evaluateExpression } = require('../parser');
|
|
5
|
+
const { evaluateExpression, parseVariableExpression, VariableNode } = require('../parser');
|
|
6
6
|
|
|
7
7
|
class StaticNode {
|
|
8
8
|
constructor(pathExpr) {
|
|
@@ -315,6 +315,147 @@ class LoadNode {
|
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
class FilterNode {
|
|
319
|
+
constructor(filterName, filterArg, body) {
|
|
320
|
+
this.filterName = filterName;
|
|
321
|
+
this.filterArg = filterArg;
|
|
322
|
+
this.body = body || [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
render(context) {
|
|
326
|
+
const raw = this.body.map(n => n.render(context)).join('');
|
|
327
|
+
const { getFilter } = require('../filters');
|
|
328
|
+
const fn = getFilter(this.filterName);
|
|
329
|
+
if (!fn) throw new Error(`Unknown filter: '${this.filterName}'`);
|
|
330
|
+
return fn(raw, this.filterArg, context);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function parseFilter(tagContent, parser) {
|
|
335
|
+
const expr = tagContent.slice(6).trim();
|
|
336
|
+
const nameMatch = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
337
|
+
const filterName = nameMatch ? nameMatch[1] : expr;
|
|
338
|
+
const rest = expr.slice(filterName.length).trim();
|
|
339
|
+
let filterArg = undefined;
|
|
340
|
+
if (rest.startsWith(':')) {
|
|
341
|
+
const argStr = rest.slice(1).trim();
|
|
342
|
+
if ((argStr.startsWith('"') && argStr.endsWith('"')) || (argStr.startsWith('\'') && argStr.endsWith('\''))) {
|
|
343
|
+
filterArg = argStr.slice(1, -1);
|
|
344
|
+
} else if (argStr) {
|
|
345
|
+
const num = Number(argStr);
|
|
346
|
+
filterArg = Number.isNaN(num) ? argStr : num;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const body = parser.parse(['endfilter']);
|
|
350
|
+
const next = parser.peek();
|
|
351
|
+
if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endfilter') {
|
|
352
|
+
parser.advance();
|
|
353
|
+
}
|
|
354
|
+
return new FilterNode(filterName, filterArg, body);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
class VerbatimNode {
|
|
358
|
+
constructor(body) {
|
|
359
|
+
this.body = body || [];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
render(_context) {
|
|
363
|
+
return this.body.map(n => {
|
|
364
|
+
if (n.constructor.name === 'TextNode') return n.content;
|
|
365
|
+
return '';
|
|
366
|
+
}).join('');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parseVerbatim(tagContent, _parser) {
|
|
371
|
+
const body = _parser.parse(['endverbatim']);
|
|
372
|
+
const next = _parser.peek();
|
|
373
|
+
if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endverbatim') {
|
|
374
|
+
_parser.advance();
|
|
375
|
+
}
|
|
376
|
+
return new VerbatimNode(body);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
class QueryStringNode {
|
|
380
|
+
constructor(args) {
|
|
381
|
+
this.args = args || [];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
render(context) {
|
|
385
|
+
const params = new URLSearchParams();
|
|
386
|
+
|
|
387
|
+
for (const arg of this.args) {
|
|
388
|
+
const parsed = parseVariableExpression(arg.valueExpr);
|
|
389
|
+
const node = new VariableNode(parsed.varPath, parsed.filters, parsed.isLiteral, parsed.literalValue);
|
|
390
|
+
const value = node.render(context);
|
|
391
|
+
|
|
392
|
+
if (value === null || value === undefined || value === '') {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
params.append(arg.name, String(value));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const qs = params.toString();
|
|
399
|
+
return qs ? '?' + qs : '';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseQuerystring(tagContent, _parser) {
|
|
404
|
+
const trimmed = tagContent.replace(/^querystring\s+/, '').trim();
|
|
405
|
+
const args = [];
|
|
406
|
+
|
|
407
|
+
const isQuotedString = (str) => {
|
|
408
|
+
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith('\'') && str.endsWith('\''))) {
|
|
409
|
+
const quote = str[0];
|
|
410
|
+
const inner = str.slice(1, -1);
|
|
411
|
+
return !inner.includes(quote);
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
if (isQuotedString(trimmed)) {
|
|
417
|
+
const raw = trimmed.slice(1, -1);
|
|
418
|
+
const pairs = raw.split('&');
|
|
419
|
+
for (const pair of pairs) {
|
|
420
|
+
const [name, value] = pair.split('=');
|
|
421
|
+
if (name) {
|
|
422
|
+
args.push({ name, valueExpr: value ? `'${value}'` : '\'\'' });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return new QueryStringNode(args);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
|
|
429
|
+
const matches = trimmed.match(argRegex) || [];
|
|
430
|
+
|
|
431
|
+
for (let i = 0; i < matches.length; i++) {
|
|
432
|
+
const tok = matches[i];
|
|
433
|
+
const isQuoted = (tok.startsWith('"') && tok.endsWith('"')) ||
|
|
434
|
+
(tok.startsWith('\'') && tok.endsWith('\''));
|
|
435
|
+
if (isQuoted) {
|
|
436
|
+
const name = tok.slice(1, -1);
|
|
437
|
+
if (name.includes('=')) {
|
|
438
|
+
const [n, v] = name.split('=');
|
|
439
|
+
args.push({ name: n, valueExpr: v ? `"${v}"` : '""' });
|
|
440
|
+
} else if (i + 1 < matches.length) {
|
|
441
|
+
const valueExpr = matches[i + 1];
|
|
442
|
+
i++;
|
|
443
|
+
args.push({ name, valueExpr });
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const eq = tok.indexOf('=');
|
|
449
|
+
if (eq > 0) {
|
|
450
|
+
const name = tok.slice(0, eq);
|
|
451
|
+
const valueExpr = tok.slice(eq + 1);
|
|
452
|
+
args.push({ name, valueExpr });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return new QueryStringNode(args);
|
|
457
|
+
}
|
|
458
|
+
|
|
318
459
|
class TemplatetagNode {
|
|
319
460
|
constructor(token) {
|
|
320
461
|
this.token = token;
|
|
@@ -370,9 +511,14 @@ module.exports = {
|
|
|
370
511
|
TemplatetagNode,
|
|
371
512
|
WidthRatioNode,
|
|
372
513
|
DebugNode,
|
|
514
|
+
FilterNode,
|
|
515
|
+
VerbatimNode,
|
|
516
|
+
QueryStringNode,
|
|
373
517
|
parsers: {
|
|
374
518
|
static: parseStatic,
|
|
375
519
|
url: parseUrl,
|
|
520
|
+
urlpk: parseUrl,
|
|
521
|
+
urlslug: parseUrl,
|
|
376
522
|
regroup: parseRegroup,
|
|
377
523
|
spaceless: parseSpaceless,
|
|
378
524
|
csrf_token: parseCsrfToken,
|
|
@@ -380,6 +526,9 @@ module.exports = {
|
|
|
380
526
|
load: parseLoad,
|
|
381
527
|
templatetag: parseTemplatetag,
|
|
382
528
|
widthratio: parseWidthRatio,
|
|
383
|
-
debug: parseDebug
|
|
529
|
+
debug: parseDebug,
|
|
530
|
+
filter: parseFilter,
|
|
531
|
+
verbatim: parseVerbatim,
|
|
532
|
+
querystring: parseQuerystring
|
|
384
533
|
}
|
|
385
534
|
};
|