tina4-nodejs 3.13.85 → 3.13.87
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 +38 -32
- package/package.json +22 -5
- package/packages/cli/dist/bin.js +245 -103
- package/packages/core/dist/index.js +244 -102
- package/packages/core/public/js/tina4-dev-admin.min.js +90 -87
- package/packages/core/src/metrics.ts +11 -2
- package/packages/core/src/session.ts +2 -2
- package/packages/core/src/sessionHandlers/databaseHandler.ts +5 -6
- package/packages/frond/dist/index.js +115 -10
- package/packages/frond/src/engine.ts +186 -11
- package/packages/orm/dist/index.js +244 -102
- package/packages/orm/src/adapters/firebird.ts +39 -13
- package/packages/orm/src/adapters/mongodb.ts +13 -13
- package/packages/orm/src/adapters/mssql.ts +14 -14
- package/packages/orm/src/adapters/mysql.ts +14 -14
- package/packages/orm/src/adapters/odbc.ts +15 -15
- package/packages/orm/src/adapters/postgres.ts +28 -17
- package/packages/orm/src/adapters/sqlite.ts +14 -14
- package/packages/orm/src/autoCrud.ts +1 -1
- package/packages/orm/src/cachedDatabase.ts +1 -1
- package/packages/orm/src/database.ts +17 -5
- package/packages/orm/src/types.ts +4 -4
|
@@ -1183,7 +1183,14 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
|
1183
1183
|
total_functions: allFunctions.length,
|
|
1184
1184
|
avg_complexity: Math.round(avgCC * 100) / 100,
|
|
1185
1185
|
avg_maintainability: Math.round(avgMI * 10) / 10,
|
|
1186
|
+
// Display-only: the top-15 for the "most complex functions" report.
|
|
1187
|
+
// Do NOT source offenders / --fail-on from this — capping here silently
|
|
1188
|
+
// hides the 16th+ over-threshold function from the gate. offenders()
|
|
1189
|
+
// reads "all_functions" (below) instead.
|
|
1186
1190
|
most_complex_functions: allFunctions.slice(0, 15),
|
|
1191
|
+
// Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
|
|
1192
|
+
// so no function over the complexity threshold ever escapes the gate.
|
|
1193
|
+
all_functions: allFunctions,
|
|
1187
1194
|
file_metrics: fileMetrics,
|
|
1188
1195
|
violations,
|
|
1189
1196
|
dependency_graph: importGraph,
|
|
@@ -1243,8 +1250,10 @@ export function offenders(root: string = "src", top: number = 20): OffendersResu
|
|
|
1243
1250
|
|
|
1244
1251
|
const items: Offender[] = [];
|
|
1245
1252
|
|
|
1246
|
-
// Function-level: cyclomatic complexity.
|
|
1247
|
-
|
|
1253
|
+
// Function-level: cyclomatic complexity. Use the FULL function list (not the
|
|
1254
|
+
// display-capped most_complex_functions[:15]) so a 16th+ over-threshold
|
|
1255
|
+
// function is never silently dropped from the offenders list or --fail-on.
|
|
1256
|
+
for (const fn of analysis.all_functions || analysis.most_complex_functions || []) {
|
|
1248
1257
|
const cc: number = fn.complexity;
|
|
1249
1258
|
if (cc > 10) {
|
|
1250
1259
|
items.push({
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tina4 Session — Pluggable session backends, zero core dependencies.
|
|
3
3
|
*
|
|
4
4
|
* File-based sessions by default. Redis backend available via raw TCP (no ioredis needed).
|
|
5
|
-
* Database (SQLite) backend available via
|
|
5
|
+
* Database (SQLite) backend available via Node's built-in node:sqlite.
|
|
6
6
|
*
|
|
7
7
|
* import { Session, RedisSessionHandler } from "@tina4/core";
|
|
8
8
|
*
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* redisPort: 6379,
|
|
16
16
|
* });
|
|
17
17
|
*
|
|
18
|
-
* // Database backend (SQLite via
|
|
18
|
+
* // Database backend (SQLite via node:sqlite)
|
|
19
19
|
* const session = new Session("database");
|
|
20
20
|
* // or: new Session("db");
|
|
21
21
|
*
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tina4 Database Session Handler — SQLite via
|
|
2
|
+
* Tina4 Database Session Handler — SQLite via Node's built-in node:sqlite,
|
|
3
|
+
* zero extra dependencies.
|
|
3
4
|
*
|
|
4
|
-
* Uses the same `
|
|
5
|
+
* Uses the same `node:sqlite` (DatabaseSync) the ORM's SQLite adapter uses —
|
|
6
|
+
* no third-party driver, nothing to install.
|
|
5
7
|
* Stores sessions in a `tina4_session` table with JSON data and expiry.
|
|
6
8
|
*
|
|
7
9
|
* Configure via environment variables:
|
|
8
10
|
* TINA4_DATABASE_URL (default: "sqlite:///data/tina4_sessions.db")
|
|
9
|
-
*
|
|
10
|
-
* The handler dynamically imports `better-sqlite3` and throws a clear
|
|
11
|
-
* error if the package is not installed.
|
|
12
11
|
*/
|
|
13
12
|
import { DatabaseSync } from "node:sqlite";
|
|
14
13
|
import type { SessionHandler } from "../session.js";
|
|
@@ -35,7 +34,7 @@ export interface DatabaseSessionConfig {
|
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
|
-
* Database session handler using
|
|
37
|
+
* Database session handler using node:sqlite (synchronous SQLite).
|
|
39
38
|
*
|
|
40
39
|
* Stores session data as JSON in a `tina4_session` table.
|
|
41
40
|
* Expiry is checked on read; expired rows are cleaned up lazily.
|
|
@@ -101,6 +101,15 @@ function liveAttr(value) {
|
|
|
101
101
|
}
|
|
102
102
|
var filterChainCache = /* @__PURE__ */ new Map();
|
|
103
103
|
var pathParseCache = /* @__PURE__ */ new Map();
|
|
104
|
+
var TEMPLATE_CACHE_MAX = 256;
|
|
105
|
+
function capCache(cache, maxEntries) {
|
|
106
|
+
if (cache.size < maxEntries) return;
|
|
107
|
+
let drop = Math.floor(maxEntries / 2);
|
|
108
|
+
for (const key of cache.keys()) {
|
|
109
|
+
cache.delete(key);
|
|
110
|
+
if (--drop <= 0) break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
104
113
|
var TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
|
|
105
114
|
var RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
|
|
106
115
|
function tokenize(source) {
|
|
@@ -316,11 +325,14 @@ function resolveVar(expr, context) {
|
|
|
316
325
|
return value;
|
|
317
326
|
}
|
|
318
327
|
function findOutsideQuotes(expr, needle) {
|
|
328
|
+
if (!expr.includes(needle)) return -1;
|
|
319
329
|
let inQuote = null;
|
|
320
330
|
let depth = 0;
|
|
321
331
|
let bracketDepth = 0;
|
|
322
332
|
let i = 0;
|
|
323
|
-
|
|
333
|
+
const needleLen = needle.length;
|
|
334
|
+
const lastStart = expr.length - needleLen;
|
|
335
|
+
while (i <= lastStart) {
|
|
324
336
|
const ch = expr[i];
|
|
325
337
|
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
326
338
|
if (inQuote === null) {
|
|
@@ -339,7 +351,7 @@ function findOutsideQuotes(expr, needle) {
|
|
|
339
351
|
else if (ch === ")") depth--;
|
|
340
352
|
else if (ch === "[") bracketDepth++;
|
|
341
353
|
else if (ch === "]") bracketDepth--;
|
|
342
|
-
if (depth === 0 && bracketDepth === 0 && expr.
|
|
354
|
+
if (depth === 0 && bracketDepth === 0 && expr.startsWith(needle, i)) {
|
|
343
355
|
return i;
|
|
344
356
|
}
|
|
345
357
|
i++;
|
|
@@ -347,13 +359,16 @@ function findOutsideQuotes(expr, needle) {
|
|
|
347
359
|
return -1;
|
|
348
360
|
}
|
|
349
361
|
function splitOutsideQuotes(expr, sep) {
|
|
362
|
+
if (!expr.includes(sep)) return [expr];
|
|
350
363
|
const parts = [];
|
|
351
364
|
let currentStart = 0;
|
|
352
365
|
let inQuote = null;
|
|
353
366
|
let depth = 0;
|
|
354
367
|
let bracketDepth = 0;
|
|
355
368
|
let i = 0;
|
|
356
|
-
|
|
369
|
+
const sepLen = sep.length;
|
|
370
|
+
const lastStart = expr.length - sepLen;
|
|
371
|
+
while (i <= lastStart) {
|
|
357
372
|
const ch = expr[i];
|
|
358
373
|
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
359
374
|
if (inQuote === null) {
|
|
@@ -372,9 +387,9 @@ function splitOutsideQuotes(expr, sep) {
|
|
|
372
387
|
else if (ch === ")") depth--;
|
|
373
388
|
else if (ch === "[") bracketDepth++;
|
|
374
389
|
else if (ch === "]") bracketDepth--;
|
|
375
|
-
if (depth === 0 && bracketDepth === 0 && expr.
|
|
390
|
+
if (depth === 0 && bracketDepth === 0 && expr.startsWith(sep, i)) {
|
|
376
391
|
parts.push(expr.slice(currentStart, i));
|
|
377
|
-
i +=
|
|
392
|
+
i += sepLen;
|
|
378
393
|
currentStart = i;
|
|
379
394
|
continue;
|
|
380
395
|
}
|
|
@@ -448,6 +463,9 @@ function evalExpr(expr, context) {
|
|
|
448
463
|
}).join("");
|
|
449
464
|
}
|
|
450
465
|
}
|
|
466
|
+
if (expr.startsWith("not ")) {
|
|
467
|
+
return evalComparison(expr, context);
|
|
468
|
+
}
|
|
451
469
|
for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
|
|
452
470
|
if (findOutsideQuotes(expr, op) >= 0) {
|
|
453
471
|
return evalComparison(expr, context);
|
|
@@ -1403,6 +1421,7 @@ var Frond = class _Frond {
|
|
|
1403
1421
|
const source = readFileSync(filePath, "utf-8");
|
|
1404
1422
|
const mtime = statSync(filePath).mtimeMs;
|
|
1405
1423
|
const tokens = tokenize(source);
|
|
1424
|
+
capCache(this.compiled, TEMPLATE_CACHE_MAX);
|
|
1406
1425
|
this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
|
|
1407
1426
|
return this.executeWithSource(source, tokens, context);
|
|
1408
1427
|
}
|
|
@@ -1417,6 +1436,7 @@ var Frond = class _Frond {
|
|
|
1417
1436
|
}
|
|
1418
1437
|
}
|
|
1419
1438
|
const tokens = tokenize(source);
|
|
1439
|
+
capCache(this.compiledStrings, TEMPLATE_CACHE_MAX);
|
|
1420
1440
|
this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
|
|
1421
1441
|
return this.executeCached(tokens, context);
|
|
1422
1442
|
}
|
|
@@ -1632,6 +1652,9 @@ var Frond = class _Frond {
|
|
|
1632
1652
|
} else if (tag === "macro") {
|
|
1633
1653
|
const skip = this.handleMacro(tokens, i, context);
|
|
1634
1654
|
i = skip;
|
|
1655
|
+
} else if (tag === "import") {
|
|
1656
|
+
this.handleImportAs(content, context);
|
|
1657
|
+
i++;
|
|
1635
1658
|
} else if (tag === "from") {
|
|
1636
1659
|
this.handleFromImport(content, context);
|
|
1637
1660
|
i++;
|
|
@@ -2198,7 +2221,7 @@ var Frond = class _Frond {
|
|
|
2198
2221
|
return i2;
|
|
2199
2222
|
}
|
|
2200
2223
|
const macroName = m[1];
|
|
2201
|
-
const
|
|
2224
|
+
const params = _Frond.parseMacroParams(m[2]);
|
|
2202
2225
|
const bodyTokens = [];
|
|
2203
2226
|
let i = start + 1;
|
|
2204
2227
|
while (i < tokens.length) {
|
|
@@ -2213,13 +2236,94 @@ var Frond = class _Frond {
|
|
|
2213
2236
|
const capturedContext = { ...context };
|
|
2214
2237
|
context[macroName] = (...args) => {
|
|
2215
2238
|
const macroCtx = { ...capturedContext };
|
|
2216
|
-
for (let pi = 0; pi <
|
|
2217
|
-
|
|
2239
|
+
for (let pi = 0; pi < params.length; pi++) {
|
|
2240
|
+
const [pname, pdefault] = params[pi];
|
|
2241
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2218
2242
|
}
|
|
2219
2243
|
return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
|
|
2220
2244
|
};
|
|
2221
2245
|
return i;
|
|
2222
2246
|
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Parse a macro parameter list into [name, default] pairs.
|
|
2249
|
+
*
|
|
2250
|
+
* Handles: name, name="default", name='default'. Splitting on "," alone left a
|
|
2251
|
+
* defaulted parameter literally NAMED `greeting='Hello'`, so the body's
|
|
2252
|
+
* {{ greeting }} matched nothing (rendered empty) AND the caller's positional
|
|
2253
|
+
* argument was stored under that junk key and lost. Mirrors the Python master's
|
|
2254
|
+
* _parse_macro_params. The default is null when none is declared.
|
|
2255
|
+
*/
|
|
2256
|
+
static parseMacroParams(rawParams) {
|
|
2257
|
+
return rawParams.split(",").map((p) => p.trim()).filter(Boolean).map((p) => {
|
|
2258
|
+
const eq = p.indexOf("=");
|
|
2259
|
+
if (eq === -1) return [p, null];
|
|
2260
|
+
const name = p.slice(0, eq).trim();
|
|
2261
|
+
let dflt = p.slice(eq + 1).trim();
|
|
2262
|
+
if (dflt.length >= 2 && (dflt.startsWith('"') && dflt.endsWith('"') || dflt.startsWith("'") && dflt.endsWith("'"))) {
|
|
2263
|
+
dflt = dflt.slice(1, -1);
|
|
2264
|
+
}
|
|
2265
|
+
return [name, dflt];
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* {% import "file" as alias %} -- load EVERY macro in a file under one namespace.
|
|
2270
|
+
*
|
|
2271
|
+
* The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }}
|
|
2272
|
+
* resolves through the engine's existing dotted-call path and each macro keeps the
|
|
2273
|
+
* same argument binding, default handling and SafeString output as any other macro.
|
|
2274
|
+
* A namespace object (not a class) is deliberate: a function stored as a class
|
|
2275
|
+
* attribute binds as a method and would inject the namespace as the first argument,
|
|
2276
|
+
* which is exactly the argument-shift bug the Python master carried (fixed there
|
|
2277
|
+
* with types.SimpleNamespace). Both import forms must render identically.
|
|
2278
|
+
*/
|
|
2279
|
+
handleImportAs(content, context) {
|
|
2280
|
+
const m = content.match(/^import\s+["'](.+?)["']\s+as\s+(\w+)/);
|
|
2281
|
+
if (!m) return;
|
|
2282
|
+
const filename = m[1];
|
|
2283
|
+
const alias = m[2];
|
|
2284
|
+
const namespace = {};
|
|
2285
|
+
const source = this.load(filename);
|
|
2286
|
+
const tokens = tokenize(source);
|
|
2287
|
+
let i = 0;
|
|
2288
|
+
while (i < tokens.length) {
|
|
2289
|
+
const [ttype, raw] = tokens[i];
|
|
2290
|
+
if (ttype === "BLOCK") {
|
|
2291
|
+
const [tagContent] = stripTag(raw);
|
|
2292
|
+
if ((tagContent.split(/\s+/)[0] || "") === "macro") {
|
|
2293
|
+
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2294
|
+
if (macroM) {
|
|
2295
|
+
const macroName = macroM[1];
|
|
2296
|
+
const params = _Frond.parseMacroParams(macroM[2]);
|
|
2297
|
+
const bodyTokens = [];
|
|
2298
|
+
i++;
|
|
2299
|
+
while (i < tokens.length) {
|
|
2300
|
+
if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
|
|
2301
|
+
i++;
|
|
2302
|
+
break;
|
|
2303
|
+
}
|
|
2304
|
+
bodyTokens.push(tokens[i]);
|
|
2305
|
+
i++;
|
|
2306
|
+
}
|
|
2307
|
+
const capturedBody = [...bodyTokens];
|
|
2308
|
+
const capturedParams = [...params];
|
|
2309
|
+
const capturedCtx = { ...context };
|
|
2310
|
+
const engine = this;
|
|
2311
|
+
namespace[macroName] = (...args) => {
|
|
2312
|
+
const macroCtx = { ...capturedCtx };
|
|
2313
|
+
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2314
|
+
const [pname, pdefault] = capturedParams[pi];
|
|
2315
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2316
|
+
}
|
|
2317
|
+
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2318
|
+
};
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
i++;
|
|
2324
|
+
}
|
|
2325
|
+
context[alias] = namespace;
|
|
2326
|
+
}
|
|
2223
2327
|
handleFromImport(content, context) {
|
|
2224
2328
|
const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
|
|
2225
2329
|
if (!m) return;
|
|
@@ -2237,7 +2341,7 @@ var Frond = class _Frond {
|
|
|
2237
2341
|
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2238
2342
|
if (macroM && names.includes(macroM[1])) {
|
|
2239
2343
|
const macroName = macroM[1];
|
|
2240
|
-
const paramNames = macroM[2]
|
|
2344
|
+
const paramNames = _Frond.parseMacroParams(macroM[2]);
|
|
2241
2345
|
const bodyTokens = [];
|
|
2242
2346
|
i++;
|
|
2243
2347
|
while (i < tokens.length) {
|
|
@@ -2255,7 +2359,8 @@ var Frond = class _Frond {
|
|
|
2255
2359
|
context[macroName] = (...args) => {
|
|
2256
2360
|
const macroCtx = { ...capturedCtx };
|
|
2257
2361
|
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2258
|
-
|
|
2362
|
+
const [pname, pdefault] = capturedParams[pi];
|
|
2363
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2259
2364
|
}
|
|
2260
2365
|
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2261
2366
|
};
|
|
@@ -202,6 +202,40 @@ const filterChainCache = new Map<string, [string, [string, unknown[]][]]>();
|
|
|
202
202
|
/** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket] */
|
|
203
203
|
const pathParseCache = new Map<string, [string[], boolean[]]>();
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Hard cap on the template caches — `compiled` and `compiledStrings`
|
|
207
|
+
* (ADR-0004, parity with PHP/Python/Ruby TEMPLATE_CACHE_MAX).
|
|
208
|
+
*
|
|
209
|
+
* An entry here is a whole token list, so the cap sits well below what a
|
|
210
|
+
* per-expression memo would justify. 256 is far above any real application's
|
|
211
|
+
* template count, so a normal app never evicts. The cap exists for the
|
|
212
|
+
* workload that genuinely grows without limit for the life of a worker:
|
|
213
|
+
* `renderString` keys on md5(source), so an app that builds template strings
|
|
214
|
+
* dynamically adds an entry per distinct string.
|
|
215
|
+
*/
|
|
216
|
+
export const TEMPLATE_CACHE_MAX = 256;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Keep a memo cache bounded. Call immediately before inserting a new entry.
|
|
220
|
+
*
|
|
221
|
+
* Eviction is insertion-ordered (oldest first), not true LRU: a `Map`
|
|
222
|
+
* preserves insertion order, so dropping from the front is cheap, whereas
|
|
223
|
+
* refreshing recency on every cache HIT would add writes to the hottest path
|
|
224
|
+
* in a render and cost more than it saves. Half the cache is dropped at once
|
|
225
|
+
* so the sweep amortises to O(1) per insert.
|
|
226
|
+
*
|
|
227
|
+
* Evicting can never change what a render produces: every read site treats a
|
|
228
|
+
* miss as "recompute", so a swept entry is rebuilt on next use.
|
|
229
|
+
*/
|
|
230
|
+
function capCache(cache: Map<string, unknown>, maxEntries: number): void {
|
|
231
|
+
if (cache.size < maxEntries) return;
|
|
232
|
+
let drop = Math.floor(maxEntries / 2);
|
|
233
|
+
for (const key of cache.keys()) {
|
|
234
|
+
cache.delete(key);
|
|
235
|
+
if (--drop <= 0) break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
205
239
|
// ── Lexer ──────────────────────────────────────────────────────
|
|
206
240
|
|
|
207
241
|
const TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
|
|
@@ -444,11 +478,23 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
444
478
|
}
|
|
445
479
|
|
|
446
480
|
function findOutsideQuotes(expr: string, needle: string): number {
|
|
481
|
+
// Fast path. This is the hottest function in a render: profiling the Python
|
|
482
|
+
// twin showed 415,200 calls and 53% of render time for one 20-row loop
|
|
483
|
+
// template, and the overwhelming majority return -1 because the needle simply
|
|
484
|
+
// is not in the expression. includes() is a native scan, so bailing here skips
|
|
485
|
+
// the whole JS character loop. Exact, not a heuristic: a needle absent from
|
|
486
|
+
// the string cannot be present outside quotes either.
|
|
487
|
+
if (!expr.includes(needle)) return -1;
|
|
488
|
+
|
|
447
489
|
let inQuote: string | null = null;
|
|
448
490
|
let depth = 0;
|
|
449
491
|
let bracketDepth = 0;
|
|
450
492
|
let i = 0;
|
|
451
|
-
|
|
493
|
+
// Hoisted out of the loop condition -- both lengths were recomputed on every
|
|
494
|
+
// single iteration.
|
|
495
|
+
const needleLen = needle.length;
|
|
496
|
+
const lastStart = expr.length - needleLen;
|
|
497
|
+
while (i <= lastStart) {
|
|
452
498
|
const ch = expr[i];
|
|
453
499
|
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
454
500
|
if (inQuote === null) {
|
|
@@ -464,7 +510,9 @@ function findOutsideQuotes(expr: string, needle: string): number {
|
|
|
464
510
|
else if (ch === ")") depth--;
|
|
465
511
|
else if (ch === "[") bracketDepth++;
|
|
466
512
|
else if (ch === "]") bracketDepth--;
|
|
467
|
-
|
|
513
|
+
// startsWith(needle, i) rather than slice(i, i + needleLen) === needle: the
|
|
514
|
+
// slice allocated a throwaway string at every character position.
|
|
515
|
+
if (depth === 0 && bracketDepth === 0 && expr.startsWith(needle, i)) {
|
|
468
516
|
return i;
|
|
469
517
|
}
|
|
470
518
|
i++;
|
|
@@ -473,13 +521,20 @@ function findOutsideQuotes(expr: string, needle: string): number {
|
|
|
473
521
|
}
|
|
474
522
|
|
|
475
523
|
function splitOutsideQuotes(expr: string, sep: string): string[] {
|
|
524
|
+
// Fast path, same reasoning as findOutsideQuotes: no separator anywhere means
|
|
525
|
+
// no split, and includes() is a native scan versus a JS character loop.
|
|
526
|
+
if (!expr.includes(sep)) return [expr];
|
|
527
|
+
|
|
476
528
|
const parts: string[] = [];
|
|
477
529
|
let currentStart = 0;
|
|
478
530
|
let inQuote: string | null = null;
|
|
479
531
|
let depth = 0;
|
|
480
532
|
let bracketDepth = 0;
|
|
481
533
|
let i = 0;
|
|
482
|
-
|
|
534
|
+
// Hoisted out of the loop condition -- recomputed every iteration before.
|
|
535
|
+
const sepLen = sep.length;
|
|
536
|
+
const lastStart = expr.length - sepLen;
|
|
537
|
+
while (i <= lastStart) {
|
|
483
538
|
const ch = expr[i];
|
|
484
539
|
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
485
540
|
if (inQuote === null) {
|
|
@@ -495,9 +550,10 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
|
|
|
495
550
|
else if (ch === ")") depth--;
|
|
496
551
|
else if (ch === "[") bracketDepth++;
|
|
497
552
|
else if (ch === "]") bracketDepth--;
|
|
498
|
-
|
|
553
|
+
// startsWith avoids allocating a throwaway slice at every position.
|
|
554
|
+
if (depth === 0 && bracketDepth === 0 && expr.startsWith(sep, i)) {
|
|
499
555
|
parts.push(expr.slice(currentStart, i));
|
|
500
|
-
i +=
|
|
556
|
+
i += sepLen;
|
|
501
557
|
currentStart = i;
|
|
502
558
|
continue;
|
|
503
559
|
}
|
|
@@ -587,7 +643,21 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
587
643
|
}
|
|
588
644
|
}
|
|
589
645
|
|
|
590
|
-
//
|
|
646
|
+
// Comparison/logical operators -> evalComparison, the SAME evaluator {% if %}
|
|
647
|
+
// uses, so a condition means the same thing in a condition and in an output
|
|
648
|
+
// expression.
|
|
649
|
+
//
|
|
650
|
+
// The LEADING unary `not` needs its own check: every operator below is matched
|
|
651
|
+
// WITH surrounding spaces, so `not x` (nothing to its left) matched none of
|
|
652
|
+
// them, fell through to the variable-resolution tail, and was looked up as a
|
|
653
|
+
// variable literally named "not x" -- found nothing, rendered EMPTY.
|
|
654
|
+
// `{% if not x %}` and `x and not y` always worked; only the standalone
|
|
655
|
+
// `{{ not x }}` was dropped, and before booleans rendered lowercase a dropped
|
|
656
|
+
// expression and `false -> ''` looked identical, which is why it survived.
|
|
657
|
+
// Fixed in 3.13.87 alongside the boolean contract.
|
|
658
|
+
if (expr.startsWith("not ")) {
|
|
659
|
+
return evalComparison(expr, context);
|
|
660
|
+
}
|
|
591
661
|
for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
|
|
592
662
|
if (findOutsideQuotes(expr, op) >= 0) {
|
|
593
663
|
return evalComparison(expr, context);
|
|
@@ -1663,6 +1733,7 @@ export class Frond {
|
|
|
1663
1733
|
const source = readFileSync(filePath, "utf-8");
|
|
1664
1734
|
const mtime = statSync(filePath).mtimeMs;
|
|
1665
1735
|
const tokens = tokenize(source);
|
|
1736
|
+
capCache(this.compiled as Map<string, unknown>, TEMPLATE_CACHE_MAX);
|
|
1666
1737
|
this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
|
|
1667
1738
|
return this.executeWithSource(source, tokens, context);
|
|
1668
1739
|
}
|
|
@@ -1680,6 +1751,7 @@ export class Frond {
|
|
|
1680
1751
|
}
|
|
1681
1752
|
|
|
1682
1753
|
const tokens = tokenize(source);
|
|
1754
|
+
capCache(this.compiledStrings as Map<string, unknown>, TEMPLATE_CACHE_MAX);
|
|
1683
1755
|
this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
|
|
1684
1756
|
return this.executeCached(tokens, context);
|
|
1685
1757
|
}
|
|
@@ -1955,6 +2027,9 @@ export class Frond {
|
|
|
1955
2027
|
} else if (tag === "macro") {
|
|
1956
2028
|
const skip = this.handleMacro(tokens, i, context);
|
|
1957
2029
|
i = skip;
|
|
2030
|
+
} else if (tag === "import") {
|
|
2031
|
+
this.handleImportAs(content, context);
|
|
2032
|
+
i++;
|
|
1958
2033
|
} else if (tag === "from") {
|
|
1959
2034
|
this.handleFromImport(content, context);
|
|
1960
2035
|
i++;
|
|
@@ -2565,7 +2640,7 @@ export class Frond {
|
|
|
2565
2640
|
}
|
|
2566
2641
|
|
|
2567
2642
|
const macroName = m[1];
|
|
2568
|
-
const
|
|
2643
|
+
const params = Frond.parseMacroParams(m[2]);
|
|
2569
2644
|
|
|
2570
2645
|
// Collect body tokens
|
|
2571
2646
|
const bodyTokens: Token[] = [];
|
|
@@ -2584,8 +2659,9 @@ export class Frond {
|
|
|
2584
2659
|
const capturedContext = { ...context };
|
|
2585
2660
|
context[macroName] = (...args: unknown[]) => {
|
|
2586
2661
|
const macroCtx: Record<string, unknown> = { ...capturedContext };
|
|
2587
|
-
for (let pi = 0; pi <
|
|
2588
|
-
|
|
2662
|
+
for (let pi = 0; pi < params.length; pi++) {
|
|
2663
|
+
const [pname, pdefault] = params[pi];
|
|
2664
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2589
2665
|
}
|
|
2590
2666
|
return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
|
|
2591
2667
|
};
|
|
@@ -2593,6 +2669,104 @@ export class Frond {
|
|
|
2593
2669
|
return i;
|
|
2594
2670
|
}
|
|
2595
2671
|
|
|
2672
|
+
/**
|
|
2673
|
+
* Parse a macro parameter list into [name, default] pairs.
|
|
2674
|
+
*
|
|
2675
|
+
* Handles: name, name="default", name='default'. Splitting on "," alone left a
|
|
2676
|
+
* defaulted parameter literally NAMED `greeting='Hello'`, so the body's
|
|
2677
|
+
* {{ greeting }} matched nothing (rendered empty) AND the caller's positional
|
|
2678
|
+
* argument was stored under that junk key and lost. Mirrors the Python master's
|
|
2679
|
+
* _parse_macro_params. The default is null when none is declared.
|
|
2680
|
+
*/
|
|
2681
|
+
static parseMacroParams(rawParams: string): Array<[string, string | null]> {
|
|
2682
|
+
return rawParams
|
|
2683
|
+
.split(",")
|
|
2684
|
+
.map(p => p.trim())
|
|
2685
|
+
.filter(Boolean)
|
|
2686
|
+
.map(p => {
|
|
2687
|
+
const eq = p.indexOf("=");
|
|
2688
|
+
if (eq === -1) return [p, null] as [string, string | null];
|
|
2689
|
+
const name = p.slice(0, eq).trim();
|
|
2690
|
+
let dflt = p.slice(eq + 1).trim();
|
|
2691
|
+
if (
|
|
2692
|
+
dflt.length >= 2 &&
|
|
2693
|
+
((dflt.startsWith('"') && dflt.endsWith('"')) ||
|
|
2694
|
+
(dflt.startsWith("'") && dflt.endsWith("'")))
|
|
2695
|
+
) {
|
|
2696
|
+
dflt = dflt.slice(1, -1);
|
|
2697
|
+
}
|
|
2698
|
+
return [name, dflt] as [string, string | null];
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* {% import "file" as alias %} -- load EVERY macro in a file under one namespace.
|
|
2704
|
+
*
|
|
2705
|
+
* The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }}
|
|
2706
|
+
* resolves through the engine's existing dotted-call path and each macro keeps the
|
|
2707
|
+
* same argument binding, default handling and SafeString output as any other macro.
|
|
2708
|
+
* A namespace object (not a class) is deliberate: a function stored as a class
|
|
2709
|
+
* attribute binds as a method and would inject the namespace as the first argument,
|
|
2710
|
+
* which is exactly the argument-shift bug the Python master carried (fixed there
|
|
2711
|
+
* with types.SimpleNamespace). Both import forms must render identically.
|
|
2712
|
+
*/
|
|
2713
|
+
private handleImportAs(content: string, context: Record<string, unknown>): void {
|
|
2714
|
+
const m = content.match(/^import\s+["'](.+?)["']\s+as\s+(\w+)/);
|
|
2715
|
+
if (!m) return;
|
|
2716
|
+
|
|
2717
|
+
const filename = m[1];
|
|
2718
|
+
const alias = m[2];
|
|
2719
|
+
const namespace: Record<string, unknown> = {};
|
|
2720
|
+
|
|
2721
|
+
const source = this.load(filename);
|
|
2722
|
+
const tokens = tokenize(source);
|
|
2723
|
+
|
|
2724
|
+
let i = 0;
|
|
2725
|
+
while (i < tokens.length) {
|
|
2726
|
+
const [ttype, raw] = tokens[i];
|
|
2727
|
+
if (ttype === "BLOCK") {
|
|
2728
|
+
const [tagContent] = stripTag(raw);
|
|
2729
|
+
if ((tagContent.split(/\s+/)[0] || "") === "macro") {
|
|
2730
|
+
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2731
|
+
if (macroM) {
|
|
2732
|
+
const macroName = macroM[1];
|
|
2733
|
+
const params = Frond.parseMacroParams(macroM[2]);
|
|
2734
|
+
|
|
2735
|
+
const bodyTokens: Token[] = [];
|
|
2736
|
+
i++;
|
|
2737
|
+
while (i < tokens.length) {
|
|
2738
|
+
if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
|
|
2739
|
+
i++;
|
|
2740
|
+
break;
|
|
2741
|
+
}
|
|
2742
|
+
bodyTokens.push(tokens[i]);
|
|
2743
|
+
i++;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// Own copies per macro — avoids closure-over-loop-variable sharing.
|
|
2747
|
+
const capturedBody = [...bodyTokens];
|
|
2748
|
+
const capturedParams = [...params];
|
|
2749
|
+
const capturedCtx = { ...context };
|
|
2750
|
+
const engine = this;
|
|
2751
|
+
|
|
2752
|
+
namespace[macroName] = (...args: unknown[]) => {
|
|
2753
|
+
const macroCtx: Record<string, unknown> = { ...capturedCtx };
|
|
2754
|
+
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2755
|
+
const [pname, pdefault] = capturedParams[pi];
|
|
2756
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2757
|
+
}
|
|
2758
|
+
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2759
|
+
};
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
i++;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
context[alias] = namespace;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2596
2770
|
private handleFromImport(content: string, context: Record<string, unknown>): void {
|
|
2597
2771
|
const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
|
|
2598
2772
|
if (!m) return;
|
|
@@ -2613,7 +2787,7 @@ export class Frond {
|
|
|
2613
2787
|
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2614
2788
|
if (macroM && names.includes(macroM[1])) {
|
|
2615
2789
|
const macroName = macroM[1];
|
|
2616
|
-
const paramNames = macroM[2]
|
|
2790
|
+
const paramNames = Frond.parseMacroParams(macroM[2]);
|
|
2617
2791
|
|
|
2618
2792
|
const bodyTokens: Token[] = [];
|
|
2619
2793
|
i++;
|
|
@@ -2635,7 +2809,8 @@ export class Frond {
|
|
|
2635
2809
|
context[macroName] = (...args: unknown[]) => {
|
|
2636
2810
|
const macroCtx: Record<string, unknown> = { ...capturedCtx };
|
|
2637
2811
|
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2638
|
-
|
|
2812
|
+
const [pname, pdefault] = capturedParams[pi];
|
|
2813
|
+
macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
|
|
2639
2814
|
}
|
|
2640
2815
|
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2641
2816
|
};
|