tina4-nodejs 3.13.86 → 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.
@@ -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) {
@@ -454,6 +463,9 @@ function evalExpr(expr, context) {
454
463
  }).join("");
455
464
  }
456
465
  }
466
+ if (expr.startsWith("not ")) {
467
+ return evalComparison(expr, context);
468
+ }
457
469
  for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
458
470
  if (findOutsideQuotes(expr, op) >= 0) {
459
471
  return evalComparison(expr, context);
@@ -1409,6 +1421,7 @@ var Frond = class _Frond {
1409
1421
  const source = readFileSync(filePath, "utf-8");
1410
1422
  const mtime = statSync(filePath).mtimeMs;
1411
1423
  const tokens = tokenize(source);
1424
+ capCache(this.compiled, TEMPLATE_CACHE_MAX);
1412
1425
  this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
1413
1426
  return this.executeWithSource(source, tokens, context);
1414
1427
  }
@@ -1423,6 +1436,7 @@ var Frond = class _Frond {
1423
1436
  }
1424
1437
  }
1425
1438
  const tokens = tokenize(source);
1439
+ capCache(this.compiledStrings, TEMPLATE_CACHE_MAX);
1426
1440
  this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
1427
1441
  return this.executeCached(tokens, context);
1428
1442
  }
@@ -1638,6 +1652,9 @@ var Frond = class _Frond {
1638
1652
  } else if (tag === "macro") {
1639
1653
  const skip = this.handleMacro(tokens, i, context);
1640
1654
  i = skip;
1655
+ } else if (tag === "import") {
1656
+ this.handleImportAs(content, context);
1657
+ i++;
1641
1658
  } else if (tag === "from") {
1642
1659
  this.handleFromImport(content, context);
1643
1660
  i++;
@@ -2204,7 +2221,7 @@ var Frond = class _Frond {
2204
2221
  return i2;
2205
2222
  }
2206
2223
  const macroName = m[1];
2207
- const paramNames = m[2].split(",").map((p) => p.trim()).filter(Boolean);
2224
+ const params = _Frond.parseMacroParams(m[2]);
2208
2225
  const bodyTokens = [];
2209
2226
  let i = start + 1;
2210
2227
  while (i < tokens.length) {
@@ -2219,13 +2236,94 @@ var Frond = class _Frond {
2219
2236
  const capturedContext = { ...context };
2220
2237
  context[macroName] = (...args) => {
2221
2238
  const macroCtx = { ...capturedContext };
2222
- for (let pi = 0; pi < paramNames.length; pi++) {
2223
- 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;
2224
2242
  }
2225
2243
  return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
2226
2244
  };
2227
2245
  return i;
2228
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
+ }
2229
2327
  handleFromImport(content, context) {
2230
2328
  const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
2231
2329
  if (!m) return;
@@ -2243,7 +2341,7 @@ var Frond = class _Frond {
2243
2341
  const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
2244
2342
  if (macroM && names.includes(macroM[1])) {
2245
2343
  const macroName = macroM[1];
2246
- const paramNames = macroM[2].split(",").map((p) => p.trim()).filter(Boolean);
2344
+ const paramNames = _Frond.parseMacroParams(macroM[2]);
2247
2345
  const bodyTokens = [];
2248
2346
  i++;
2249
2347
  while (i < tokens.length) {
@@ -2261,7 +2359,8 @@ var Frond = class _Frond {
2261
2359
  context[macroName] = (...args) => {
2262
2360
  const macroCtx = { ...capturedCtx };
2263
2361
  for (let pi = 0; pi < capturedParams.length; pi++) {
2264
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
2362
+ const [pname, pdefault] = capturedParams[pi];
2363
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
2265
2364
  }
2266
2365
  return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
2267
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;
@@ -609,7 +643,21 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
609
643
  }
610
644
  }
611
645
 
612
- // 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
+ }
613
661
  for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
614
662
  if (findOutsideQuotes(expr, op) >= 0) {
615
663
  return evalComparison(expr, context);
@@ -1685,6 +1733,7 @@ export class Frond {
1685
1733
  const source = readFileSync(filePath, "utf-8");
1686
1734
  const mtime = statSync(filePath).mtimeMs;
1687
1735
  const tokens = tokenize(source);
1736
+ capCache(this.compiled as Map<string, unknown>, TEMPLATE_CACHE_MAX);
1688
1737
  this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
1689
1738
  return this.executeWithSource(source, tokens, context);
1690
1739
  }
@@ -1702,6 +1751,7 @@ export class Frond {
1702
1751
  }
1703
1752
 
1704
1753
  const tokens = tokenize(source);
1754
+ capCache(this.compiledStrings as Map<string, unknown>, TEMPLATE_CACHE_MAX);
1705
1755
  this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
1706
1756
  return this.executeCached(tokens, context);
1707
1757
  }
@@ -1977,6 +2027,9 @@ export class Frond {
1977
2027
  } else if (tag === "macro") {
1978
2028
  const skip = this.handleMacro(tokens, i, context);
1979
2029
  i = skip;
2030
+ } else if (tag === "import") {
2031
+ this.handleImportAs(content, context);
2032
+ i++;
1980
2033
  } else if (tag === "from") {
1981
2034
  this.handleFromImport(content, context);
1982
2035
  i++;
@@ -2587,7 +2640,7 @@ export class Frond {
2587
2640
  }
2588
2641
 
2589
2642
  const macroName = m[1];
2590
- const paramNames = m[2].split(",").map(p => p.trim()).filter(Boolean);
2643
+ const params = Frond.parseMacroParams(m[2]);
2591
2644
 
2592
2645
  // Collect body tokens
2593
2646
  const bodyTokens: Token[] = [];
@@ -2606,8 +2659,9 @@ export class Frond {
2606
2659
  const capturedContext = { ...context };
2607
2660
  context[macroName] = (...args: unknown[]) => {
2608
2661
  const macroCtx: Record<string, unknown> = { ...capturedContext };
2609
- for (let pi = 0; pi < paramNames.length; pi++) {
2610
- 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;
2611
2665
  }
2612
2666
  return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
2613
2667
  };
@@ -2615,6 +2669,104 @@ export class Frond {
2615
2669
  return i;
2616
2670
  }
2617
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
+
2618
2770
  private handleFromImport(content: string, context: Record<string, unknown>): void {
2619
2771
  const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
2620
2772
  if (!m) return;
@@ -2635,7 +2787,7 @@ export class Frond {
2635
2787
  const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
2636
2788
  if (macroM && names.includes(macroM[1])) {
2637
2789
  const macroName = macroM[1];
2638
- const paramNames = macroM[2].split(",").map(p => p.trim()).filter(Boolean);
2790
+ const paramNames = Frond.parseMacroParams(macroM[2]);
2639
2791
 
2640
2792
  const bodyTokens: Token[] = [];
2641
2793
  i++;
@@ -2657,7 +2809,8 @@ export class Frond {
2657
2809
  context[macroName] = (...args: unknown[]) => {
2658
2810
  const macroCtx: Record<string, unknown> = { ...capturedCtx };
2659
2811
  for (let pi = 0; pi < capturedParams.length; pi++) {
2660
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
2812
+ const [pname, pdefault] = capturedParams[pi];
2813
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
2661
2814
  }
2662
2815
  return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
2663
2816
  };