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 +3 -1
- package/package.json +2 -24
- package/src/codegen.js +189 -10
- package/src/filters.js +243 -4
- package/src/libraries.js +1 -1
- package/src/parser.js +69 -1
- package/src/tags/control.js +148 -52
- package/src/tags/extra.js +4 -17
- package/src/tags/i18n.js +78 -17
- package/src/tags/inheritance.js +6 -20
- package/src/tags/util.js +155 -21
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.
|
|
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
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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/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
|
};
|