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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.99)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.100)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.99 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.100 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.99",
3
+ "version": "3.13.100",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -1461,7 +1461,10 @@ var init_trustedProxy = __esm({
1461
1461
  var engine_exports = {};
1462
1462
  __export(engine_exports, {
1463
1463
  Frond: () => Frond,
1464
+ MEMO_CACHE_MAX: () => MEMO_CACHE_MAX,
1464
1465
  TEMPLATE_CACHE_MAX: () => TEMPLATE_CACHE_MAX,
1466
+ filterChainCache: () => filterChainCache,
1467
+ pathParseCache: () => pathParseCache,
1465
1468
  setFormTokenSessionId: () => setFormTokenSessionId
1466
1469
  });
1467
1470
  import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
@@ -1565,6 +1568,12 @@ function capCache(cache, maxEntries) {
1565
1568
  if (--drop <= 0) break;
1566
1569
  }
1567
1570
  }
1571
+ function sweepExpiredCache(cache) {
1572
+ const now = Date.now();
1573
+ for (const [key, [, expiresAt]] of cache) {
1574
+ if (expiresAt <= now) cache.delete(key);
1575
+ }
1576
+ }
1568
1577
  function tokenize(source) {
1569
1578
  const rawBlocks = [];
1570
1579
  source = source.replace(RAW_BLOCK_RE, (_match, content) => {
@@ -1628,6 +1637,16 @@ function stripTag(raw) {
1628
1637
  }
1629
1638
  return [inner.trim(), stripBefore, stripAfter];
1630
1639
  }
1640
+ function extendsTarget(source) {
1641
+ const matches = source.match(EXTENDS_RE_GLOBAL);
1642
+ if (matches && matches.length > 1) {
1643
+ throw new Error(
1644
+ `Frond: template has ${matches.length} "{% extends %}" tags -- a template can extend only one parent`
1645
+ );
1646
+ }
1647
+ const match = source.match(EXTENDS_RE);
1648
+ return match ? match[1] : "";
1649
+ }
1631
1650
  function resolveVar(expr, context) {
1632
1651
  expr = expr.trim();
1633
1652
  if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'")) {
@@ -1708,6 +1727,7 @@ function resolveVar(expr, context) {
1708
1727
  fromBracket.push(false);
1709
1728
  }
1710
1729
  }
1730
+ capCache(pathParseCache, MEMO_CACHE_MAX);
1711
1731
  pathParseCache.set(expr, [parts, fromBracket]);
1712
1732
  }
1713
1733
  let value = context;
@@ -2295,6 +2315,7 @@ function parseFilterChain(expr) {
2295
2315
  }
2296
2316
  }
2297
2317
  const result = [variable, filters];
2318
+ capCache(filterChainCache, MEMO_CACHE_MAX);
2298
2319
  filterChainCache.set(expr, result);
2299
2320
  return result;
2300
2321
  }
@@ -2474,7 +2495,7 @@ function _generateFormToken(descriptor = "") {
2474
2495
  function _generateFormTokenValue(descriptor = "") {
2475
2496
  return new SafeString(_buildFormTokenJwt(descriptor));
2476
2497
  }
2477
- var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
2498
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, EXTENDS_RE, EXTENDS_RE_GLOBAL, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, MEMO_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
2478
2499
  var init_engine = __esm({
2479
2500
  "../frond/src/engine.ts"() {
2480
2501
  "use strict";
@@ -2568,9 +2589,12 @@ var init_engine = __esm({
2568
2589
  LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
2569
2590
  LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
2570
2591
  LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
2592
+ EXTENDS_RE = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/;
2593
+ EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/g;
2571
2594
  filterChainCache = /* @__PURE__ */ new Map();
2572
2595
  pathParseCache = /* @__PURE__ */ new Map();
2573
2596
  TEMPLATE_CACHE_MAX = 256;
2597
+ MEMO_CACHE_MAX = 1024;
2574
2598
  TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
2575
2599
  RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
2576
2600
  VarRef = class {
@@ -2923,29 +2947,22 @@ var init_engine = __esm({
2923
2947
  return this;
2924
2948
  }
2925
2949
  /**
2926
- * Register a custom filter. The filter is persisted at class level
2927
- * so new instances created by hot-reload inherit it automatically;
2928
- * the live instance's local filter map also receives the addition
2929
- * immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
2950
+ * Register a custom filter on this instance only. Use the static method
2951
+ * for process-global registration. tina4: ADR-0052.
2930
2952
  */
2931
2953
  addFilter(name, fn) {
2932
- _Frond.classFilters.set(name, fn);
2933
2954
  this.filters[name] = fn;
2934
2955
  }
2935
2956
  /**
2936
- * Register a global variable available in all templates. Persisted
2937
- * at class level — see ``addFilter`` for the dual-call semantics.
2957
+ * Register a global variable on this instance only.
2938
2958
  */
2939
2959
  addGlobal(name, value) {
2940
- _Frond.classGlobals.set(name, value);
2941
2960
  this.globals[name] = value;
2942
2961
  }
2943
2962
  /**
2944
- * Register a custom test. Persisted at class level — see
2945
- * ``addFilter`` for the dual-call semantics.
2963
+ * Register a custom test on this instance only.
2946
2964
  */
2947
2965
  addTest(name, fn) {
2948
- _Frond.classTests.set(name, fn);
2949
2966
  this.tests[name] = fn;
2950
2967
  }
2951
2968
  /**
@@ -3060,9 +3077,8 @@ var init_engine = __esm({
3060
3077
  if (Object.keys(this.tests).length > 0) {
3061
3078
  context.__frond_tests__ = this.tests;
3062
3079
  }
3063
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
3064
- if (extendsMatch) {
3065
- const parentName = extendsMatch[1];
3080
+ const parentName = extendsTarget(source);
3081
+ if (parentName) {
3066
3082
  const parentSource = this.load(parentName);
3067
3083
  const childBlocks = this.extractBlocks(source);
3068
3084
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -3073,9 +3089,8 @@ var init_engine = __esm({
3073
3089
  if (Object.keys(this.tests).length > 0) {
3074
3090
  context.__frond_tests__ = this.tests;
3075
3091
  }
3076
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
3077
- if (extendsMatch) {
3078
- const parentName = extendsMatch[1];
3092
+ const parentName = extendsTarget(source);
3093
+ if (parentName) {
3079
3094
  const parentSource = this.load(parentName);
3080
3095
  const childBlocks = this.extractBlocks(source);
3081
3096
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -3120,10 +3135,93 @@ var init_engine = __esm({
3120
3135
  }
3121
3136
  return blocks;
3122
3137
  }
3138
+ /**
3139
+ * Depth-aware block substitution against `source` (typically the
3140
+ * fully-resolved root template).
3141
+ *
3142
+ * A single regex `.replace()` pass (the flat `pattern` this replaces in
3143
+ * renderWithBlocks) pairs an OUTER block's open tag with the FIRST
3144
+ * `{% endblock %}` found -- which, when the outer block wraps a NESTED
3145
+ * `{% block %}`, is the nested block's own close tag, not the outer's.
3146
+ * That silently truncates the outer block's captured content and drops
3147
+ * everything after the inner endblock (the root-nested-block
3148
+ * content-loss bug). This scans with an open/close depth counter
3149
+ * instead (mirroring extractBlocks), so an outer block always captures
3150
+ * its FULL body, nested child blocks included.
3151
+ *
3152
+ * The content chosen for each block -- the child override in `blocks`
3153
+ * if present, else the block's own default body -- is then recursively
3154
+ * substituted against the SAME `blocks` map before being tokenized and
3155
+ * rendered, so a block nested inside another block resolves correctly
3156
+ * regardless of which template in the inheritance chain declared the
3157
+ * nesting (the root, an intermediate, however many levels deep).
3158
+ *
3159
+ * `{{ parent() }}` / `{{ super() }}` inside a block still render that
3160
+ * block's OWN default content at this level (lazy, on first call).
3161
+ */
3162
+ substituteBlocks(source, blocks, context) {
3163
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
3164
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
3165
+ const engine = this;
3166
+ const pieces = [];
3167
+ let pos = 0;
3168
+ while (pos < source.length) {
3169
+ blockOpen.lastIndex = pos;
3170
+ const mOpen = blockOpen.exec(source);
3171
+ if (!mOpen) {
3172
+ pieces.push(source.slice(pos));
3173
+ break;
3174
+ }
3175
+ pieces.push(source.slice(pos, mOpen.index));
3176
+ const name = mOpen[1];
3177
+ const contentStart = mOpen.index + mOpen[0].length;
3178
+ let depth = 1;
3179
+ let scan = contentStart;
3180
+ let closeMatch = null;
3181
+ while (depth > 0 && scan < source.length) {
3182
+ blockOpen.lastIndex = scan;
3183
+ blockClose.lastIndex = scan;
3184
+ const nextOpen = blockOpen.exec(source);
3185
+ const nextClose = blockClose.exec(source);
3186
+ if (!nextClose) break;
3187
+ if (nextOpen && nextOpen.index < nextClose.index) {
3188
+ depth++;
3189
+ scan = nextOpen.index + nextOpen[0].length;
3190
+ } else {
3191
+ depth--;
3192
+ if (depth === 0) {
3193
+ closeMatch = nextClose;
3194
+ } else {
3195
+ scan = nextClose.index + nextClose[0].length;
3196
+ }
3197
+ }
3198
+ }
3199
+ if (!closeMatch) {
3200
+ pieces.push(source.slice(mOpen.index));
3201
+ pos = source.length;
3202
+ break;
3203
+ }
3204
+ const parentContent = source.slice(contentStart, closeMatch.index);
3205
+ const blockSource = blocks[name] ?? parentContent;
3206
+ const resolvedSource = engine.substituteBlocks(blockSource, blocks, context);
3207
+ let renderedParent = null;
3208
+ const getParent = () => {
3209
+ if (renderedParent === null) {
3210
+ renderedParent = new SafeString(
3211
+ engine.renderTokens(tokenize(parentContent), context)
3212
+ );
3213
+ }
3214
+ return renderedParent;
3215
+ };
3216
+ const blockCtx = { ...context, parent: getParent, super: getParent };
3217
+ pieces.push(engine.renderTokens(tokenize(resolvedSource), blockCtx));
3218
+ pos = closeMatch.index + closeMatch[0].length;
3219
+ }
3220
+ return pieces.join("");
3221
+ }
3123
3222
  renderWithBlocks(parentSource, context, childBlocks) {
3124
- const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
3125
- if (extendsMatch) {
3126
- const grandparentName = extendsMatch[1];
3223
+ const grandparentName = extendsTarget(parentSource);
3224
+ if (grandparentName) {
3127
3225
  const grandparentSource = this.load(grandparentName);
3128
3226
  const parentBlocks = this.extractBlocks(parentSource);
3129
3227
  const mergedBlocks = { ...parentBlocks, ...childBlocks };
@@ -3143,22 +3241,7 @@ var init_engine = __esm({
3143
3241
  }
3144
3242
  return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
3145
3243
  }
3146
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
3147
- const engine = this;
3148
- const result = parentSource.replace(pattern, (_match, name, parentContent) => {
3149
- const blockSource = childBlocks[name] ?? parentContent;
3150
- let renderedParent = null;
3151
- const getParent = () => {
3152
- if (renderedParent === null) {
3153
- renderedParent = new SafeString(
3154
- engine.renderTokens(tokenize(parentContent), context)
3155
- );
3156
- }
3157
- return renderedParent;
3158
- };
3159
- const blockCtx = { ...context, parent: getParent, super: getParent };
3160
- return this.renderTokens(tokenize(blockSource), blockCtx);
3161
- });
3244
+ const result = this.substituteBlocks(parentSource, childBlocks, context);
3162
3245
  return this.renderTokens(tokenize(result), context);
3163
3246
  }
3164
3247
  renderTokens(tokens, context) {
@@ -3981,6 +4064,7 @@ var init_engine = __esm({
3981
4064
  const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
3982
4065
  const cacheKey = m ? m[1] : "default";
3983
4066
  const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
4067
+ sweepExpiredCache(this.fragmentCache);
3984
4068
  const cached = this.fragmentCache.get(cacheKey);
3985
4069
  if (cached) {
3986
4070
  const [htmlContent, expiresAt] = cached;
@@ -4028,6 +4112,7 @@ var init_engine = __esm({
4028
4112
  i++;
4029
4113
  }
4030
4114
  const rendered = this.renderTokens([...bodyTokens], context);
4115
+ capCache(this.fragmentCache, TEMPLATE_CACHE_MAX);
4031
4116
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1e3]);
4032
4117
  return [rendered, i];
4033
4118
  }
@@ -39672,6 +39757,7 @@ var ai_exports = {};
39672
39757
  __export(ai_exports, {
39673
39758
  AI_TOOLS: () => AI_TOOLS,
39674
39759
  DEV_SKILL: () => DEV_SKILL,
39760
+ downloadSkillsSync: () => downloadSkillsSync,
39675
39761
  generateContext: () => generateContext,
39676
39762
  hasMarkers: () => hasMarkers,
39677
39763
  installAll: () => installAll,
@@ -39713,14 +39799,16 @@ function downloadSkillsSync(jobs) {
39713
39799
  const jobs = JSON.parse(process.argv[1]);
39714
39800
  const fs = require("node:fs");
39715
39801
  const path = require("node:path");
39802
+ const transientStatuses = new Set([429, 500, 502, 503, 504]);
39716
39803
  async function fetchOne(job) {
39717
39804
  const resp = await fetch(job.url, { signal: AbortSignal.timeout(15000) });
39718
- if (!resp.ok) throw new Error("HTTP " + resp.status);
39805
+ if (!resp.ok) return { ok: false, retry: transientStatuses.has(resp.status) };
39719
39806
  const buf = Buffer.from(await resp.arrayBuffer());
39720
39807
  for (const dest of job.dests) {
39721
39808
  fs.mkdirSync(path.dirname(dest), { recursive: true });
39722
39809
  fs.writeFileSync(dest, buf);
39723
39810
  }
39811
+ return { ok: true, retry: false };
39724
39812
  }
39725
39813
  (async () => {
39726
39814
  const ok = [];
@@ -39729,9 +39817,11 @@ function downloadSkillsSync(jobs) {
39729
39817
  const failed = [];
39730
39818
  await Promise.all(pending.map(async (job) => {
39731
39819
  try {
39732
- await fetchOne(job);
39733
- ok.push(job.url);
39820
+ const result = await fetchOne(job);
39821
+ if (result.ok) ok.push(job.url);
39822
+ else if (result.retry) failed.push(job);
39734
39823
  } catch {
39824
+ // DNS, TLS, timeout and connection failures are transient.
39735
39825
  failed.push(job);
39736
39826
  }
39737
39827
  }));
@@ -1460,7 +1460,10 @@ var init_trustedProxy = __esm({
1460
1460
  var engine_exports = {};
1461
1461
  __export(engine_exports, {
1462
1462
  Frond: () => Frond,
1463
+ MEMO_CACHE_MAX: () => MEMO_CACHE_MAX,
1463
1464
  TEMPLATE_CACHE_MAX: () => TEMPLATE_CACHE_MAX,
1465
+ filterChainCache: () => filterChainCache,
1466
+ pathParseCache: () => pathParseCache,
1464
1467
  setFormTokenSessionId: () => setFormTokenSessionId
1465
1468
  });
1466
1469
  import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
@@ -1564,6 +1567,12 @@ function capCache(cache, maxEntries) {
1564
1567
  if (--drop <= 0) break;
1565
1568
  }
1566
1569
  }
1570
+ function sweepExpiredCache(cache) {
1571
+ const now = Date.now();
1572
+ for (const [key, [, expiresAt]] of cache) {
1573
+ if (expiresAt <= now) cache.delete(key);
1574
+ }
1575
+ }
1567
1576
  function tokenize(source) {
1568
1577
  const rawBlocks = [];
1569
1578
  source = source.replace(RAW_BLOCK_RE, (_match, content) => {
@@ -1627,6 +1636,16 @@ function stripTag(raw) {
1627
1636
  }
1628
1637
  return [inner.trim(), stripBefore, stripAfter];
1629
1638
  }
1639
+ function extendsTarget(source) {
1640
+ const matches = source.match(EXTENDS_RE_GLOBAL);
1641
+ if (matches && matches.length > 1) {
1642
+ throw new Error(
1643
+ `Frond: template has ${matches.length} "{% extends %}" tags -- a template can extend only one parent`
1644
+ );
1645
+ }
1646
+ const match = source.match(EXTENDS_RE);
1647
+ return match ? match[1] : "";
1648
+ }
1630
1649
  function resolveVar(expr, context) {
1631
1650
  expr = expr.trim();
1632
1651
  if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'")) {
@@ -1707,6 +1726,7 @@ function resolveVar(expr, context) {
1707
1726
  fromBracket.push(false);
1708
1727
  }
1709
1728
  }
1729
+ capCache(pathParseCache, MEMO_CACHE_MAX);
1710
1730
  pathParseCache.set(expr, [parts, fromBracket]);
1711
1731
  }
1712
1732
  let value = context;
@@ -2294,6 +2314,7 @@ function parseFilterChain(expr) {
2294
2314
  }
2295
2315
  }
2296
2316
  const result = [variable, filters];
2317
+ capCache(filterChainCache, MEMO_CACHE_MAX);
2297
2318
  filterChainCache.set(expr, result);
2298
2319
  return result;
2299
2320
  }
@@ -2473,7 +2494,7 @@ function _generateFormToken(descriptor = "") {
2473
2494
  function _generateFormTokenValue(descriptor = "") {
2474
2495
  return new SafeString(_buildFormTokenJwt(descriptor));
2475
2496
  }
2476
- var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
2497
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, EXTENDS_RE, EXTENDS_RE_GLOBAL, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, MEMO_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
2477
2498
  var init_engine = __esm({
2478
2499
  "../frond/src/engine.ts"() {
2479
2500
  "use strict";
@@ -2567,9 +2588,12 @@ var init_engine = __esm({
2567
2588
  LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
2568
2589
  LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
2569
2590
  LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
2591
+ EXTENDS_RE = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/;
2592
+ EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/g;
2570
2593
  filterChainCache = /* @__PURE__ */ new Map();
2571
2594
  pathParseCache = /* @__PURE__ */ new Map();
2572
2595
  TEMPLATE_CACHE_MAX = 256;
2596
+ MEMO_CACHE_MAX = 1024;
2573
2597
  TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
2574
2598
  RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
2575
2599
  VarRef = class {
@@ -2922,29 +2946,22 @@ var init_engine = __esm({
2922
2946
  return this;
2923
2947
  }
2924
2948
  /**
2925
- * Register a custom filter. The filter is persisted at class level
2926
- * so new instances created by hot-reload inherit it automatically;
2927
- * the live instance's local filter map also receives the addition
2928
- * immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
2949
+ * Register a custom filter on this instance only. Use the static method
2950
+ * for process-global registration. tina4: ADR-0052.
2929
2951
  */
2930
2952
  addFilter(name, fn) {
2931
- _Frond.classFilters.set(name, fn);
2932
2953
  this.filters[name] = fn;
2933
2954
  }
2934
2955
  /**
2935
- * Register a global variable available in all templates. Persisted
2936
- * at class level — see ``addFilter`` for the dual-call semantics.
2956
+ * Register a global variable on this instance only.
2937
2957
  */
2938
2958
  addGlobal(name, value) {
2939
- _Frond.classGlobals.set(name, value);
2940
2959
  this.globals[name] = value;
2941
2960
  }
2942
2961
  /**
2943
- * Register a custom test. Persisted at class level — see
2944
- * ``addFilter`` for the dual-call semantics.
2962
+ * Register a custom test on this instance only.
2945
2963
  */
2946
2964
  addTest(name, fn) {
2947
- _Frond.classTests.set(name, fn);
2948
2965
  this.tests[name] = fn;
2949
2966
  }
2950
2967
  /**
@@ -3059,9 +3076,8 @@ var init_engine = __esm({
3059
3076
  if (Object.keys(this.tests).length > 0) {
3060
3077
  context.__frond_tests__ = this.tests;
3061
3078
  }
3062
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
3063
- if (extendsMatch) {
3064
- const parentName = extendsMatch[1];
3079
+ const parentName = extendsTarget(source);
3080
+ if (parentName) {
3065
3081
  const parentSource = this.load(parentName);
3066
3082
  const childBlocks = this.extractBlocks(source);
3067
3083
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -3072,9 +3088,8 @@ var init_engine = __esm({
3072
3088
  if (Object.keys(this.tests).length > 0) {
3073
3089
  context.__frond_tests__ = this.tests;
3074
3090
  }
3075
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
3076
- if (extendsMatch) {
3077
- const parentName = extendsMatch[1];
3091
+ const parentName = extendsTarget(source);
3092
+ if (parentName) {
3078
3093
  const parentSource = this.load(parentName);
3079
3094
  const childBlocks = this.extractBlocks(source);
3080
3095
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -3119,10 +3134,93 @@ var init_engine = __esm({
3119
3134
  }
3120
3135
  return blocks;
3121
3136
  }
3137
+ /**
3138
+ * Depth-aware block substitution against `source` (typically the
3139
+ * fully-resolved root template).
3140
+ *
3141
+ * A single regex `.replace()` pass (the flat `pattern` this replaces in
3142
+ * renderWithBlocks) pairs an OUTER block's open tag with the FIRST
3143
+ * `{% endblock %}` found -- which, when the outer block wraps a NESTED
3144
+ * `{% block %}`, is the nested block's own close tag, not the outer's.
3145
+ * That silently truncates the outer block's captured content and drops
3146
+ * everything after the inner endblock (the root-nested-block
3147
+ * content-loss bug). This scans with an open/close depth counter
3148
+ * instead (mirroring extractBlocks), so an outer block always captures
3149
+ * its FULL body, nested child blocks included.
3150
+ *
3151
+ * The content chosen for each block -- the child override in `blocks`
3152
+ * if present, else the block's own default body -- is then recursively
3153
+ * substituted against the SAME `blocks` map before being tokenized and
3154
+ * rendered, so a block nested inside another block resolves correctly
3155
+ * regardless of which template in the inheritance chain declared the
3156
+ * nesting (the root, an intermediate, however many levels deep).
3157
+ *
3158
+ * `{{ parent() }}` / `{{ super() }}` inside a block still render that
3159
+ * block's OWN default content at this level (lazy, on first call).
3160
+ */
3161
+ substituteBlocks(source, blocks, context) {
3162
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
3163
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
3164
+ const engine = this;
3165
+ const pieces = [];
3166
+ let pos = 0;
3167
+ while (pos < source.length) {
3168
+ blockOpen.lastIndex = pos;
3169
+ const mOpen = blockOpen.exec(source);
3170
+ if (!mOpen) {
3171
+ pieces.push(source.slice(pos));
3172
+ break;
3173
+ }
3174
+ pieces.push(source.slice(pos, mOpen.index));
3175
+ const name = mOpen[1];
3176
+ const contentStart = mOpen.index + mOpen[0].length;
3177
+ let depth = 1;
3178
+ let scan = contentStart;
3179
+ let closeMatch = null;
3180
+ while (depth > 0 && scan < source.length) {
3181
+ blockOpen.lastIndex = scan;
3182
+ blockClose.lastIndex = scan;
3183
+ const nextOpen = blockOpen.exec(source);
3184
+ const nextClose = blockClose.exec(source);
3185
+ if (!nextClose) break;
3186
+ if (nextOpen && nextOpen.index < nextClose.index) {
3187
+ depth++;
3188
+ scan = nextOpen.index + nextOpen[0].length;
3189
+ } else {
3190
+ depth--;
3191
+ if (depth === 0) {
3192
+ closeMatch = nextClose;
3193
+ } else {
3194
+ scan = nextClose.index + nextClose[0].length;
3195
+ }
3196
+ }
3197
+ }
3198
+ if (!closeMatch) {
3199
+ pieces.push(source.slice(mOpen.index));
3200
+ pos = source.length;
3201
+ break;
3202
+ }
3203
+ const parentContent = source.slice(contentStart, closeMatch.index);
3204
+ const blockSource = blocks[name] ?? parentContent;
3205
+ const resolvedSource = engine.substituteBlocks(blockSource, blocks, context);
3206
+ let renderedParent = null;
3207
+ const getParent = () => {
3208
+ if (renderedParent === null) {
3209
+ renderedParent = new SafeString(
3210
+ engine.renderTokens(tokenize(parentContent), context)
3211
+ );
3212
+ }
3213
+ return renderedParent;
3214
+ };
3215
+ const blockCtx = { ...context, parent: getParent, super: getParent };
3216
+ pieces.push(engine.renderTokens(tokenize(resolvedSource), blockCtx));
3217
+ pos = closeMatch.index + closeMatch[0].length;
3218
+ }
3219
+ return pieces.join("");
3220
+ }
3122
3221
  renderWithBlocks(parentSource, context, childBlocks) {
3123
- const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
3124
- if (extendsMatch) {
3125
- const grandparentName = extendsMatch[1];
3222
+ const grandparentName = extendsTarget(parentSource);
3223
+ if (grandparentName) {
3126
3224
  const grandparentSource = this.load(grandparentName);
3127
3225
  const parentBlocks = this.extractBlocks(parentSource);
3128
3226
  const mergedBlocks = { ...parentBlocks, ...childBlocks };
@@ -3142,22 +3240,7 @@ var init_engine = __esm({
3142
3240
  }
3143
3241
  return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
3144
3242
  }
3145
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
3146
- const engine = this;
3147
- const result = parentSource.replace(pattern, (_match, name, parentContent) => {
3148
- const blockSource = childBlocks[name] ?? parentContent;
3149
- let renderedParent = null;
3150
- const getParent = () => {
3151
- if (renderedParent === null) {
3152
- renderedParent = new SafeString(
3153
- engine.renderTokens(tokenize(parentContent), context)
3154
- );
3155
- }
3156
- return renderedParent;
3157
- };
3158
- const blockCtx = { ...context, parent: getParent, super: getParent };
3159
- return this.renderTokens(tokenize(blockSource), blockCtx);
3160
- });
3243
+ const result = this.substituteBlocks(parentSource, childBlocks, context);
3161
3244
  return this.renderTokens(tokenize(result), context);
3162
3245
  }
3163
3246
  renderTokens(tokens, context) {
@@ -3980,6 +4063,7 @@ var init_engine = __esm({
3980
4063
  const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
3981
4064
  const cacheKey = m ? m[1] : "default";
3982
4065
  const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
4066
+ sweepExpiredCache(this.fragmentCache);
3983
4067
  const cached = this.fragmentCache.get(cacheKey);
3984
4068
  if (cached) {
3985
4069
  const [htmlContent, expiresAt] = cached;
@@ -4027,6 +4111,7 @@ var init_engine = __esm({
4027
4111
  i++;
4028
4112
  }
4029
4113
  const rendered = this.renderTokens([...bodyTokens], context);
4114
+ capCache(this.fragmentCache, TEMPLATE_CACHE_MAX);
4030
4115
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1e3]);
4031
4116
  return [rendered, i];
4032
4117
  }
@@ -39664,14 +39749,16 @@ function downloadSkillsSync(jobs) {
39664
39749
  const jobs = JSON.parse(process.argv[1]);
39665
39750
  const fs = require("node:fs");
39666
39751
  const path = require("node:path");
39752
+ const transientStatuses = new Set([429, 500, 502, 503, 504]);
39667
39753
  async function fetchOne(job) {
39668
39754
  const resp = await fetch(job.url, { signal: AbortSignal.timeout(15000) });
39669
- if (!resp.ok) throw new Error("HTTP " + resp.status);
39755
+ if (!resp.ok) return { ok: false, retry: transientStatuses.has(resp.status) };
39670
39756
  const buf = Buffer.from(await resp.arrayBuffer());
39671
39757
  for (const dest of job.dests) {
39672
39758
  fs.mkdirSync(path.dirname(dest), { recursive: true });
39673
39759
  fs.writeFileSync(dest, buf);
39674
39760
  }
39761
+ return { ok: true, retry: false };
39675
39762
  }
39676
39763
  (async () => {
39677
39764
  const ok = [];
@@ -39680,9 +39767,11 @@ function downloadSkillsSync(jobs) {
39680
39767
  const failed = [];
39681
39768
  await Promise.all(pending.map(async (job) => {
39682
39769
  try {
39683
- await fetchOne(job);
39684
- ok.push(job.url);
39770
+ const result = await fetchOne(job);
39771
+ if (result.ok) ok.push(job.url);
39772
+ else if (result.retry) failed.push(job);
39685
39773
  } catch {
39774
+ // DNS, TLS, timeout and connection failures are transient.
39686
39775
  failed.push(job);
39687
39776
  }
39688
39777
  }));