tina4-nodejs 3.13.100 → 3.13.103
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/CLAUDE.md +3 -3
- package/README.md +16 -0
- package/package.json +2 -2
- package/packages/cli/dist/bin.js +572 -764
- package/packages/cli/src/bin.ts +0 -6
- package/packages/core/dist/index.js +561 -622
- package/packages/core/public/js/tina4-dev-admin.min.js +2 -2
- package/packages/core/src/aiClient.ts +288 -0
- package/packages/core/src/devAdmin.ts +1 -2
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/metrics.ts +80 -632
- package/packages/frond/dist/index.js +113 -109
- package/packages/frond/src/engine.ts +128 -130
- package/packages/orm/dist/index.js +562 -629
- package/types/core/src/aiClient.d.ts +66 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/metrics.d.ts +0 -35
- package/types/frond/src/engine.d.ts +4 -2
- package/packages/cli/src/commands/metrics.ts +0 -160
- package/types/cli/src/commands/metrics.d.ts +0 -6
|
@@ -501,62 +501,59 @@ function splitOutsideQuotes(expr, sep2) {
|
|
|
501
501
|
parts.push(expr.slice(currentStart));
|
|
502
502
|
return parts;
|
|
503
503
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (expr.length
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
504
|
+
var EXPR_NOT_MATCHED = Symbol("frond-expression-not-matched");
|
|
505
|
+
function parenthesizedInner(expr) {
|
|
506
|
+
if (expr.length < 2 || expr[0] !== "(" || !expr.endsWith(")")) return null;
|
|
507
|
+
let depth = 0;
|
|
508
|
+
for (let index = 0; index < expr.length; index++) {
|
|
509
|
+
if (expr[index] === "(") depth++;
|
|
510
|
+
else if (expr[index] === ")") depth--;
|
|
511
|
+
if (depth === 0 && index < expr.length - 1) return null;
|
|
511
512
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if (depth === 0 && pi < expr.length - 1) {
|
|
519
|
-
matched = false;
|
|
520
|
-
break;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
if (matched) {
|
|
524
|
-
return evalExpr(expr.slice(1, -1), context);
|
|
525
|
-
}
|
|
513
|
+
return expr.slice(1, -1);
|
|
514
|
+
}
|
|
515
|
+
function evalPrimary(expr, context) {
|
|
516
|
+
const quote = expr[0];
|
|
517
|
+
if (expr.length >= 2 && (quote === '"' || quote === "'") && expr.endsWith(quote) && !expr.slice(1, -1).includes(quote)) {
|
|
518
|
+
return expr.slice(1, -1);
|
|
526
519
|
}
|
|
520
|
+
const inner = parenthesizedInner(expr);
|
|
521
|
+
if (inner !== null) return evalExpr(inner, context);
|
|
522
|
+
return EXPR_NOT_MATCHED;
|
|
523
|
+
}
|
|
524
|
+
function evalTernaryExpression(expr, context) {
|
|
527
525
|
const ternaryIdx = findTernary(expr);
|
|
528
|
-
if (ternaryIdx
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
}
|
|
526
|
+
if (ternaryIdx === -1) return EXPR_NOT_MATCHED;
|
|
527
|
+
const rest = expr.slice(ternaryIdx + 1);
|
|
528
|
+
const colonIdx = findColon(rest);
|
|
529
|
+
if (colonIdx === -1) return EXPR_NOT_MATCHED;
|
|
530
|
+
const condition = evalExpr(expr.slice(0, ternaryIdx).trim(), context);
|
|
531
|
+
const branch = condition ? rest.slice(0, colonIdx) : rest.slice(colonIdx + 1);
|
|
532
|
+
return evalExpr(branch.trim(), context);
|
|
533
|
+
}
|
|
534
|
+
function evalInlineIfExpression(expr, context) {
|
|
539
535
|
const ifIdx = findOutsideQuotes(expr, " if ");
|
|
540
|
-
if (ifIdx
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
}
|
|
536
|
+
if (ifIdx < 0) return EXPR_NOT_MATCHED;
|
|
537
|
+
const elseIdx = findOutsideQuotes(expr, " else ");
|
|
538
|
+
if (elseIdx < 0 || elseIdx <= ifIdx) return EXPR_NOT_MATCHED;
|
|
539
|
+
const condition = evalExpr(expr.slice(ifIdx + 4, elseIdx).trim(), context);
|
|
540
|
+
const branch = condition ? expr.slice(0, ifIdx) : expr.slice(elseIdx + 6);
|
|
541
|
+
return evalExpr(branch.trim(), context);
|
|
542
|
+
}
|
|
543
|
+
function evalCoalesceExpression(expr, context) {
|
|
550
544
|
const qqIdx = findOutsideQuotes(expr, "??");
|
|
551
|
-
if (qqIdx
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return
|
|
545
|
+
if (qqIdx === -1) return EXPR_NOT_MATCHED;
|
|
546
|
+
const value = evalExpr(expr.slice(0, qqIdx).trim(), context);
|
|
547
|
+
return value === null || value === void 0 ? evalExpr(expr.slice(qqIdx + 2).trim(), context) : value;
|
|
548
|
+
}
|
|
549
|
+
function evalConditional(expr, context) {
|
|
550
|
+
for (const evaluator of [evalTernaryExpression, evalInlineIfExpression, evalCoalesceExpression]) {
|
|
551
|
+
const result = evaluator(expr, context);
|
|
552
|
+
if (result !== EXPR_NOT_MATCHED) return result;
|
|
559
553
|
}
|
|
554
|
+
return EXPR_NOT_MATCHED;
|
|
555
|
+
}
|
|
556
|
+
function evalConcatOrComparison(expr, context) {
|
|
560
557
|
if (findOutsideQuotes(expr, "~") >= 0) {
|
|
561
558
|
const parts = splitOutsideQuotes(expr, "~");
|
|
562
559
|
if (parts.length > 1) {
|
|
@@ -574,6 +571,18 @@ function evalExpr(expr, context) {
|
|
|
574
571
|
return evalComparison(expr, context);
|
|
575
572
|
}
|
|
576
573
|
}
|
|
574
|
+
return EXPR_NOT_MATCHED;
|
|
575
|
+
}
|
|
576
|
+
var ARITHMETIC_OPERATIONS = {
|
|
577
|
+
"+": (left, right) => left + right,
|
|
578
|
+
"-": (left, right) => left - right,
|
|
579
|
+
"*": (left, right) => left * right,
|
|
580
|
+
"//": (left, right) => right !== 0 ? Math.floor(left / right) : 0,
|
|
581
|
+
"/": (left, right) => right !== 0 ? left / right : 0,
|
|
582
|
+
"%": (left, right) => right !== 0 ? left % right : 0,
|
|
583
|
+
"**": (left, right) => left ** right
|
|
584
|
+
};
|
|
585
|
+
function evalArithmeticExpression(expr, context) {
|
|
577
586
|
for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
|
|
578
587
|
const pos = findOutsideQuotes(expr, op);
|
|
579
588
|
if (pos >= 0) {
|
|
@@ -586,40 +595,15 @@ function evalExpr(expr, context) {
|
|
|
586
595
|
let rNum = rVal != null ? Number(rVal) : 0;
|
|
587
596
|
if (isNaN(lNum)) lNum = 0;
|
|
588
597
|
if (isNaN(rNum)) rNum = 0;
|
|
589
|
-
|
|
590
|
-
const bothInt = Number.isInteger(lNum) && Number.isInteger(rNum) && opS !== "/";
|
|
591
|
-
let result;
|
|
592
|
-
switch (opS) {
|
|
593
|
-
case "+":
|
|
594
|
-
result = lNum + rNum;
|
|
595
|
-
break;
|
|
596
|
-
case "-":
|
|
597
|
-
result = lNum - rNum;
|
|
598
|
-
break;
|
|
599
|
-
case "*":
|
|
600
|
-
result = lNum * rNum;
|
|
601
|
-
break;
|
|
602
|
-
case "//":
|
|
603
|
-
result = rNum !== 0 ? Math.floor(lNum / rNum) : 0;
|
|
604
|
-
break;
|
|
605
|
-
case "/":
|
|
606
|
-
result = rNum !== 0 ? lNum / rNum : 0;
|
|
607
|
-
break;
|
|
608
|
-
case "%":
|
|
609
|
-
result = rNum !== 0 ? lNum % rNum : 0;
|
|
610
|
-
break;
|
|
611
|
-
case "**":
|
|
612
|
-
result = lNum ** rNum;
|
|
613
|
-
break;
|
|
614
|
-
default:
|
|
615
|
-
result = 0;
|
|
616
|
-
}
|
|
617
|
-
return bothInt && Number.isInteger(result) ? result : result;
|
|
598
|
+
return ARITHMETIC_OPERATIONS[op.trim()](lNum, rNum);
|
|
618
599
|
} catch {
|
|
619
600
|
return null;
|
|
620
601
|
}
|
|
621
602
|
}
|
|
622
603
|
}
|
|
604
|
+
return EXPR_NOT_MATCHED;
|
|
605
|
+
}
|
|
606
|
+
function evalFilterExpression(expr, context) {
|
|
623
607
|
if (findOutsideQuotes(expr, "|") >= 0) {
|
|
624
608
|
const [baseExpr, filters] = parseFilterChain(expr);
|
|
625
609
|
if (filters.length > 0) {
|
|
@@ -638,38 +622,58 @@ function evalExpr(expr, context) {
|
|
|
638
622
|
return value;
|
|
639
623
|
}
|
|
640
624
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
625
|
+
return EXPR_NOT_MATCHED;
|
|
626
|
+
}
|
|
627
|
+
function evaluateCallArgs(rawArgs, context) {
|
|
628
|
+
return rawArgs.trim() ? splitArgs(rawArgs).map((arg) => evalExpr(arg.trim(), context)) : [];
|
|
629
|
+
}
|
|
630
|
+
function evalDottedFunction(name, rawArgs, context) {
|
|
631
|
+
const lastDot = name.lastIndexOf(".");
|
|
632
|
+
const owner = resolveVar(name.slice(0, lastDot), context);
|
|
633
|
+
const member = name.slice(lastDot + 1);
|
|
634
|
+
if (!owner || typeof owner !== "object" || !(member in owner)) {
|
|
635
|
+
return EXPR_NOT_MATCHED;
|
|
636
|
+
}
|
|
637
|
+
const method = owner[member];
|
|
638
|
+
return typeof method === "function" ? method.apply(owner, evaluateCallArgs(rawArgs, context)) : EXPR_NOT_MATCHED;
|
|
639
|
+
}
|
|
640
|
+
function evalFunctionExpression(expr, context) {
|
|
641
|
+
const match = expr.match(FN_CALL_RE);
|
|
642
|
+
if (!match) return EXPR_NOT_MATCHED;
|
|
643
|
+
const name = match[1];
|
|
644
|
+
const rawArgs = match[2] || "";
|
|
645
|
+
if (name.includes(".")) return evalDottedFunction(name, rawArgs, context);
|
|
646
|
+
const fn = context[name] ?? resolveVar(name, context);
|
|
647
|
+
if (typeof fn === "function") return fn(...evaluateCallArgs(rawArgs, context));
|
|
648
|
+
return EXPR_NOT_MATCHED;
|
|
649
|
+
}
|
|
650
|
+
var EXPR_EVALUATORS = [
|
|
651
|
+
evalPrimary,
|
|
652
|
+
evalConditional,
|
|
653
|
+
evalConcatOrComparison,
|
|
654
|
+
evalArithmeticExpression,
|
|
655
|
+
evalFilterExpression,
|
|
656
|
+
evalFunctionExpression
|
|
657
|
+
];
|
|
658
|
+
var expressionFormCache = /* @__PURE__ */ new Map();
|
|
659
|
+
function evalExpr(expr, context) {
|
|
660
|
+
expr = expr.trim();
|
|
661
|
+
const cachedForm = expressionFormCache.get(expr);
|
|
662
|
+
if (cachedForm !== void 0) {
|
|
663
|
+
if (cachedForm === -1) return resolveVar(expr, context);
|
|
664
|
+
const result = EXPR_EVALUATORS[cachedForm](expr, context);
|
|
665
|
+
return result === EXPR_NOT_MATCHED ? resolveVar(expr, context) : result;
|
|
666
|
+
}
|
|
667
|
+
for (let index = 0; index < EXPR_EVALUATORS.length; index++) {
|
|
668
|
+
const result = EXPR_EVALUATORS[index](expr, context);
|
|
669
|
+
if (result !== EXPR_NOT_MATCHED) {
|
|
670
|
+
capCache(expressionFormCache, MEMO_CACHE_MAX);
|
|
671
|
+
expressionFormCache.set(expr, index);
|
|
672
|
+
return result;
|
|
671
673
|
}
|
|
672
674
|
}
|
|
675
|
+
capCache(expressionFormCache, MEMO_CACHE_MAX);
|
|
676
|
+
expressionFormCache.set(expr, FN_CALL_RE.test(expr) ? EXPR_EVALUATORS.length - 1 : -1);
|
|
673
677
|
return resolveVar(expr, context);
|
|
674
678
|
}
|
|
675
679
|
function findTernary(expr) {
|
|
@@ -342,8 +342,8 @@ export const pathParseCache = new Map<string, [string[], boolean[]]>();
|
|
|
342
342
|
export const TEMPLATE_CACHE_MAX = 256;
|
|
343
343
|
|
|
344
344
|
/**
|
|
345
|
-
* Hard cap on every per-expression memo cache — `filterChainCache
|
|
346
|
-
* `pathParseCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
|
|
345
|
+
* Hard cap on every per-expression memo cache — `filterChainCache`,
|
|
346
|
+
* `pathParseCache`, and `expressionFormCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
|
|
347
347
|
* Python master's `@lru_cache(maxsize=1024)` on the equivalent module-level
|
|
348
348
|
* parsers). Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a
|
|
349
349
|
* small parsed-path array, orders of magnitude smaller than a token list.
|
|
@@ -750,76 +750,71 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
|
|
|
750
750
|
return parts;
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
-
|
|
754
|
-
expr = expr.trim();
|
|
753
|
+
const EXPR_NOT_MATCHED = Symbol("frond-expression-not-matched");
|
|
755
754
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
755
|
+
type ExprResult = unknown | typeof EXPR_NOT_MATCHED;
|
|
756
|
+
|
|
757
|
+
function parenthesizedInner(expr: string): string | null {
|
|
758
|
+
if (expr.length < 2 || expr[0] !== "(" || !expr.endsWith(")")) return null;
|
|
759
|
+
let depth = 0;
|
|
760
|
+
for (let index = 0; index < expr.length; index++) {
|
|
761
|
+
if (expr[index] === "(") depth++;
|
|
762
|
+
else if (expr[index] === ")") depth--;
|
|
763
|
+
if (depth === 0 && index < expr.length - 1) return null;
|
|
763
764
|
}
|
|
765
|
+
return expr.slice(1, -1);
|
|
766
|
+
}
|
|
764
767
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (expr[pi] === "(") depth++;
|
|
771
|
-
else if (expr[pi] === ")") depth--;
|
|
772
|
-
if (depth === 0 && pi < expr.length - 1) {
|
|
773
|
-
matched = false;
|
|
774
|
-
break;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (matched) {
|
|
778
|
-
return evalExpr(expr.slice(1, -1), context);
|
|
779
|
-
}
|
|
768
|
+
function evalPrimary(expr: string, context: Record<string, unknown>): ExprResult {
|
|
769
|
+
const quote = expr[0];
|
|
770
|
+
if (expr.length >= 2 && (quote === '"' || quote === "'") &&
|
|
771
|
+
expr.endsWith(quote) && !expr.slice(1, -1).includes(quote)) {
|
|
772
|
+
return expr.slice(1, -1);
|
|
780
773
|
}
|
|
774
|
+
const inner = parenthesizedInner(expr);
|
|
775
|
+
if (inner !== null) return evalExpr(inner, context);
|
|
776
|
+
return EXPR_NOT_MATCHED;
|
|
777
|
+
}
|
|
781
778
|
|
|
782
|
-
|
|
783
|
-
// Match carefully to handle nested ternaries
|
|
779
|
+
function evalTernaryExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
784
780
|
const ternaryIdx = findTernary(expr);
|
|
785
|
-
if (ternaryIdx
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return cond ? evalExpr(truePart, context) : evalExpr(falsePart, context);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
781
|
+
if (ternaryIdx === -1) return EXPR_NOT_MATCHED;
|
|
782
|
+
const rest = expr.slice(ternaryIdx + 1);
|
|
783
|
+
const colonIdx = findColon(rest);
|
|
784
|
+
if (colonIdx === -1) return EXPR_NOT_MATCHED;
|
|
785
|
+
const condition = evalExpr(expr.slice(0, ternaryIdx).trim(), context);
|
|
786
|
+
const branch = condition ? rest.slice(0, colonIdx) : rest.slice(colonIdx + 1);
|
|
787
|
+
return evalExpr(branch.trim(), context);
|
|
788
|
+
}
|
|
796
789
|
|
|
797
|
-
|
|
790
|
+
function evalInlineIfExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
798
791
|
const ifIdx = findOutsideQuotes(expr, " if ");
|
|
799
|
-
if (ifIdx
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
return cond ? evalExpr(valuePart, context) : evalExpr(elsePart, context);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
792
|
+
if (ifIdx < 0) return EXPR_NOT_MATCHED;
|
|
793
|
+
const elseIdx = findOutsideQuotes(expr, " else ");
|
|
794
|
+
if (elseIdx < 0 || elseIdx <= ifIdx) return EXPR_NOT_MATCHED;
|
|
795
|
+
const condition = evalExpr(expr.slice(ifIdx + 4, elseIdx).trim(), context);
|
|
796
|
+
const branch = condition ? expr.slice(0, ifIdx) : expr.slice(elseIdx + 6);
|
|
797
|
+
return evalExpr(branch.trim(), context);
|
|
798
|
+
}
|
|
809
799
|
|
|
810
|
-
|
|
800
|
+
function evalCoalesceExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
811
801
|
const qqIdx = findOutsideQuotes(expr, "??");
|
|
812
|
-
if (qqIdx
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
802
|
+
if (qqIdx === -1) return EXPR_NOT_MATCHED;
|
|
803
|
+
const value = evalExpr(expr.slice(0, qqIdx).trim(), context);
|
|
804
|
+
return value === null || value === undefined
|
|
805
|
+
? evalExpr(expr.slice(qqIdx + 2).trim(), context)
|
|
806
|
+
: value;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function evalConditional(expr: string, context: Record<string, unknown>): ExprResult {
|
|
810
|
+
for (const evaluator of [evalTernaryExpression, evalInlineIfExpression, evalCoalesceExpression]) {
|
|
811
|
+
const result = evaluator(expr, context);
|
|
812
|
+
if (result !== EXPR_NOT_MATCHED) return result;
|
|
820
813
|
}
|
|
814
|
+
return EXPR_NOT_MATCHED;
|
|
815
|
+
}
|
|
821
816
|
|
|
822
|
-
|
|
817
|
+
function evalConcatOrComparison(expr: string, context: Record<string, unknown>): ExprResult {
|
|
823
818
|
if (findOutsideQuotes(expr, "~") >= 0) {
|
|
824
819
|
const parts = splitOutsideQuotes(expr, "~");
|
|
825
820
|
if (parts.length > 1) {
|
|
@@ -829,19 +824,6 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
829
824
|
}).join("");
|
|
830
825
|
}
|
|
831
826
|
}
|
|
832
|
-
|
|
833
|
-
// Comparison/logical operators -> evalComparison, the SAME evaluator {% if %}
|
|
834
|
-
// uses, so a condition means the same thing in a condition and in an output
|
|
835
|
-
// expression.
|
|
836
|
-
//
|
|
837
|
-
// The LEADING unary `not` needs its own check: every operator below is matched
|
|
838
|
-
// WITH surrounding spaces, so `not x` (nothing to its left) matched none of
|
|
839
|
-
// them, fell through to the variable-resolution tail, and was looked up as a
|
|
840
|
-
// variable literally named "not x" -- found nothing, rendered EMPTY.
|
|
841
|
-
// `{% if not x %}` and `x and not y` always worked; only the standalone
|
|
842
|
-
// `{{ not x }}` was dropped, and before booleans rendered lowercase a dropped
|
|
843
|
-
// expression and `false -> ''` looked identical, which is why it survived.
|
|
844
|
-
// Fixed in 3.13.87 alongside the boolean contract.
|
|
845
827
|
if (expr.startsWith("not ")) {
|
|
846
828
|
return evalComparison(expr, context);
|
|
847
829
|
}
|
|
@@ -850,8 +832,20 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
850
832
|
return evalComparison(expr, context);
|
|
851
833
|
}
|
|
852
834
|
}
|
|
835
|
+
return EXPR_NOT_MATCHED;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const ARITHMETIC_OPERATIONS: Record<string, (left: number, right: number) => number> = {
|
|
839
|
+
"+": (left, right) => left + right,
|
|
840
|
+
"-": (left, right) => left - right,
|
|
841
|
+
"*": (left, right) => left * right,
|
|
842
|
+
"//": (left, right) => right !== 0 ? Math.floor(left / right) : 0,
|
|
843
|
+
"/": (left, right) => right !== 0 ? left / right : 0,
|
|
844
|
+
"%": (left, right) => right !== 0 ? left % right : 0,
|
|
845
|
+
"**": (left, right) => left ** right,
|
|
846
|
+
};
|
|
853
847
|
|
|
854
|
-
|
|
848
|
+
function evalArithmeticExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
855
849
|
for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
|
|
856
850
|
const pos = findOutsideQuotes(expr, op);
|
|
857
851
|
if (pos >= 0) {
|
|
@@ -864,35 +858,16 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
864
858
|
let rNum = rVal != null ? Number(rVal) : 0;
|
|
865
859
|
if (isNaN(lNum)) lNum = 0;
|
|
866
860
|
if (isNaN(rNum)) rNum = 0;
|
|
867
|
-
|
|
868
|
-
// Preserve int type when both operands are int-like (except for / which returns float)
|
|
869
|
-
const bothInt = Number.isInteger(lNum) && Number.isInteger(rNum) && opS !== "/";
|
|
870
|
-
let result: number;
|
|
871
|
-
switch (opS) {
|
|
872
|
-
case "+": result = lNum + rNum; break;
|
|
873
|
-
case "-": result = lNum - rNum; break;
|
|
874
|
-
case "*": result = lNum * rNum; break;
|
|
875
|
-
case "//": result = rNum !== 0 ? Math.floor(lNum / rNum) : 0; break;
|
|
876
|
-
case "/": result = rNum !== 0 ? lNum / rNum : 0; break;
|
|
877
|
-
case "%": result = rNum !== 0 ? lNum % rNum : 0; break;
|
|
878
|
-
case "**": result = lNum ** rNum; break;
|
|
879
|
-
default: result = 0;
|
|
880
|
-
}
|
|
881
|
-
return bothInt && Number.isInteger(result) ? result : result;
|
|
861
|
+
return ARITHMETIC_OPERATIONS[op.trim()](lNum, rNum);
|
|
882
862
|
} catch {
|
|
883
863
|
return null;
|
|
884
864
|
}
|
|
885
865
|
}
|
|
886
866
|
}
|
|
867
|
+
return EXPR_NOT_MATCHED;
|
|
868
|
+
}
|
|
887
869
|
|
|
888
|
-
|
|
889
|
-
// concat (`~`), comparisons and arithmetic, but looser than member/function
|
|
890
|
-
// access — so it is resolved here, AFTER those operators have had a chance to
|
|
891
|
-
// split the expression. Handling it inside the expression evaluator (not only
|
|
892
|
-
// at the {{ }} output layer) makes filters work at any nesting depth: concat
|
|
893
|
-
// operands, ternary branches, and parenthesised sub-expressions. This fixes
|
|
894
|
-
// both the `|`-vs-`~` precedence bug and the "pipe inside parens returns empty"
|
|
895
|
-
// defect. (#171)
|
|
870
|
+
function evalFilterExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
896
871
|
if (findOutsideQuotes(expr, "|") >= 0) {
|
|
897
872
|
const [baseExpr, filters] = parseFilterChain(expr);
|
|
898
873
|
if (filters.length > 0) {
|
|
@@ -919,43 +894,66 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
919
894
|
return value;
|
|
920
895
|
}
|
|
921
896
|
}
|
|
897
|
+
return EXPR_NOT_MATCHED;
|
|
898
|
+
}
|
|
922
899
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
const fnName = fnMatch[1];
|
|
927
|
-
const rawArgs = fnMatch[2] || "";
|
|
900
|
+
function evaluateCallArgs(rawArgs: string, context: Record<string, unknown>): unknown[] {
|
|
901
|
+
return rawArgs.trim() ? splitArgs(rawArgs).map(arg => evalExpr(arg.trim(), context)) : [];
|
|
902
|
+
}
|
|
928
903
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
904
|
+
function evalDottedFunction(name: string, rawArgs: string, context: Record<string, unknown>): ExprResult {
|
|
905
|
+
const lastDot = name.lastIndexOf(".");
|
|
906
|
+
const owner = resolveVar(name.slice(0, lastDot), context);
|
|
907
|
+
const member = name.slice(lastDot + 1);
|
|
908
|
+
if (!owner || typeof owner !== "object" || !(member in (owner as Record<string, unknown>))) {
|
|
909
|
+
return EXPR_NOT_MATCHED;
|
|
910
|
+
}
|
|
911
|
+
const method = (owner as Record<string, unknown>)[member];
|
|
912
|
+
return typeof method === "function"
|
|
913
|
+
? method.apply(owner, evaluateCallArgs(rawArgs, context))
|
|
914
|
+
: EXPR_NOT_MATCHED;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function evalFunctionExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
918
|
+
const match = expr.match(FN_CALL_RE);
|
|
919
|
+
if (!match) return EXPR_NOT_MATCHED;
|
|
920
|
+
const name = match[1];
|
|
921
|
+
const rawArgs = match[2] || "";
|
|
922
|
+
if (name.includes(".")) return evalDottedFunction(name, rawArgs, context);
|
|
923
|
+
const fn = context[name] ?? resolveVar(name, context);
|
|
924
|
+
if (typeof fn === "function") return fn(...evaluateCallArgs(rawArgs, context));
|
|
925
|
+
return EXPR_NOT_MATCHED;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const EXPR_EVALUATORS = [
|
|
929
|
+
evalPrimary,
|
|
930
|
+
evalConditional,
|
|
931
|
+
evalConcatOrComparison,
|
|
932
|
+
evalArithmeticExpression,
|
|
933
|
+
evalFilterExpression,
|
|
934
|
+
evalFunctionExpression,
|
|
935
|
+
] as const;
|
|
936
|
+
/** Cached expression dispatcher branch; exported only for cache-bound verification. */
|
|
937
|
+
export const expressionFormCache = new Map<string, number>();
|
|
938
|
+
|
|
939
|
+
function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
940
|
+
expr = expr.trim();
|
|
941
|
+
const cachedForm = expressionFormCache.get(expr);
|
|
942
|
+
if (cachedForm !== undefined) {
|
|
943
|
+
if (cachedForm === -1) return resolveVar(expr, context);
|
|
944
|
+
const result = EXPR_EVALUATORS[cachedForm](expr, context);
|
|
945
|
+
return result === EXPR_NOT_MATCHED ? resolveVar(expr, context) : result;
|
|
946
|
+
}
|
|
947
|
+
for (let index = 0; index < EXPR_EVALUATORS.length; index++) {
|
|
948
|
+
const result = EXPR_EVALUATORS[index](expr, context);
|
|
949
|
+
if (result !== EXPR_NOT_MATCHED) {
|
|
950
|
+
capCache(expressionFormCache, MEMO_CACHE_MAX);
|
|
951
|
+
expressionFormCache.set(expr, index);
|
|
952
|
+
return result;
|
|
956
953
|
}
|
|
957
954
|
}
|
|
958
|
-
|
|
955
|
+
capCache(expressionFormCache, MEMO_CACHE_MAX);
|
|
956
|
+
expressionFormCache.set(expr, FN_CALL_RE.test(expr) ? EXPR_EVALUATORS.length - 1 : -1);
|
|
959
957
|
return resolveVar(expr, context);
|
|
960
958
|
}
|
|
961
959
|
|