@akanjs/devkit 3.1.0-rc.9 → 3.1.1-rc.0
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/CHANGELOG.md +382 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +8 -4
- package/akanConfig/akanConfig.ts +3 -2
- package/applicationBuildRunner.test.ts +77 -2
- package/applicationBuildRunner.ts +28 -5
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +33 -8
- package/biome.base.json +8 -3
- package/biomeBase.test.ts +78 -0
- package/biomeBase.ts +1 -1
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +4 -2
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +149 -25
- package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +49 -10
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +169 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/executors.test.ts +53 -1
- package/executors.ts +10 -6
- package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
- package/frontendBuild/csrArtifactBuilder.ts +24 -1
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/dictionaryAssetBuilder.test.ts +77 -0
- package/frontendBuild/dictionaryAssetBuilder.ts +49 -0
- package/frontendBuild/fontPruner.ts +1 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
- package/frontendBuild/mcpAppViewBuilder.ts +245 -0
- package/frontendBuild/precompressArtifacts.test.ts +42 -0
- package/frontendBuild/precompressArtifacts.ts +21 -10
- package/frontendBuild/routeClientBuilder.ts +1 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +3 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/incrementalBuilder/buildBatch.proc.ts +3 -3
- package/incrementalBuilder/buildBatchProtocol.ts +2 -2
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
- package/lint/no-conditional-st-hook.grit +146 -0
- package/mobile/desktopServerStage.ts +2 -1
- package/mobile/nativeApi.ts +1 -1
- package/mobile/nativeApp.ts +1 -1
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updatePackFolder.ts +1 -1
- package/mobile/updateUploader.ts +1 -1
- package/package.json +15 -15
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/scanInfo.ts +36 -3
- package/subspace.test.ts +3 -2
- package/subspaceClone.ts +1 -12
- package/transforms/barrelImportsPlugin.ts +4 -2
- 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
- package/transforms/sideEffectImportOrder.test.ts +183 -0
- package/transforms/sideEffectImportOrder.ts +109 -0
|
@@ -61,4 +61,44 @@ console.info(await result.outputs[0].text());
|
|
|
61
61
|
expect(firstOutput).toBe(secondOutput);
|
|
62
62
|
expect(firstOutput).not.toContain(path.basename(first));
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
//? The shape of `@lexical/code-prism` under a `lazy()` editor, which threw in every view and CSR shell carrying it.
|
|
66
|
+
test("runs a lazily imported module's CommonJS side-effect import before the imports after it", async () => {
|
|
67
|
+
const root = await makeTempRoot();
|
|
68
|
+
const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: "main" }) };
|
|
69
|
+
await writeText(path.join(root, "node_modules/core/package.json"), '{ "name": "core", "main": "index.js" }\n');
|
|
70
|
+
await writeText(
|
|
71
|
+
path.join(root, "node_modules/core/index.js"),
|
|
72
|
+
"var Core = { langs: [] };\nglobalThis.Core = Core;\nmodule.exports = Core;\n",
|
|
73
|
+
);
|
|
74
|
+
await writeText(path.join(root, "node_modules/core/components/a.js"), 'Core.langs.push("a");\n');
|
|
75
|
+
await writeText(
|
|
76
|
+
path.join(root, "node_modules/highlight/package.json"),
|
|
77
|
+
'{ "name": "highlight", "type": "module", "main": "index.js" }\n',
|
|
78
|
+
);
|
|
79
|
+
await writeText(
|
|
80
|
+
path.join(root, "node_modules/highlight/index.js"),
|
|
81
|
+
'import "core";\nimport "core/components/a.js";\nexport const langs = () => globalThis.Core.langs;\n',
|
|
82
|
+
);
|
|
83
|
+
await writeText(
|
|
84
|
+
path.join(root, "entry.ts"),
|
|
85
|
+
'import("highlight").then(({ langs }) => console.info(langs().join()));\n',
|
|
86
|
+
);
|
|
87
|
+
const result = await Bun.build({
|
|
88
|
+
...CsrArtifactBuilder.bundleOptions(app as never, "build"),
|
|
89
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
90
|
+
});
|
|
91
|
+
expect(result.success).toBe(true);
|
|
92
|
+
await Bun.write(path.join(root, "out.js"), (await result.outputs[0]?.text()) ?? "");
|
|
93
|
+
|
|
94
|
+
const proc = Bun.spawn([process.execPath, "out.js"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
95
|
+
const [stdout, stderr] = await Promise.all([
|
|
96
|
+
new Response(proc.stdout).text(),
|
|
97
|
+
new Response(proc.stderr).text(),
|
|
98
|
+
proc.exited,
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(stderr).toBe("");
|
|
102
|
+
expect(stdout.trim()).toBe("a");
|
|
103
|
+
});
|
|
64
104
|
});
|
|
@@ -4,8 +4,10 @@ import type { BaseBuildArtifact } from "akanjs/server";
|
|
|
4
4
|
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
|
|
7
|
+
import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
|
|
7
8
|
import { bundleDefine } from "./bundleDefine";
|
|
8
9
|
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
10
|
+
import { type McpAppView, McpAppViewBuilder } from "./mcpAppViewBuilder";
|
|
9
11
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
10
12
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
11
13
|
|
|
@@ -77,11 +79,32 @@ export class CsrArtifactBuilder {
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
await this.#inlineCsrArtifacts(cssAssets);
|
|
82
|
+
await this.#buildMcpAppViews(pageEntries, { basePaths, cssAssets, options });
|
|
80
83
|
if (this.#command === "build") await CsrArtifactBuilder.writePublicEnv(this.#app, options.define);
|
|
81
84
|
this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
|
|
82
85
|
return { outputDir: this.#outputDir };
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/** The MCP App views alone: a dev server under the CSR registry reads them, with no `csr/` to build beside them. */
|
|
89
|
+
async buildMcpAppViews(): Promise<McpAppView[]> {
|
|
90
|
+
const pageKeys = await this.#app.getPageKeys();
|
|
91
|
+
if (pageKeys.length === 0) return [];
|
|
92
|
+
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
93
|
+
const akanConfig = await this.#app.getConfig();
|
|
94
|
+
return await this.#buildMcpAppViews(pageEntries, {
|
|
95
|
+
basePaths: [...akanConfig.basePaths],
|
|
96
|
+
cssAssets: await this.#loadCssAssets(),
|
|
97
|
+
options: CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async #buildMcpAppViews(pageEntries: PageEntry[], option: Parameters<McpAppViewBuilder["build"]>[1]) {
|
|
102
|
+
const views = await new McpAppViewBuilder(this.#app, this.#command).build(pageEntries, option);
|
|
103
|
+
if (views.length)
|
|
104
|
+
this.#app.verbose(`[csr-build] ${views.length} MCP App view(s): ${views.map((view) => view.name).join(", ")}`);
|
|
105
|
+
return views;
|
|
106
|
+
}
|
|
107
|
+
|
|
85
108
|
//? No `env` option: Bun inlines the `.env` it read at process start over `define`, so a native build's `--env` lost.
|
|
86
109
|
static bundleOptions(app: App, command: "build" | "start", optimizeImports: string[] = []) {
|
|
87
110
|
return {
|
|
@@ -91,7 +114,7 @@ export class CsrArtifactBuilder {
|
|
|
91
114
|
define: bundleDefine(app, command, "csr"),
|
|
92
115
|
optimizeImports,
|
|
93
116
|
// A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
|
|
94
|
-
plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin()],
|
|
117
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin(), SideEffectImportOrder.plugin()],
|
|
95
118
|
};
|
|
96
119
|
}
|
|
97
120
|
|
|
@@ -23,6 +23,22 @@ describe("CssCandidateCache token extraction", () => {
|
|
|
23
23
|
expect(candidates).not.toContain("_td");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("keeps a bracket nested one level inside another whole, rather than cutting it at the inner `]`", async () => {
|
|
27
|
+
const candidates = await scan(
|
|
28
|
+
`const c = "hidden [[data-theme=light]_&]:block has-[[aria-expanded=true]]:opacity-100 [&_[data-x]]:p-2 [&:has([data-state=open])]:p-1";`,
|
|
29
|
+
);
|
|
30
|
+
expect(candidates).toEqual(
|
|
31
|
+
expect.arrayContaining([
|
|
32
|
+
"[[data-theme=light]_&]:block",
|
|
33
|
+
"has-[[aria-expanded=true]]:opacity-100",
|
|
34
|
+
"[&_[data-x]]:p-2",
|
|
35
|
+
"[&:has([data-state=open])]:p-1",
|
|
36
|
+
]),
|
|
37
|
+
);
|
|
38
|
+
expect(candidates).not.toContain("[[data-theme=light]_");
|
|
39
|
+
expect(candidates).not.toContain("[&_[data-x]");
|
|
40
|
+
});
|
|
41
|
+
|
|
26
42
|
test("keeps Tailwind v4's variable shorthand whole, and leaves a call's argument its own candidate", async () => {
|
|
27
43
|
const candidates = await scan(
|
|
28
44
|
`const c = "max-md:top-(--keyboard-top) min-h-(--x) bg-(color:--brand)/50"; cn("flex"); fn(value);`,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
// The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
3
|
+
// The leading `[` alternation keeps arbitrary variants (`[&_td]:px-3`) whole, and a bracket may hold one nested
|
|
4
|
+
// bracket (`[&_[data-slot=x]]:p-2`, `has-[[aria-expanded=true]]:x`), or the candidate is cut at the inner `]` and the
|
|
5
|
+
// rule silently never compiles. Brackets hold no whitespace: a labelled tuple type scanned as a class compiles to a
|
|
6
|
+
// rule lightningcss cannot parse, and it then drops every variant rule. Parentheses pass only as Tailwind v4's
|
|
7
|
+
// variable shorthand, `(--x)` or `(length:--x)`, so `cn("flex")` still yields `flex` on its own.
|
|
8
|
+
const CANDIDATE_RE =
|
|
9
|
+
/-?(?:[\w@]|\[(?:[^[\]\s]|\[[^[\]\s]+\])+\])[\w:/.-]*(?:(?:\[(?:[^[\]\s]|\[[^[\]\s]+\])+\]|\((?:[a-z-]+:)?--[^)\s]+\))[\w:/.-]*)*/g;
|
|
8
10
|
|
|
9
11
|
interface CachedFile {
|
|
10
12
|
mtimeMs: number;
|
|
@@ -20,7 +22,7 @@ interface CacheFile {
|
|
|
20
22
|
/** Tailwind candidates per source file, on disk: the batch worker that compiles CSS exits before the next save. */
|
|
21
23
|
export class CssCandidateCache {
|
|
22
24
|
// Bump when the token regex or the entry shape changes, so stale extractions are not reused.
|
|
23
|
-
static readonly #version =
|
|
25
|
+
static readonly #version = 5;
|
|
24
26
|
readonly #path: string;
|
|
25
27
|
readonly #entries = new Map<string, CachedFile>();
|
|
26
28
|
#dirty = false;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { tempDirs } from "../testHelpers";
|
|
6
|
+
import { DictionaryAssetBuilder } from "./dictionaryAssetBuilder";
|
|
7
|
+
|
|
8
|
+
const tempRoot = tempDirs("akan-devkit-dict-asset-");
|
|
9
|
+
|
|
10
|
+
const allDictionary = {
|
|
11
|
+
en: { app: { title: { t: "Title" }, partial: { t: "Partial" } }, libOnly: { signin: { t: "Sign In" } } },
|
|
12
|
+
ko: { app: { title: { t: "제목" } } },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const makeApp = async (dictSource?: string) => {
|
|
16
|
+
const root = await tempRoot();
|
|
17
|
+
const cwdPath = path.join(root, "apps/demo");
|
|
18
|
+
await mkdir(path.join(cwdPath, "lib"), { recursive: true });
|
|
19
|
+
if (dictSource) await writeFile(path.join(cwdPath, "lib/dict.ts"), dictSource);
|
|
20
|
+
const app = { cwdPath, verbose: () => undefined } as unknown as App;
|
|
21
|
+
return { app, artifactDir: path.join(root, "artifact") };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const dictSourceOf = (dictionary: object) => `export const getAllDictionary = () => (${JSON.stringify(dictionary)});\n`;
|
|
25
|
+
|
|
26
|
+
describe("DictionaryAssetBuilder", () => {
|
|
27
|
+
test("writes one module per locale, the default locale's text filled into the gaps", async () => {
|
|
28
|
+
const { app, artifactDir } = await makeApp(dictSourceOf(allDictionary));
|
|
29
|
+
const assets = await new DictionaryAssetBuilder(app, artifactDir).build({
|
|
30
|
+
defaultLocale: "en",
|
|
31
|
+
locales: ["en", "ko"],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(Object.keys(assets)).toEqual(["en", "ko"]);
|
|
35
|
+
expect(assets.ko.url).toMatch(/^\/_akan\/dict\/ko-[a-z0-9]+\.js$/);
|
|
36
|
+
expect(assets.ko.url).toBe(`/_akan/${assets.ko.relPath}`);
|
|
37
|
+
const ko = (await import(path.join(artifactDir, assets.ko.relPath))) as { lang: string; default: object };
|
|
38
|
+
expect(ko.lang).toBe("ko");
|
|
39
|
+
expect(ko.default).toEqual({
|
|
40
|
+
app: { title: { t: "제목" }, partial: { t: "Partial" } },
|
|
41
|
+
libOnly: { signin: { t: "Sign In" } },
|
|
42
|
+
});
|
|
43
|
+
const en = (await import(path.join(artifactDir, assets.en.relPath))) as { default: object };
|
|
44
|
+
expect(en.default).toEqual(allDictionary.en);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("takes the default locale from the config it is handed", async () => {
|
|
48
|
+
const { app, artifactDir } = await makeApp(dictSourceOf(allDictionary));
|
|
49
|
+
const assets = await new DictionaryAssetBuilder(app, artifactDir).build({
|
|
50
|
+
defaultLocale: "ko",
|
|
51
|
+
locales: ["en", "ko"],
|
|
52
|
+
});
|
|
53
|
+
const en = (await import(path.join(artifactDir, assets.en.relPath))) as { default: object };
|
|
54
|
+
expect(en.default).toEqual(allDictionary.en);
|
|
55
|
+
const ko = (await import(path.join(artifactDir, assets.ko.relPath))) as { default: object };
|
|
56
|
+
expect(ko.default).toEqual(allDictionary.ko);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("names a module by its content, so a rebuild of the same dictionary keeps the URL", async () => {
|
|
60
|
+
const { app, artifactDir } = await makeApp(dictSourceOf(allDictionary));
|
|
61
|
+
const i18n = { defaultLocale: "en", locales: ["en", "ko"] };
|
|
62
|
+
const first = await new DictionaryAssetBuilder(app, artifactDir).build(i18n);
|
|
63
|
+
const second = await new DictionaryAssetBuilder(app, artifactDir).build(i18n);
|
|
64
|
+
expect(second).toEqual(first);
|
|
65
|
+
expect((await readdir(path.join(artifactDir, "dict"))).length).toBe(2);
|
|
66
|
+
expect(DictionaryAssetBuilder.source("en", { a: { t: "A" } })).not.toBe(
|
|
67
|
+
DictionaryAssetBuilder.source("en", { a: { t: "B" } }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("writes nothing for an app without a dictionary", async () => {
|
|
72
|
+
const { app, artifactDir } = await makeApp();
|
|
73
|
+
expect(await new DictionaryAssetBuilder(app, artifactDir).build({ defaultLocale: "en", locales: ["en"] })).toEqual(
|
|
74
|
+
{},
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { type AllDictionary, Translator } from "akanjs/client";
|
|
3
|
+
import type { AkanI18nConfig } from "akanjs/common";
|
|
4
|
+
import type { DictAsset } from "akanjs/server";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
|
|
7
|
+
export class DictionaryAssetBuilder {
|
|
8
|
+
static readonly urlPrefix = "/_akan/dict";
|
|
9
|
+
readonly #app: App;
|
|
10
|
+
readonly #absArtifactDir: string;
|
|
11
|
+
|
|
12
|
+
constructor(app: App, absArtifactDir: string) {
|
|
13
|
+
this.#app = app;
|
|
14
|
+
this.#absArtifactDir = absArtifactDir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async build(i18n: AkanI18nConfig): Promise<Record<string, DictAsset>> {
|
|
18
|
+
const allDictionary = await this.#loadAllDictionary();
|
|
19
|
+
if (!allDictionary) return {};
|
|
20
|
+
const assets = await Promise.all(
|
|
21
|
+
i18n.locales.map((lang) =>
|
|
22
|
+
this.#write(lang, Translator.fillFromDefault(allDictionary, lang, i18n.defaultLocale)),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
this.#app.verbose(`[base-artifact] dictionaries -> ${assets.map(([, asset]) => asset.relPath).join(", ")}`);
|
|
26
|
+
return Object.fromEntries(assets);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//? The same module the root layout's macro evaluates in this process, so the asset and the RSC worker's copy agree.
|
|
30
|
+
async #loadAllDictionary(): Promise<AllDictionary | null> {
|
|
31
|
+
const dictPath = path.resolve(this.#app.cwdPath, "lib", "dict.ts");
|
|
32
|
+
if (!(await Bun.file(dictPath).exists())) return null;
|
|
33
|
+
const { getAllDictionary } = (await import(dictPath)) as { getAllDictionary?: () => AllDictionary };
|
|
34
|
+
return getAllDictionary ? getAllDictionary() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async #write(lang: string, dictionary: object): Promise<readonly [string, DictAsset]> {
|
|
38
|
+
const source = DictionaryAssetBuilder.source(lang, dictionary);
|
|
39
|
+
const name = `${lang}-${Bun.hash(source).toString(36)}.js`;
|
|
40
|
+
const relPath = `dict/${name}`;
|
|
41
|
+
await Bun.write(path.join(this.#absArtifactDir, relPath), source);
|
|
42
|
+
return [lang, { url: `${DictionaryAssetBuilder.urlPrefix}/${name}`, relPath }] as const;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//? A string for JSON.parse, not an object literal: engines parse the JSON grammar faster than a JS expression.
|
|
46
|
+
static source(lang: string, dictionary: object): string {
|
|
47
|
+
return `export const lang = ${JSON.stringify(lang)};\nexport default JSON.parse(${JSON.stringify(JSON.stringify(dictionary))});\n`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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
|
+
});
|