tina4-nodejs 3.13.131 → 3.13.133

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.
@@ -576,9 +576,6 @@ export class DevAdmin {
576
576
  { method: "POST", pattern: "/__dev/api/websockets/disconnect", handler: handleWebsocketsDisconnect },
577
577
  // Tools
578
578
  { method: "POST", pattern: "/__dev/api/tool", handler: handleTool },
579
- // Chat — proxies to Rust agent /chat (SSE passthrough). Forwards
580
- // active_file and any other body keys verbatim. See proxyToSupervisor.
581
- { method: "POST", pattern: "/__dev/api/chat", handler: handleChat },
582
579
  // Threads — proxies to Rust agent /threads. Mirrors Python's
583
580
  // _api_threads + _api_threads_sub.
584
581
  { method: "GET", pattern: "/__dev/api/threads", handler: handleThreads },
@@ -658,8 +655,6 @@ export class DevAdmin {
658
655
  { method: "GET", pattern: "/__dev/api/supervise/diff", handler: handleSuperviseStub },
659
656
  { method: "POST", pattern: "/__dev/api/supervise/commit", handler: handleSuperviseStub },
660
657
  { method: "POST", pattern: "/__dev/api/supervise/cancel", handler: handleSuperviseStub },
661
- // Execute — proxies to the framework_port+2000 Rust agent (SSE passthrough)
662
- { method: "POST", pattern: "/__dev/api/execute", handler: handleExecute },
663
658
  // Framework-grounding MCP token config — self-contained (.env upsert)
664
659
  { method: "GET", pattern: "/__dev/api/grounding/status", handler: handleGroundingStatus },
665
660
  { method: "POST", pattern: "/__dev/api/grounding/token", handler: handleGroundingToken },
@@ -1638,16 +1633,6 @@ async function proxyToSupervisor(
1638
1633
  }
1639
1634
  }
1640
1635
 
1641
- // -- Chat handler --
1642
- //
1643
- // Proxies POST /__dev/api/chat → Rust agent `POST /chat`. The SPA's Chat
1644
- // view POSTs `{message, settings?, thread_id?, active_file?, files?}` and
1645
- // expects an SSE stream of `event: status / message / done` chunks.
1646
- // active_file (and any other body keys) are forwarded verbatim.
1647
- const handleChat: RouteHandler = async (req, res) => {
1648
- await proxyToSupervisor(req, res, "/chat");
1649
- };
1650
-
1651
1636
  // -- Framework-grounding (mcp.tina4.com) token config --
1652
1637
  //
1653
1638
  // TINA4_MCP_TOKEN grounds the coder against mcp.tina4.com's tina4_context.
@@ -2138,37 +2123,6 @@ const handleSuperviseStub: RouteHandler = (_req, res) => {
2138
2123
  );
2139
2124
  };
2140
2125
 
2141
- const handleExecute: RouteHandler = async (req, res) => {
2142
- // Proxy to framework_port+2000 Rust agent (SSE passthrough).
2143
- const port = parseInt(process.env.TINA4_PORT ?? process.env.PORT ?? "7148", 10);
2144
- const agentUrl = `http://127.0.0.1:${port + 2000}/execute`;
2145
- try {
2146
- const upstream = await fetch(agentUrl, {
2147
- method: "POST",
2148
- headers: { "Content-Type": "application/json" },
2149
- body: JSON.stringify(req.body ?? {}),
2150
- });
2151
- if (!upstream.body) {
2152
- res.json({ error: "agent returned no body" }, 502);
2153
- return;
2154
- }
2155
- res.raw.writeHead(upstream.status || 200, {
2156
- "Content-Type": upstream.headers.get("content-type") || "text/event-stream",
2157
- "Cache-Control": "no-cache",
2158
- Connection: "keep-alive",
2159
- });
2160
- const reader = upstream.body.getReader();
2161
- while (true) {
2162
- const { done, value } = await reader.read();
2163
- if (done) break;
2164
- if (value) res.raw.write(Buffer.from(value));
2165
- }
2166
- res.raw.end();
2167
- } catch (e) {
2168
- res.json({ error: `agent unreachable at ${agentUrl}: ${(e as Error).message}` }, 502);
2169
- }
2170
- };
2171
-
2172
2126
  // --- file-browser noise filter + git decoration (mirrors PHP/Python dev-admin) ---
2173
2127
  const DEV_FILES_IGNORED = new Set([
2174
2128
  "__pycache__", "node_modules", "vendor", ".git",
@@ -14,7 +14,7 @@ export type {
14
14
  WebSocketRouteDefinition,
15
15
  } from "./types.js";
16
16
 
17
- export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
17
+ export { startServer, resolvePortAndHost, loopbackBindHosts, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
18
18
  export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
19
19
  export { Router, RouteGroup, RouteRef, WsRouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
20
20
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
@@ -1280,7 +1280,9 @@ function injectIntoHtml(ctx: ResponseWrapContext, devToolbar: boolean, html: str
1280
1280
  // Suppress the live reloader on the AI/stable port (data-reload="0"); the
1281
1281
  // toolbar JS early-returns when data-reload !== "1". Mirrors PHP's
1282
1282
  // suppressReload flag.
1283
- reload: !ctx.isAiPortRequest,
1283
+ // Also suppress on the dev-admin dashboard (any /__dev page): its SPA reloads
1284
+ // itself gently, so the toolbar's full-page reloader must not fire there.
1285
+ reload: !ctx.isAiPortRequest && !ctx.pathname.startsWith("/__dev"),
1284
1286
  };
1285
1287
  return injectFeedbackWidget(ctx.req, injectDevToolbar(html, toolbarCtx));
1286
1288
  }
@@ -1847,6 +1849,98 @@ export async function runDispatch(
1847
1849
  return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
1848
1850
  }
1849
1851
 
1852
+ /**
1853
+ * Sibling loopback addresses to ALSO listen on, so `localhost` reaches this
1854
+ * server whether the OS resolves it to IPv4 (127.0.0.1) or IPv6 (::1).
1855
+ *
1856
+ * `localhost` resolves to `::1` (IPv6) FIRST on Windows, so a server bound only
1857
+ * to `127.0.0.1` — or `0.0.0.0`, the IPv4 wildcard, which does NOT cover IPv6 —
1858
+ * refuses the browser with ERR_CONNECTION_REFUSED even though it is serving,
1859
+ * because nothing listens on `::1`. Binding the sibling family closes that gap.
1860
+ *
1861
+ * Returns only the families a direct bind of `host` does not already cover, as
1862
+ * UNBRACKETED addresses (Node's net/http take a bare "::1"). A host that is
1863
+ * neither loopback nor a wildcard yields an empty list — an explicit LAN
1864
+ * address is bound exactly as asked. Mirrors PHP `Server::loopbackBindHosts`.
1865
+ */
1866
+ export function loopbackBindHosts(host: string): string[] {
1867
+ const normalized = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
1868
+ switch (normalized) {
1869
+ case "localhost":
1870
+ return ["127.0.0.1", "::1"];
1871
+ case "127.0.0.1":
1872
+ case "0.0.0.0":
1873
+ return ["::1"];
1874
+ case "::1":
1875
+ case "::":
1876
+ return ["127.0.0.1"];
1877
+ default:
1878
+ return [];
1879
+ }
1880
+ }
1881
+
1882
+ /**
1883
+ * Best-effort extra listeners on the SAME port for the sibling loopback family
1884
+ * (see loopbackBindHosts), each REUSING the primary `dispatch` handler so a
1885
+ * request on `::1` is served identically to one on `127.0.0.1`.
1886
+ *
1887
+ * CRITICAL Node gotcha: a listen failure is an ASYNCHRONOUS `'error'` EVENT,
1888
+ * never a thrown exception, so every sibling attaches an `'error'` handler
1889
+ * BEFORE it can fire. A sibling is pure convenience — the primary is the source
1890
+ * of truth — so ANY bind error is swallowed and that sibling skipped:
1891
+ * EADDRINUSE (the family the primary already answers, e.g. the `localhost`
1892
+ * case), EADDRNOTAVAIL / EAFNOSUPPORT (no IPv6 loopback here) are the expected
1893
+ * ones and stay silent; anything else is logged at debug. It never rejects,
1894
+ * never bubbles to `uncaughtException`, and never fails the primary startup.
1895
+ * The handler stays attached for the life of the socket, so a late error on a
1896
+ * kept sibling is swallowed too and cannot crash the process.
1897
+ *
1898
+ * Resolves to the siblings that actually reached `'listening'`, so the shutdown
1899
+ * path closes exactly those.
1900
+ */
1901
+ function startLoopbackSiblings(
1902
+ port: number,
1903
+ host: string,
1904
+ dispatch: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
1905
+ ): Promise<ReturnType<typeof createServer>[]> {
1906
+ const siblingHosts = loopbackBindHosts(host);
1907
+ if (siblingHosts.length === 0) return Promise.resolve([]);
1908
+
1909
+ return Promise.all(
1910
+ siblingHosts.map(
1911
+ (siblingHost) =>
1912
+ new Promise<ReturnType<typeof createServer> | null>((resolveSibling) => {
1913
+ const sibling = createServer(dispatch);
1914
+ let settled = false;
1915
+ sibling.on("error", (err: NodeJS.ErrnoException) => {
1916
+ const expected =
1917
+ err.code === "EADDRINUSE" ||
1918
+ err.code === "EADDRNOTAVAIL" ||
1919
+ err.code === "EAFNOSUPPORT";
1920
+ if (!expected) {
1921
+ Log.debug(
1922
+ `Loopback sibling ${siblingHost}:${port} not bound ` +
1923
+ `(${err.code ?? err.message}) — skipped, primary already serves`,
1924
+ );
1925
+ }
1926
+ if (!settled) {
1927
+ settled = true;
1928
+ resolveSibling(null);
1929
+ }
1930
+ // Already kept/settled: swallow so a late error never reaches
1931
+ // uncaughtException and never disturbs the primary listener.
1932
+ });
1933
+ sibling.listen(port, siblingHost, () => {
1934
+ settled = true;
1935
+ resolveSibling(sibling);
1936
+ });
1937
+ }),
1938
+ ),
1939
+ ).then((results) =>
1940
+ results.filter((s): s is ReturnType<typeof createServer> => s !== null),
1941
+ );
1942
+ }
1943
+
1850
1944
  export async function startServer(config?: Tina4Config): Promise<{
1851
1945
  close: () => void;
1852
1946
  router: Router;
@@ -2269,11 +2363,21 @@ ${reset}
2269
2363
  });
2270
2364
 
2271
2365
  return new Promise((resolvePromise) => {
2272
- server.listen(port, host, () => {
2366
+ server.listen(port, host, async () => {
2273
2367
  // Record THIS process as the Tina4 dev server on this port, so a later
2274
2368
  // `tina4 serve` can identify it as reclaimable (TAKEOVER-DEC-01). Only the
2275
2369
  // single dev process needs it; takeover is dev-gated off in cluster/prod.
2276
2370
  if (!cluster.isWorker) writePidfile(port);
2371
+
2372
+ // Dual-stack loopback: ALSO listen on the sibling loopback family on the
2373
+ // SAME port, so `localhost` is reachable whether the OS resolves it to
2374
+ // IPv4 or IPv6 (the Windows `localhost` -> `::1` gap). Best-effort and
2375
+ // async-error-guarded — a sibling that cannot bind is skipped and never
2376
+ // fails the primary startup (see startLoopbackSiblings). Parity with PHP
2377
+ // PR #206 / Python / Ruby. Awaited so it is settled before the caller's
2378
+ // promise resolves (a client can then rely on both families being up).
2379
+ const siblingServers = await startLoopbackSiblings(port, host, dispatch);
2380
+
2277
2381
  const displayHost = host === "0.0.0.0" ? "localhost" : host;
2278
2382
  const isDebug = isTruthy(process.env.TINA4_DEBUG);
2279
2383
  const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
@@ -2400,18 +2504,30 @@ ${reset}
2400
2504
 
2401
2505
  const closeListeners = (): Promise<void> =>
2402
2506
  new Promise((done) => {
2403
- let pending = aiServer ? 2 : 1;
2507
+ let pending = 1 + (aiServer ? 1 : 0) + siblingServers.length;
2404
2508
  const one = (): void => {
2405
2509
  if (--pending === 0) done();
2406
2510
  };
2407
2511
  server.close(one);
2408
2512
  if (aiServer) aiServer.close(one);
2513
+ // Sibling loopback listeners share this port and are drained too. Each
2514
+ // reached 'listening' (startLoopbackSiblings filters), so close(one)
2515
+ // fires its callback; a throw only if it is already gone, in which
2516
+ // case count it as closed so the counter still settles.
2517
+ for (const sibling of siblingServers) {
2518
+ try {
2519
+ sibling.close(one);
2520
+ } catch {
2521
+ one();
2522
+ }
2523
+ }
2409
2524
  // A keep-alive socket with no request on it still counts as an open
2410
2525
  // connection, so close() would sit on it until the client wandered
2411
2526
  // off. Without this a fully drained server still burns the whole
2412
2527
  // shutdown budget.
2413
2528
  server.closeIdleConnections();
2414
2529
  aiServer?.closeIdleConnections();
2530
+ for (const sibling of siblingServers) sibling.closeIdleConnections();
2415
2531
  });
2416
2532
 
2417
2533
  const gracefulShutdown = async (signal: string): Promise<void> => {
@@ -2458,6 +2574,7 @@ ${reset}
2458
2574
  );
2459
2575
  server.closeAllConnections();
2460
2576
  aiServer?.closeAllConnections();
2577
+ for (const sibling of siblingServers) sibling.closeAllConnections();
2461
2578
  }
2462
2579
 
2463
2580
  try {
@@ -2506,6 +2623,15 @@ ${reset}
2506
2623
  stopAllBackgroundTasks();
2507
2624
  if (aiServer) aiServer.close();
2508
2625
  server.close();
2626
+ // Sibling loopback listeners share the port — close them alongside the
2627
+ // primary (guarded: a sibling already gone must not throw out of close()).
2628
+ for (const sibling of siblingServers) {
2629
+ try {
2630
+ sibling.close();
2631
+ } catch {
2632
+ /* already not running */
2633
+ }
2634
+ }
2509
2635
  // Close database if ORM was initialized
2510
2636
  import("../../orm/src/index.js").then((orm) => orm.closeDatabase()).catch(() => {});
2511
2637
  },
@@ -2588,33 +2588,17 @@ var Frond = class _Frond {
2588
2588
  i++;
2589
2589
  }
2590
2590
  }
2591
- handleCache(tokens, start, context) {
2592
- const [content] = stripTag(tokens[start][1]);
2593
- const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
2594
- const cacheKey = m ? m[1] : "default";
2595
- const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
2596
- sweepExpiredCache(this.fragmentCache);
2597
- const cached = this.fragmentCache.get(cacheKey);
2598
- if (cached) {
2599
- const [htmlContent, expiresAt] = cached;
2600
- if (Date.now() < expiresAt) {
2601
- let i2 = start + 1;
2602
- let depth2 = 0;
2603
- while (i2 < tokens.length) {
2604
- if (tokens[i2][0] === "BLOCK") {
2605
- const [tagContent] = stripTag(tokens[i2][1]);
2606
- const tag = tagContent.split(/\s+/)[0] || "";
2607
- if (tag === "cache") depth2++;
2608
- else if (tag === "endcache") {
2609
- if (depth2 === 0) return [htmlContent, i2 + 1];
2610
- depth2--;
2611
- }
2612
- }
2613
- i2++;
2614
- }
2615
- return [htmlContent, i2];
2616
- }
2617
- }
2591
+ /**
2592
+ * Collect the body tokens of a {% <openTag> %}...{% end<openTag> %} block,
2593
+ * starting from the token after the opening tag (start + 1). Nested same-tag
2594
+ * blocks are kept in the body and balanced by depth; the matching closing tag is
2595
+ * consumed but NOT included. Returns [bodyTokens, indexAfterClosingTag].
2596
+ *
2597
+ * canNest guards the open-tag count: handleSetBlock passes it so the inline
2598
+ * {% set x = 1 %} form (which has no {% endset %}) never opens a nested block —
2599
+ * only the block form {% set x %} nests. Omitted, every openTag occurrence nests.
2600
+ */
2601
+ collectBlockBody(tokens, start, openTag, closeTag, canNest) {
2618
2602
  const bodyTokens = [];
2619
2603
  let i = start + 1;
2620
2604
  let depth = 0;
@@ -2622,10 +2606,10 @@ var Frond = class _Frond {
2622
2606
  if (tokens[i][0] === "BLOCK") {
2623
2607
  const [tagContent] = stripTag(tokens[i][1]);
2624
2608
  const tag = tagContent.split(/\s+/)[0] || "";
2625
- if (tag === "cache") {
2609
+ if (tag === openTag && (canNest ? canNest(tagContent) : true)) {
2626
2610
  depth++;
2627
2611
  bodyTokens.push(tokens[i]);
2628
- } else if (tag === "endcache") {
2612
+ } else if (tag === closeTag) {
2629
2613
  if (depth === 0) {
2630
2614
  i++;
2631
2615
  break;
@@ -2640,6 +2624,36 @@ var Frond = class _Frond {
2640
2624
  }
2641
2625
  i++;
2642
2626
  }
2627
+ return [bodyTokens, i];
2628
+ }
2629
+ handleCache(tokens, start, context) {
2630
+ const [content] = stripTag(tokens[start][1]);
2631
+ const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
2632
+ const cacheKey = m ? m[1] : "default";
2633
+ const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
2634
+ sweepExpiredCache(this.fragmentCache);
2635
+ const cached = this.fragmentCache.get(cacheKey);
2636
+ if (cached) {
2637
+ const [htmlContent, expiresAt] = cached;
2638
+ if (Date.now() < expiresAt) {
2639
+ let i2 = start + 1;
2640
+ let depth = 0;
2641
+ while (i2 < tokens.length) {
2642
+ if (tokens[i2][0] === "BLOCK") {
2643
+ const [tagContent] = stripTag(tokens[i2][1]);
2644
+ const tag = tagContent.split(/\s+/)[0] || "";
2645
+ if (tag === "cache") depth++;
2646
+ else if (tag === "endcache") {
2647
+ if (depth === 0) return [htmlContent, i2 + 1];
2648
+ depth--;
2649
+ }
2650
+ }
2651
+ i2++;
2652
+ }
2653
+ return [htmlContent, i2];
2654
+ }
2655
+ }
2656
+ const [bodyTokens, i] = this.collectBlockBody(tokens, start, "cache", "endcache");
2643
2657
  const rendered = this.renderTokens([...bodyTokens], context);
2644
2658
  capCache(this.fragmentCache, TEMPLATE_CACHE_MAX);
2645
2659
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1e3]);
@@ -2804,62 +2818,20 @@ var Frond = class _Frond {
2804
2818
  handleSetBlock(tokens, start, context) {
2805
2819
  const [content] = stripTag(tokens[start][1]);
2806
2820
  const name = (content.split(/\s+/)[1] || "").trim();
2807
- const bodyTokens = [];
2808
- let i = start + 1;
2809
- let depth = 0;
2810
- while (i < tokens.length) {
2811
- if (tokens[i][0] === "BLOCK") {
2812
- const [tagContent] = stripTag(tokens[i][1]);
2813
- const tag = tagContent.split(/\s+/)[0] || "";
2814
- if (tag === "set" && !tagContent.includes("=")) {
2815
- depth++;
2816
- bodyTokens.push(tokens[i]);
2817
- } else if (tag === "endset") {
2818
- if (depth === 0) {
2819
- i++;
2820
- break;
2821
- }
2822
- depth--;
2823
- bodyTokens.push(tokens[i]);
2824
- } else {
2825
- bodyTokens.push(tokens[i]);
2826
- }
2827
- } else {
2828
- bodyTokens.push(tokens[i]);
2829
- }
2830
- i++;
2831
- }
2821
+ const [bodyTokens, i] = this.collectBlockBody(
2822
+ tokens,
2823
+ start,
2824
+ "set",
2825
+ "endset",
2826
+ (tagContent) => !tagContent.includes("=")
2827
+ );
2832
2828
  if (name) {
2833
2829
  context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
2834
2830
  }
2835
2831
  return i;
2836
2832
  }
2837
2833
  handleSpaceless(tokens, start, context) {
2838
- const bodyTokens = [];
2839
- let i = start + 1;
2840
- let depth = 0;
2841
- while (i < tokens.length) {
2842
- if (tokens[i][0] === "BLOCK") {
2843
- const [tagContent] = stripTag(tokens[i][1]);
2844
- const tag = tagContent.split(/\s+/)[0] || "";
2845
- if (tag === "spaceless") {
2846
- depth++;
2847
- bodyTokens.push(tokens[i]);
2848
- } else if (tag === "endspaceless") {
2849
- if (depth === 0) {
2850
- i++;
2851
- break;
2852
- }
2853
- depth--;
2854
- bodyTokens.push(tokens[i]);
2855
- } else {
2856
- bodyTokens.push(tokens[i]);
2857
- }
2858
- } else {
2859
- bodyTokens.push(tokens[i]);
2860
- }
2861
- i++;
2862
- }
2834
+ const [bodyTokens, i] = this.collectBlockBody(tokens, start, "spaceless", "endspaceless");
2863
2835
  let rendered = this.renderTokens([...bodyTokens], context);
2864
2836
  rendered = rendered.replace(/>\s+</g, "><");
2865
2837
  return [rendered, i];
@@ -2868,31 +2840,7 @@ var Frond = class _Frond {
2868
2840
  const [content] = stripTag(tokens[start][1]);
2869
2841
  const modeMatch = content.match(/^autoescape\s+(false|true)/);
2870
2842
  const autoEscapeOn = !(modeMatch && modeMatch[1] === "false");
2871
- const bodyTokens = [];
2872
- let i = start + 1;
2873
- let depth = 0;
2874
- while (i < tokens.length) {
2875
- if (tokens[i][0] === "BLOCK") {
2876
- const [tagContent] = stripTag(tokens[i][1]);
2877
- const tag = tagContent.split(/\s+/)[0] || "";
2878
- if (tag === "autoescape") {
2879
- depth++;
2880
- bodyTokens.push(tokens[i]);
2881
- } else if (tag === "endautoescape") {
2882
- if (depth === 0) {
2883
- i++;
2884
- break;
2885
- }
2886
- depth--;
2887
- bodyTokens.push(tokens[i]);
2888
- } else {
2889
- bodyTokens.push(tokens[i]);
2890
- }
2891
- } else {
2892
- bodyTokens.push(tokens[i]);
2893
- }
2894
- i++;
2895
- }
2843
+ const [bodyTokens, i] = this.collectBlockBody(tokens, start, "autoescape", "endautoescape");
2896
2844
  if (!autoEscapeOn) {
2897
2845
  const oldAutoEscape = this._autoEscape;
2898
2846
  this._autoEscape = false;
@@ -3175,6 +3175,51 @@ export class Frond {
3175
3175
  }
3176
3176
  }
3177
3177
 
3178
+ /**
3179
+ * Collect the body tokens of a {% <openTag> %}...{% end<openTag> %} block,
3180
+ * starting from the token after the opening tag (start + 1). Nested same-tag
3181
+ * blocks are kept in the body and balanced by depth; the matching closing tag is
3182
+ * consumed but NOT included. Returns [bodyTokens, indexAfterClosingTag].
3183
+ *
3184
+ * canNest guards the open-tag count: handleSetBlock passes it so the inline
3185
+ * {% set x = 1 %} form (which has no {% endset %}) never opens a nested block —
3186
+ * only the block form {% set x %} nests. Omitted, every openTag occurrence nests.
3187
+ */
3188
+ private collectBlockBody(
3189
+ tokens: Token[],
3190
+ start: number,
3191
+ openTag: string,
3192
+ closeTag: string,
3193
+ canNest?: (tagContent: string) => boolean,
3194
+ ): [Token[], number] {
3195
+ const bodyTokens: Token[] = [];
3196
+ let i = start + 1;
3197
+ let depth = 0;
3198
+ while (i < tokens.length) {
3199
+ if (tokens[i][0] === "BLOCK") {
3200
+ const [tagContent] = stripTag(tokens[i][1]);
3201
+ const tag = tagContent.split(/\s+/)[0] || "";
3202
+ if (tag === openTag && (canNest ? canNest(tagContent) : true)) {
3203
+ depth++;
3204
+ bodyTokens.push(tokens[i]);
3205
+ } else if (tag === closeTag) {
3206
+ if (depth === 0) {
3207
+ i++;
3208
+ break;
3209
+ }
3210
+ depth--;
3211
+ bodyTokens.push(tokens[i]);
3212
+ } else {
3213
+ bodyTokens.push(tokens[i]);
3214
+ }
3215
+ } else {
3216
+ bodyTokens.push(tokens[i]);
3217
+ }
3218
+ i++;
3219
+ }
3220
+ return [bodyTokens, i];
3221
+ }
3222
+
3178
3223
  private handleCache(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
3179
3224
  const [content] = stripTag(tokens[start][1]);
3180
3225
  const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
@@ -3208,31 +3253,7 @@ export class Frond {
3208
3253
  }
3209
3254
 
3210
3255
  // Collect body tokens
3211
- const bodyTokens: Token[] = [];
3212
- let i = start + 1;
3213
- let depth = 0;
3214
- while (i < tokens.length) {
3215
- if (tokens[i][0] === "BLOCK") {
3216
- const [tagContent] = stripTag(tokens[i][1]);
3217
- const tag = tagContent.split(/\s+/)[0] || "";
3218
- if (tag === "cache") {
3219
- depth++;
3220
- bodyTokens.push(tokens[i]);
3221
- } else if (tag === "endcache") {
3222
- if (depth === 0) {
3223
- i++;
3224
- break;
3225
- }
3226
- depth--;
3227
- bodyTokens.push(tokens[i]);
3228
- } else {
3229
- bodyTokens.push(tokens[i]);
3230
- }
3231
- } else {
3232
- bodyTokens.push(tokens[i]);
3233
- }
3234
- i++;
3235
- }
3256
+ const [bodyTokens, i] = this.collectBlockBody(tokens, start, "cache", "endcache");
3236
3257
 
3237
3258
  // Render and cache
3238
3259
  const rendered = this.renderTokens([...bodyTokens], context);
@@ -3422,31 +3443,10 @@ export class Frond {
3422
3443
  const [content] = stripTag(tokens[start][1]);
3423
3444
  const name = (content.split(/\s+/)[1] || "").trim();
3424
3445
 
3425
- const bodyTokens: Token[] = [];
3426
- let i = start + 1;
3427
- let depth = 0;
3428
- while (i < tokens.length) {
3429
- if (tokens[i][0] === "BLOCK") {
3430
- const [tagContent] = stripTag(tokens[i][1]);
3431
- const tag = tagContent.split(/\s+/)[0] || "";
3432
- if (tag === "set" && !tagContent.includes("=")) {
3433
- depth++;
3434
- bodyTokens.push(tokens[i]);
3435
- } else if (tag === "endset") {
3436
- if (depth === 0) {
3437
- i++;
3438
- break;
3439
- }
3440
- depth--;
3441
- bodyTokens.push(tokens[i]);
3442
- } else {
3443
- bodyTokens.push(tokens[i]);
3444
- }
3445
- } else {
3446
- bodyTokens.push(tokens[i]);
3447
- }
3448
- i++;
3449
- }
3446
+ const [bodyTokens, i] = this.collectBlockBody(
3447
+ tokens, start, "set", "endset",
3448
+ (tagContent) => !tagContent.includes("="),
3449
+ );
3450
3450
 
3451
3451
  if (name) {
3452
3452
  context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
@@ -3455,31 +3455,7 @@ export class Frond {
3455
3455
  }
3456
3456
 
3457
3457
  private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
3458
- const bodyTokens: Token[] = [];
3459
- let i = start + 1;
3460
- let depth = 0;
3461
- while (i < tokens.length) {
3462
- if (tokens[i][0] === "BLOCK") {
3463
- const [tagContent] = stripTag(tokens[i][1]);
3464
- const tag = tagContent.split(/\s+/)[0] || "";
3465
- if (tag === "spaceless") {
3466
- depth++;
3467
- bodyTokens.push(tokens[i]);
3468
- } else if (tag === "endspaceless") {
3469
- if (depth === 0) {
3470
- i++;
3471
- break;
3472
- }
3473
- depth--;
3474
- bodyTokens.push(tokens[i]);
3475
- } else {
3476
- bodyTokens.push(tokens[i]);
3477
- }
3478
- } else {
3479
- bodyTokens.push(tokens[i]);
3480
- }
3481
- i++;
3482
- }
3458
+ const [bodyTokens, i] = this.collectBlockBody(tokens, start, "spaceless", "endspaceless");
3483
3459
 
3484
3460
  let rendered = this.renderTokens([...bodyTokens], context);
3485
3461
  rendered = rendered.replace(/>\s+</g, "><");
@@ -3491,31 +3467,7 @@ export class Frond {
3491
3467
  const modeMatch = content.match(/^autoescape\s+(false|true)/);
3492
3468
  const autoEscapeOn = !(modeMatch && modeMatch[1] === "false");
3493
3469
 
3494
- const bodyTokens: Token[] = [];
3495
- let i = start + 1;
3496
- let depth = 0;
3497
- while (i < tokens.length) {
3498
- if (tokens[i][0] === "BLOCK") {
3499
- const [tagContent] = stripTag(tokens[i][1]);
3500
- const tag = tagContent.split(/\s+/)[0] || "";
3501
- if (tag === "autoescape") {
3502
- depth++;
3503
- bodyTokens.push(tokens[i]);
3504
- } else if (tag === "endautoescape") {
3505
- if (depth === 0) {
3506
- i++;
3507
- break;
3508
- }
3509
- depth--;
3510
- bodyTokens.push(tokens[i]);
3511
- } else {
3512
- bodyTokens.push(tokens[i]);
3513
- }
3514
- } else {
3515
- bodyTokens.push(tokens[i]);
3516
- }
3517
- i++;
3518
- }
3470
+ const [bodyTokens, i] = this.collectBlockBody(tokens, start, "autoescape", "endautoescape");
3519
3471
 
3520
3472
  if (!autoEscapeOn) {
3521
3473
  const oldAutoEscape = this._autoEscape;