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.
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +130 -40
- package/packages/core/dist/index.js +129 -40
- package/packages/core/src/ai.ts +15 -6
- package/packages/frond/dist/index.js +118 -36
- package/packages/frond/src/engine.ts +195 -45
- package/packages/orm/dist/index.js +129 -40
- package/types/core/src/ai.d.ts +29 -0
- package/types/frond/src/engine.d.ts +50 -8
package/packages/core/src/ai.ts
CHANGED
|
@@ -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
|
|
123
|
-
* child process, fixes that for real installer
|
|
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)
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
1455
|
-
*
|
|
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
|
|
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
|
|
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
|
|
1592
|
-
if (
|
|
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
|
|
1605
|
-
if (
|
|
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
|
|
1653
|
-
if (
|
|
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
|
|
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
|
-
/**
|
|
317
|
-
|
|
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
|
|
1794
|
-
*
|
|
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
|
|
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
|
|
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
|
|
1961
|
-
if (
|
|
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
|
|
1979
|
-
if (
|
|
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
|
|
2042
|
-
if (
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const result =
|
|
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
|
}
|