@akanjs/devkit 3.1.0-rc.20 → 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 +6 -2
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +47 -10
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +130 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/executors.test.ts +23 -0
- 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/scanInfo.ts +36 -3
- 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
|
@@ -6,6 +6,7 @@ import type { App } from "../commandDecorators";
|
|
|
6
6
|
import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
|
|
7
7
|
import { bundleDefine } from "./bundleDefine";
|
|
8
8
|
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
9
|
+
import { type McpAppView, McpAppViewBuilder } from "./mcpAppViewBuilder";
|
|
9
10
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
10
11
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
11
12
|
|
|
@@ -77,11 +78,32 @@ export class CsrArtifactBuilder {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
await this.#inlineCsrArtifacts(cssAssets);
|
|
81
|
+
await this.#buildMcpAppViews(pageEntries, { basePaths, cssAssets, options });
|
|
80
82
|
if (this.#command === "build") await CsrArtifactBuilder.writePublicEnv(this.#app, options.define);
|
|
81
83
|
this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
|
|
82
84
|
return { outputDir: this.#outputDir };
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/** The MCP App views alone: a dev server under the CSR registry reads them, with no `csr/` to build beside them. */
|
|
88
|
+
async buildMcpAppViews(): Promise<McpAppView[]> {
|
|
89
|
+
const pageKeys = await this.#app.getPageKeys();
|
|
90
|
+
if (pageKeys.length === 0) return [];
|
|
91
|
+
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
92
|
+
const akanConfig = await this.#app.getConfig();
|
|
93
|
+
return await this.#buildMcpAppViews(pageEntries, {
|
|
94
|
+
basePaths: [...akanConfig.basePaths],
|
|
95
|
+
cssAssets: await this.#loadCssAssets(),
|
|
96
|
+
options: CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async #buildMcpAppViews(pageEntries: PageEntry[], option: Parameters<McpAppViewBuilder["build"]>[1]) {
|
|
101
|
+
const views = await new McpAppViewBuilder(this.#app, this.#command).build(pageEntries, option);
|
|
102
|
+
if (views.length)
|
|
103
|
+
this.#app.verbose(`[csr-build] ${views.length} MCP App view(s): ${views.map((view) => view.name).join(", ")}`);
|
|
104
|
+
return views;
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
//? No `env` option: Bun inlines the `.env` it read at process start over `define`, so a native build's `--env` lost.
|
|
86
108
|
static bundleOptions(app: App, command: "build" | "start", optimizeImports: string[] = []) {
|
|
87
109
|
return {
|
|
@@ -103,6 +103,7 @@ export class FontPruner {
|
|
|
103
103
|
{ dir: path.join(this.#artifactRoot, "client-ssr"), label: ".akan/artifact/client-ssr", needles: bundled },
|
|
104
104
|
{ dir: path.join(this.#artifactRoot, "server"), label: ".akan/artifact/server", needles: bundled },
|
|
105
105
|
{ dir: path.join(this.#app.dist.cwdPath, "csr"), label: "csr", needles: bundled },
|
|
106
|
+
{ dir: path.join(this.#app.dist.cwdPath, "mcp-apps"), label: "mcp-apps", needles: bundled },
|
|
106
107
|
];
|
|
107
108
|
for (const root of roots) {
|
|
108
109
|
const pending = root.needles.filter((candidate) => !referrers.has(candidate.rel));
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
4
|
+
import { McpAppViewBuilder } from "./mcpAppViewBuilder";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-mcp-app-views-");
|
|
7
|
+
|
|
8
|
+
const memoPage = [
|
|
9
|
+
'import { page } from "akanjs/client";',
|
|
10
|
+
"export default page()",
|
|
11
|
+
' .param("memoId", ID)',
|
|
12
|
+
' .app("showMemo", "Show one memo.", ({ memoId }) => null)',
|
|
13
|
+
' .app("showMemoImages", "Show its images.", ({ memoId }) => null)',
|
|
14
|
+
" .render(() => null);",
|
|
15
|
+
].join("\n");
|
|
16
|
+
|
|
17
|
+
describe("McpAppViewBuilder.discover", () => {
|
|
18
|
+
test("finds each app of each page, under the root layout that renders the system provider", async () => {
|
|
19
|
+
const appRoot = await makeTempRoot();
|
|
20
|
+
const file = (rel: string) => path.join(appRoot, rel);
|
|
21
|
+
await write(file("generated/__root_layout.tsx"), "export function McpAppRoot() {}\n");
|
|
22
|
+
await write(file("generated/(home)/__root_layout.tsx"), "export const guard = userLayout.guard;\n");
|
|
23
|
+
await write(file("page/(home)/memo/[memoId].tsx"), memoPage);
|
|
24
|
+
await write(
|
|
25
|
+
file("page/admin/report.tsx"),
|
|
26
|
+
'export default page().app("showReport", "Show the report.", () => null).render(() => null);',
|
|
27
|
+
);
|
|
28
|
+
await write(file("page/plain.tsx"), "export default page().render(() => null);");
|
|
29
|
+
const entries = [
|
|
30
|
+
{ key: "./__root_layout.tsx", moduleAbsPath: file("generated/__root_layout.tsx") },
|
|
31
|
+
{ key: "./(home)/__root_layout.tsx", moduleAbsPath: file("generated/(home)/__root_layout.tsx") },
|
|
32
|
+
{ key: "./(home)/memo/[memoId].tsx", moduleAbsPath: file("page/(home)/memo/[memoId].tsx") },
|
|
33
|
+
{ key: "./admin/report.tsx", moduleAbsPath: file("page/admin/report.tsx") },
|
|
34
|
+
{ key: "./plain.tsx", moduleAbsPath: file("page/plain.tsx") },
|
|
35
|
+
];
|
|
36
|
+
const views = await McpAppViewBuilder.discover(entries, ["admin"]);
|
|
37
|
+
expect(views.map(({ name, page, root, basePath }) => [name, page.key, root?.key, basePath])).toEqual([
|
|
38
|
+
["showMemo", "./(home)/memo/[memoId].tsx", "./__root_layout.tsx", ""],
|
|
39
|
+
["showMemoImages", "./(home)/memo/[memoId].tsx", "./__root_layout.tsx", ""],
|
|
40
|
+
["showReport", "./admin/report.tsx", "./__root_layout.tsx", "admin"],
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("takes the overrides over each page by route, outermost first, and none from a sibling group", async () => {
|
|
45
|
+
const appRoot = await makeTempRoot();
|
|
46
|
+
const file = (rel: string) => path.join(appRoot, rel);
|
|
47
|
+
await write(file("page/(home)/memo.tsx"), memoPage);
|
|
48
|
+
const entries = [
|
|
49
|
+
{ key: "./(home)/memo.tsx", moduleAbsPath: file("page/(home)/memo.tsx") },
|
|
50
|
+
{ key: "./(home)/memo/_overrides.tsx", moduleAbsPath: file("generated/memo_overrides.tsx") },
|
|
51
|
+
{ key: "./_overrides.tsx", moduleAbsPath: file("generated/_overrides.tsx") },
|
|
52
|
+
{ key: "./(home)/_overrides.tsx", moduleAbsPath: file("generated/home_overrides.tsx") },
|
|
53
|
+
{ key: "./(tab)/_overrides.tsx", moduleAbsPath: file("generated/tab_overrides.tsx") },
|
|
54
|
+
];
|
|
55
|
+
const [view] = await McpAppViewBuilder.discover(entries, []);
|
|
56
|
+
expect(view?.rootOverrides).toEqual([]);
|
|
57
|
+
expect(view?.overrides.map((entry) => entry.key)).toEqual([
|
|
58
|
+
"./_overrides.tsx",
|
|
59
|
+
"./(home)/_overrides.tsx",
|
|
60
|
+
"./(home)/memo/_overrides.tsx",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("puts the overrides on the root layout's node and above it outside that layout, as the route tree does", async () => {
|
|
65
|
+
const appRoot = await makeTempRoot();
|
|
66
|
+
const file = (rel: string) => path.join(appRoot, rel);
|
|
67
|
+
await write(file("generated/(home)/__root_layout.tsx"), "export function McpAppRoot() {}\n");
|
|
68
|
+
await write(file("page/(home)/memo.tsx"), memoPage);
|
|
69
|
+
const entries = [
|
|
70
|
+
{ key: "./(home)/__root_layout.tsx", moduleAbsPath: file("generated/(home)/__root_layout.tsx") },
|
|
71
|
+
{ key: "./(home)/memo.tsx", moduleAbsPath: file("page/(home)/memo.tsx") },
|
|
72
|
+
{ key: "./(home)/memo/_overrides.tsx", moduleAbsPath: file("generated/memo_overrides.tsx") },
|
|
73
|
+
{ key: "./(home)/_overrides.tsx", moduleAbsPath: file("generated/home_overrides.tsx") },
|
|
74
|
+
{ key: "./_overrides.tsx", moduleAbsPath: file("generated/_overrides.tsx") },
|
|
75
|
+
];
|
|
76
|
+
const [view] = await McpAppViewBuilder.discover(entries, []);
|
|
77
|
+
expect(view?.root?.key).toBe("./(home)/__root_layout.tsx");
|
|
78
|
+
expect(view?.rootOverrides.map((entry) => entry.key)).toEqual(["./_overrides.tsx", "./(home)/_overrides.tsx"]);
|
|
79
|
+
expect(view?.overrides.map((entry) => entry.key)).toEqual(["./(home)/memo/_overrides.tsx"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("keeps an override inside the root layout when its folder holds a layout above the root's", async () => {
|
|
83
|
+
const appRoot = await makeTempRoot();
|
|
84
|
+
const file = (rel: string) => path.join(appRoot, rel);
|
|
85
|
+
await write(file("generated/oauth/__root_layout.tsx"), "export function McpAppRoot() {}\n");
|
|
86
|
+
await write(file("page/(libs)/(shared)/oauth/consent.tsx"), memoPage);
|
|
87
|
+
const entries = [
|
|
88
|
+
{ key: "./(libs)/(shared)/oauth/__root_layout.tsx", moduleAbsPath: file("generated/oauth/__root_layout.tsx") },
|
|
89
|
+
{ key: "./(libs)/(shared)/_layout.tsx", moduleAbsPath: file("page/(libs)/(shared)/_layout.tsx") },
|
|
90
|
+
{ key: "./(libs)/(shared)/oauth/consent.tsx", moduleAbsPath: file("page/(libs)/(shared)/oauth/consent.tsx") },
|
|
91
|
+
{ key: "./(libs)/(shared)/oauth/_overrides.tsx", moduleAbsPath: file("generated/oauth_overrides.tsx") },
|
|
92
|
+
{ key: "./(libs)/(shared)/_overrides.tsx", moduleAbsPath: file("generated/shared_overrides.tsx") },
|
|
93
|
+
{ key: "./_overrides.tsx", moduleAbsPath: file("generated/_overrides.tsx") },
|
|
94
|
+
];
|
|
95
|
+
const [view] = await McpAppViewBuilder.discover(entries, []);
|
|
96
|
+
expect(view?.rootOverrides.map((entry) => entry.key)).toEqual([
|
|
97
|
+
"./_overrides.tsx",
|
|
98
|
+
"./(libs)/(shared)/oauth/_overrides.tsx",
|
|
99
|
+
]);
|
|
100
|
+
expect(view?.overrides.map((entry) => entry.key)).toEqual(["./(libs)/(shared)/_overrides.tsx"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("names two pages that claim one app, and a page with no root layout to provide for it", async () => {
|
|
104
|
+
const appRoot = await makeTempRoot();
|
|
105
|
+
const file = (rel: string) => path.join(appRoot, rel);
|
|
106
|
+
await write(file("page/a.tsx"), memoPage);
|
|
107
|
+
await write(file("page/b.tsx"), memoPage);
|
|
108
|
+
const entries = [
|
|
109
|
+
{ key: "./a.tsx", moduleAbsPath: file("page/a.tsx") },
|
|
110
|
+
{ key: "./b.tsx", moduleAbsPath: file("page/b.tsx") },
|
|
111
|
+
];
|
|
112
|
+
await expect(McpAppViewBuilder.discover(entries, [])).rejects.toThrow(
|
|
113
|
+
'app "showMemo" is declared by both ./a.tsx and ./b.tsx',
|
|
114
|
+
);
|
|
115
|
+
const [view] = await McpAppViewBuilder.discover(entries.slice(0, 1), []);
|
|
116
|
+
expect(view?.root).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("refuses a name MCP would not list, and two that differ only in case", async () => {
|
|
120
|
+
const appRoot = await makeTempRoot();
|
|
121
|
+
const file = (rel: string) => path.join(appRoot, rel);
|
|
122
|
+
await write(file("page/a.tsx"), memoPage);
|
|
123
|
+
await write(file("page/b.tsx"), memoPage.replace('"showMemo"', '"ShowMemo"'));
|
|
124
|
+
await write(file("page/c.tsx"), memoPage.replace('"showMemo"', '"../showMemo"'));
|
|
125
|
+
const entryOf = (name: string) => ({ key: `./${name}.tsx`, moduleAbsPath: file(`page/${name}.tsx`) });
|
|
126
|
+
await expect(McpAppViewBuilder.discover([entryOf("a"), entryOf("b")], [])).rejects.toThrow(
|
|
127
|
+
'apps "showMemo" (./a.tsx) and "ShowMemo" (./b.tsx) differ only in case',
|
|
128
|
+
);
|
|
129
|
+
await expect(McpAppViewBuilder.discover([entryOf("c")], [])).rejects.toThrow(
|
|
130
|
+
'app name "../showMemo" in ./c.tsx must match',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("McpAppViewBuilder.entryOf", () => {
|
|
136
|
+
test("boots the one app with its page, the root layout and overrides it sits under, by relative path", () => {
|
|
137
|
+
const view = {
|
|
138
|
+
name: "showMemo",
|
|
139
|
+
page: { key: "./memo/[memoId].tsx", moduleAbsPath: "/app/page/memo/[memoId].tsx" },
|
|
140
|
+
root: { key: "./__root_layout.tsx", moduleAbsPath: "/app/.akan/generated/root-layouts/__root_layout.tsx" },
|
|
141
|
+
rootOverrides: [{ key: "./_overrides.tsx", moduleAbsPath: "/app/.akan/generated/overrides/_overrides.tsx" }],
|
|
142
|
+
overrides: [{ key: "./memo/_overrides.tsx", moduleAbsPath: "/app/.akan/generated/overrides/memo.tsx" }],
|
|
143
|
+
basePath: "admin",
|
|
144
|
+
};
|
|
145
|
+
expect(McpAppViewBuilder.entryOf(view, "/app/.akan/generated/mcp-apps")).toBe(
|
|
146
|
+
[
|
|
147
|
+
'import { McpAppBoot } from "akanjs/webkit";',
|
|
148
|
+
'import * as page from "../../../page/memo/[memoId].tsx";',
|
|
149
|
+
'import * as root from "../root-layouts/__root_layout.tsx";',
|
|
150
|
+
'import rootOverride0 from "../overrides/_overrides.tsx";',
|
|
151
|
+
'import override0 from "../overrides/memo.tsx";',
|
|
152
|
+
"void McpAppBoot.run({",
|
|
153
|
+
' app: "showMemo",',
|
|
154
|
+
" page,",
|
|
155
|
+
" root,",
|
|
156
|
+
" rootOverrides: [rootOverride0],",
|
|
157
|
+
" overrides: [override0],",
|
|
158
|
+
' basePath: "admin",',
|
|
159
|
+
"});",
|
|
160
|
+
"",
|
|
161
|
+
].join("\n"),
|
|
162
|
+
);
|
|
163
|
+
expect(
|
|
164
|
+
McpAppViewBuilder.entryOf(
|
|
165
|
+
{ ...view, root: null, rootOverrides: [], overrides: [] },
|
|
166
|
+
"/app/.akan/generated/mcp-apps",
|
|
167
|
+
),
|
|
168
|
+
).toContain("const root = {};\nvoid McpAppBoot.run({");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseRouteModuleKey } from "akanjs/common";
|
|
4
|
+
import type { BaseBuildArtifact } from "akanjs/server";
|
|
5
|
+
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
6
|
+
import type { App } from "../commandDecorators";
|
|
7
|
+
import { McpAppPageTrimmer } from "../transforms/mcpAppPageTrimmer";
|
|
8
|
+
import { PureLazyMarker } from "../transforms/pureLazyMarker";
|
|
9
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
10
|
+
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
11
|
+
|
|
12
|
+
type CssAsset = NonNullable<BaseBuildArtifact["cssAssets"]>[string];
|
|
13
|
+
|
|
14
|
+
interface McpAppViewBuildOption {
|
|
15
|
+
basePaths: string[];
|
|
16
|
+
cssAssets: Record<string, CssAsset>;
|
|
17
|
+
/** The CSR shell's own bundle options, so a view inlines the same env and stubs the same stylesheets. */
|
|
18
|
+
options: ReturnType<typeof CsrArtifactBuilder.bundleOptions>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpAppView {
|
|
22
|
+
name: string;
|
|
23
|
+
page: PageEntry;
|
|
24
|
+
/** The generated root layout that renders `System.Provider` for the page, whose `McpAppRoot` the view uses. */
|
|
25
|
+
root: PageEntry | null;
|
|
26
|
+
/** The generated wrappers of the `_overrides.tsx` the web draws outside the root layout, outermost first: they also
|
|
27
|
+
* reach what it draws beside the page, its toasts and approvals. */
|
|
28
|
+
rootOverrides: PageEntry[];
|
|
29
|
+
/** The generated wrappers of the rest of the `_overrides.tsx` over the page, outermost first, around it alone. */
|
|
30
|
+
overrides: PageEntry[];
|
|
31
|
+
basePath: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//* One single-file HTML per `.app()`, which an MCP host renders as that app's view: its entry imports the app's page,
|
|
35
|
+
//* trimmed to its loads, and the page's root layout for its providers. Built beside `csr/`, never inside it: a native
|
|
36
|
+
//* app packs `csr/`, which needs none of them.
|
|
37
|
+
export class McpAppViewBuilder {
|
|
38
|
+
/** The folder the server reads views from: `WebRouter.mcpAppViewDir`. */
|
|
39
|
+
static readonly dirName = "mcp-apps";
|
|
40
|
+
/** `PageDefinition.promptName`, which also keeps a view's file name inside its folder. */
|
|
41
|
+
static readonly #appName = /^[A-Za-z0-9_-]{1,64}$/;
|
|
42
|
+
|
|
43
|
+
readonly #app: App;
|
|
44
|
+
readonly #command: "build" | "start";
|
|
45
|
+
|
|
46
|
+
constructor(app: App, command: "build" | "start") {
|
|
47
|
+
this.#app = app;
|
|
48
|
+
this.#command = command;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get outputDir(): string {
|
|
52
|
+
return this.#command === "build"
|
|
53
|
+
? path.join(this.#app.dist.cwdPath, McpAppViewBuilder.dirName)
|
|
54
|
+
: path.join(this.#app.cwdPath, ".akan", "artifact", McpAppViewBuilder.dirName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async build(
|
|
58
|
+
pageEntries: PageEntry[],
|
|
59
|
+
{ basePaths, cssAssets, options }: McpAppViewBuildOption,
|
|
60
|
+
): Promise<McpAppView[]> {
|
|
61
|
+
const views = await McpAppViewBuilder.discover(pageEntries, basePaths);
|
|
62
|
+
if (!views.length) {
|
|
63
|
+
await rm(this.outputDir, { recursive: true, force: true });
|
|
64
|
+
return views;
|
|
65
|
+
}
|
|
66
|
+
//? Built aside, then moved in a file at a time: a host may read a view while `akan start` rebuilds them.
|
|
67
|
+
const stagingDir = `${this.outputDir}.next`;
|
|
68
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
69
|
+
const generatedDir = path.join(this.#app.cwdPath, ".akan", "generated", McpAppViewBuilder.dirName);
|
|
70
|
+
await rm(generatedDir, { recursive: true, force: true });
|
|
71
|
+
await mkdir(generatedDir, { recursive: true });
|
|
72
|
+
const files = Object.fromEntries(
|
|
73
|
+
views.flatMap((view) => [
|
|
74
|
+
[path.join(generatedDir, `${view.name}.html`), McpAppViewBuilder.htmlOf(view)],
|
|
75
|
+
[path.join(generatedDir, `${view.name}.view.tsx`), McpAppViewBuilder.entryOf(view, generatedDir)],
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
const pagePaths = [...new Set(views.map((view) => view.page.moduleAbsPath))];
|
|
79
|
+
const result = await Bun.build({
|
|
80
|
+
...options,
|
|
81
|
+
plugins: [...options.plugins, McpAppPageTrimmer.plugin(pagePaths), PureLazyMarker.plugin()],
|
|
82
|
+
entrypoints: views.map((view) => path.join(generatedDir, `${view.name}.html`)),
|
|
83
|
+
files,
|
|
84
|
+
root: generatedDir,
|
|
85
|
+
outdir: stagingDir,
|
|
86
|
+
});
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
const logs = result.logs.map((log) => log.message).join("\n");
|
|
89
|
+
throw new Error(`[mcp-app-build] failed${logs ? `\n${logs}` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
for (const view of views) await this.#inline(view, stagingDir, cssAssets[view.basePath]);
|
|
92
|
+
await this.#publish(views, stagingDir);
|
|
93
|
+
return views;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #publish(views: McpAppView[], stagingDir: string) {
|
|
97
|
+
const names = new Set(views.map((view) => `${view.name}.html`));
|
|
98
|
+
await mkdir(this.outputDir, { recursive: true });
|
|
99
|
+
for (const name of names) await rename(path.join(stagingDir, name), path.join(this.outputDir, name));
|
|
100
|
+
for (const name of await readdir(this.outputDir))
|
|
101
|
+
if (!names.has(name)) await rm(path.join(this.outputDir, name), { recursive: true, force: true });
|
|
102
|
+
const strays = await readdir(stagingDir);
|
|
103
|
+
if (strays.length)
|
|
104
|
+
this.#app.logger.warn(
|
|
105
|
+
`[mcp-app-build] ${strays.join(", ")} reached no view; a view loads nothing beside its own file`,
|
|
106
|
+
);
|
|
107
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every `.app()` the pages declare, read off their sources without evaluating them. */
|
|
111
|
+
static async discover(pageEntries: PageEntry[], basePaths: string[]): Promise<McpAppView[]> {
|
|
112
|
+
const { RouteSourceValidator } = await import("../routeSourceValidator");
|
|
113
|
+
const roots = pageEntries.filter((entry) => parseRouteModuleKey(entry.key).isInternalRootLayout);
|
|
114
|
+
const overrides = pageEntries.filter((entry) => parseRouteModuleKey(entry.key).kind === "overrides");
|
|
115
|
+
const layoutNodes = new Set(
|
|
116
|
+
pageEntries.flatMap((entry) => {
|
|
117
|
+
const parsed = parseRouteModuleKey(entry.key);
|
|
118
|
+
return parsed.kind === "layout" && !parsed.isInternalRootLayout ? [parsed.routeSegments.join("/")] : [];
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const owners = new Map<string, { name: string; key: string }>();
|
|
122
|
+
const views: McpAppView[] = [];
|
|
123
|
+
for (const page of pageEntries) {
|
|
124
|
+
if (parseRouteModuleKey(page.key).kind !== "page") continue;
|
|
125
|
+
const source = await Bun.file(page.moduleAbsPath).text();
|
|
126
|
+
const apps =
|
|
127
|
+
RouteSourceValidator.validateRouteSourceExports(source, page.moduleAbsPath, "page").chain?.apps ?? [];
|
|
128
|
+
for (const name of apps) {
|
|
129
|
+
if (!McpAppViewBuilder.#appName.test(name))
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[route-convention] app name "${name}" in ${page.key} must match ${McpAppViewBuilder.#appName}`,
|
|
132
|
+
);
|
|
133
|
+
//? Keyed in lower case: a case-insensitive disk would write two such views to one file.
|
|
134
|
+
const owner = owners.get(name.toLowerCase());
|
|
135
|
+
if (owner?.name === name)
|
|
136
|
+
throw new Error(`[route-convention] app "${name}" is declared by both ${owner.key} and ${page.key}`);
|
|
137
|
+
if (owner)
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[route-convention] apps "${owner.name}" (${owner.key}) and "${name}" (${page.key}) differ only in case; their views would share one file`,
|
|
140
|
+
);
|
|
141
|
+
owners.set(name.toLowerCase(), { name, key: page.key });
|
|
142
|
+
const root = await McpAppViewBuilder.#rootOf(page.key, roots);
|
|
143
|
+
views.push({
|
|
144
|
+
name,
|
|
145
|
+
page,
|
|
146
|
+
root,
|
|
147
|
+
...McpAppViewBuilder.#overridesOf(page.key, root, overrides, layoutNodes),
|
|
148
|
+
basePath: CsrEntryFiles.basePathOfPageKey(page.key, basePaths) ?? "",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return views;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
static htmlOf(view: McpAppView): string {
|
|
156
|
+
return `<!doctype html>
|
|
157
|
+
<html lang="en">
|
|
158
|
+
<head>
|
|
159
|
+
<meta charset="utf-8" />
|
|
160
|
+
<title>${view.name}</title>
|
|
161
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<div id="root"></div>
|
|
165
|
+
<script type="module" src="./${view.name}.view.tsx"></script>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static entryOf(view: McpAppView, fromDir: string): string {
|
|
172
|
+
const specifier = (absPath: string) => {
|
|
173
|
+
const relative = path.relative(fromDir, absPath).split(path.sep).join("/");
|
|
174
|
+
return JSON.stringify(relative.startsWith(".") ? relative : `./${relative}`);
|
|
175
|
+
};
|
|
176
|
+
const importsOf = (entries: PageEntry[], name: string) =>
|
|
177
|
+
entries.map((entry, idx) => `import ${name}${idx} from ${specifier(entry.moduleAbsPath)};\n`).join("");
|
|
178
|
+
const namesOf = (entries: PageEntry[], name: string) => entries.map((_, idx) => `${name}${idx}`).join(", ");
|
|
179
|
+
return `import { McpAppBoot } from "akanjs/webkit";
|
|
180
|
+
import * as page from ${specifier(view.page.moduleAbsPath)};
|
|
181
|
+
${view.root ? `import * as root from ${specifier(view.root.moduleAbsPath)};` : "const root = {};"}
|
|
182
|
+
${importsOf(view.rootOverrides, "rootOverride")}${importsOf(view.overrides, "override")}void McpAppBoot.run({
|
|
183
|
+
app: ${JSON.stringify(view.name)},
|
|
184
|
+
page,
|
|
185
|
+
root,
|
|
186
|
+
rootOverrides: [${namesOf(view.rootOverrides, "rootOverride")}],
|
|
187
|
+
overrides: [${namesOf(view.overrides, "override")}],
|
|
188
|
+
basePath: ${JSON.stringify(view.basePath)},
|
|
189
|
+
});
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
//? By route segments, as the route tree nests them (`RouteLayering`): a folder's `_overrides.tsx` also covers the
|
|
194
|
+
//? page file named like the folder (`memo/_overrides.tsx` over `memo.tsx`), and one on a root node sits outside the
|
|
195
|
+
//? root layout — the root layout's own node, or one above it that holds no layout of its own.
|
|
196
|
+
static #overridesOf(pageKey: string, root: PageEntry | null, overrides: PageEntry[], layoutNodes: Set<string>) {
|
|
197
|
+
const pageSegments = parseRouteModuleKey(pageKey).routeSegments;
|
|
198
|
+
const rootDepth = root ? parseRouteModuleKey(root.key).routeSegments.length : 0;
|
|
199
|
+
const over = overrides
|
|
200
|
+
.map((entry) => ({ entry, segments: parseRouteModuleKey(entry.key).routeSegments }))
|
|
201
|
+
.filter(({ segments }) => segments.every((segment, idx) => pageSegments[idx] === segment))
|
|
202
|
+
.sort((a, b) => a.segments.length - b.segments.length);
|
|
203
|
+
const onRootNode = (segments: string[]) =>
|
|
204
|
+
segments.length === rootDepth || (segments.length < rootDepth && !layoutNodes.has(segments.join("/")));
|
|
205
|
+
return {
|
|
206
|
+
rootOverrides: over.filter(({ segments }) => onRootNode(segments)).map(({ entry }) => entry),
|
|
207
|
+
overrides: over.filter(({ segments }) => !onRootNode(segments)).map(({ entry }) => entry),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
//? The outermost root boundary over the page: the one a page is drawn under without an ancestor root, which alone
|
|
212
|
+
//? renders `System.Provider` and so exports `McpAppRoot`.
|
|
213
|
+
static async #rootOf(pageKey: string, roots: PageEntry[]): Promise<PageEntry | null> {
|
|
214
|
+
const dirOf = (key: string) => key.slice(0, key.lastIndexOf("/") + 1);
|
|
215
|
+
const pageDir = dirOf(pageKey);
|
|
216
|
+
const over = roots
|
|
217
|
+
.filter((root) => pageDir.startsWith(dirOf(root.key)))
|
|
218
|
+
.sort((a, b) => dirOf(a.key).length - dirOf(b.key).length);
|
|
219
|
+
for (const root of over)
|
|
220
|
+
if ((await Bun.file(root.moduleAbsPath).text()).includes("export function McpAppRoot")) return root;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #inline(view: McpAppView, dir: string, cssAsset: CssAsset | undefined) {
|
|
225
|
+
const htmlPath = path.join(dir, `${view.name}.html`);
|
|
226
|
+
let html = await Bun.file(htmlPath).text();
|
|
227
|
+
if (cssAsset) {
|
|
228
|
+
const artifactDir = path.join(
|
|
229
|
+
this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
|
|
230
|
+
".akan",
|
|
231
|
+
"artifact",
|
|
232
|
+
);
|
|
233
|
+
const css = await Bun.file(path.join(artifactDir, cssAsset.cssRelPath)).text();
|
|
234
|
+
html = CsrArtifactBuilder.injectBeforeHeadEnd(html, CsrArtifactBuilder.createInlineStyle(css));
|
|
235
|
+
} else this.#app.logger.warn(`[mcp-app-build] no compiled stylesheet for ${view.name}; its view ships without CSS`);
|
|
236
|
+
const scripts: string[] = [];
|
|
237
|
+
html = await CsrArtifactBuilder.replaceModuleScriptSrc(html, async (src) => {
|
|
238
|
+
const jsPath = CsrArtifactBuilder.resolveHtmlAssetPath(htmlPath, src);
|
|
239
|
+
scripts.push(jsPath);
|
|
240
|
+
return await Bun.file(jsPath).text();
|
|
241
|
+
});
|
|
242
|
+
await Bun.write(htmlPath, html);
|
|
243
|
+
for (const script of scripts) await unlink(script).catch(() => undefined);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -108,10 +108,10 @@ class BuildBatch {
|
|
|
108
108
|
async #buildShell(): Promise<void> {
|
|
109
109
|
const started = Date.now();
|
|
110
110
|
try {
|
|
111
|
-
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).
|
|
112
|
-
this.#logger.verbose(`
|
|
111
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).buildMcpAppViews();
|
|
112
|
+
this.#logger.verbose(`mcp-app-views ok (${Date.now() - started}ms)`);
|
|
113
113
|
} catch (err) {
|
|
114
|
-
this.#fail("shell", "
|
|
114
|
+
this.#fail("shell", "mcp-app-views", err);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -3,8 +3,8 @@ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from
|
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
|
-
// `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the
|
|
7
|
-
//
|
|
6
|
+
// `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the MCP App views'
|
|
7
|
+
// single-file build under the dev CSR registry, made on request and reported to no tab.
|
|
8
8
|
export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr" | "shell";
|
|
9
9
|
|
|
10
10
|
// JSON only: the worker is spawned per batch and receives this in its argv.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const A = () => {
|
|
2
|
+
const busy = st.use.xUploading() || !!st.use.xFormUploading().voice; // @flag
|
|
3
|
+
const shown = open && st.sel((s) => s.a); // @flag
|
|
4
|
+
const label = name ?? st.use.defaultName(); // @flag
|
|
5
|
+
const picked = open ? st.use.b() : null; // @flag
|
|
6
|
+
const latest = open ? null : st.ref((s) => s.c); // @flag
|
|
7
|
+
if (open) { const d = st.use.d(); } // @flag
|
|
8
|
+
if (open) { run(); } else { const e = st.slice.inOrg.use.e(); } // @flag
|
|
9
|
+
switch (mode) { case "a": st.use.f(); } // @flag
|
|
10
|
+
for (const item of items) st.use.g(); // @flag
|
|
11
|
+
while (more) st.use.h(); // @flag
|
|
12
|
+
try { run(); } catch { st.use.i(); } // @flag
|
|
13
|
+
const rows = items.map((item) => st.use.j()); // @flag
|
|
14
|
+
useEffect(() => { st.use.k(); }, []); // @flag
|
|
15
|
+
const tool = open && st.tool("x").desc("d").exec(() => run()); // @flag
|
|
16
|
+
const card = open ? st.tool("y").desc("d").card(() => null) : null; // @flag
|
|
17
|
+
const [l] = open ? st.useState("l", String).desc("d").init("") : []; // @flag
|
|
18
|
+
if (open) st.expose("m", String).desc("d").value("x"); // @flag
|
|
19
|
+
const save = st.tool("z").desc("d").exec(async () => { st.use.n(); }); // @flag
|
|
20
|
+
return <div onClick={() => st.use.o()} />; // @flag
|
|
21
|
+
};
|
|
22
|
+
export const B = () => <Load.Units renderList={(list) => <X a={st.use.p()} />} />; // @flag
|
|
23
|
+
export const C = () => <Load.Stream of={q}>{(v) => <X a={st.sel((s) => s.q)} />}</Load.Stream>; // @flag
|
|
24
|
+
export const useD = () => useMemo(() => st.use.r(), []); // @flag
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const A = () => {
|
|
2
|
+
const uploading = st.use.xUploading(); // @ok
|
|
3
|
+
const formUploading = st.use.xFormUploading(); // @ok
|
|
4
|
+
const busy = uploading || !!formUploading.voice; // @ok
|
|
5
|
+
const first = st.use.a() || fallback; // @ok
|
|
6
|
+
const picked = st.use.b() ? one : two; // @ok
|
|
7
|
+
if (st.use.c()) run(); // @ok
|
|
8
|
+
switch (st.use.mode()) { case "a": run(); } // @ok
|
|
9
|
+
for (const item of st.use.items()) run(item); // @ok
|
|
10
|
+
const selected = st.sel((s) => s.d); // @ok
|
|
11
|
+
const latest = st.ref((s) => s.e); // @ok
|
|
12
|
+
const save = st.tool("save").desc("d").exec(async () => { await st.do.saveX(); }); // @ok
|
|
13
|
+
const [f, setF] = st.useState("f", String).desc("d").init(""); // @ok
|
|
14
|
+
st.expose("g", String).desc("d").value(f); // @ok
|
|
15
|
+
useEffect(() => st.sub((s) => s.h, run), []); // @ok
|
|
16
|
+
return <div onClick={() => st.do.setNameOnX(st.get().name)}>{open ? <Child /> : null}</div>; // @ok
|
|
17
|
+
};
|
|
18
|
+
export const B = memo(() => { const a = st.use.a(); return null; }); // @ok
|
|
19
|
+
export const C = forwardRef((props, ref) => { const b = st.sel((s) => s.b); return null; }); // @ok
|
|
20
|
+
export const useTools = () => { const tool = (name) => st.tool(name).desc("d").exec(() => run()); tool("a"); }; // @ok
|
|
21
|
+
export const D = () => <Load.Units renderItem={(item) => <Row key={item.id} id={item.id} />} />; // @ok
|
|
22
|
+
export const E = () => { const busy = open && other.use.x(); return busy; }; // @ok
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// `st.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step (`st.tool(…)….exec()` / `.card()`,
|
|
5
|
+
// `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's `useHookAtTopLevel` cannot see:
|
|
6
|
+
// it recognises a hook by a `use` prefix on the called name. A hook a render skips shifts every later hook by one
|
|
7
|
+
// slot, and production React does not check the order — a later `useRef` then receives a frozen store value and
|
|
8
|
+
// the screen dies far from the cause ("Cannot add property current, object is not extensible").
|
|
9
|
+
//
|
|
10
|
+
// Reported: the right operand of `||` / `&&` / `??`, a ternary branch, an `if` / `else` body, a `switch` case, a
|
|
11
|
+
// loop, a `catch` block, and a callback handed to a call or a JSX prop inside a component (`.map`, `useEffect`,
|
|
12
|
+
// an event handler, a render prop — `Load.Units` calls `renderList` only while the list is non-empty). A test
|
|
13
|
+
// position is not reported: `if (st.use.x())` always runs.
|
|
14
|
+
//
|
|
15
|
+
// Not reported: a hook after an early `return` (GritQL sees no statement order), a local helper the component
|
|
16
|
+
// calls (`const tool = (name) => st.tool(name)…exec(…)` runs a fixed number of times when called at top level),
|
|
17
|
+
// and a hook reached through anything but the `st` identifier.
|
|
18
|
+
//
|
|
19
|
+
pattern st_hook_call() {
|
|
20
|
+
or {
|
|
21
|
+
`st.use.$key($args)`,
|
|
22
|
+
`st.slice.$slice.use.$key($args)`,
|
|
23
|
+
`st.sel($args)`,
|
|
24
|
+
`st.ref($args)`,
|
|
25
|
+
`$chain.exec($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
26
|
+
`$chain.card($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
27
|
+
`$chain.init($args)` where { $chain <: r"st\s*\.\s*useState\s*\([\s\S]*" },
|
|
28
|
+
`$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pattern function_like() {
|
|
33
|
+
or {
|
|
34
|
+
JsArrowFunctionExpression(),
|
|
35
|
+
JsFunctionExpression(),
|
|
36
|
+
JsFunctionDeclaration(),
|
|
37
|
+
JsMethodObjectMember(),
|
|
38
|
+
JsMethodClassMember()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
JsCallExpression() as $call where {
|
|
43
|
+
$call <: st_hook_call(),
|
|
44
|
+
or {
|
|
45
|
+
$call <: within JsLogicalExpression(right = $right) where { $right <: contains $call },
|
|
46
|
+
$call <: within JsConditionalExpression(consequent = $then, alternate = $else) where {
|
|
47
|
+
or { $then <: contains $call, $else <: contains $call }
|
|
48
|
+
},
|
|
49
|
+
$call <: within JsIfStatement(consequent = $then, else_clause = $else) where {
|
|
50
|
+
or { $then <: contains $call, $else <: contains $call }
|
|
51
|
+
},
|
|
52
|
+
$call <: within JsForOfStatement(body = $body) where { $body <: contains $call },
|
|
53
|
+
$call <: within JsForInStatement(body = $body) where { $body <: contains $call },
|
|
54
|
+
$call <: within or {
|
|
55
|
+
JsCaseClause(),
|
|
56
|
+
JsDefaultClause(),
|
|
57
|
+
JsForStatement(),
|
|
58
|
+
JsWhileStatement(),
|
|
59
|
+
JsDoWhileStatement(),
|
|
60
|
+
JsCatchClause()
|
|
61
|
+
},
|
|
62
|
+
// The callback must be handed over inside another function: a component passed to `memo()` / `forwardRef()`
|
|
63
|
+
// at module level is not one.
|
|
64
|
+
$call <: within function_like() as $inner where {
|
|
65
|
+
$inner <: within or { JsCallArguments(), JsxExpressionAttributeValue(), JsxExpressionChild() } as $slot where {
|
|
66
|
+
$slot <: within function_like()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
register_diagnostic(
|
|
71
|
+
span = $call,
|
|
72
|
+
message = "This st hook does not run on every render — it sits behind a condition, in a loop, or in a nested function. React matches hooks by call order, so the hooks after it shift by one slot and a later one reads another hook's state; production React does not check, and the screen crashes somewhere else. Read each st.use.* into its own const at the top of the component, then combine the values: const uploading = st.use.xUploading(); const formUploading = st.use.xFormUploading(); const busy = uploading || !!formUploading.voice. For a list, move the hook into a child component rendered per item.",
|
|
73
|
+
severity = "error"
|
|
74
|
+
)
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.22",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
51
|
+
"akanjs": "3.1.0-rc.22",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|