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.
@@ -2620,7 +2620,10 @@ var init_trustedProxy = __esm({
2620
2620
  var engine_exports = {};
2621
2621
  __export(engine_exports, {
2622
2622
  Frond: () => Frond,
2623
+ MEMO_CACHE_MAX: () => MEMO_CACHE_MAX,
2623
2624
  TEMPLATE_CACHE_MAX: () => TEMPLATE_CACHE_MAX,
2625
+ filterChainCache: () => filterChainCache,
2626
+ pathParseCache: () => pathParseCache,
2624
2627
  setFormTokenSessionId: () => setFormTokenSessionId
2625
2628
  });
2626
2629
  import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
@@ -2724,6 +2727,12 @@ function capCache(cache, maxEntries) {
2724
2727
  if (--drop <= 0) break;
2725
2728
  }
2726
2729
  }
2730
+ function sweepExpiredCache(cache) {
2731
+ const now = Date.now();
2732
+ for (const [key, [, expiresAt]] of cache) {
2733
+ if (expiresAt <= now) cache.delete(key);
2734
+ }
2735
+ }
2727
2736
  function tokenize(source) {
2728
2737
  const rawBlocks = [];
2729
2738
  source = source.replace(RAW_BLOCK_RE, (_match, content) => {
@@ -2787,6 +2796,16 @@ function stripTag(raw) {
2787
2796
  }
2788
2797
  return [inner.trim(), stripBefore, stripAfter];
2789
2798
  }
2799
+ function extendsTarget(source) {
2800
+ const matches = source.match(EXTENDS_RE_GLOBAL);
2801
+ if (matches && matches.length > 1) {
2802
+ throw new Error(
2803
+ `Frond: template has ${matches.length} "{% extends %}" tags -- a template can extend only one parent`
2804
+ );
2805
+ }
2806
+ const match = source.match(EXTENDS_RE);
2807
+ return match ? match[1] : "";
2808
+ }
2790
2809
  function resolveVar(expr, context) {
2791
2810
  expr = expr.trim();
2792
2811
  if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'")) {
@@ -2867,6 +2886,7 @@ function resolveVar(expr, context) {
2867
2886
  fromBracket.push(false);
2868
2887
  }
2869
2888
  }
2889
+ capCache(pathParseCache, MEMO_CACHE_MAX);
2870
2890
  pathParseCache.set(expr, [parts, fromBracket]);
2871
2891
  }
2872
2892
  let value = context;
@@ -3454,6 +3474,7 @@ function parseFilterChain(expr) {
3454
3474
  }
3455
3475
  }
3456
3476
  const result = [variable, filters];
3477
+ capCache(filterChainCache, MEMO_CACHE_MAX);
3457
3478
  filterChainCache.set(expr, result);
3458
3479
  return result;
3459
3480
  }
@@ -3633,7 +3654,7 @@ function _generateFormToken(descriptor = "") {
3633
3654
  function _generateFormTokenValue(descriptor = "") {
3634
3655
  return new SafeString(_buildFormTokenJwt(descriptor));
3635
3656
  }
3636
- 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;
3657
+ 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;
3637
3658
  var init_engine = __esm({
3638
3659
  "../frond/src/engine.ts"() {
3639
3660
  "use strict";
@@ -3727,9 +3748,12 @@ var init_engine = __esm({
3727
3748
  LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
3728
3749
  LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
3729
3750
  LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
3751
+ EXTENDS_RE = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/;
3752
+ EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/g;
3730
3753
  filterChainCache = /* @__PURE__ */ new Map();
3731
3754
  pathParseCache = /* @__PURE__ */ new Map();
3732
3755
  TEMPLATE_CACHE_MAX = 256;
3756
+ MEMO_CACHE_MAX = 1024;
3733
3757
  TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
3734
3758
  RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
3735
3759
  VarRef = class {
@@ -4082,29 +4106,22 @@ var init_engine = __esm({
4082
4106
  return this;
4083
4107
  }
4084
4108
  /**
4085
- * Register a custom filter. The filter is persisted at class level
4086
- * so new instances created by hot-reload inherit it automatically;
4087
- * the live instance's local filter map also receives the addition
4088
- * immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
4109
+ * Register a custom filter on this instance only. Use the static method
4110
+ * for process-global registration. tina4: ADR-0052.
4089
4111
  */
4090
4112
  addFilter(name, fn) {
4091
- _Frond.classFilters.set(name, fn);
4092
4113
  this.filters[name] = fn;
4093
4114
  }
4094
4115
  /**
4095
- * Register a global variable available in all templates. Persisted
4096
- * at class level — see ``addFilter`` for the dual-call semantics.
4116
+ * Register a global variable on this instance only.
4097
4117
  */
4098
4118
  addGlobal(name, value) {
4099
- _Frond.classGlobals.set(name, value);
4100
4119
  this.globals[name] = value;
4101
4120
  }
4102
4121
  /**
4103
- * Register a custom test. Persisted at class level — see
4104
- * ``addFilter`` for the dual-call semantics.
4122
+ * Register a custom test on this instance only.
4105
4123
  */
4106
4124
  addTest(name, fn) {
4107
- _Frond.classTests.set(name, fn);
4108
4125
  this.tests[name] = fn;
4109
4126
  }
4110
4127
  /**
@@ -4219,9 +4236,8 @@ var init_engine = __esm({
4219
4236
  if (Object.keys(this.tests).length > 0) {
4220
4237
  context.__frond_tests__ = this.tests;
4221
4238
  }
4222
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
4223
- if (extendsMatch) {
4224
- const parentName = extendsMatch[1];
4239
+ const parentName = extendsTarget(source);
4240
+ if (parentName) {
4225
4241
  const parentSource = this.load(parentName);
4226
4242
  const childBlocks = this.extractBlocks(source);
4227
4243
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -4232,9 +4248,8 @@ var init_engine = __esm({
4232
4248
  if (Object.keys(this.tests).length > 0) {
4233
4249
  context.__frond_tests__ = this.tests;
4234
4250
  }
4235
- const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
4236
- if (extendsMatch) {
4237
- const parentName = extendsMatch[1];
4251
+ const parentName = extendsTarget(source);
4252
+ if (parentName) {
4238
4253
  const parentSource = this.load(parentName);
4239
4254
  const childBlocks = this.extractBlocks(source);
4240
4255
  return this.renderWithBlocks(parentSource, context, childBlocks);
@@ -4279,10 +4294,93 @@ var init_engine = __esm({
4279
4294
  }
4280
4295
  return blocks;
4281
4296
  }
4297
+ /**
4298
+ * Depth-aware block substitution against `source` (typically the
4299
+ * fully-resolved root template).
4300
+ *
4301
+ * A single regex `.replace()` pass (the flat `pattern` this replaces in
4302
+ * renderWithBlocks) pairs an OUTER block's open tag with the FIRST
4303
+ * `{% endblock %}` found -- which, when the outer block wraps a NESTED
4304
+ * `{% block %}`, is the nested block's own close tag, not the outer's.
4305
+ * That silently truncates the outer block's captured content and drops
4306
+ * everything after the inner endblock (the root-nested-block
4307
+ * content-loss bug). This scans with an open/close depth counter
4308
+ * instead (mirroring extractBlocks), so an outer block always captures
4309
+ * its FULL body, nested child blocks included.
4310
+ *
4311
+ * The content chosen for each block -- the child override in `blocks`
4312
+ * if present, else the block's own default body -- is then recursively
4313
+ * substituted against the SAME `blocks` map before being tokenized and
4314
+ * rendered, so a block nested inside another block resolves correctly
4315
+ * regardless of which template in the inheritance chain declared the
4316
+ * nesting (the root, an intermediate, however many levels deep).
4317
+ *
4318
+ * `{{ parent() }}` / `{{ super() }}` inside a block still render that
4319
+ * block's OWN default content at this level (lazy, on first call).
4320
+ */
4321
+ substituteBlocks(source, blocks, context) {
4322
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
4323
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
4324
+ const engine = this;
4325
+ const pieces = [];
4326
+ let pos = 0;
4327
+ while (pos < source.length) {
4328
+ blockOpen.lastIndex = pos;
4329
+ const mOpen = blockOpen.exec(source);
4330
+ if (!mOpen) {
4331
+ pieces.push(source.slice(pos));
4332
+ break;
4333
+ }
4334
+ pieces.push(source.slice(pos, mOpen.index));
4335
+ const name = mOpen[1];
4336
+ const contentStart = mOpen.index + mOpen[0].length;
4337
+ let depth = 1;
4338
+ let scan = contentStart;
4339
+ let closeMatch = null;
4340
+ while (depth > 0 && scan < source.length) {
4341
+ blockOpen.lastIndex = scan;
4342
+ blockClose.lastIndex = scan;
4343
+ const nextOpen = blockOpen.exec(source);
4344
+ const nextClose = blockClose.exec(source);
4345
+ if (!nextClose) break;
4346
+ if (nextOpen && nextOpen.index < nextClose.index) {
4347
+ depth++;
4348
+ scan = nextOpen.index + nextOpen[0].length;
4349
+ } else {
4350
+ depth--;
4351
+ if (depth === 0) {
4352
+ closeMatch = nextClose;
4353
+ } else {
4354
+ scan = nextClose.index + nextClose[0].length;
4355
+ }
4356
+ }
4357
+ }
4358
+ if (!closeMatch) {
4359
+ pieces.push(source.slice(mOpen.index));
4360
+ pos = source.length;
4361
+ break;
4362
+ }
4363
+ const parentContent = source.slice(contentStart, closeMatch.index);
4364
+ const blockSource = blocks[name] ?? parentContent;
4365
+ const resolvedSource = engine.substituteBlocks(blockSource, blocks, context);
4366
+ let renderedParent = null;
4367
+ const getParent = () => {
4368
+ if (renderedParent === null) {
4369
+ renderedParent = new SafeString(
4370
+ engine.renderTokens(tokenize(parentContent), context)
4371
+ );
4372
+ }
4373
+ return renderedParent;
4374
+ };
4375
+ const blockCtx = { ...context, parent: getParent, super: getParent };
4376
+ pieces.push(engine.renderTokens(tokenize(resolvedSource), blockCtx));
4377
+ pos = closeMatch.index + closeMatch[0].length;
4378
+ }
4379
+ return pieces.join("");
4380
+ }
4282
4381
  renderWithBlocks(parentSource, context, childBlocks) {
4283
- const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
4284
- if (extendsMatch) {
4285
- const grandparentName = extendsMatch[1];
4382
+ const grandparentName = extendsTarget(parentSource);
4383
+ if (grandparentName) {
4286
4384
  const grandparentSource = this.load(grandparentName);
4287
4385
  const parentBlocks = this.extractBlocks(parentSource);
4288
4386
  const mergedBlocks = { ...parentBlocks, ...childBlocks };
@@ -4302,22 +4400,7 @@ var init_engine = __esm({
4302
4400
  }
4303
4401
  return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
4304
4402
  }
4305
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
4306
- const engine = this;
4307
- const result = parentSource.replace(pattern, (_match, name, parentContent) => {
4308
- const blockSource = childBlocks[name] ?? parentContent;
4309
- let renderedParent = null;
4310
- const getParent = () => {
4311
- if (renderedParent === null) {
4312
- renderedParent = new SafeString(
4313
- engine.renderTokens(tokenize(parentContent), context)
4314
- );
4315
- }
4316
- return renderedParent;
4317
- };
4318
- const blockCtx = { ...context, parent: getParent, super: getParent };
4319
- return this.renderTokens(tokenize(blockSource), blockCtx);
4320
- });
4403
+ const result = this.substituteBlocks(parentSource, childBlocks, context);
4321
4404
  return this.renderTokens(tokenize(result), context);
4322
4405
  }
4323
4406
  renderTokens(tokens, context) {
@@ -5140,6 +5223,7 @@ var init_engine = __esm({
5140
5223
  const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
5141
5224
  const cacheKey = m ? m[1] : "default";
5142
5225
  const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
5226
+ sweepExpiredCache(this.fragmentCache);
5143
5227
  const cached = this.fragmentCache.get(cacheKey);
5144
5228
  if (cached) {
5145
5229
  const [htmlContent, expiresAt] = cached;
@@ -5187,6 +5271,7 @@ var init_engine = __esm({
5187
5271
  i++;
5188
5272
  }
5189
5273
  const rendered = this.renderTokens([...bodyTokens], context);
5274
+ capCache(this.fragmentCache, TEMPLATE_CACHE_MAX);
5190
5275
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1e3]);
5191
5276
  return [rendered, i];
5192
5277
  }
@@ -29385,14 +29470,16 @@ function downloadSkillsSync(jobs) {
29385
29470
  const jobs = JSON.parse(process.argv[1]);
29386
29471
  const fs = require("node:fs");
29387
29472
  const path = require("node:path");
29473
+ const transientStatuses = new Set([429, 500, 502, 503, 504]);
29388
29474
  async function fetchOne(job) {
29389
29475
  const resp = await fetch(job.url, { signal: AbortSignal.timeout(15000) });
29390
- if (!resp.ok) throw new Error("HTTP " + resp.status);
29476
+ if (!resp.ok) return { ok: false, retry: transientStatuses.has(resp.status) };
29391
29477
  const buf = Buffer.from(await resp.arrayBuffer());
29392
29478
  for (const dest of job.dests) {
29393
29479
  fs.mkdirSync(path.dirname(dest), { recursive: true });
29394
29480
  fs.writeFileSync(dest, buf);
29395
29481
  }
29482
+ return { ok: true, retry: false };
29396
29483
  }
29397
29484
  (async () => {
29398
29485
  const ok = [];
@@ -29401,9 +29488,11 @@ function downloadSkillsSync(jobs) {
29401
29488
  const failed = [];
29402
29489
  await Promise.all(pending.map(async (job) => {
29403
29490
  try {
29404
- await fetchOne(job);
29405
- ok.push(job.url);
29491
+ const result = await fetchOne(job);
29492
+ if (result.ok) ok.push(job.url);
29493
+ else if (result.retry) failed.push(job);
29406
29494
  } catch {
29495
+ // DNS, TLS, timeout and connection failures are transient.
29407
29496
  failed.push(job);
29408
29497
  }
29409
29498
  }));
@@ -6,6 +6,35 @@ export interface AiTool {
6
6
  }
7
7
  export declare const AI_TOOLS: AiTool[];
8
8
  export declare const DEV_SKILL = "tina4-developer-nodejs";
9
+ /**
10
+ * Fetch a set of skill files over the network SYNCHRONOUSLY.
11
+ *
12
+ * Node has no built-in synchronous HTTP, and the whole installer chain
13
+ * (installSelected → installForTool → installClaudeSkills) is synchronous and
14
+ * can't be made async without breaking its existing callers/tests. So we run
15
+ * ONE blocking child `node` process that fetches every URL in parallel with the
16
+ * global `fetch` (Node 18+) and writes each body to all of its destinations.
17
+ * The child prints a JSON array of the URLs it fetched successfully; any fetch
18
+ * failure is skipped, never fatal.
19
+ *
20
+ * MEASURED (2026-08-13): a real GitHub raw-content fetch occasionally drops a
21
+ * request under load (transient DNS/TLS hiccup, not a missing file — every
22
+ * URL here resolves fine on its own) while its siblings in the same batch
23
+ * succeed. One retry pass over only transport failures and transient HTTP
24
+ * statuses, still inside the same child process, fixes that for real installer
25
+ * users too. Permanent 4xx responses are final answers and are not retried.
26
+ *
27
+ * Exported (like `writeOrMerge`/`markersFor`/`skillBlock` above) so
28
+ * aiFetchRetry.test.ts can drive it directly against a real local server —
29
+ * a pure visibility change, no behaviour change.
30
+ *
31
+ * @param jobs one entry per unique URL, with every file path it should land in
32
+ * @returns the set of URLs that were fetched and written to disk
33
+ */
34
+ export declare function downloadSkillsSync(jobs: {
35
+ url: string;
36
+ dests: string[];
37
+ }[]): Set<string>;
9
38
  /**
10
39
  * Install the Tina4 SKILL.md skills into the project AND the global
11
40
  * ~/.claude/skills, fetched from the release ref matching this framework
@@ -14,6 +14,15 @@ export interface LiveResponse {
14
14
  }
15
15
  /** WebSocket broadcaster hook wired by @tina4/core so pushLive can broadcast. */
16
16
  export type LiveBroadcaster = (wsPath: string | null, name: string, envelope: string) => void;
17
+ /**
18
+ * Cache for parsed filter chains: expr string -> [variable, filters].
19
+ * Exported (like TEMPLATE_CACHE_MAX) so the ADR-0004 bound has something for
20
+ * a test to inspect directly — module-level state has no instance to read
21
+ * off, unlike `compiled`/`compiledStrings`/`fragmentCache`.
22
+ */
23
+ export declare const filterChainCache: Map<string, [string, [string, unknown[]][]]>;
24
+ /** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket]. Exported for the same reason as filterChainCache. */
25
+ export declare const pathParseCache: Map<string, [string[], boolean[]]>;
17
26
  /**
18
27
  * Hard cap on the template caches — `compiled` and `compiledStrings`
19
28
  * (ADR-0004, parity with PHP/Python/Ruby TEMPLATE_CACHE_MAX).
@@ -26,6 +35,18 @@ export type LiveBroadcaster = (wsPath: string | null, name: string, envelope: st
26
35
  * dynamically adds an entry per distinct string.
27
36
  */
28
37
  export declare const TEMPLATE_CACHE_MAX = 256;
38
+ /**
39
+ * Hard cap on every per-expression memo cache — `filterChainCache` and
40
+ * `pathParseCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
41
+ * Python master's `@lru_cache(maxsize=1024)` on the equivalent module-level
42
+ * parsers). Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a
43
+ * small parsed-path array, orders of magnitude smaller than a token list.
44
+ *
45
+ * Also reused for `fragmentCache` (the `{% cache %}` tag's runtime store):
46
+ * TEMPLATE_CACHE_MAX, not this one — a rendered fragment is a whole HTML
47
+ * string, the same order of magnitude as a compiled template.
48
+ */
49
+ export declare const MEMO_CACHE_MAX = 1024;
29
50
  /**
30
51
  * Set the session ID used by formToken() / form_token() for CSRF session binding.
31
52
  */
@@ -95,20 +116,16 @@ export declare class Frond {
95
116
  sandbox(filters?: string[], tags?: string[], vars?: string[]): Frond;
96
117
  unsandbox(): Frond;
97
118
  /**
98
- * Register a custom filter. The filter is persisted at class level
99
- * so new instances created by hot-reload inherit it automatically;
100
- * the live instance's local filter map also receives the addition
101
- * immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
119
+ * Register a custom filter on this instance only. Use the static method
120
+ * for process-global registration. tina4: ADR-0052.
102
121
  */
103
122
  addFilter(name: string, fn: FilterFn): void;
104
123
  /**
105
- * Register a global variable available in all templates. Persisted
106
- * at class level — see ``addFilter`` for the dual-call semantics.
124
+ * Register a global variable on this instance only.
107
125
  */
108
126
  addGlobal(name: string, value: unknown): void;
109
127
  /**
110
- * Register a custom test. Persisted at class level — see
111
- * ``addFilter`` for the dual-call semantics.
128
+ * Register a custom test on this instance only.
112
129
  */
113
130
  addTest(name: string, fn: TestFn): void;
114
131
  /**
@@ -143,6 +160,31 @@ export declare class Frond {
143
160
  private executeWithSource;
144
161
  private execute;
145
162
  private extractBlocks;
163
+ /**
164
+ * Depth-aware block substitution against `source` (typically the
165
+ * fully-resolved root template).
166
+ *
167
+ * A single regex `.replace()` pass (the flat `pattern` this replaces in
168
+ * renderWithBlocks) pairs an OUTER block's open tag with the FIRST
169
+ * `{% endblock %}` found -- which, when the outer block wraps a NESTED
170
+ * `{% block %}`, is the nested block's own close tag, not the outer's.
171
+ * That silently truncates the outer block's captured content and drops
172
+ * everything after the inner endblock (the root-nested-block
173
+ * content-loss bug). This scans with an open/close depth counter
174
+ * instead (mirroring extractBlocks), so an outer block always captures
175
+ * its FULL body, nested child blocks included.
176
+ *
177
+ * The content chosen for each block -- the child override in `blocks`
178
+ * if present, else the block's own default body -- is then recursively
179
+ * substituted against the SAME `blocks` map before being tokenized and
180
+ * rendered, so a block nested inside another block resolves correctly
181
+ * regardless of which template in the inheritance chain declared the
182
+ * nesting (the root, an intermediate, however many levels deep).
183
+ *
184
+ * `{{ parent() }}` / `{{ super() }}` inside a block still render that
185
+ * block's OWN default content at this level (lazy, on first call).
186
+ */
187
+ private substituteBlocks;
146
188
  private renderWithBlocks;
147
189
  private renderTokens;
148
190
  /**