miki-template 2.3.2 → 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
@@ -18,7 +18,9 @@ you can register your own custom filters and filters in miki templates.
18
18
  - **Partial-powered templating**: Define reusable chunks with `{% partialdef %}` and render them by name anywhere — `res.render('home#card')`, `renderPartialFromSource(...)`, or `compiled.renderBlock('block')`. Built for HTMX-style partial responses without the hassle.
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
- - **Full Syntax Parity**: Supports variables, dotted lookups, filters (`|`), and block tags (`{% %}`).
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.2",
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": {
@@ -32,28 +32,6 @@
32
32
  "src/tags/i18n.js",
33
33
  "src/tags/extra.js"
34
34
  ],
35
- "files": [
36
- "src/index.js",
37
- "src/esm.mjs",
38
- "src/lexer.js",
39
- "src/parser.js",
40
- "src/context.js",
41
- "src/context_processors.js",
42
- "src/filters.js",
43
- "src/security.js",
44
- "src/cache.js",
45
- "src/utils.js",
46
- "src/codegen.js",
47
- "src/i18n.js",
48
- "src/libraries.js",
49
- "src/tags/helpers.js",
50
- "src/tags/registry.js",
51
- "src/tags/control.js",
52
- "src/tags/inheritance.js",
53
- "src/tags/util.js",
54
- "src/tags/i18n.js",
55
- "src/tags/extra.js"
56
- ],
57
35
  "keywords": [
58
36
  "django",
59
37
  "template",
@@ -91,4 +69,4 @@
91
69
  "release:minor": "npm version minor --no-git-tag-version -m \"chore(release): v%s\" && git push origin main",
92
70
  "release:major": "npm version major --no-git-tag-version -m \"chore(release): v%s\" && git push origin main"
93
71
  }
94
- }
72
+ }
package/src/codegen.js CHANGED
@@ -212,7 +212,7 @@ function genNode(node, stmts, level, buf = 'out', loopVarMap = {}) {
212
212
  case 'ForNode': {
213
213
  const s = '_' + level;
214
214
  stmts.push(pad('{', level));
215
- const iterExpr = (loopVarMap && loopVarMap[node.iterablePath]) ? loopVarMap[node.iterablePath] : `_get(_ctx, ${js(node.iterablePath)})`;
215
+ const iterExpr = node.isLiteral ? js(node.literalValue) : ((loopVarMap && loopVarMap[node.iterablePath]) ? loopVarMap[node.iterablePath] : `_get(_ctx, ${js(node.iterablePath)})`);
216
216
  stmts.push(pad(`let _raw${s} = ${iterExpr};`, level + 1));
217
217
  for (let i = 0; i < node.filters.length; i++) {
218
218
  const f = node.filters[i];
@@ -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} }`;
@@ -670,14 +700,145 @@ function resolveVal(token, context) {
670
700
  if (token === undefined || token === null) return '';
671
701
  if (typeof token !== 'string') return token;
672
702
  if (token === '') return '';
673
- if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
674
- return token.slice(1, -1);
703
+
704
+ let idx = 0;
705
+ const len = token.length;
706
+
707
+ function skipWs() {
708
+ while (idx < len && /\s/.test(token[idx])) idx++;
709
+ }
710
+
711
+ let isLiteral = false;
712
+ let literalValue = null;
713
+ let varPath = '';
714
+
715
+ if (idx < len && (token[idx] === '"' || token[idx] === '\'')) {
716
+ const quote = token[idx];
717
+ idx++;
718
+ let str = '';
719
+ while (idx < len && token[idx] !== quote) {
720
+ if (token[idx] === '\\' && idx + 1 < len) idx++;
721
+ str += token[idx];
722
+ idx++;
723
+ }
724
+ idx++;
725
+ isLiteral = true;
726
+ literalValue = str;
727
+ skipWs();
675
728
  }
676
- if (token === 'true' || token === 'True') return true;
677
- if (token === 'false' || token === 'False') return false;
678
- if (token === 'none' || token === 'None' || token === 'null') return null;
679
- if (/^-?\d+(\.\d+)?$/.test(token)) return Number(token);
680
- return context.get(token);
729
+
730
+ if (!isLiteral && idx < len) {
731
+ const remaining = token.slice(idx);
732
+ if (remaining.startsWith('true') || remaining.startsWith('True')) {
733
+ isLiteral = true;
734
+ literalValue = true;
735
+ idx += remaining.startsWith('True') ? 4 : 5;
736
+ skipWs();
737
+ } else if (remaining.startsWith('false') || remaining.startsWith('False')) {
738
+ isLiteral = true;
739
+ literalValue = false;
740
+ idx += remaining.startsWith('False') ? 5 : 6;
741
+ skipWs();
742
+ } else if (remaining.startsWith('null') || remaining.startsWith('None') || remaining.startsWith('none')) {
743
+ isLiteral = true;
744
+ literalValue = null;
745
+ idx += 4;
746
+ skipWs();
747
+ }
748
+ }
749
+
750
+ if (!isLiteral && idx < len && /[\d.-]/.test(token[idx])) {
751
+ let numStr = '';
752
+ if (token[idx] === '-') {
753
+ numStr += '-';
754
+ idx++;
755
+ }
756
+ while (idx < len && /[\d]/.test(token[idx])) {
757
+ numStr += token[idx];
758
+ idx++;
759
+ }
760
+ if (token[idx] === '.') {
761
+ numStr += '.';
762
+ idx++;
763
+ while (idx < len && /[\d]/.test(token[idx])) {
764
+ numStr += token[idx];
765
+ idx++;
766
+ }
767
+ }
768
+ isLiteral = true;
769
+ literalValue = Number(numStr);
770
+ skipWs();
771
+ }
772
+
773
+ if (!isLiteral) {
774
+ while (idx < len && token[idx] !== '|') {
775
+ varPath += token[idx];
776
+ idx++;
777
+ }
778
+ varPath = varPath.trim();
779
+ }
780
+
781
+ let val = isLiteral ? literalValue : (varPath ? context.get(varPath) : undefined);
782
+
783
+ while (idx < len) {
784
+ if (token[idx] === '|') {
785
+ idx++;
786
+ skipWs();
787
+ let name = '';
788
+ while (idx < len && token[idx] !== ':' && token[idx] !== '|' && !/\s/.test(token[idx])) {
789
+ name += token[idx];
790
+ idx++;
791
+ }
792
+ skipWs();
793
+ let argVal = undefined;
794
+ if (idx < len && token[idx] === ':') {
795
+ idx++;
796
+ skipWs();
797
+ if (idx < len && (token[idx] === '"' || token[idx] === '\'')) {
798
+ const quote = token[idx];
799
+ idx++;
800
+ let s = '';
801
+ while (idx < len && token[idx] !== quote) {
802
+ if (token[idx] === '\\' && idx + 1 < len) idx++;
803
+ s += token[idx];
804
+ idx++;
805
+ }
806
+ idx++;
807
+ argVal = s;
808
+ } else {
809
+ let s = '';
810
+ while (idx < len && token[idx] !== '|' && !/\s/.test(token[idx])) {
811
+ s += token[idx];
812
+ idx++;
813
+ }
814
+ s = s.trim();
815
+ if (s !== '' && !isNaN(s)) argVal = Number(s);
816
+ else if (s === 'true') argVal = true;
817
+ else if (s === 'false') argVal = false;
818
+ else if (s === 'null' || s === 'None' || s === 'none') argVal = null;
819
+ else argVal = s;
820
+ }
821
+ }
822
+ const fn = filtersModule.getFilter(name);
823
+ if (!fn) throw new Error(`Unknown filter: '${name}'`);
824
+ val = fn(val, argVal, context);
825
+ skipWs();
826
+ } else {
827
+ idx++;
828
+ }
829
+ }
830
+
831
+ if (val === undefined) {
832
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
833
+ return token.slice(1, -1);
834
+ }
835
+ if (token === 'true' || token === 'True') return true;
836
+ if (token === 'false' || token === 'False') return false;
837
+ if (token === 'none' || token === 'None' || token === 'null') return null;
838
+ if (/^-?\d+(\.\d+)?$/.test(token)) return Number(token);
839
+ return context.get(token);
840
+ }
841
+ return val;
681
842
  }
682
843
 
683
844
  function getVal(ctx, path) {
@@ -721,6 +882,22 @@ function firstofHelper(ctx, args) {
721
882
  return '';
722
883
  }
723
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
+
724
901
  function partialHelper(ctx, name, extra) {
725
902
  const partial = ctx.getPartial(name);
726
903
  if (!partial) throw new Error(`Partial '${name}' not found`);
@@ -888,7 +1065,9 @@ function generateCode(nodes) {
888
1065
  flat,
889
1066
  partialsList,
890
1067
  languagesList,
891
- inHelper
1068
+ inHelper,
1069
+ applyFilterHelper,
1070
+ resetCycleHelper
892
1071
  );
893
1072
  };
894
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
  },
package/src/parser.js CHANGED
@@ -88,6 +88,51 @@ function parseVariableExpression(expr) {
88
88
  skipWhitespace();
89
89
  }
90
90
 
91
+ // Check for boolean/null literals as base value
92
+ if (!isLiteral && idx < len) {
93
+ const remaining = expr.slice(idx);
94
+ if (remaining.startsWith('true') || remaining.startsWith('True')) {
95
+ isLiteral = true;
96
+ literalValue = true;
97
+ idx += remaining.startsWith('True') ? 4 : 5;
98
+ skipWhitespace();
99
+ } else if (remaining.startsWith('false') || remaining.startsWith('False')) {
100
+ isLiteral = true;
101
+ literalValue = false;
102
+ idx += remaining.startsWith('False') ? 5 : 6;
103
+ skipWhitespace();
104
+ } else if (remaining.startsWith('null') || remaining.startsWith('None') || remaining.startsWith('none')) {
105
+ isLiteral = true;
106
+ literalValue = null;
107
+ idx += remaining.startsWith('None') ? 4 : (remaining.startsWith('null') ? 4 : 4);
108
+ skipWhitespace();
109
+ }
110
+ }
111
+
112
+ // Check for numeric literal as base value
113
+ if (!isLiteral && idx < len && /[\d.-]/.test(expr[idx])) {
114
+ let numStr = '';
115
+ if (expr[idx] === '-') {
116
+ numStr += '-';
117
+ idx++;
118
+ }
119
+ while (idx < len && /[\d]/.test(expr[idx])) {
120
+ numStr += expr[idx];
121
+ idx++;
122
+ }
123
+ if (expr[idx] === '.') {
124
+ numStr += '.';
125
+ idx++;
126
+ while (idx < len && /[\d]/.test(expr[idx])) {
127
+ numStr += expr[idx];
128
+ idx++;
129
+ }
130
+ }
131
+ isLiteral = true;
132
+ literalValue = Number(numStr);
133
+ skipWhitespace();
134
+ }
135
+
91
136
  // Parse main variable path
92
137
  if (!isLiteral) {
93
138
  while (idx < len && expr[idx] !== '|') {
@@ -262,9 +307,32 @@ class Parser {
262
307
  }
263
308
  }
264
309
 
310
+ function evaluateExpression(exprStr, context) {
311
+ if (!exprStr || typeof exprStr !== 'string') return exprStr === undefined || exprStr === null ? '' : exprStr;
312
+ const parsed = parseVariableExpression(exprStr);
313
+ let val = parsed.isLiteral ? parsed.literalValue : (parsed.varPath ? context.get(parsed.varPath) : undefined);
314
+ for (const filterInfo of parsed.filters) {
315
+ const filterFn = filtersModule.getFilter(filterInfo.name);
316
+ if (!filterFn) {
317
+ throw new Error(`Unknown filter: '${filterInfo.name}'`);
318
+ }
319
+ let argVal = undefined;
320
+ if (filterInfo.arg) {
321
+ if (filterInfo.arg.type === 'literal') {
322
+ argVal = filterInfo.arg.value;
323
+ } else if (filterInfo.arg.type === 'variable') {
324
+ argVal = context.get(filterInfo.arg.value);
325
+ }
326
+ }
327
+ val = filterFn(val, argVal, context);
328
+ }
329
+ return val === undefined ? '' : val;
330
+ }
331
+
265
332
  module.exports = {
266
333
  Parser,
267
334
  TextNode,
268
335
  VariableNode,
269
- parseVariableExpression
336
+ parseVariableExpression,
337
+ evaluateExpression
270
338
  };