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.
- package/CLAUDE.md +3 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +398 -302
- package/packages/core/dist/index.js +399 -302
- package/packages/core/public/js/tina4-dev-admin.min.js +148 -512
- package/packages/core/src/devAdmin.ts +0 -46
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/server.ts +129 -3
- package/packages/frond/dist/index.js +52 -104
- package/packages/frond/src/engine.ts +52 -100
- package/packages/orm/dist/index.js +399 -302
- package/packages/orm/src/baseModel.ts +64 -55
- package/packages/orm/src/index.ts +2 -0
- package/packages/orm/src/modelCollection.ts +149 -0
- package/packages/swagger/dist/index.js +150 -116
- package/packages/swagger/src/generator.ts +261 -154
- package/types/core/src/index.d.ts +1 -1
- package/types/core/src/server.d.ts +15 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/orm/src/baseModel.d.ts +20 -5
- package/types/orm/src/index.d.ts +2 -0
- package/types/orm/src/modelCollection.d.ts +101 -0
|
@@ -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
|
-
|
|
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 ?
|
|
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
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
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 ===
|
|
2609
|
+
if (tag === openTag && (canNest ? canNest(tagContent) : true)) {
|
|
2626
2610
|
depth++;
|
|
2627
2611
|
bodyTokens.push(tokens[i]);
|
|
2628
|
-
} else if (tag ===
|
|
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
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
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
|
|
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
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
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
|
|
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
|
|
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;
|