@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.
@@ -1,5 +1,5 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AkanAppConfig } from "./akanConfig";
@@ -111,6 +111,45 @@ console.info(new SampleService().name);
111
111
  }
112
112
  });
113
113
 
114
+ test("bakes into the bundles every AKAN_PUBLIC_* its Dockerfile carries, and names the shell values it outranked", async () => {
115
+ const { root, workspace } = await createTempApp("portal");
116
+ const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
117
+ const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
118
+ await writeFile(
119
+ path.join(root, ".env"),
120
+ "AKAN_PUBLIC_ENV=main\nAKAN_PUBLIC_REPO_NAME=repo\nAKAN_PUBLIC_SERVE_DOMAIN=akanjs.com\n",
121
+ );
122
+ await Promise.all(["private", "public"].map((dir) => mkdir(path.join(root, "apps", "portal", dir))));
123
+ Object.assign(process.env, {
124
+ AKAN_PUBLIC_ENV: "local",
125
+ AKAN_PUBLIC_REPO_NAME: "shell-repo",
126
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
127
+ });
128
+ const app = AppExecutor.from(workspace, "portal");
129
+ const warn = spyOn(app.logger, "warn").mockImplementation(() => undefined);
130
+ const runner = new ApplicationBuildRunner(app);
131
+ runner.typecheck = async () => {
132
+ throw new Error("stopped after prepare");
133
+ };
134
+ try {
135
+ await expect(runner.build()).rejects.toThrow("stopped after prepare");
136
+ const define = bundleDefine(app, "build", "csr") as { [key: string]: string };
137
+ const imageEnv = (await app.getConfig()).getProductionEnv();
138
+ for (const key of keys) expect(define[`process.env.${key}`]).toBe(JSON.stringify(imageEnv[key]));
139
+ expect(imageEnv.AKAN_PUBLIC_SERVE_DOMAIN).toBe("akanjs.com");
140
+ expect(warn).toHaveBeenCalledTimes(1);
141
+ expect(warn.mock.calls[0]?.[0]).toBe(
142
+ "The workspace .env outranks this process's environment: the bundles and the image take AKAN_PUBLIC_ENV=main (not local), AKAN_PUBLIC_REPO_NAME=repo (not shell-repo), AKAN_PUBLIC_SERVE_DOMAIN=akanjs.com (not localhost).",
143
+ );
144
+ } finally {
145
+ for (const key of keys) {
146
+ if (shellEnv[key] === undefined) delete process.env[key];
147
+ else process.env[key] = shellEnv[key];
148
+ }
149
+ await rm(root, { recursive: true, force: true });
150
+ }
151
+ });
152
+
114
153
  test("a build for a real environment stops before any phase when the serve domain is only the local default", async () => {
115
154
  const { root, workspace } = await createTempApp("portal");
116
155
  await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
@@ -18,6 +18,7 @@ import { Spinner } from "./spinner";
18
18
  import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
19
19
  import { ServerEnvProfile } from "./transforms/serverEnvProfile";
20
20
  import { ProjectTypecheck } from "./typecheck/projectTypecheck";
21
+ import type { BaseDevEnv } from "./types";
21
22
 
22
23
  export interface TypecheckOptions {
23
24
  clean?: boolean;
@@ -97,14 +98,34 @@ export class ApplicationBuildRunner {
97
98
  return path.join(this.#app.cwdPath, "env");
98
99
  }
99
100
 
101
+ //? Bundles bake AKAN_PUBLIC_* from process.env, where the shell outranks the .env that the server env and the
102
+ //? Dockerfile read: a page built otherwise drops the image's tokens as foreign or dials another serve domain.
103
+ #pinImageEnv(baseDevEnv: BaseDevEnv, environment: string) {
104
+ const imageEnv = {
105
+ AKAN_PUBLIC_ENV: environment,
106
+ AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName,
107
+ AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain,
108
+ };
109
+ const outranked = Object.entries(imageEnv).filter(([key, value]) => {
110
+ const given = process.env[key];
111
+ return !!given && given !== value && !(key === "AKAN_PUBLIC_ENV" && this.#environment);
112
+ });
113
+ if (outranked.length)
114
+ this.#app.logger.warn(
115
+ `The workspace .env outranks this process's environment: the bundles and the image take ${outranked
116
+ .map(([key, value]) => `${key}=${value} (not ${process.env[key]})`)
117
+ .join(", ")}.`,
118
+ );
119
+ Object.assign(process.env, imageEnv);
120
+ }
121
+
100
122
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
101
123
  // serial build is needed because of Bun.build is unstable for parallel build
102
124
  this.#spinner = spinner;
103
125
  const { web, assets, baseDevEnv } = await this.#app.getConfig();
104
126
  const environment = this.#environment ?? baseDevEnv.env;
105
127
  WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
106
- //? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
107
- process.env.AKAN_PUBLIC_ENV = environment;
128
+ this.#pinImageEnv(baseDevEnv, environment);
108
129
  //? Before any phase: a profile that would ship a secret stops the build, not the image after it.
109
130
  if (this.#profile)
110
131
  await ServerEnvProfile.assertShippable({ envDir: this.#envDir, environment, profile: this.#profile });
@@ -36,7 +36,9 @@ describe("resolveSsrPageEntries", () => {
36
36
  const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
37
37
  // Both user modules go through the route resolver, so a `rootLayout()` chain and the legacy exports read alike.
38
38
  expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
39
- expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
39
+ expect(generatedSource).toContain(
40
+ 'const inheritedLayout = assertGuardOnChain("page/_layout.tsx", resolveRouteModule(inheritedModule as never',
41
+ );
40
42
  expect(generatedSource).not.toContain("<System.Provider");
41
43
  expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
42
44
  // The ancestor root is a level of the route tree, which merges its head; this boundary returns only its own.
@@ -48,6 +50,9 @@ describe("resolveSsrPageEntries", () => {
48
50
  expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
49
51
  // The ancestor root is its own level of the stack, so inheriting its loading would nest it inside itself.
50
52
  expect(generatedSource).toContain("export const Loading = userLayout.Loading;");
53
+ // Its guard runs from that level too, ahead of this one, and an MCP view takes its providers from there.
54
+ expect(generatedSource).toContain("export const guard = userLayout.guard;");
55
+ expect(generatedSource).not.toContain("McpAppRoot");
51
56
  expect(generatedSource).toContain(
52
57
  "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
53
58
  );
@@ -77,6 +82,11 @@ describe("resolveSsrPageEntries", () => {
77
82
  expect(generatedSource).toContain("export const viewport = userLayout.viewport ?? inheritedLayout.viewport;");
78
83
  expect(generatedSource).toContain("viewport={viewport}");
79
84
  expect(generatedSource).toContain("[inheritedLayout, userLayout].map((layout)");
85
+ expect(generatedSource).toContain(
86
+ "export const guard = RouteGuards.compose(inheritedLayout.guard, userLayout.guard);",
87
+ );
88
+ expect(generatedSource).toContain("export function McpAppRoot({ children, params, path }: McpAppRootProps) {");
89
+ expect(generatedSource).toContain("<System.App");
80
90
  expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
81
91
  expect(generatedSource).toContain(
82
92
  'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
@@ -16,8 +16,11 @@ const IMPLICIT_DICT_DIR = path.join(".akan", "generated", "dict");
16
16
  const IMPLICIT_OVERRIDES_DIR = path.join(".akan", "generated", "overrides");
17
17
  const OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
18
18
 
19
+ const posixRelative = (fromDir: string, toAbsPath: string) =>
20
+ path.relative(fromDir, toAbsPath).split(path.sep).join("/");
21
+
19
22
  const importSpecifier = (fromAbsPath: string, toAbsPath: string) => {
20
- const rel = path.relative(path.dirname(fromAbsPath), toAbsPath).split(path.sep).join("/");
23
+ const rel = posixRelative(path.dirname(fromAbsPath), toAbsPath);
21
24
  return rel.startsWith(".") ? rel : `./${rel}`;
22
25
  };
23
26
 
@@ -162,14 +165,14 @@ async function writeGeneratedRootLayoutFile(opts: {
162
165
  ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
163
166
  : `import "@apps/${opts.appName}/client";\n`;
164
167
  //? resolveRouteModule reads a rootLayout() chain and the legacy named exports alike.
165
- const inheritedLabel = inheritedSourceAbsPath ? path.relative(opts.appCwdPath, inheritedSourceAbsPath) : "";
168
+ const inheritedLabel = inheritedSourceAbsPath ? posixRelative(opts.appCwdPath, inheritedSourceAbsPath) : "";
166
169
  const inheritedImport = inheritedSourceSpecifier
167
- ? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)}).module as LayoutModule;\n`
170
+ ? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = assertGuardOnChain(${JSON.stringify(inheritedLabel)}, resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)})).module as LayoutModule;\n`
168
171
  : "const inheritedLayout: LayoutModule = {};\n";
169
172
  const prefix = opts.boundary.segments.find((segment) => !/^\(.+\)$/.test(segment)) ?? null;
170
- const userLabel = opts.boundary.sourceAbsPath ? path.relative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
173
+ const userLabel = opts.boundary.sourceAbsPath ? posixRelative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
171
174
  const userImport = sourceSpecifier
172
- ? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)}).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
175
+ ? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = assertGuardOnChain(${JSON.stringify(userLabel)}, resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)})).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
173
176
  : "const UserLayout = ({ children }: LayoutProps) => children;\nconst userLayout: LayoutModule = {};\n";
174
177
  const isAsyncUserLayout = opts.boundary.sourceAbsPath
175
178
  ? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
@@ -191,6 +194,10 @@ async function writeGeneratedRootLayoutFile(opts: {
191
194
  const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
192
195
  //? Under an ancestor root boundary the route tree merges that root's head itself; merging it here would repeat it.
193
196
  const headLayouts = opts.includeSystemProvider ? "[inheritedLayout, userLayout]" : "[userLayout]";
197
+ //? Likewise for the guard: an ancestor root boundary runs its own from its own layer.
198
+ const guardExport = opts.includeSystemProvider
199
+ ? "export const guard = RouteGuards.compose(inheritedLayout.guard, userLayout.guard);"
200
+ : "export const guard = userLayout.guard;";
194
201
  const layoutExports = `export async function generateHead(props: PageProps) {
195
202
  const heads = await Promise.all(
196
203
  ${headLayouts}.map((layout) => (layout.generateHead ? layout.generateHead(props) : layout.head)),
@@ -202,14 +209,16 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
202
209
  export const Error = userLayout.Error ?? inheritedLayout.Error;
203
210
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
204
211
  export const Loading = userLayout.Loading;
212
+ ${guardExport}
205
213
 
206
214
  ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
207
215
  ${layoutBinding} return `;
208
216
  const source = opts.includeSystemProvider
209
217
  ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
210
- import { loadFonts, resolveRouteModule, RouteHead } from "akanjs/client";
218
+ import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
211
219
  import type { ReactNode } from "react";
212
220
  import { System } from "akanjs/ui";
221
+ import type { McpAppRootProps } from "akanjs/webkit";
213
222
  import { env } from "@apps/${opts.appName}/env/env.client";
214
223
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
215
224
  ${clientImport}${inheritedImport}${userImport}
@@ -242,9 +251,25 @@ ${layoutExports}(
242
251
  </System.Provider>
243
252
  );
244
253
  }
254
+
255
+ export function McpAppRoot({ children, params, path }: McpAppRootProps) {
256
+ return (
257
+ <System.App
258
+ params={params}
259
+ path={path}
260
+ env={env}
261
+ theme={theme}
262
+ viewport={viewport}
263
+ fonts={loadFonts(userFonts)}
264
+ className={defaultFontClassName}
265
+ >
266
+ {children}
267
+ </System.App>
268
+ );
269
+ }
245
270
  `
246
271
  : `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
247
- import { resolveRouteModule, RouteHead } from "akanjs/client";
272
+ import { assertGuardOnChain, resolveRouteModule, RouteHead } from "akanjs/client";
248
273
  import type { ReactNode } from "react";
249
274
  ${inheritedImport}${userImport}
250
275
  ${layoutExports}${layoutReturn};
package/biome.base.json CHANGED
@@ -196,7 +196,10 @@
196
196
  },
197
197
  {
198
198
  "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
199
- "plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
199
+ "plugins": [
200
+ "./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit",
201
+ "./node_modules/@akanjs/devkit/lint/no-conditional-st-hook.grit"
202
+ ]
200
203
  },
201
204
  {
202
205
  "includes": [
@@ -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
+ });