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.
- package/CLAUDE.md +24 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20698 -18983
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20561 -18828
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +38 -13
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +149 -49
- package/packages/frond/src/engine.ts +234 -52
- package/packages/orm/dist/index.js +10941 -9231
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/ai.d.ts +29 -0
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +60 -8
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- 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
|
-
/**
|
|
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
|
}
|
|
@@ -1587,7 +1658,14 @@ export function setFormTokenSessionId(sessionId: string): void {
|
|
|
1587
1658
|
}
|
|
1588
1659
|
|
|
1589
1660
|
function _buildFormTokenJwt(descriptor: string = ""): string {
|
|
1590
|
-
|
|
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
|
|
1787
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1932
|
-
if (
|
|
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
|
|
1950
|
-
if (
|
|
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
|
|
2013
|
-
if (
|
|
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
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
const result =
|
|
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
|
|
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
|
}
|