@akanjs/devkit 3.1.0-rc.21 → 3.1.0-rc.22
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/applicationBuildRunner.test.ts +41 -2
- package/applicationBuildRunner.ts +23 -2
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +32 -7
- package/biome.base.json +4 -1
- package/frontendBuild/csrArtifactBuilder.ts +22 -0
- package/frontendBuild/fontPruner.ts +1 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
- package/frontendBuild/mcpAppViewBuilder.ts +245 -0
- package/incrementalBuilder/buildBatch.proc.ts +3 -3
- package/incrementalBuilder/buildBatchProtocol.ts +2 -2
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +24 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +22 -0
- package/lint/no-conditional-st-hook.grit +75 -0
- package/mobile/desktopServerStage.ts +1 -0
- package/package.json +2 -2
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/transforms/mcpAppPageTrimmer.test.ts +89 -0
- package/transforms/mcpAppPageTrimmer.ts +70 -0
- package/transforms/pureLazyMarker.test.ts +58 -0
- package/transforms/pureLazyMarker.ts +42 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { symlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
5
|
+
import { McpAppPageTrimmer } from "./mcpAppPageTrimmer";
|
|
6
|
+
|
|
7
|
+
const makeTempRoot = tempDirs("akan-mcp-app-trim-");
|
|
8
|
+
|
|
9
|
+
describe("McpAppPageTrimmer", () => {
|
|
10
|
+
test("hands every stage but the apps a function that draws nothing, and keeps the rest of the chain as written", () => {
|
|
11
|
+
const source = [
|
|
12
|
+
'import { fetch, Memo } from "@apps/minimal/client";',
|
|
13
|
+
'import { Screen } from "@apps/minimal/ui";',
|
|
14
|
+
"export default page()",
|
|
15
|
+
' .param("memoId", ID)',
|
|
16
|
+
" .config({ topInset: 48 })",
|
|
17
|
+
" .guard(async ({ memoId }) => { await fetch.memo(memoId); })",
|
|
18
|
+
" .head(<title>Memo</title>)",
|
|
19
|
+
" .loading(() => <Screen>loading</Screen>)",
|
|
20
|
+
' .app("showMemo", "Show one memo.", ({ memoId }) => <Memo.Zone.View view={fetch.viewMemo(memoId).memoView} />)',
|
|
21
|
+
" .render(({ memoId, showMemo }) => (",
|
|
22
|
+
' <Screen className="pb-10">{showMemo}</Screen>',
|
|
23
|
+
" ));",
|
|
24
|
+
].join("\n");
|
|
25
|
+
expect(McpAppPageTrimmer.trim(source, "memo.tsx")).toBe(
|
|
26
|
+
[
|
|
27
|
+
'import { fetch, Memo } from "@apps/minimal/client";',
|
|
28
|
+
'import { Screen } from "@apps/minimal/ui";',
|
|
29
|
+
"export default page()",
|
|
30
|
+
' .param("memoId", ID)',
|
|
31
|
+
" .config({ topInset: 48 })",
|
|
32
|
+
" .guard(() => null)",
|
|
33
|
+
" .head(() => null)",
|
|
34
|
+
" .loading(() => null)",
|
|
35
|
+
' .app("showMemo", "Show one memo.", ({ memoId }) => <Memo.Zone.View view={fetch.viewMemo(memoId).memoView} />)',
|
|
36
|
+
" .render(() => null);",
|
|
37
|
+
].join("\n"),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("trims a page reached through a link, as a synced lib page is", async () => {
|
|
42
|
+
const root = await makeTempRoot();
|
|
43
|
+
await writeText(
|
|
44
|
+
path.join(root, "libs/shared/page/login.tsx"),
|
|
45
|
+
[
|
|
46
|
+
"const page = () => ({ render: (draw: () => string) => draw });",
|
|
47
|
+
'export default page().render(() => "drawn by the render");',
|
|
48
|
+
].join("\n"),
|
|
49
|
+
);
|
|
50
|
+
await symlink(path.join(root, "libs/shared/page"), path.join(root, "linked"));
|
|
51
|
+
await writeText(path.join(root, "entry.ts"), 'import login from "./linked/login.tsx";\nexport { login };\n');
|
|
52
|
+
const result = await Bun.build({
|
|
53
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
54
|
+
plugins: [McpAppPageTrimmer.plugin([path.join(root, "linked/login.tsx")])],
|
|
55
|
+
});
|
|
56
|
+
const [output] = result.outputs;
|
|
57
|
+
expect(result.success).toBe(true);
|
|
58
|
+
expect(await output?.text()).not.toContain("drawn by the render");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("drops a lazy component only the page's render drew, with what it imports", async () => {
|
|
62
|
+
const root = await makeTempRoot();
|
|
63
|
+
await writeText(path.join(root, "Globe.ts"), 'export default "a heavy globe";\n');
|
|
64
|
+
await writeText(
|
|
65
|
+
path.join(root, "trip.tsx"),
|
|
66
|
+
[
|
|
67
|
+
"const lazy = (load: () => Promise<unknown>) => ({ load });",
|
|
68
|
+
'const Globe = lazy(() => import("./Globe"));',
|
|
69
|
+
"const page = () => ({ app: (name: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, draw, render] }) });",
|
|
70
|
+
'export default page().app("showTrip", () => "a trip card").render(() => Globe);',
|
|
71
|
+
].join("\n"),
|
|
72
|
+
);
|
|
73
|
+
await writeText(path.join(root, "entry.ts"), 'import trip from "./trip.tsx";\nconsole.info(trip);\n');
|
|
74
|
+
const result = await Bun.build({
|
|
75
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
76
|
+
plugins: [McpAppPageTrimmer.plugin([path.join(root, "trip.tsx")])],
|
|
77
|
+
splitting: false,
|
|
78
|
+
});
|
|
79
|
+
const text = (await result.outputs[0]?.text()) ?? "";
|
|
80
|
+
expect(result.success).toBe(true);
|
|
81
|
+
expect(text).toContain("a trip card");
|
|
82
|
+
expect(text).not.toContain("a heavy globe");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("leaves a module with no chain as it is", () => {
|
|
86
|
+
const legacy = "export default function Page() { return <main />; }\nexport const pageConfig = {};";
|
|
87
|
+
expect(McpAppPageTrimmer.trim(legacy, "legacy.tsx")).toBe(legacy);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { BunPlugin } from "bun";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { PureLazyMarker } from "./pureLazyMarker";
|
|
6
|
+
|
|
7
|
+
type ChainStage = ts.CallExpression & { expression: ts.PropertyAccessExpression };
|
|
8
|
+
|
|
9
|
+
//* An MCP App's view runs its page's `.app()` loads and no other stage, so the view build hands each such stage a
|
|
10
|
+
//* function that draws nothing, and the bundler drops what only the page's own markup reached.
|
|
11
|
+
export class McpAppPageTrimmer {
|
|
12
|
+
static readonly #unused = new Set(["render", "head", "loading", "guard"]);
|
|
13
|
+
|
|
14
|
+
/** Trims exactly `pagePaths`: the rest of the graph loads as it does for the CSR shell. */
|
|
15
|
+
static plugin(pagePaths: string[]): BunPlugin {
|
|
16
|
+
return {
|
|
17
|
+
name: "akan-mcp-app-page",
|
|
18
|
+
setup(build) {
|
|
19
|
+
if (!pagePaths.length) return;
|
|
20
|
+
//? The bundler loads a module by the path a link points at: a synced lib page is one under `page/(libs)`.
|
|
21
|
+
const loadedPaths = new Set(
|
|
22
|
+
pagePaths.flatMap((pagePath) => [pagePath, McpAppPageTrimmer.#realPathOf(pagePath)]),
|
|
23
|
+
);
|
|
24
|
+
const filter = new RegExp(`^(?:${[...loadedPaths].map((pagePath) => RegExp.escape(pagePath)).join("|")})$`);
|
|
25
|
+
//? The bundler runs one onLoad per module, so this one also marks the page's own `lazy()` components.
|
|
26
|
+
build.onLoad({ filter }, async (args) => ({
|
|
27
|
+
contents: PureLazyMarker.mark(McpAppPageTrimmer.trim(await Bun.file(args.path).text(), args.path), args.path),
|
|
28
|
+
loader: path.extname(args.path).slice(1) as "tsx" | "ts" | "jsx" | "js",
|
|
29
|
+
}));
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static trim(source: string, filePath: string): string {
|
|
35
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
36
|
+
const spans = McpAppPageTrimmer.#stagesOf(sourceFile)
|
|
37
|
+
.flatMap((stage) => {
|
|
38
|
+
const [first] = stage.arguments;
|
|
39
|
+
if (!first || !McpAppPageTrimmer.#unused.has(stage.expression.name.text)) return [];
|
|
40
|
+
return [{ start: first.getStart(sourceFile), end: first.getEnd() }];
|
|
41
|
+
})
|
|
42
|
+
.sort((a, b) => b.start - a.start);
|
|
43
|
+
let trimmed = source;
|
|
44
|
+
for (const { start, end } of spans) trimmed = `${trimmed.slice(0, start)}() => null${trimmed.slice(end)}`;
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static #realPathOf(filePath: string) {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(filePath);
|
|
51
|
+
} catch {
|
|
52
|
+
return filePath;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static #stagesOf(sourceFile: ts.SourceFile): ChainStage[] {
|
|
57
|
+
const stages: ChainStage[] = [];
|
|
58
|
+
for (const statement of sourceFile.statements) {
|
|
59
|
+
if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
|
|
60
|
+
let node: ts.Expression = statement.expression;
|
|
61
|
+
while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node))
|
|
62
|
+
node = node.expression;
|
|
63
|
+
while (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
64
|
+
stages.push(node as ChainStage);
|
|
65
|
+
node = node.expression.expression;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return stages;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
4
|
+
import { PureLazyMarker } from "./pureLazyMarker";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-pure-lazy-");
|
|
7
|
+
|
|
8
|
+
describe("PureLazyMarker", () => {
|
|
9
|
+
test("marks each lazy() a declaration holds, and nothing else", () => {
|
|
10
|
+
const source = [
|
|
11
|
+
'import { lazy } from "akanjs/webkit";',
|
|
12
|
+
'export const Globe = lazy(() => import("./Globe_Dynamic"), { ssr: false });',
|
|
13
|
+
"const Editor: Component = lazy(() =>",
|
|
14
|
+
' import("./Editor"),',
|
|
15
|
+
");",
|
|
16
|
+
'const sample = `const Chart = lazy(() => import("./Chart"));`;',
|
|
17
|
+
'register(lazy(() => import("./Side")));',
|
|
18
|
+
].join("\n");
|
|
19
|
+
expect(PureLazyMarker.mark(source, "ui.tsx")).toBe(
|
|
20
|
+
[
|
|
21
|
+
'import { lazy } from "akanjs/webkit";',
|
|
22
|
+
'export const Globe = /* @__PURE__ */ lazy(() => import("./Globe_Dynamic"), { ssr: false });',
|
|
23
|
+
"const Editor: Component = /* @__PURE__ */ lazy(() =>",
|
|
24
|
+
' import("./Editor"),',
|
|
25
|
+
");",
|
|
26
|
+
'const sample = `const Chart = lazy(() => import("./Chart"));`;',
|
|
27
|
+
'register(lazy(() => import("./Side")));',
|
|
28
|
+
].join("\n"),
|
|
29
|
+
);
|
|
30
|
+
const plain = "export const one = 1;";
|
|
31
|
+
expect(PureLazyMarker.mark(plain, "plain.ts")).toBe(plain);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("lets the bundler drop a barrel's lazy component that nothing renders, with what it imports", async () => {
|
|
35
|
+
const root = await makeTempRoot();
|
|
36
|
+
await writeText(path.join(root, "lazy.ts"), "export const lazy = (load: () => Promise<unknown>) => ({ load });\n");
|
|
37
|
+
await writeText(path.join(root, "Heavy.ts"), 'export default "a heavy chunk";\n');
|
|
38
|
+
await writeText(
|
|
39
|
+
path.join(root, "ui.ts"),
|
|
40
|
+
[
|
|
41
|
+
'import { lazy } from "./lazy";',
|
|
42
|
+
'export const Heavy = lazy(() => import("./Heavy"));',
|
|
43
|
+
'export const Card = "a card";',
|
|
44
|
+
].join("\n"),
|
|
45
|
+
);
|
|
46
|
+
await writeText(path.join(root, "entry.ts"), 'import { Card } from "./ui";\nconsole.info(Card);\n');
|
|
47
|
+
const result = await Bun.build({
|
|
48
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
49
|
+
plugins: [PureLazyMarker.plugin()],
|
|
50
|
+
splitting: false,
|
|
51
|
+
});
|
|
52
|
+
const [output] = result.outputs;
|
|
53
|
+
expect(result.success).toBe(true);
|
|
54
|
+
const text = (await output?.text()) ?? "";
|
|
55
|
+
expect(text).toContain("a card");
|
|
56
|
+
expect(text).not.toContain("a heavy chunk");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BunPlugin } from "bun";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
//* A view inlines every module its loads reach, so a `lazy()` component that nothing renders (one of a barrel's, or
|
|
5
|
+
//* of an `_overrides.tsx` manifest's) would still carry its chunk. `lazy()` only builds a component, so its call is
|
|
6
|
+
//* marked pure, and the bundler drops it, chunk and all, wherever its result goes unread.
|
|
7
|
+
export class PureLazyMarker {
|
|
8
|
+
static plugin(): BunPlugin {
|
|
9
|
+
return {
|
|
10
|
+
name: "akan-pure-lazy",
|
|
11
|
+
setup(build) {
|
|
12
|
+
build.onLoad({ filter: /\.tsx?$/ }, async (args) => {
|
|
13
|
+
const file = Bun.file(args.path);
|
|
14
|
+
//? A view's own entry lives only in the build's `files`, where the bundler reads it without a plugin.
|
|
15
|
+
if (!(await file.exists())) return undefined;
|
|
16
|
+
const source = await file.text();
|
|
17
|
+
const marked = PureLazyMarker.mark(source, args.path);
|
|
18
|
+
return marked === source
|
|
19
|
+
? undefined
|
|
20
|
+
: { contents: marked, loader: args.path.endsWith(".tsx") ? "tsx" : "ts" };
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static mark(source: string, filePath: string): string {
|
|
27
|
+
if (!source.includes("lazy(")) return source;
|
|
28
|
+
const kind = filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
29
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, kind);
|
|
30
|
+
const starts: number[] = [];
|
|
31
|
+
const visit = (node: ts.Node) => {
|
|
32
|
+
const call = ts.isVariableDeclaration(node) ? node.initializer : undefined;
|
|
33
|
+
if (call && ts.isCallExpression(call) && ts.isIdentifier(call.expression) && call.expression.text === "lazy")
|
|
34
|
+
starts.push(call.getStart(sourceFile));
|
|
35
|
+
ts.forEachChild(node, visit);
|
|
36
|
+
};
|
|
37
|
+
visit(sourceFile);
|
|
38
|
+
return starts
|
|
39
|
+
.sort((a, b) => b - a)
|
|
40
|
+
.reduce((marked, start) => `${marked.slice(0, start)}/* @__PURE__ */ ${marked.slice(start)}`, source);
|
|
41
|
+
}
|
|
42
|
+
}
|