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.
@@ -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
- for (const fn of analysis.most_complex_functions || []) {
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 better-sqlite3.
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 better-sqlite3)
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 better-sqlite3, zero extra dependencies.
2
+ * Tina4 Database Session Handler — SQLite via Node's built-in node:sqlite,
3
+ * zero extra dependencies.
3
4
  *
4
- * Uses the same `better-sqlite3` library the ORM already depends on.
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 better-sqlite3 (synchronous SQLite).
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
- while (i <= expr.length - needle.length) {
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.slice(i, i + needle.length) === needle) {
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
- while (i <= expr.length - sep.length) {
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.slice(i, i + sep.length) === sep) {
390
+ if (depth === 0 && bracketDepth === 0 && expr.startsWith(sep, i)) {
376
391
  parts.push(expr.slice(currentStart, i));
377
- i += sep.length;
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 paramNames = m[2].split(",").map((p) => p.trim()).filter(Boolean);
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 < paramNames.length; pi++) {
2217
- macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
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].split(",").map((p) => p.trim()).filter(Boolean);
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
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
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
- while (i <= expr.length - needle.length) {
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
- if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + needle.length) === needle) {
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
- while (i <= expr.length - sep.length) {
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
- if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + sep.length) === sep) {
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 += sep.length;
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
- // Check for comparison/logical operators
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 paramNames = m[2].split(",").map(p => p.trim()).filter(Boolean);
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 < paramNames.length; pi++) {
2588
- macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
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].split(",").map(p => p.trim()).filter(Boolean);
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
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
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
  };