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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miki-template",
3
- "version": "2.3.3",
3
+ "version": "2.3.4",
4
4
  "description": "Django-Style template engine for Node.js and Express",
5
5
  "main": "src/index.js",
6
6
  "exports": {
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
- 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;
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, '&amp;').replace(/&amp;#/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
  },
@@ -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
  };