tina4-nodejs 3.13.98 → 3.13.100

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.
Files changed (97) hide show
  1. package/CLAUDE.md +24 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20698 -18983
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20561 -18828
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +38 -13
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +149 -49
  37. package/packages/frond/src/engine.ts +234 -52
  38. package/packages/orm/dist/index.js +10941 -9231
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/ai.d.ts +29 -0
  63. package/types/core/src/api.d.ts +11 -4
  64. package/types/core/src/background.d.ts +5 -2
  65. package/types/core/src/devAdmin.d.ts +35 -0
  66. package/types/core/src/dispatchPipeline.d.ts +41 -1
  67. package/types/core/src/errorOverlay.d.ts +13 -13
  68. package/types/core/src/index.d.ts +9 -6
  69. package/types/core/src/logger.d.ts +111 -185
  70. package/types/core/src/middleware.d.ts +40 -5
  71. package/types/core/src/portTakeover.d.ts +50 -0
  72. package/types/core/src/request.d.ts +15 -0
  73. package/types/core/src/response.d.ts +29 -0
  74. package/types/core/src/server.d.ts +92 -0
  75. package/types/core/src/testClient.d.ts +29 -3
  76. package/types/core/src/testing.d.ts +16 -12
  77. package/types/core/src/types.d.ts +21 -9
  78. package/types/core/src/version.d.ts +11 -0
  79. package/types/core/src/websocketBackplane.d.ts +1 -1
  80. package/types/frond/src/engine.d.ts +60 -8
  81. package/types/orm/src/adapters/firebird.d.ts +61 -2
  82. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  83. package/types/orm/src/adapters/mssql.d.ts +11 -0
  84. package/types/orm/src/adapters/mysql.d.ts +11 -0
  85. package/types/orm/src/adapters/odbc.d.ts +35 -4
  86. package/types/orm/src/adapters/postgres.d.ts +11 -0
  87. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  88. package/types/orm/src/baseModel.d.ts +45 -25
  89. package/types/orm/src/cachedDatabase.d.ts +27 -1
  90. package/types/orm/src/database.d.ts +56 -6
  91. package/types/orm/src/index.d.ts +3 -2
  92. package/types/orm/src/migration.d.ts +23 -5
  93. package/types/orm/src/query.d.ts +3 -0
  94. package/types/orm/src/seeder.d.ts +15 -2
  95. package/types/orm/src/sqlTranslator.d.ts +17 -4
  96. package/types/orm/src/types.d.ts +75 -16
  97. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -5,8 +5,8 @@
5
5
  * extends/block, include, macro, set, comments, whitespace control, tests.
6
6
  */
7
7
  import { createHash, createHmac, randomBytes } from "node:crypto";
8
- import { readFileSync, existsSync, statSync } from "node:fs";
9
- import { join, resolve } from "node:path";
8
+ import { readFileSync, existsSync, statSync, realpathSync } from "node:fs";
9
+ import { join, resolve, isAbsolute, sep } from "node:path";
10
10
 
11
11
  // ── Types ──────────────────────────────────────────────────────
12
12
 
@@ -299,6 +299,10 @@ const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
299
299
  const LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
300
300
  const LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
301
301
  const LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
302
+ const EXTENDS_RE = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/;
303
+ // Global-flag twin of EXTENDS_RE purely for counting every occurrence (a
304
+ // non-global RegExp's .exec()/.match() only ever reports the first match).
305
+ const EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/g;
302
306
 
303
307
  /** Escape a value for a live-marker HTML attribute. Byte-identical order to
304
308
  * the Python master / PHP liveAttr / Ruby live_attr so the emitted marker
@@ -313,11 +317,16 @@ function liveAttr(value: unknown): string {
313
317
 
314
318
  // ── Caches (module level) ─────────────────────────────────────
315
319
 
316
- /** Cache for parsed filter chains: expr string -> [variable, filters] */
317
- const filterChainCache = new Map<string, [string, [string, unknown[]][]]>();
320
+ /**
321
+ * Cache for parsed filter chains: expr string -> [variable, filters].
322
+ * Exported (like TEMPLATE_CACHE_MAX) so the ADR-0004 bound has something for
323
+ * a test to inspect directly — module-level state has no instance to read
324
+ * off, unlike `compiled`/`compiledStrings`/`fragmentCache`.
325
+ */
326
+ export const filterChainCache = new Map<string, [string, [string, unknown[]][]]>();
318
327
 
319
- /** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket] */
320
- const pathParseCache = new Map<string, [string[], boolean[]]>();
328
+ /** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket]. Exported for the same reason as filterChainCache. */
329
+ export const pathParseCache = new Map<string, [string[], boolean[]]>();
321
330
 
322
331
  /**
323
332
  * Hard cap on the template caches — `compiled` and `compiledStrings`
@@ -332,6 +341,19 @@ const pathParseCache = new Map<string, [string[], boolean[]]>();
332
341
  */
333
342
  export const TEMPLATE_CACHE_MAX = 256;
334
343
 
344
+ /**
345
+ * Hard cap on every per-expression memo cache — `filterChainCache` and
346
+ * `pathParseCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
347
+ * Python master's `@lru_cache(maxsize=1024)` on the equivalent module-level
348
+ * parsers). Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a
349
+ * small parsed-path array, orders of magnitude smaller than a token list.
350
+ *
351
+ * Also reused for `fragmentCache` (the `{% cache %}` tag's runtime store):
352
+ * TEMPLATE_CACHE_MAX, not this one — a rendered fragment is a whole HTML
353
+ * string, the same order of magnitude as a compiled template.
354
+ */
355
+ export const MEMO_CACHE_MAX = 1024;
356
+
335
357
  /**
336
358
  * Keep a memo cache bounded. Call immediately before inserting a new entry.
337
359
  *
@@ -353,6 +375,29 @@ function capCache(cache: Map<string, unknown>, maxEntries: number): void {
353
375
  }
354
376
  }
355
377
 
378
+ /**
379
+ * Drop every TTL-expired entry from the `{% cache %}` fragment store:
380
+ * key -> [html, expiresAtEpochMs].
381
+ *
382
+ * `capCache` bounds a cache by SIZE (insertion order, oldest first) but says
383
+ * nothing about STALENESS: a key that expired and is never visited again
384
+ * would otherwise sit in the Map, still counted against the cap, until
385
+ * something else finally evicts it. An app keying fragments on a dynamic
386
+ * value (a page id, a user id) can churn through many such keys, so
387
+ * staleness has to be swept on its own schedule, not just bounded by count.
388
+ *
389
+ * Called on every `{% cache %}` render (cheap: bounded by TEMPLATE_CACHE_MAX
390
+ * entries, so at most 256 comparisons) rather than only for the key being
391
+ * read, so an unrelated key's expiry is cleaned up as a side effect of ANY
392
+ * fragment-cache render, not just a future hit on that same key.
393
+ */
394
+ function sweepExpiredCache(cache: Map<string, [string, number]>): void {
395
+ const now = Date.now();
396
+ for (const [key, [, expiresAt]] of cache) {
397
+ if (expiresAt <= now) cache.delete(key);
398
+ }
399
+ }
400
+
356
401
  // ── Lexer ──────────────────────────────────────────────────────
357
402
 
358
403
  const TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
@@ -437,6 +482,30 @@ function stripTag(raw: string): [string, boolean, boolean] {
437
482
  return [inner.trim(), stripBefore, stripAfter];
438
483
  }
439
484
 
485
+ /**
486
+ * Return this template's OWN `{% extends %}` parent name, or "".
487
+ *
488
+ * A template may extend at most one parent. Before 3.13.100 a SECOND
489
+ * `{% extends %}` tag anywhere in the source was silently invisible: only
490
+ * the first occurrence was ever matched, and the rest of the child's
491
+ * non-block content -- including the second extends tag -- was already
492
+ * discarded the same way ordinary non-block child content is discarded
493
+ * during inheritance. That hid what is almost always a mistake (a
494
+ * copy-paste, a bad merge) with zero signal. Throw clearly instead, the
495
+ * same policy 3.13.89 applied to an unknown tag.
496
+ */
497
+ function extendsTarget(source: string): string {
498
+ const matches = source.match(EXTENDS_RE_GLOBAL);
499
+ if (matches && matches.length > 1) {
500
+ throw new Error(
501
+ `Frond: template has ${matches.length} "{% extends %}" tags -- ` +
502
+ "a template can extend only one parent",
503
+ );
504
+ }
505
+ const match = source.match(EXTENDS_RE);
506
+ return match ? match[1] : "";
507
+ }
508
+
440
509
  // ── Expression Evaluator ───────────────────────────────────────
441
510
 
442
511
  function resolveVar(expr: string, context: Record<string, unknown>): unknown {
@@ -511,6 +580,7 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
511
580
  }
512
581
  if (current) { parts.push(current); fromBracket.push(false); }
513
582
  }
583
+ capCache(pathParseCache, MEMO_CACHE_MAX);
514
584
  pathParseCache.set(expr, [parts, fromBracket]);
515
585
  }
516
586
 
@@ -1202,6 +1272,7 @@ function parseFilterChain(expr: string): [string, [string, unknown[]][]] {
1202
1272
  }
1203
1273
 
1204
1274
  const result: [string, [string, unknown[]][]] = [variable, filters];
1275
+ capCache(filterChainCache, MEMO_CACHE_MAX);
1205
1276
  filterChainCache.set(expr, result);
1206
1277
  return result;
1207
1278
  }
@@ -1587,7 +1658,14 @@ export function setFormTokenSessionId(sessionId: string): void {
1587
1658
  }
1588
1659
 
1589
1660
  function _buildFormTokenJwt(descriptor: string = ""): string {
1590
- const secret = process.env.TINA4_SECRET || "tina4-default-secret";
1661
+ // Fail-closed, IDENTICAL to the validator (auth.ts validToken:
1662
+ // `secret ?? process.env.TINA4_SECRET ?? ""`). With TINA4_SECRET unset the
1663
+ // signing secret resolves to BLANK — there is NO built-in default. Signing
1664
+ // with the retired public 'tina4-default-secret' made the generator disagree
1665
+ // with the fail-closed validator: a legitimately-rendered token was rejected
1666
+ // AND a ''-forged token was accepted (CSRF-NODE-SECRET-SPLIT, feature 37).
1667
+ // The generator and the validator MUST resolve the same secret.
1668
+ const secret = process.env.TINA4_SECRET ?? "";
1591
1669
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
1592
1670
 
1593
1671
  const header = { alg: "HS256", typ: "JWT" };
@@ -1783,31 +1861,24 @@ export class Frond {
1783
1861
  }
1784
1862
 
1785
1863
  /**
1786
- * Register a custom filter. The filter is persisted at class level
1787
- * so new instances created by hot-reload inherit it automatically;
1788
- * the live instance's local filter map also receives the addition
1789
- * immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
1864
+ * Register a custom filter on this instance only. Use the static method
1865
+ * for process-global registration. tina4: ADR-0052.
1790
1866
  */
1791
1867
  addFilter(name: string, fn: FilterFn): void {
1792
- Frond.classFilters.set(name, fn);
1793
1868
  this.filters[name] = fn;
1794
1869
  }
1795
1870
 
1796
1871
  /**
1797
- * Register a global variable available in all templates. Persisted
1798
- * at class level — see ``addFilter`` for the dual-call semantics.
1872
+ * Register a global variable on this instance only.
1799
1873
  */
1800
1874
  addGlobal(name: string, value: unknown): void {
1801
- Frond.classGlobals.set(name, value);
1802
1875
  this.globals[name] = value;
1803
1876
  }
1804
1877
 
1805
1878
  /**
1806
- * Register a custom test. Persisted at class level — see
1807
- * ``addFilter`` for the dual-call semantics.
1879
+ * Register a custom test on this instance only.
1808
1880
  */
1809
1881
  addTest(name: string, fn: TestFn): void {
1810
- Frond.classTests.set(name, fn);
1811
1882
  this.tests[name] = fn;
1812
1883
  }
1813
1884
 
@@ -1889,12 +1960,34 @@ export class Frond {
1889
1960
  return renderDump(value).toString();
1890
1961
  }
1891
1962
 
1963
+ /**
1964
+ * Load a template's source, CONFINED under the templates directory.
1965
+ *
1966
+ * Every path-taking tag ({% include %}, {% extends %}, {% import %},
1967
+ * {% from ... import %}) funnels through this one loader, so this single guard
1968
+ * confines them all (TAG-DEC-01): a name that is absolute, climbs out with a
1969
+ * `..` up-level segment, or resolves through a symlink to a location OUTSIDE
1970
+ * the templates root is REFUSED -- the outside file is never read. Template
1971
+ * -side analogue of the static-asset confinement (feature 41 / ADR-0050).
1972
+ */
1892
1973
  private load(name: string): string {
1974
+ // Lexical belt: refuse an absolute path or a `..` up-level segment before
1975
+ // touching the filesystem (defense in depth in front of the realpath check).
1976
+ if (isAbsolute(name) || name.split(/[\\/]/).includes("..")) {
1977
+ throw new Error(`Template path escapes the templates directory: ${name}`);
1978
+ }
1893
1979
  const filePath = join(this.templateDir, name);
1894
- if (!existsSync(filePath)) {
1980
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
1895
1981
  throw new Error(`Template not found: ${filePath}`);
1896
1982
  }
1897
- return readFileSync(filePath, "utf-8");
1983
+ // Realpath containment: a symlink INSIDE the templates dir whose target
1984
+ // resolves OUTSIDE it is refused (the lexical belt cannot see a symlink).
1985
+ const root = realpathSync(this.templateDir);
1986
+ const real = realpathSync(filePath);
1987
+ if (real !== root && !real.startsWith(root + sep)) {
1988
+ throw new Error(`Template path escapes the templates directory: ${name}`);
1989
+ }
1990
+ return readFileSync(real, "utf-8");
1898
1991
  }
1899
1992
 
1900
1993
  /** Execute pre-tokenized template against context. */
@@ -1928,9 +2021,8 @@ export class Frond {
1928
2021
  context.__frond_tests__ = this.tests;
1929
2022
  }
1930
2023
 
1931
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1932
- if (extendsMatch) {
1933
- const parentName = extendsMatch[1];
2024
+ const parentName = extendsTarget(source);
2025
+ if (parentName) {
1934
2026
  const parentSource = this.load(parentName);
1935
2027
  const childBlocks = this.extractBlocks(source);
1936
2028
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -1946,9 +2038,8 @@ export class Frond {
1946
2038
  }
1947
2039
 
1948
2040
  // Handle extends first
1949
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1950
- if (extendsMatch) {
1951
- const parentName = extendsMatch[1];
2041
+ const parentName = extendsTarget(source);
2042
+ if (parentName) {
1952
2043
  const parentSource = this.load(parentName);
1953
2044
  const childBlocks = this.extractBlocks(source);
1954
2045
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -2003,15 +2094,117 @@ export class Frond {
2003
2094
  return blocks;
2004
2095
  }
2005
2096
 
2097
+ /**
2098
+ * Depth-aware block substitution against `source` (typically the
2099
+ * fully-resolved root template).
2100
+ *
2101
+ * A single regex `.replace()` pass (the flat `pattern` this replaces in
2102
+ * renderWithBlocks) pairs an OUTER block's open tag with the FIRST
2103
+ * `{% endblock %}` found -- which, when the outer block wraps a NESTED
2104
+ * `{% block %}`, is the nested block's own close tag, not the outer's.
2105
+ * That silently truncates the outer block's captured content and drops
2106
+ * everything after the inner endblock (the root-nested-block
2107
+ * content-loss bug). This scans with an open/close depth counter
2108
+ * instead (mirroring extractBlocks), so an outer block always captures
2109
+ * its FULL body, nested child blocks included.
2110
+ *
2111
+ * The content chosen for each block -- the child override in `blocks`
2112
+ * if present, else the block's own default body -- is then recursively
2113
+ * substituted against the SAME `blocks` map before being tokenized and
2114
+ * rendered, so a block nested inside another block resolves correctly
2115
+ * regardless of which template in the inheritance chain declared the
2116
+ * nesting (the root, an intermediate, however many levels deep).
2117
+ *
2118
+ * `{{ parent() }}` / `{{ super() }}` inside a block still render that
2119
+ * block's OWN default content at this level (lazy, on first call).
2120
+ */
2121
+ private substituteBlocks(
2122
+ source: string,
2123
+ blocks: Record<string, string>,
2124
+ context: Record<string, unknown>,
2125
+ ): string {
2126
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
2127
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
2128
+ const engine = this;
2129
+ const pieces: string[] = [];
2130
+ let pos = 0;
2131
+
2132
+ while (pos < source.length) {
2133
+ blockOpen.lastIndex = pos;
2134
+ const mOpen = blockOpen.exec(source);
2135
+ if (!mOpen) {
2136
+ pieces.push(source.slice(pos));
2137
+ break;
2138
+ }
2139
+
2140
+ pieces.push(source.slice(pos, mOpen.index)); // untouched text before the tag
2141
+
2142
+ const name = mOpen[1];
2143
+ const contentStart = mOpen.index + mOpen[0].length;
2144
+ let depth = 1;
2145
+ let scan = contentStart;
2146
+ let closeMatch: RegExpExecArray | null = null;
2147
+
2148
+ while (depth > 0 && scan < source.length) {
2149
+ blockOpen.lastIndex = scan;
2150
+ blockClose.lastIndex = scan;
2151
+ const nextOpen = blockOpen.exec(source);
2152
+ const nextClose = blockClose.exec(source);
2153
+
2154
+ if (!nextClose) break; // malformed — no matching endblock
2155
+
2156
+ if (nextOpen && nextOpen.index < nextClose.index) {
2157
+ depth++;
2158
+ scan = nextOpen.index + nextOpen[0].length;
2159
+ } else {
2160
+ depth--;
2161
+ if (depth === 0) {
2162
+ closeMatch = nextClose;
2163
+ } else {
2164
+ scan = nextClose.index + nextClose[0].length;
2165
+ }
2166
+ }
2167
+ }
2168
+
2169
+ if (!closeMatch) {
2170
+ // Malformed template (no matching endblock) — keep the rest
2171
+ // verbatim rather than lose it, the same leniency extractBlocks
2172
+ // applies to this case.
2173
+ pieces.push(source.slice(mOpen.index));
2174
+ pos = source.length;
2175
+ break;
2176
+ }
2177
+
2178
+ const parentContent = source.slice(contentStart, closeMatch.index);
2179
+ const blockSource = blocks[name] ?? parentContent;
2180
+ const resolvedSource = engine.substituteBlocks(blockSource, blocks, context);
2181
+
2182
+ let renderedParent: SafeString | null = null;
2183
+ const getParent = (): SafeString => {
2184
+ if (renderedParent === null) {
2185
+ renderedParent = new SafeString(
2186
+ engine.renderTokens(tokenize(parentContent), context),
2187
+ );
2188
+ }
2189
+ return renderedParent;
2190
+ };
2191
+
2192
+ const blockCtx = { ...context, parent: getParent, super: getParent };
2193
+ pieces.push(engine.renderTokens(tokenize(resolvedSource), blockCtx));
2194
+ pos = closeMatch.index + closeMatch[0].length;
2195
+ }
2196
+
2197
+ return pieces.join("");
2198
+ }
2199
+
2006
2200
  private renderWithBlocks(
2007
2201
  parentSource: string,
2008
2202
  context: Record<string, unknown>,
2009
2203
  childBlocks: Record<string, string>,
2010
2204
  ): string {
2011
2205
  // --- Multi-level extends: check if parent itself extends a grandparent ---
2012
- const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
2013
- if (extendsMatch) {
2014
- const grandparentName = extendsMatch[1];
2206
+ const grandparentName = extendsTarget(parentSource);
2207
+ if (grandparentName) {
2015
2208
  const grandparentSource = this.load(grandparentName);
2016
2209
 
2017
2210
  // Extract block defaults defined in the parent template
@@ -2042,27 +2235,10 @@ export class Frond {
2042
2235
  }
2043
2236
 
2044
2237
  // --- Leaf parent (no extends) — resolve blocks and render ---
2045
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
2046
- const engine = this;
2047
-
2048
- const result = parentSource.replace(pattern, (_match, name: string, parentContent: string) => {
2049
- const blockSource = childBlocks[name] ?? parentContent;
2050
-
2051
- // Make parent() and super() available inside child blocks
2052
- let renderedParent: SafeString | null = null;
2053
- const getParent = (): SafeString => {
2054
- if (renderedParent === null) {
2055
- renderedParent = new SafeString(
2056
- engine.renderTokens(tokenize(parentContent), context),
2057
- );
2058
- }
2059
- return renderedParent;
2060
- };
2061
-
2062
- const blockCtx = { ...context, parent: getParent, super: getParent };
2063
- return this.renderTokens(tokenize(blockSource), blockCtx);
2064
- });
2065
-
2238
+ // First pass: depth-aware block substitution (handles a block nested
2239
+ // inside another block at ANY level of the chain, including the root
2240
+ // itself — see substituteBlocks).
2241
+ const result = this.substituteBlocks(parentSource, childBlocks, context);
2066
2242
  return this.renderTokens(tokenize(result), context);
2067
2243
  }
2068
2244
 
@@ -2784,9 +2960,12 @@ export class Frond {
2784
2960
  let source: string;
2785
2961
  try {
2786
2962
  source = this.load(filename);
2787
- } catch {
2963
+ } catch (err) {
2964
+ // A genuinely missing optional include is silenced; an escape/other error
2965
+ // is re-thrown with its own message (so a confinement refusal is loud, not
2966
+ // masked as "not found").
2788
2967
  if (ignoreMissing) return "";
2789
- throw new Error(`Template not found: ${join(this.templateDir, filename)}`);
2968
+ throw err;
2790
2969
  }
2791
2970
 
2792
2971
  const incContext = { ...context };
@@ -3004,6 +3183,8 @@ export class Frond {
3004
3183
  const cacheKey = m ? m[1] : "default";
3005
3184
  const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
3006
3185
 
3186
+ sweepExpiredCache(this.fragmentCache);
3187
+
3007
3188
  // Check cache
3008
3189
  const cached = this.fragmentCache.get(cacheKey);
3009
3190
  if (cached) {
@@ -3057,6 +3238,7 @@ export class Frond {
3057
3238
 
3058
3239
  // Render and cache
3059
3240
  const rendered = this.renderTokens([...bodyTokens], context);
3241
+ capCache(this.fragmentCache as Map<string, unknown>, TEMPLATE_CACHE_MAX);
3060
3242
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1000]);
3061
3243
  return [rendered, i];
3062
3244
  }