tina4-nodejs 3.13.86 → 3.13.88
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/package.json +1 -1
- package/packages/cli/dist/bin.js +185 -16
- package/packages/core/dist/index.js +184 -15
- package/packages/core/src/metrics.ts +11 -2
- package/packages/frond/dist/index.js +141 -9
- package/packages/frond/src/engine.ts +230 -10
- package/packages/orm/dist/index.js +184 -15
- package/packages/orm/src/adapters/firebird.ts +28 -2
- package/packages/orm/src/adapters/postgres.ts +11 -0
- package/packages/orm/src/database.ts +15 -3
|
@@ -34,6 +34,69 @@ class SafeString {
|
|
|
34
34
|
toString() { return this.value; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Serialize a value to compact JSON text that is always valid JSON.
|
|
39
|
+
*
|
|
40
|
+
* Never throws and never returns an empty string. JSON.stringify already maps a
|
|
41
|
+
* non-finite number to null, but it returns the VALUE `undefined` for undefined,
|
|
42
|
+
* a function, or a symbol, and it throws on a BigInt or a circular structure --
|
|
43
|
+
* all four of which would otherwise reach the page as nothing or as a crash.
|
|
44
|
+
*/
|
|
45
|
+
function jsonText(value: unknown): string {
|
|
46
|
+
try {
|
|
47
|
+
const text = JSON.stringify(value);
|
|
48
|
+
return text === undefined ? "null" : text;
|
|
49
|
+
} catch {
|
|
50
|
+
// Only reached when the happy path threw, so a well-formed payload never
|
|
51
|
+
// pays for the walk.
|
|
52
|
+
const seen = new WeakSet<object>();
|
|
53
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
54
|
+
if (typeof v === "bigint") return v.toString();
|
|
55
|
+
if (typeof v === "object" && v !== null) {
|
|
56
|
+
if (seen.has(v)) return null;
|
|
57
|
+
seen.add(v);
|
|
58
|
+
}
|
|
59
|
+
return v;
|
|
60
|
+
});
|
|
61
|
+
return text === undefined ? "null" : text;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
|
|
66
|
+
const JSON_UNSAFE_MAP: Record<string, string> = {
|
|
67
|
+
"<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "'": "\\u0027",
|
|
68
|
+
"\u2028": "\\u2028", "\u2029": "\\u2029",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Serialize to JSON that is valid JSON, valid JavaScript, and safe in HTML.
|
|
73
|
+
*
|
|
74
|
+
* THE cross-framework contract for json_encode / to_json / tojson. Keep the four
|
|
75
|
+
* implementations byte-identical; frond_expression_corpus.txt locks it.
|
|
76
|
+
*
|
|
77
|
+
* Three things this must never do, each of which was a real bug in one of the
|
|
78
|
+
* four engines:
|
|
79
|
+
*
|
|
80
|
+
* 1. Never emit a non-finite literal. Python wrote a bare `Infinity`, PHP
|
|
81
|
+
* returned false, Ruby fell back to inspect output -- none of them parse.
|
|
82
|
+
* Node was the one that already got this right. Reported as tina4-php#184 by
|
|
83
|
+
* justin-k-bruce.
|
|
84
|
+
* 2. Never emit nothing, and never emit something that still parses and means
|
|
85
|
+
* something else. "var ROWS = ;" is at least a loud SyntaxError.
|
|
86
|
+
* 3. Never HTML-escape it. Entity-encoding JSON produces {"a":1}, a
|
|
87
|
+
* SyntaxError inside <script>, which is the filter's whole point. Escape only
|
|
88
|
+
* the dangerous characters, as JSON \uXXXX escapes: the result stays valid
|
|
89
|
+
* JSON AND valid JavaScript, </script> cannot terminate the block, and it is
|
|
90
|
+
* safe inside a single-quoted attribute. This is what Jinja2's tojson does,
|
|
91
|
+
* and it is why the result is a SafeString.
|
|
92
|
+
*
|
|
93
|
+
* U+2028 and U+2029 join that escape set. Both are legal inside a JSON string and
|
|
94
|
+
* both were illegal inside a JavaScript string literal before ES2019.
|
|
95
|
+
*/
|
|
96
|
+
function jsonSafe(value: unknown): SafeString {
|
|
97
|
+
return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
|
|
98
|
+
}
|
|
99
|
+
|
|
37
100
|
/**
|
|
38
101
|
* Produce a human-readable, debugger-friendly inspection of any value.
|
|
39
102
|
*
|
|
@@ -202,6 +265,40 @@ const filterChainCache = new Map<string, [string, [string, unknown[]][]]>();
|
|
|
202
265
|
/** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket] */
|
|
203
266
|
const pathParseCache = new Map<string, [string[], boolean[]]>();
|
|
204
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Hard cap on the template caches — `compiled` and `compiledStrings`
|
|
270
|
+
* (ADR-0004, parity with PHP/Python/Ruby TEMPLATE_CACHE_MAX).
|
|
271
|
+
*
|
|
272
|
+
* An entry here is a whole token list, so the cap sits well below what a
|
|
273
|
+
* per-expression memo would justify. 256 is far above any real application's
|
|
274
|
+
* template count, so a normal app never evicts. The cap exists for the
|
|
275
|
+
* workload that genuinely grows without limit for the life of a worker:
|
|
276
|
+
* `renderString` keys on md5(source), so an app that builds template strings
|
|
277
|
+
* dynamically adds an entry per distinct string.
|
|
278
|
+
*/
|
|
279
|
+
export const TEMPLATE_CACHE_MAX = 256;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Keep a memo cache bounded. Call immediately before inserting a new entry.
|
|
283
|
+
*
|
|
284
|
+
* Eviction is insertion-ordered (oldest first), not true LRU: a `Map`
|
|
285
|
+
* preserves insertion order, so dropping from the front is cheap, whereas
|
|
286
|
+
* refreshing recency on every cache HIT would add writes to the hottest path
|
|
287
|
+
* in a render and cost more than it saves. Half the cache is dropped at once
|
|
288
|
+
* so the sweep amortises to O(1) per insert.
|
|
289
|
+
*
|
|
290
|
+
* Evicting can never change what a render produces: every read site treats a
|
|
291
|
+
* miss as "recompute", so a swept entry is rebuilt on next use.
|
|
292
|
+
*/
|
|
293
|
+
function capCache(cache: Map<string, unknown>, maxEntries: number): void {
|
|
294
|
+
if (cache.size < maxEntries) return;
|
|
295
|
+
let drop = Math.floor(maxEntries / 2);
|
|
296
|
+
for (const key of cache.keys()) {
|
|
297
|
+
cache.delete(key);
|
|
298
|
+
if (--drop <= 0) break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
205
302
|
// ── Lexer ──────────────────────────────────────────────────────
|
|
206
303
|
|
|
207
304
|
const TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
|
|
@@ -609,7 +706,21 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
609
706
|
}
|
|
610
707
|
}
|
|
611
708
|
|
|
612
|
-
//
|
|
709
|
+
// Comparison/logical operators -> evalComparison, the SAME evaluator {% if %}
|
|
710
|
+
// uses, so a condition means the same thing in a condition and in an output
|
|
711
|
+
// expression.
|
|
712
|
+
//
|
|
713
|
+
// The LEADING unary `not` needs its own check: every operator below is matched
|
|
714
|
+
// WITH surrounding spaces, so `not x` (nothing to its left) matched none of
|
|
715
|
+
// them, fell through to the variable-resolution tail, and was looked up as a
|
|
716
|
+
// variable literally named "not x" -- found nothing, rendered EMPTY.
|
|
717
|
+
// `{% if not x %}` and `x and not y` always worked; only the standalone
|
|
718
|
+
// `{{ not x }}` was dropped, and before booleans rendered lowercase a dropped
|
|
719
|
+
// expression and `false -> ''` looked identical, which is why it survived.
|
|
720
|
+
// Fixed in 3.13.87 alongside the boolean contract.
|
|
721
|
+
if (expr.startsWith("not ")) {
|
|
722
|
+
return evalComparison(expr, context);
|
|
723
|
+
}
|
|
613
724
|
for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
|
|
614
725
|
if (findOutsideQuotes(expr, op) >= 0) {
|
|
615
726
|
return evalComparison(expr, context);
|
|
@@ -1259,7 +1370,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1259
1370
|
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
1260
1371
|
float: (v) => v ? parseFloat(String(v)) || 0.0 : 0.0,
|
|
1261
1372
|
string: (v) => String(v),
|
|
1262
|
-
json_encode: (v) =>
|
|
1373
|
+
json_encode: (v) => jsonSafe(v),
|
|
1263
1374
|
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
1264
1375
|
keys: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.keys(v) : [],
|
|
1265
1376
|
values: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.values(v) : [],
|
|
@@ -1383,8 +1494,12 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1383
1494
|
form_token: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
|
|
1384
1495
|
formTokenValue: (v?: unknown) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1385
1496
|
form_token_value: (v?: unknown) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1386
|
-
|
|
1387
|
-
|
|
1497
|
+
// Same serializer as json_encode -- the three names are one behaviour. The
|
|
1498
|
+
// old indent argument is gone: PHP cannot honour an arbitrary indent
|
|
1499
|
+
// (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
|
|
1500
|
+
// broke byte-parity for the one filter whose whole job is a wire format.
|
|
1501
|
+
tojson: (v) => jsonSafe(v),
|
|
1502
|
+
to_json: (v) => jsonSafe(v),
|
|
1388
1503
|
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")),
|
|
1389
1504
|
};
|
|
1390
1505
|
|
|
@@ -1685,6 +1800,7 @@ export class Frond {
|
|
|
1685
1800
|
const source = readFileSync(filePath, "utf-8");
|
|
1686
1801
|
const mtime = statSync(filePath).mtimeMs;
|
|
1687
1802
|
const tokens = tokenize(source);
|
|
1803
|
+
capCache(this.compiled as Map<string, unknown>, TEMPLATE_CACHE_MAX);
|
|
1688
1804
|
this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
|
|
1689
1805
|
return this.executeWithSource(source, tokens, context);
|
|
1690
1806
|
}
|
|
@@ -1702,6 +1818,7 @@ export class Frond {
|
|
|
1702
1818
|
}
|
|
1703
1819
|
|
|
1704
1820
|
const tokens = tokenize(source);
|
|
1821
|
+
capCache(this.compiledStrings as Map<string, unknown>, TEMPLATE_CACHE_MAX);
|
|
1705
1822
|
this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
|
|
1706
1823
|
return this.executeCached(tokens, context);
|
|
1707
1824
|
}
|
|
@@ -1977,6 +2094,9 @@ export class Frond {
|
|
|
1977
2094
|
} else if (tag === "macro") {
|
|
1978
2095
|
const skip = this.handleMacro(tokens, i, context);
|
|
1979
2096
|
i = skip;
|
|
2097
|
+
} else if (tag === "import") {
|
|
2098
|
+
this.handleImportAs(content, context);
|
|
2099
|
+
i++;
|
|
1980
2100
|
} else if (tag === "from") {
|
|
1981
2101
|
this.handleFromImport(content, context);
|
|
1982
2102
|
i++;
|
|
@@ -2293,7 +2413,7 @@ export class Frond {
|
|
|
2293
2413
|
case "last": value = Array.isArray(value) ? value[value.length - 1] ?? null : null; continue;
|
|
2294
2414
|
case "keys": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.keys(value) : []; continue;
|
|
2295
2415
|
case "values": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.values(value) : []; continue;
|
|
2296
|
-
case "json_encode": value =
|
|
2416
|
+
case "json_encode": value = jsonSafe(value); continue;
|
|
2297
2417
|
case "dump":
|
|
2298
2418
|
// Delegates to renderDump(), which is gated on TINA4_DEBUG.
|
|
2299
2419
|
// In production this emits an empty SafeString (no leaked state).
|
|
@@ -2587,7 +2707,7 @@ export class Frond {
|
|
|
2587
2707
|
}
|
|
2588
2708
|
|
|
2589
2709
|
const macroName = m[1];
|
|
2590
|
-
const
|
|
2710
|
+
const params = Frond.parseMacroParams(m[2]);
|
|
2591
2711
|
|
|
2592
2712
|
// Collect body tokens
|
|
2593
2713
|
const bodyTokens: Token[] = [];
|
|
@@ -2606,8 +2726,9 @@ export class Frond {
|
|
|
2606
2726
|
const capturedContext = { ...context };
|
|
2607
2727
|
context[macroName] = (...args: unknown[]) => {
|
|
2608
2728
|
const macroCtx: Record<string, unknown> = { ...capturedContext };
|
|
2609
|
-
for (let pi = 0; pi <
|
|
2610
|
-
|
|
2729
|
+
for (let pi = 0; pi < params.length; pi++) {
|
|
2730
|
+
const [pname, pdefault] = params[pi];
|
|
2731
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2611
2732
|
}
|
|
2612
2733
|
return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
|
|
2613
2734
|
};
|
|
@@ -2615,6 +2736,104 @@ export class Frond {
|
|
|
2615
2736
|
return i;
|
|
2616
2737
|
}
|
|
2617
2738
|
|
|
2739
|
+
/**
|
|
2740
|
+
* Parse a macro parameter list into [name, default] pairs.
|
|
2741
|
+
*
|
|
2742
|
+
* Handles: name, name="default", name='default'. Splitting on "," alone left a
|
|
2743
|
+
* defaulted parameter literally NAMED `greeting='Hello'`, so the body's
|
|
2744
|
+
* {{ greeting }} matched nothing (rendered empty) AND the caller's positional
|
|
2745
|
+
* argument was stored under that junk key and lost. Mirrors the Python master's
|
|
2746
|
+
* _parse_macro_params. The default is null when none is declared.
|
|
2747
|
+
*/
|
|
2748
|
+
static parseMacroParams(rawParams: string): Array<[string, string | null]> {
|
|
2749
|
+
return rawParams
|
|
2750
|
+
.split(",")
|
|
2751
|
+
.map(p => p.trim())
|
|
2752
|
+
.filter(Boolean)
|
|
2753
|
+
.map(p => {
|
|
2754
|
+
const eq = p.indexOf("=");
|
|
2755
|
+
if (eq === -1) return [p, null] as [string, string | null];
|
|
2756
|
+
const name = p.slice(0, eq).trim();
|
|
2757
|
+
let dflt = p.slice(eq + 1).trim();
|
|
2758
|
+
if (
|
|
2759
|
+
dflt.length >= 2 &&
|
|
2760
|
+
((dflt.startsWith('"') && dflt.endsWith('"')) ||
|
|
2761
|
+
(dflt.startsWith("'") && dflt.endsWith("'")))
|
|
2762
|
+
) {
|
|
2763
|
+
dflt = dflt.slice(1, -1);
|
|
2764
|
+
}
|
|
2765
|
+
return [name, dflt] as [string, string | null];
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
/**
|
|
2770
|
+
* {% import "file" as alias %} -- load EVERY macro in a file under one namespace.
|
|
2771
|
+
*
|
|
2772
|
+
* The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }}
|
|
2773
|
+
* resolves through the engine's existing dotted-call path and each macro keeps the
|
|
2774
|
+
* same argument binding, default handling and SafeString output as any other macro.
|
|
2775
|
+
* A namespace object (not a class) is deliberate: a function stored as a class
|
|
2776
|
+
* attribute binds as a method and would inject the namespace as the first argument,
|
|
2777
|
+
* which is exactly the argument-shift bug the Python master carried (fixed there
|
|
2778
|
+
* with types.SimpleNamespace). Both import forms must render identically.
|
|
2779
|
+
*/
|
|
2780
|
+
private handleImportAs(content: string, context: Record<string, unknown>): void {
|
|
2781
|
+
const m = content.match(/^import\s+["'](.+?)["']\s+as\s+(\w+)/);
|
|
2782
|
+
if (!m) return;
|
|
2783
|
+
|
|
2784
|
+
const filename = m[1];
|
|
2785
|
+
const alias = m[2];
|
|
2786
|
+
const namespace: Record<string, unknown> = {};
|
|
2787
|
+
|
|
2788
|
+
const source = this.load(filename);
|
|
2789
|
+
const tokens = tokenize(source);
|
|
2790
|
+
|
|
2791
|
+
let i = 0;
|
|
2792
|
+
while (i < tokens.length) {
|
|
2793
|
+
const [ttype, raw] = tokens[i];
|
|
2794
|
+
if (ttype === "BLOCK") {
|
|
2795
|
+
const [tagContent] = stripTag(raw);
|
|
2796
|
+
if ((tagContent.split(/\s+/)[0] || "") === "macro") {
|
|
2797
|
+
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2798
|
+
if (macroM) {
|
|
2799
|
+
const macroName = macroM[1];
|
|
2800
|
+
const params = Frond.parseMacroParams(macroM[2]);
|
|
2801
|
+
|
|
2802
|
+
const bodyTokens: Token[] = [];
|
|
2803
|
+
i++;
|
|
2804
|
+
while (i < tokens.length) {
|
|
2805
|
+
if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
|
|
2806
|
+
i++;
|
|
2807
|
+
break;
|
|
2808
|
+
}
|
|
2809
|
+
bodyTokens.push(tokens[i]);
|
|
2810
|
+
i++;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// Own copies per macro — avoids closure-over-loop-variable sharing.
|
|
2814
|
+
const capturedBody = [...bodyTokens];
|
|
2815
|
+
const capturedParams = [...params];
|
|
2816
|
+
const capturedCtx = { ...context };
|
|
2817
|
+
const engine = this;
|
|
2818
|
+
|
|
2819
|
+
namespace[macroName] = (...args: unknown[]) => {
|
|
2820
|
+
const macroCtx: Record<string, unknown> = { ...capturedCtx };
|
|
2821
|
+
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2822
|
+
const [pname, pdefault] = capturedParams[pi];
|
|
2823
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2824
|
+
}
|
|
2825
|
+
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2826
|
+
};
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
i++;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
context[alias] = namespace;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2618
2837
|
private handleFromImport(content: string, context: Record<string, unknown>): void {
|
|
2619
2838
|
const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
|
|
2620
2839
|
if (!m) return;
|
|
@@ -2635,7 +2854,7 @@ export class Frond {
|
|
|
2635
2854
|
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2636
2855
|
if (macroM && names.includes(macroM[1])) {
|
|
2637
2856
|
const macroName = macroM[1];
|
|
2638
|
-
const paramNames = macroM[2]
|
|
2857
|
+
const paramNames = Frond.parseMacroParams(macroM[2]);
|
|
2639
2858
|
|
|
2640
2859
|
const bodyTokens: Token[] = [];
|
|
2641
2860
|
i++;
|
|
@@ -2657,7 +2876,8 @@ export class Frond {
|
|
|
2657
2876
|
context[macroName] = (...args: unknown[]) => {
|
|
2658
2877
|
const macroCtx: Record<string, unknown> = { ...capturedCtx };
|
|
2659
2878
|
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2660
|
-
|
|
2879
|
+
const [pname, pdefault] = capturedParams[pi];
|
|
2880
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2661
2881
|
}
|
|
2662
2882
|
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2663
2883
|
};
|