@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
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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 ?
|
|
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 ?
|
|
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": [
|
|
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
|
+
});
|