tina4-nodejs 3.13.99 → 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.
@@ -119,26 +119,33 @@ function skillsRef(): string {
119
119
  * MEASURED (2026-08-13): a real GitHub raw-content fetch occasionally drops a
120
120
  * request under load (transient DNS/TLS hiccup, not a missing file — every
121
121
  * URL here resolves fine on its own) while its siblings in the same batch
122
- * succeed. One retry pass over just the stragglers, still inside the same
123
- * child process, fixes that for real installer users too, not only the test.
122
+ * succeed. One retry pass over only transport failures and transient HTTP
123
+ * statuses, still inside the same child process, fixes that for real installer
124
+ * users too. Permanent 4xx responses are final answers and are not retried.
125
+ *
126
+ * Exported (like `writeOrMerge`/`markersFor`/`skillBlock` above) so
127
+ * aiFetchRetry.test.ts can drive it directly against a real local server —
128
+ * a pure visibility change, no behaviour change.
124
129
  *
125
130
  * @param jobs one entry per unique URL, with every file path it should land in
126
131
  * @returns the set of URLs that were fetched and written to disk
127
132
  */
128
- function downloadSkillsSync(jobs: { url: string; dests: string[] }[]): Set<string> {
133
+ export function downloadSkillsSync(jobs: { url: string; dests: string[] }[]): Set<string> {
129
134
  if (jobs.length === 0) return new Set();
130
135
  const child = `
131
136
  const jobs = JSON.parse(process.argv[1]);
132
137
  const fs = require("node:fs");
133
138
  const path = require("node:path");
139
+ const transientStatuses = new Set([429, 500, 502, 503, 504]);
134
140
  async function fetchOne(job) {
135
141
  const resp = await fetch(job.url, { signal: AbortSignal.timeout(15000) });
136
- if (!resp.ok) throw new Error("HTTP " + resp.status);
142
+ if (!resp.ok) return { ok: false, retry: transientStatuses.has(resp.status) };
137
143
  const buf = Buffer.from(await resp.arrayBuffer());
138
144
  for (const dest of job.dests) {
139
145
  fs.mkdirSync(path.dirname(dest), { recursive: true });
140
146
  fs.writeFileSync(dest, buf);
141
147
  }
148
+ return { ok: true, retry: false };
142
149
  }
143
150
  (async () => {
144
151
  const ok = [];
@@ -147,9 +154,11 @@ function downloadSkillsSync(jobs: { url: string; dests: string[] }[]): Set<strin
147
154
  const failed = [];
148
155
  await Promise.all(pending.map(async (job) => {
149
156
  try {
150
- await fetchOne(job);
151
- ok.push(job.url);
157
+ const result = await fetchOne(job);
158
+ if (result.ok) ok.push(job.url);
159
+ else if (result.retry) failed.push(job);
152
160
  } catch {
161
+ // DNS, TLS, timeout and connection failures are transient.
153
162
  failed.push(job);
154
163
  }
155
164
  }));
@@ -179,12 +179,15 @@ var THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
179
179
  var LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
180
180
  var LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
181
181
  var LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
182
+ var EXTENDS_RE = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/;
183
+ var EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/g;
182
184
  function liveAttr(value) {
183
185
  return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
184
186
  }
185
187
  var filterChainCache = /* @__PURE__ */ new Map();
186
188
  var pathParseCache = /* @__PURE__ */ new Map();
187
189
  var TEMPLATE_CACHE_MAX = 256;
190
+ var MEMO_CACHE_MAX = 1024;
188
191
  function capCache(cache, maxEntries) {
189
192
  if (cache.size < maxEntries) return;
190
193
  let drop = Math.floor(maxEntries / 2);
@@ -193,6 +196,12 @@ function capCache(cache, maxEntries) {
193
196
  if (--drop <= 0) break;
194
197
  }
195
198
  }
199
+ function sweepExpiredCache(cache) {
200
+ const now = Date.now();
201
+ for (const [key, [, expiresAt]] of cache) {
202
+ if (expiresAt <= now) cache.delete(key);
203
+ }
204
+ }
196
205
  var TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
197
206
  var RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
198
207
  function tokenize(source) {
@@ -258,6 +267,16 @@ function stripTag(raw) {
258
267
  }
259
268
  return [inner.trim(), stripBefore, stripAfter];
260
269
  }
270
+ function extendsTarget(source) {
271
+ const matches = source.match(EXTENDS_RE_GLOBAL);
272
+ if (matches && matches.length > 1) {
273
+ throw new Error(
274
+ `Frond: template has ${matches.length} "{% extends %}" tags -- a template can extend only one parent`
275
+ );
276
+ }
277
+ const match = source.match(EXTENDS_RE);
278
+ return match ? match[1] : "";
279
+ }
261
280
  function resolveVar(expr, context) {
262
281
  expr = expr.trim();
263
282
  if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'")) {
@@ -338,6 +357,7 @@ function resolveVar(expr, context) {
338
357
  fromBracket.push(false);
339
358
  }
340
359
  }
360
+ capCache(pathParseCache, MEMO_CACHE_MAX);
341
361
  pathParseCache.set(expr, [parts, fromBracket]);
342
362
  }
343
363
  let value = context;
@@ -925,6 +945,7 @@ function parseFilterChain(expr) {
925
945
  }
926
946
  }
927
947
  const result = [variable, filters];
948
+ capCache(filterChainCache, MEMO_CACHE_MAX);
928
949
  filterChainCache.set(expr, result);
929
950
  return result;
930
951
  }
@@ -1451,29 +1472,22 @@ var Frond = class _Frond {
1451
1472
  return this;
1452
1473
  }
1453
1474
  /**
1454
- * Register a custom filter. The filter is persisted at class level
1455
- * so new instances created by hot-reload inherit it automatically;
1456
- * the live instance's local filter map also receives the addition
1457
- * immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
1475
+ * Register a custom filter on this instance only. Use the static method
1476
+ * for process-global registration. tina4: ADR-0052.
1458
1477
  */
1459
1478
  addFilter(name, fn) {
1460
- _Frond.classFilters.set(name, fn);
1461
1479
  this.filters[name] = fn;
1462
1480
  }
1463
1481
  /**
1464
- * Register a global variable available in all templates. Persisted
1465
- * at class level — see ``addFilter`` for the dual-call semantics.
1482
+ * Register a global variable on this instance only.
1466
1483
  */
1467
1484
  addGlobal(name, value) {
1468
- _Frond.classGlobals.set(name, value);
1469
1485
  this.globals[name] = value;
1470
1486
  }
1471
1487
  /**
1472
- * Register a custom test. Persisted at class level — see
1473
- * ``addFilter`` for the dual-call semantics.
1488
+ * Register a custom test on this instance only.
1474
1489
  */
1475
1490
  addTest(name, fn) {
1476
- _Frond.classTests.set(name, fn);
1477
1491
  this.tests[name] = fn;
1478
1492
  }
1479
1493
  /**
@@ -1588,9 +1602,8 @@ var Frond = class _Frond {
1588
1602
  if (Object.keys(this.tests).length > 0) {
1589
1603
  context.__frond_tests__ = this.tests;
1590
1604
  }
1591
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1592
- if (extendsMatch) {
1593
- const parentName = extendsMatch[1];
1605
+ const parentName = extendsTarget(source);
1606
+ if (parentName) {
1594
1607
  const parentSource = this.load(parentName);
1595
1608
  const childBlocks = this.extractBlocks(source);
1596
1609
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -1601,9 +1614,8 @@ var Frond = class _Frond {
1601
1614
  if (Object.keys(this.tests).length > 0) {
1602
1615
  context.__frond_tests__ = this.tests;
1603
1616
  }
1604
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1605
- if (extendsMatch) {
1606
- const parentName = extendsMatch[1];
1617
+ const parentName = extendsTarget(source);
1618
+ if (parentName) {
1607
1619
  const parentSource = this.load(parentName);
1608
1620
  const childBlocks = this.extractBlocks(source);
1609
1621
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -1648,10 +1660,93 @@ var Frond = class _Frond {
1648
1660
  }
1649
1661
  return blocks;
1650
1662
  }
1663
+ /**
1664
+ * Depth-aware block substitution against `source` (typically the
1665
+ * fully-resolved root template).
1666
+ *
1667
+ * A single regex `.replace()` pass (the flat `pattern` this replaces in
1668
+ * renderWithBlocks) pairs an OUTER block's open tag with the FIRST
1669
+ * `{% endblock %}` found -- which, when the outer block wraps a NESTED
1670
+ * `{% block %}`, is the nested block's own close tag, not the outer's.
1671
+ * That silently truncates the outer block's captured content and drops
1672
+ * everything after the inner endblock (the root-nested-block
1673
+ * content-loss bug). This scans with an open/close depth counter
1674
+ * instead (mirroring extractBlocks), so an outer block always captures
1675
+ * its FULL body, nested child blocks included.
1676
+ *
1677
+ * The content chosen for each block -- the child override in `blocks`
1678
+ * if present, else the block's own default body -- is then recursively
1679
+ * substituted against the SAME `blocks` map before being tokenized and
1680
+ * rendered, so a block nested inside another block resolves correctly
1681
+ * regardless of which template in the inheritance chain declared the
1682
+ * nesting (the root, an intermediate, however many levels deep).
1683
+ *
1684
+ * `{{ parent() }}` / `{{ super() }}` inside a block still render that
1685
+ * block's OWN default content at this level (lazy, on first call).
1686
+ */
1687
+ substituteBlocks(source, blocks, context) {
1688
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
1689
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
1690
+ const engine = this;
1691
+ const pieces = [];
1692
+ let pos = 0;
1693
+ while (pos < source.length) {
1694
+ blockOpen.lastIndex = pos;
1695
+ const mOpen = blockOpen.exec(source);
1696
+ if (!mOpen) {
1697
+ pieces.push(source.slice(pos));
1698
+ break;
1699
+ }
1700
+ pieces.push(source.slice(pos, mOpen.index));
1701
+ const name = mOpen[1];
1702
+ const contentStart = mOpen.index + mOpen[0].length;
1703
+ let depth = 1;
1704
+ let scan = contentStart;
1705
+ let closeMatch = null;
1706
+ while (depth > 0 && scan < source.length) {
1707
+ blockOpen.lastIndex = scan;
1708
+ blockClose.lastIndex = scan;
1709
+ const nextOpen = blockOpen.exec(source);
1710
+ const nextClose = blockClose.exec(source);
1711
+ if (!nextClose) break;
1712
+ if (nextOpen && nextOpen.index < nextClose.index) {
1713
+ depth++;
1714
+ scan = nextOpen.index + nextOpen[0].length;
1715
+ } else {
1716
+ depth--;
1717
+ if (depth === 0) {
1718
+ closeMatch = nextClose;
1719
+ } else {
1720
+ scan = nextClose.index + nextClose[0].length;
1721
+ }
1722
+ }
1723
+ }
1724
+ if (!closeMatch) {
1725
+ pieces.push(source.slice(mOpen.index));
1726
+ pos = source.length;
1727
+ break;
1728
+ }
1729
+ const parentContent = source.slice(contentStart, closeMatch.index);
1730
+ const blockSource = blocks[name] ?? parentContent;
1731
+ const resolvedSource = engine.substituteBlocks(blockSource, blocks, context);
1732
+ let renderedParent = null;
1733
+ const getParent = () => {
1734
+ if (renderedParent === null) {
1735
+ renderedParent = new SafeString(
1736
+ engine.renderTokens(tokenize(parentContent), context)
1737
+ );
1738
+ }
1739
+ return renderedParent;
1740
+ };
1741
+ const blockCtx = { ...context, parent: getParent, super: getParent };
1742
+ pieces.push(engine.renderTokens(tokenize(resolvedSource), blockCtx));
1743
+ pos = closeMatch.index + closeMatch[0].length;
1744
+ }
1745
+ return pieces.join("");
1746
+ }
1651
1747
  renderWithBlocks(parentSource, context, childBlocks) {
1652
- const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1653
- if (extendsMatch) {
1654
- const grandparentName = extendsMatch[1];
1748
+ const grandparentName = extendsTarget(parentSource);
1749
+ if (grandparentName) {
1655
1750
  const grandparentSource = this.load(grandparentName);
1656
1751
  const parentBlocks = this.extractBlocks(parentSource);
1657
1752
  const mergedBlocks = { ...parentBlocks, ...childBlocks };
@@ -1671,22 +1766,7 @@ var Frond = class _Frond {
1671
1766
  }
1672
1767
  return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
1673
1768
  }
1674
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1675
- const engine = this;
1676
- const result = parentSource.replace(pattern, (_match, name, parentContent) => {
1677
- const blockSource = childBlocks[name] ?? parentContent;
1678
- let renderedParent = null;
1679
- const getParent = () => {
1680
- if (renderedParent === null) {
1681
- renderedParent = new SafeString(
1682
- engine.renderTokens(tokenize(parentContent), context)
1683
- );
1684
- }
1685
- return renderedParent;
1686
- };
1687
- const blockCtx = { ...context, parent: getParent, super: getParent };
1688
- return this.renderTokens(tokenize(blockSource), blockCtx);
1689
- });
1769
+ const result = this.substituteBlocks(parentSource, childBlocks, context);
1690
1770
  return this.renderTokens(tokenize(result), context);
1691
1771
  }
1692
1772
  renderTokens(tokens, context) {
@@ -2509,6 +2589,7 @@ var Frond = class _Frond {
2509
2589
  const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
2510
2590
  const cacheKey = m ? m[1] : "default";
2511
2591
  const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
2592
+ sweepExpiredCache(this.fragmentCache);
2512
2593
  const cached = this.fragmentCache.get(cacheKey);
2513
2594
  if (cached) {
2514
2595
  const [htmlContent, expiresAt] = cached;
@@ -2556,6 +2637,7 @@ var Frond = class _Frond {
2556
2637
  i++;
2557
2638
  }
2558
2639
  const rendered = this.renderTokens([...bodyTokens], context);
2640
+ capCache(this.fragmentCache, TEMPLATE_CACHE_MAX);
2559
2641
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1e3]);
2560
2642
  return [rendered, i];
2561
2643
  }
@@ -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
  }
@@ -1790,31 +1861,24 @@ export class Frond {
1790
1861
  }
1791
1862
 
1792
1863
  /**
1793
- * Register a custom filter. The filter is persisted at class level
1794
- * so new instances created by hot-reload inherit it automatically;
1795
- * the live instance's local filter map also receives the addition
1796
- * 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.
1797
1866
  */
1798
1867
  addFilter(name: string, fn: FilterFn): void {
1799
- Frond.classFilters.set(name, fn);
1800
1868
  this.filters[name] = fn;
1801
1869
  }
1802
1870
 
1803
1871
  /**
1804
- * Register a global variable available in all templates. Persisted
1805
- * at class level — see ``addFilter`` for the dual-call semantics.
1872
+ * Register a global variable on this instance only.
1806
1873
  */
1807
1874
  addGlobal(name: string, value: unknown): void {
1808
- Frond.classGlobals.set(name, value);
1809
1875
  this.globals[name] = value;
1810
1876
  }
1811
1877
 
1812
1878
  /**
1813
- * Register a custom test. Persisted at class level — see
1814
- * ``addFilter`` for the dual-call semantics.
1879
+ * Register a custom test on this instance only.
1815
1880
  */
1816
1881
  addTest(name: string, fn: TestFn): void {
1817
- Frond.classTests.set(name, fn);
1818
1882
  this.tests[name] = fn;
1819
1883
  }
1820
1884
 
@@ -1957,9 +2021,8 @@ export class Frond {
1957
2021
  context.__frond_tests__ = this.tests;
1958
2022
  }
1959
2023
 
1960
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1961
- if (extendsMatch) {
1962
- const parentName = extendsMatch[1];
2024
+ const parentName = extendsTarget(source);
2025
+ if (parentName) {
1963
2026
  const parentSource = this.load(parentName);
1964
2027
  const childBlocks = this.extractBlocks(source);
1965
2028
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -1975,9 +2038,8 @@ export class Frond {
1975
2038
  }
1976
2039
 
1977
2040
  // Handle extends first
1978
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1979
- if (extendsMatch) {
1980
- const parentName = extendsMatch[1];
2041
+ const parentName = extendsTarget(source);
2042
+ if (parentName) {
1981
2043
  const parentSource = this.load(parentName);
1982
2044
  const childBlocks = this.extractBlocks(source);
1983
2045
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -2032,15 +2094,117 @@ export class Frond {
2032
2094
  return blocks;
2033
2095
  }
2034
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
+
2035
2200
  private renderWithBlocks(
2036
2201
  parentSource: string,
2037
2202
  context: Record<string, unknown>,
2038
2203
  childBlocks: Record<string, string>,
2039
2204
  ): string {
2040
2205
  // --- Multi-level extends: check if parent itself extends a grandparent ---
2041
- const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
2042
- if (extendsMatch) {
2043
- const grandparentName = extendsMatch[1];
2206
+ const grandparentName = extendsTarget(parentSource);
2207
+ if (grandparentName) {
2044
2208
  const grandparentSource = this.load(grandparentName);
2045
2209
 
2046
2210
  // Extract block defaults defined in the parent template
@@ -2071,27 +2235,10 @@ export class Frond {
2071
2235
  }
2072
2236
 
2073
2237
  // --- Leaf parent (no extends) — resolve blocks and render ---
2074
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
2075
- const engine = this;
2076
-
2077
- const result = parentSource.replace(pattern, (_match, name: string, parentContent: string) => {
2078
- const blockSource = childBlocks[name] ?? parentContent;
2079
-
2080
- // Make parent() and super() available inside child blocks
2081
- let renderedParent: SafeString | null = null;
2082
- const getParent = (): SafeString => {
2083
- if (renderedParent === null) {
2084
- renderedParent = new SafeString(
2085
- engine.renderTokens(tokenize(parentContent), context),
2086
- );
2087
- }
2088
- return renderedParent;
2089
- };
2090
-
2091
- const blockCtx = { ...context, parent: getParent, super: getParent };
2092
- return this.renderTokens(tokenize(blockSource), blockCtx);
2093
- });
2094
-
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);
2095
2242
  return this.renderTokens(tokenize(result), context);
2096
2243
  }
2097
2244
 
@@ -3036,6 +3183,8 @@ export class Frond {
3036
3183
  const cacheKey = m ? m[1] : "default";
3037
3184
  const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
3038
3185
 
3186
+ sweepExpiredCache(this.fragmentCache);
3187
+
3039
3188
  // Check cache
3040
3189
  const cached = this.fragmentCache.get(cacheKey);
3041
3190
  if (cached) {
@@ -3089,6 +3238,7 @@ export class Frond {
3089
3238
 
3090
3239
  // Render and cache
3091
3240
  const rendered = this.renderTokens([...bodyTokens], context);
3241
+ capCache(this.fragmentCache as Map<string, unknown>, TEMPLATE_CACHE_MAX);
3092
3242
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1000]);
3093
3243
  return [rendered, i];
3094
3244
  }