@akanjs/devkit 3.0.0-alpha.74 → 3.0.0-alpha.76
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/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +25 -5
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +90 -9
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/package.json +2 -2
- package/transforms/asyncDefaultExportDetector.ts +103 -0
|
@@ -102,4 +102,71 @@ describe("resolveSsrPageEntries", () => {
|
|
|
102
102
|
);
|
|
103
103
|
expect(generatedDictMacro).toContain("export const allDictionary = getAllDictionary();");
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
test("awaits an async grouped root layout in the CSR bundle instead of mounting it as JSX", async () => {
|
|
107
|
+
const appRoot = await makeTempRoot();
|
|
108
|
+
const pageRoot = path.join(appRoot, "page");
|
|
109
|
+
|
|
110
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
111
|
+
await write(
|
|
112
|
+
path.join(pageRoot, "(user)", "_layout.tsx"),
|
|
113
|
+
"export default async function Layout({ children }) { await Promise.resolve(); return <div>{children}</div>; }\n",
|
|
114
|
+
);
|
|
115
|
+
await write(
|
|
116
|
+
path.join(pageRoot, "(sign)", "_layout.tsx"),
|
|
117
|
+
"export default function Layout({ children }) { return children; }\n",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const entries = await resolveSsrPageEntries({
|
|
121
|
+
appCwdPath: appRoot,
|
|
122
|
+
appName: "demo",
|
|
123
|
+
pageKeys: ["./(user)/_layout.tsx", "./(user)/self/_index.tsx", "./(sign)/_layout.tsx", "./(sign)/signin.tsx"],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const userRoot = entries.find((entry) => entry.key === "./(user)/__root_layout.tsx");
|
|
127
|
+
const userSource = await Bun.file(userRoot?.moduleAbsPath ?? "").text();
|
|
128
|
+
expect(userSource).toContain("<System.Provider");
|
|
129
|
+
expect(userSource).toContain("export default async function GeneratedLayout(");
|
|
130
|
+
expect(userSource).toContain('process.env.AKAN_PUBLIC_RENDER_ENV === "csr"');
|
|
131
|
+
expect(userSource).toContain("? await UserLayout({ params, searchParams, children })");
|
|
132
|
+
expect(userSource).toContain(": <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;");
|
|
133
|
+
expect(userSource).toContain(" {layout}\n </System.Provider>");
|
|
134
|
+
|
|
135
|
+
const signRoot = entries.find((entry) => entry.key === "./(sign)/__root_layout.tsx");
|
|
136
|
+
const signSource = await Bun.file(signRoot?.moduleAbsPath ?? "").text();
|
|
137
|
+
expect(signSource).toContain("<System.Provider");
|
|
138
|
+
expect(signSource).toContain("export default function GeneratedLayout(");
|
|
139
|
+
expect(signSource).not.toContain("await UserLayout(");
|
|
140
|
+
expect(signSource).toContain(
|
|
141
|
+
" <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("returns the awaited node directly for an async nested root boundary", async () => {
|
|
146
|
+
const appRoot = await makeTempRoot();
|
|
147
|
+
const pageRoot = path.join(appRoot, "page");
|
|
148
|
+
|
|
149
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
150
|
+
await write(
|
|
151
|
+
path.join(pageRoot, "_layout.tsx"),
|
|
152
|
+
"export default function Layout({ children }) { return children; }\n",
|
|
153
|
+
);
|
|
154
|
+
await write(
|
|
155
|
+
path.join(pageRoot, "(home)", "_layout.tsx"),
|
|
156
|
+
"const Layout = async ({ children }) => children;\nexport default Layout;\n",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const entries = await resolveSsrPageEntries({
|
|
160
|
+
appCwdPath: appRoot,
|
|
161
|
+
appName: "demo",
|
|
162
|
+
pageKeys: ["./_layout.tsx", "./(home)/_layout.tsx", "./(home)/_index.tsx"],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const groupedRoot = entries.find((entry) => entry.key === "./(home)/__root_layout.tsx");
|
|
166
|
+
const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
|
|
167
|
+
expect(generatedSource).not.toContain("<System.Provider");
|
|
168
|
+
expect(generatedSource).toContain("export default async function GeneratedLayout(");
|
|
169
|
+
expect(generatedSource).toContain("? await UserLayout({ params, searchParams, children })");
|
|
170
|
+
expect(generatedSource).toContain(" return layout;\n}");
|
|
171
|
+
});
|
|
105
172
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
4
5
|
|
|
5
6
|
export interface PageEntry {
|
|
6
7
|
key: string;
|
|
@@ -191,6 +192,25 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
191
192
|
const userImport = sourceSpecifier
|
|
192
193
|
? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
|
|
193
194
|
: "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
|
|
195
|
+
const isAsyncUserLayout = opts.boundary.sourceAbsPath
|
|
196
|
+
? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
|
|
197
|
+
: false;
|
|
198
|
+
const userLayoutElement = "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>";
|
|
199
|
+
// React has no async client component, so the CSR bundle calls an async layout and awaits its node the way
|
|
200
|
+
// `RenderLayer` does for pages. The RSC render keeps the element: awaiting there would hold the shell behind
|
|
201
|
+
// the layout's own awaits instead of streaming it as its own Flight chunk.
|
|
202
|
+
const layoutSignature = isAsyncUserLayout
|
|
203
|
+
? "export default async function GeneratedLayout"
|
|
204
|
+
: "export default function GeneratedLayout";
|
|
205
|
+
const layoutBinding = isAsyncUserLayout
|
|
206
|
+
? ` const layout =
|
|
207
|
+
process.env.AKAN_PUBLIC_RENDER_ENV === "csr"
|
|
208
|
+
? await UserLayout({ params, searchParams, children })
|
|
209
|
+
: ${userLayoutElement};
|
|
210
|
+
`
|
|
211
|
+
: "";
|
|
212
|
+
const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
|
|
213
|
+
const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
|
|
194
214
|
const source = opts.includeSystemProvider
|
|
195
215
|
? `import type { LayoutProps, PageProps } from "akanjs/client";
|
|
196
216
|
import { loadFonts } from "akanjs/client";
|
|
@@ -222,8 +242,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
|
222
242
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
223
243
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
224
244
|
|
|
225
|
-
|
|
226
|
-
return (
|
|
245
|
+
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
246
|
+
${layoutBinding} return (
|
|
227
247
|
<System.Provider
|
|
228
248
|
of={GeneratedLayout as never}
|
|
229
249
|
appName=${JSON.stringify(opts.appName)}
|
|
@@ -239,7 +259,7 @@ export default function GeneratedLayout({ children, params, searchParams }: Layo
|
|
|
239
259
|
wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
|
|
240
260
|
allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}
|
|
241
261
|
>
|
|
242
|
-
|
|
262
|
+
${layoutChild}
|
|
243
263
|
</System.Provider>
|
|
244
264
|
);
|
|
245
265
|
}
|
|
@@ -264,8 +284,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
|
264
284
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
265
285
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
266
286
|
|
|
267
|
-
|
|
268
|
-
return
|
|
287
|
+
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
288
|
+
${layoutBinding} return ${layoutReturn};
|
|
269
289
|
}
|
|
270
290
|
`;
|
|
271
291
|
await Bun.write(absPath, source);
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { mkdir, rm, unlink } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
4
|
-
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
4
|
+
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
|
+
import { getPageKeyBasePath } from "./cssCompiler";
|
|
7
|
+
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
6
8
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
7
9
|
|
|
8
10
|
export interface BuildCsrArtifactResult {
|
|
9
11
|
outputDir: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
type CssAsset = NonNullable<BaseBuildArtifact["cssAssets"]>[string];
|
|
15
|
+
|
|
12
16
|
export class CsrArtifactBuilder {
|
|
13
17
|
#app: App;
|
|
14
18
|
#command: "build" | "start";
|
|
@@ -29,31 +33,39 @@ export class CsrArtifactBuilder {
|
|
|
29
33
|
|
|
30
34
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
31
35
|
const akanConfig = await this.#app.getConfig();
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
36
|
+
const cssAssets = await this.#loadCssAssets();
|
|
37
|
+
const basePaths = [...akanConfig.basePaths];
|
|
38
|
+
const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
|
|
35
39
|
await rm(this.#outputDir, { recursive: true, force: true });
|
|
36
|
-
await mkdir(
|
|
37
|
-
const
|
|
40
|
+
await mkdir(this.#generatedDir, { recursive: true });
|
|
41
|
+
const generatedFiles = Object.fromEntries(
|
|
42
|
+
(
|
|
43
|
+
await Promise.all(
|
|
44
|
+
htmlBasePaths.map(async (basePath) => [
|
|
45
|
+
this.#createHtmlFile(basePath),
|
|
46
|
+
await this.#createEntryFile(
|
|
47
|
+
basePath,
|
|
48
|
+
CsrArtifactBuilder.pageEntriesForBasePath(pageEntries, basePath, basePaths),
|
|
49
|
+
),
|
|
50
|
+
]),
|
|
51
|
+
)
|
|
52
|
+
).flat(),
|
|
53
|
+
);
|
|
38
54
|
|
|
39
55
|
const result = await Bun.build({
|
|
40
56
|
target: "browser",
|
|
41
|
-
entrypoints:
|
|
42
|
-
files:
|
|
43
|
-
|
|
44
|
-
[`${this.#app.cwdPath}/.akan/generated/csr/csr.tsx`]: `
|
|
45
|
-
import { bootCsr } from "akanjs/webkit";
|
|
46
|
-
${PagesEntrySourceGenerator.generateStatic(pageEntries)}
|
|
47
|
-
void bootCsr(pages);
|
|
48
|
-
`,
|
|
49
|
-
},
|
|
50
|
-
root: `${this.#app.cwdPath}/.akan/generated/csr`,
|
|
57
|
+
entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
|
|
58
|
+
files: generatedFiles,
|
|
59
|
+
root: this.#generatedDir,
|
|
51
60
|
outdir: this.#outputDir,
|
|
52
61
|
splitting: false,
|
|
53
62
|
minify: true,
|
|
54
63
|
env: "AKAN_PUBLIC_*",
|
|
55
64
|
define: this.#define(),
|
|
56
65
|
optimizeImports: akanConfig.optimizeImports,
|
|
66
|
+
// The base artifact's compiled sheet is the only stylesheet, as it is for SSR: a raw `.css` reached through
|
|
67
|
+
// the route graph is Tailwind source, and every root layout stylesheet in the graph would land in every HTML.
|
|
68
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
if (!result.success) {
|
|
@@ -61,11 +73,32 @@ void bootCsr(pages);
|
|
|
61
73
|
throw new Error(`[csr-build] failed${logs ? `\n${logs}` : ""}`);
|
|
62
74
|
}
|
|
63
75
|
|
|
64
|
-
await this.#inlineCsrArtifacts(
|
|
76
|
+
await this.#inlineCsrArtifacts(cssAssets);
|
|
65
77
|
this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
|
|
66
78
|
return { outputDir: this.#outputDir };
|
|
67
79
|
}
|
|
68
80
|
|
|
81
|
+
/** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
|
|
82
|
+
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
83
|
+
return pageEntries.filter((entry) => {
|
|
84
|
+
const entryBasePath = getPageKeyBasePath(entry.key, basePaths);
|
|
85
|
+
return entryBasePath === null || entryBasePath === basePath;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static htmlFilename(basePath: string): string {
|
|
90
|
+
return `${basePath || "index"}.html`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static entryFilename(basePath: string): string {
|
|
94
|
+
return `${basePath || "index"}.csr.tsx`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static basePathOfHtml(htmlPath: string): string {
|
|
98
|
+
const name = path.basename(htmlPath, ".html");
|
|
99
|
+
return name === "index" ? "" : name;
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
get #outputDir(): string {
|
|
70
103
|
return path.join(
|
|
71
104
|
this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
|
|
@@ -73,6 +106,18 @@ void bootCsr(pages);
|
|
|
73
106
|
);
|
|
74
107
|
}
|
|
75
108
|
|
|
109
|
+
get #generatedDir(): string {
|
|
110
|
+
return path.join(this.#app.cwdPath, ".akan/generated/csr");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get #artifactDir(): string {
|
|
114
|
+
return path.join(this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath, ".akan/artifact");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#generatedPath(filename: string): string {
|
|
118
|
+
return path.join(this.#generatedDir, filename);
|
|
119
|
+
}
|
|
120
|
+
|
|
76
121
|
#define(): Record<string, string> {
|
|
77
122
|
const nodeEnv = this.#command === "build" ? "production" : (process.env.NODE_ENV ?? "development");
|
|
78
123
|
return {
|
|
@@ -84,10 +129,19 @@ void bootCsr(pages);
|
|
|
84
129
|
};
|
|
85
130
|
}
|
|
86
131
|
|
|
132
|
+
async #createEntryFile(basePath: string, pageEntries: PageEntry[]): Promise<readonly [string, string]> {
|
|
133
|
+
return [
|
|
134
|
+
this.#generatedPath(CsrArtifactBuilder.entryFilename(basePath)),
|
|
135
|
+
`import { bootCsr } from "akanjs/webkit";
|
|
136
|
+
${await PagesEntrySourceGenerator.generateStatic(pageEntries)}
|
|
137
|
+
void bootCsr(pages);
|
|
138
|
+
`,
|
|
139
|
+
] as const;
|
|
140
|
+
}
|
|
141
|
+
|
|
87
142
|
#createHtmlFile(basePath: string): readonly [string, string] {
|
|
88
|
-
const filename = `${basePath}.html`;
|
|
89
143
|
return [
|
|
90
|
-
|
|
144
|
+
this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath)),
|
|
91
145
|
`<!doctype html>
|
|
92
146
|
<html lang="${this.#lang}">
|
|
93
147
|
<head>
|
|
@@ -98,41 +152,40 @@ void bootCsr(pages);
|
|
|
98
152
|
</head>
|
|
99
153
|
<body>
|
|
100
154
|
<div id="root"></div>
|
|
101
|
-
<script type="module" src="
|
|
155
|
+
<script type="module" src="./${CsrArtifactBuilder.entryFilename(basePath)}"></script>
|
|
102
156
|
</body>
|
|
103
157
|
</html>
|
|
104
158
|
`,
|
|
105
159
|
] as const;
|
|
106
160
|
}
|
|
107
161
|
|
|
108
|
-
async #
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const artifactFile = Bun.file(path.join(artifactDir, "base-artifact.json"));
|
|
114
|
-
if (!(await artifactFile.exists())) return { cssAssets: {} };
|
|
115
|
-
const artifact = (await artifactFile.json()) as Pick<BaseBuildArtifact, "cssAssets">;
|
|
116
|
-
return { cssAssets: artifact.cssAssets ?? {} };
|
|
162
|
+
async #loadCssAssets(): Promise<Record<string, CssAsset>> {
|
|
163
|
+
const artifactFile = Bun.file(path.join(this.#artifactDir, "base-artifact.json"));
|
|
164
|
+
if (!(await artifactFile.exists())) return {};
|
|
165
|
+
const artifact = (await artifactFile.json()) as Partial<Pick<BaseBuildArtifact, "cssAssets">>;
|
|
166
|
+
return artifact.cssAssets ?? {};
|
|
117
167
|
}
|
|
118
168
|
|
|
119
|
-
async #inlineCsrArtifacts(cssAssets: Record<string,
|
|
169
|
+
async #inlineCsrArtifacts(cssAssets: Record<string, CssAsset>): Promise<void> {
|
|
120
170
|
const jsFiles = new Set<string>();
|
|
121
|
-
const cssFiles = new Set<string>();
|
|
122
171
|
for (const htmlPath of await this.#htmlOutputPaths()) {
|
|
123
172
|
const htmlFile = Bun.file(htmlPath);
|
|
124
173
|
if (!(await htmlFile.exists())) continue;
|
|
125
|
-
const basePath =
|
|
126
|
-
const
|
|
174
|
+
const basePath = CsrArtifactBuilder.basePathOfHtml(htmlPath);
|
|
175
|
+
const cssAsset = cssAssets[basePath];
|
|
176
|
+
if (!cssAsset) {
|
|
177
|
+
this.#app.logger.warn(
|
|
178
|
+
`[csr-build] base-artifact.json has no compiled stylesheet for ${basePath || "root"}; ${path.basename(htmlPath)} ships without CSS`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const inlined = await this.#inlineHtmlAssets(await htmlFile.text(), htmlPath, cssAsset);
|
|
127
182
|
for (const filePath of inlined.jsFiles) jsFiles.add(filePath);
|
|
128
|
-
for (const filePath of inlined.cssFiles) cssFiles.add(filePath);
|
|
129
183
|
await Bun.write(htmlPath, inlined.html);
|
|
130
184
|
}
|
|
131
185
|
for (const filePath of jsFiles) await unlink(filePath).catch(() => undefined);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const remainingAssets = [...remainingJs, ...remainingCss];
|
|
186
|
+
const remainingAssets = await this.#listOutputFiles(
|
|
187
|
+
(filePath) => filePath.endsWith(".js") || filePath.endsWith(".css"),
|
|
188
|
+
);
|
|
136
189
|
if (remainingAssets.length > 0) {
|
|
137
190
|
throw new Error(`[csr-build] expected single-file HTML, but CSR assets remain:\n${remainingAssets.join("\n")}`);
|
|
138
191
|
}
|
|
@@ -141,44 +194,20 @@ void bootCsr(pages);
|
|
|
141
194
|
async #inlineHtmlAssets(
|
|
142
195
|
html: string,
|
|
143
196
|
htmlPath: string,
|
|
144
|
-
cssAsset?:
|
|
145
|
-
): Promise<{ html: string; jsFiles: string[]
|
|
197
|
+
cssAsset?: CssAsset,
|
|
198
|
+
): Promise<{ html: string; jsFiles: string[] }> {
|
|
199
|
+
let next = html;
|
|
200
|
+
if (cssAsset) {
|
|
201
|
+
const css = await Bun.file(path.join(this.#artifactDir, cssAsset.cssRelPath)).text();
|
|
202
|
+
next = CsrArtifactBuilder.injectBeforeHeadEnd(next, CsrArtifactBuilder.createInlineStyle(css));
|
|
203
|
+
}
|
|
146
204
|
const jsFiles: string[] = [];
|
|
147
|
-
const cssFiles = CsrArtifactBuilder.collectStylesheetHrefs(html).map((href) =>
|
|
148
|
-
CsrArtifactBuilder.resolveHtmlAssetPath(htmlPath, href),
|
|
149
|
-
);
|
|
150
|
-
let next = CsrArtifactBuilder.stripBundledStylesheetLinks(html);
|
|
151
205
|
next = await CsrArtifactBuilder.replaceModuleScriptSrc(next, async (src) => {
|
|
152
206
|
const jsPath = CsrArtifactBuilder.resolveHtmlAssetPath(htmlPath, src);
|
|
153
207
|
jsFiles.push(jsPath);
|
|
154
208
|
return await Bun.file(jsPath).text();
|
|
155
209
|
});
|
|
156
|
-
|
|
157
|
-
await Promise.all(
|
|
158
|
-
cssFiles.map((cssFile) =>
|
|
159
|
-
Bun.file(cssFile)
|
|
160
|
-
.text()
|
|
161
|
-
.catch(() => ""),
|
|
162
|
-
),
|
|
163
|
-
)
|
|
164
|
-
)
|
|
165
|
-
.filter(Boolean)
|
|
166
|
-
.join("\n");
|
|
167
|
-
if (bundledCss) {
|
|
168
|
-
const style = CsrArtifactBuilder.createInlineStyle(bundledCss);
|
|
169
|
-
if (!next.includes(style)) next = CsrArtifactBuilder.injectBeforeHeadEnd(next, style);
|
|
170
|
-
}
|
|
171
|
-
if (cssAsset) {
|
|
172
|
-
const cssPath = path.join(
|
|
173
|
-
this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
|
|
174
|
-
".akan/artifact",
|
|
175
|
-
cssAsset.cssRelPath,
|
|
176
|
-
);
|
|
177
|
-
const css = await Bun.file(cssPath).text();
|
|
178
|
-
const style = CsrArtifactBuilder.createInlineStyle(css);
|
|
179
|
-
if (!next.includes(style)) next = CsrArtifactBuilder.injectBeforeHeadEnd(next, style);
|
|
180
|
-
}
|
|
181
|
-
return { html: next, jsFiles, cssFiles };
|
|
210
|
+
return { html: next, jsFiles };
|
|
182
211
|
}
|
|
183
212
|
|
|
184
213
|
async #htmlOutputPaths(): Promise<string[]> {
|
|
@@ -194,23 +223,26 @@ void bootCsr(pages);
|
|
|
194
223
|
return files.sort();
|
|
195
224
|
}
|
|
196
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Bun's HTML bundler hoists the module script into `<head>`, so once that script is inline its source is part
|
|
228
|
+
* of the text being searched — and a React bundle contains `<body` and `</head>` as strings. Positions are
|
|
229
|
+
* taken on a copy with script, style and comment bodies blanked, and the snippet always lands after whatever
|
|
230
|
+
* was injected before it: prepending would reverse the cascade order the caller chose.
|
|
231
|
+
*/
|
|
197
232
|
static injectBeforeHeadEnd(html: string, snippet: string): string {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return `${html.slice(0, headEnd.index)}${snippet}\n${html.slice(headEnd.index)}`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
static stripBundledStylesheetLinks(html: string): string {
|
|
208
|
-
return html.replace(/<link\b(?=[^>]*\brel=["']stylesheet["'])[^>]*>\s*/gi, "");
|
|
233
|
+
const scannable = CsrArtifactBuilder.blankEmbeddedContent(html);
|
|
234
|
+
const headEnd = scannable.search(/<\/head\s*>/i);
|
|
235
|
+
if (headEnd !== -1) return `${html.slice(0, headEnd)}${snippet}\n${html.slice(headEnd)}`;
|
|
236
|
+
const bodyStart = scannable.search(/<body(?:\s|>)/i);
|
|
237
|
+
if (bodyStart !== -1) return `${html.slice(0, bodyStart)}${snippet}\n${html.slice(bodyStart)}`;
|
|
238
|
+
return `${html}\n${snippet}`;
|
|
209
239
|
}
|
|
210
240
|
|
|
211
|
-
static
|
|
212
|
-
|
|
213
|
-
|
|
241
|
+
static blankEmbeddedContent(html: string): string {
|
|
242
|
+
return html.replace(
|
|
243
|
+
/<script\b[^>]*>[\s\S]*?<\/script\s*>|<style\b[^>]*>[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->/gi,
|
|
244
|
+
(match) => " ".repeat(match.length),
|
|
245
|
+
);
|
|
214
246
|
}
|
|
215
247
|
|
|
216
248
|
static createInlineStyle(css: string): string {
|
|
@@ -441,7 +441,7 @@ export function declaredCustomProperties(css: string): string[] {
|
|
|
441
441
|
);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
|
|
444
|
+
export function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
|
|
445
445
|
const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
|
|
446
446
|
const segments = normalized.split("/");
|
|
447
447
|
const firstPublicSegment = segments.find((segment) => segment !== "[lang]" && !/^\(.+\)$/.test(segment));
|
|
@@ -54,10 +54,10 @@ describe("PagesEntrySourceGenerator", () => {
|
|
|
54
54
|
);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("generates static import source for single-file CSR bundles", () => {
|
|
57
|
+
test("generates static import source for single-file CSR bundles", async () => {
|
|
58
58
|
const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
|
|
59
59
|
const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
|
|
60
|
-
const source = PagesEntrySourceGenerator.generateStatic([
|
|
60
|
+
const source = await PagesEntrySourceGenerator.generateStatic([
|
|
61
61
|
{ key: "./_index.tsx", moduleAbsPath: indexAbs },
|
|
62
62
|
{ key: "./admin.tsx", moduleAbsPath: adminAbs },
|
|
63
63
|
]);
|
|
@@ -88,7 +88,7 @@ describe("PagesEntrySourceGenerator", () => {
|
|
|
88
88
|
await write(expressionPath, "export default async () => null;");
|
|
89
89
|
await write(namedExportPath, "async function NamedExport() { return null; }\nexport { NamedExport as default };");
|
|
90
90
|
|
|
91
|
-
const source = PagesEntrySourceGenerator.generateStatic([
|
|
91
|
+
const source = await PagesEntrySourceGenerator.generateStatic([
|
|
92
92
|
{ key: "./_index.tsx", moduleAbsPath: indexPath },
|
|
93
93
|
{ key: "./admin.tsx", moduleAbsPath: adminPath },
|
|
94
94
|
{ key: "./typed.tsx", moduleAbsPath: typedPath },
|
|
@@ -123,7 +123,7 @@ describe("PagesBundleBuilder", () => {
|
|
|
123
123
|
outdir,
|
|
124
124
|
target: "bun",
|
|
125
125
|
format: "esm",
|
|
126
|
-
plugins: [PagesBundleBuilder.
|
|
126
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
expect(result.success).toBe(true);
|
|
@@ -178,15 +178,96 @@ describe("CsrArtifactBuilder", () => {
|
|
|
178
178
|
expect(inlined).not.toContain("src=");
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
test("creates inline stylesheet
|
|
182
|
-
const html =
|
|
183
|
-
'<head><link rel="stylesheet" href="/_akan/styles/akanjs.css" data-akan-css="active" /><link rel="stylesheet" href="./generated.css" /></head>';
|
|
184
|
-
const stripped = CsrArtifactBuilder.stripBundledStylesheetLinks(html);
|
|
181
|
+
test("creates inline stylesheet with the closing tag escaped", () => {
|
|
185
182
|
const style = CsrArtifactBuilder.createInlineStyle("body::before{content:'</style>';}");
|
|
186
183
|
|
|
187
|
-
expect(stripped).toBe("<head></head>");
|
|
188
184
|
expect(style).toBe("<style data-akan-css=\"active\">\nbody::before{content:'<\\/style>';}\n</style>");
|
|
189
185
|
});
|
|
186
|
+
|
|
187
|
+
test("injects into the real head even when an inline bundle quotes body and head tags", () => {
|
|
188
|
+
const bundle = 'console.info("<body>", "</head>", "<!--");';
|
|
189
|
+
const html = [
|
|
190
|
+
"<!doctype html>",
|
|
191
|
+
"<html>",
|
|
192
|
+
"<head>",
|
|
193
|
+
`<script type="module">${bundle}</script>`,
|
|
194
|
+
"</head>",
|
|
195
|
+
'<body><div id="root"></div></body>',
|
|
196
|
+
"</html>",
|
|
197
|
+
].join("\n");
|
|
198
|
+
|
|
199
|
+
const first = CsrArtifactBuilder.injectBeforeHeadEnd(html, "<style>a{}</style>");
|
|
200
|
+
const second = CsrArtifactBuilder.injectBeforeHeadEnd(first, "<style>b{}</style>");
|
|
201
|
+
|
|
202
|
+
expect(second.startsWith("<!doctype html>")).toBe(true);
|
|
203
|
+
expect(second.indexOf("<style>a{}</style>")).toBeGreaterThan(second.indexOf(bundle));
|
|
204
|
+
expect(second.indexOf("<style>b{}</style>")).toBeGreaterThan(second.indexOf("<style>a{}</style>"));
|
|
205
|
+
expect(second.indexOf("<style>b{}</style>")).toBeLessThan(second.indexOf("\n</head>"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("never prepends: without a head the snippet lands before body, else at the end", () => {
|
|
209
|
+
expect(CsrArtifactBuilder.injectBeforeHeadEnd("<html><body></body></html>", "<style></style>")).toBe(
|
|
210
|
+
"<html><style></style>\n<body></body></html>",
|
|
211
|
+
);
|
|
212
|
+
expect(CsrArtifactBuilder.injectBeforeHeadEnd("<div></div>", "<style></style>")).toBe(
|
|
213
|
+
"<div></div>\n<style></style>",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("gives each basePath its own routes plus the routes outside every basePath", () => {
|
|
218
|
+
const entries = [
|
|
219
|
+
{ key: "./_layout.tsx", moduleAbsPath: "/repo/page/_layout.tsx" },
|
|
220
|
+
{ key: "./(sign)/signin.tsx", moduleAbsPath: "/repo/page/(sign)/signin.tsx" },
|
|
221
|
+
{
|
|
222
|
+
key: "./office/__root_layout.tsx",
|
|
223
|
+
moduleAbsPath: "/repo/.akan/generated/root-layouts/office__root_layout.tsx",
|
|
224
|
+
},
|
|
225
|
+
{ key: "./office/_index.tsx", moduleAbsPath: "/repo/page/office/_index.tsx" },
|
|
226
|
+
{
|
|
227
|
+
key: "./(user)/soft/__root_layout.tsx",
|
|
228
|
+
moduleAbsPath: "/repo/.akan/generated/root-layouts/soft__root_layout.tsx",
|
|
229
|
+
},
|
|
230
|
+
{ key: "./(user)/soft/home.tsx", moduleAbsPath: "/repo/page/(user)/soft/home.tsx" },
|
|
231
|
+
];
|
|
232
|
+
const basePaths = ["office", "soft"];
|
|
233
|
+
|
|
234
|
+
expect(CsrArtifactBuilder.pageEntriesForBasePath(entries, "office", basePaths).map((entry) => entry.key)).toEqual([
|
|
235
|
+
"./_layout.tsx",
|
|
236
|
+
"./(sign)/signin.tsx",
|
|
237
|
+
"./office/__root_layout.tsx",
|
|
238
|
+
"./office/_index.tsx",
|
|
239
|
+
]);
|
|
240
|
+
expect(CsrArtifactBuilder.pageEntriesForBasePath(entries, "soft", basePaths).map((entry) => entry.key)).toEqual([
|
|
241
|
+
"./_layout.tsx",
|
|
242
|
+
"./(sign)/signin.tsx",
|
|
243
|
+
"./(user)/soft/__root_layout.tsx",
|
|
244
|
+
"./(user)/soft/home.tsx",
|
|
245
|
+
]);
|
|
246
|
+
expect(CsrArtifactBuilder.pageEntriesForBasePath(entries, "", [])).toEqual(entries);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("keeps route stylesheets out of the browser bundle so the HTML links no CSS", async () => {
|
|
250
|
+
const root = await makeTempRoot();
|
|
251
|
+
const htmlPath = path.join(root, "src/index.html");
|
|
252
|
+
await write(
|
|
253
|
+
htmlPath,
|
|
254
|
+
'<!doctype html><html><head></head><body><script type="module" src="./index.csr.tsx"></script></body></html>',
|
|
255
|
+
);
|
|
256
|
+
await write(path.join(root, "src/index.csr.tsx"), 'import "./styles.css";\nconsole.info("boot");\n');
|
|
257
|
+
await write(path.join(root, "src/styles.css"), ":root { --foreground: #fff; }\n");
|
|
258
|
+
|
|
259
|
+
const result = await Bun.build({
|
|
260
|
+
target: "browser",
|
|
261
|
+
entrypoints: [htmlPath],
|
|
262
|
+
root: path.join(root, "src"),
|
|
263
|
+
outdir: path.join(root, "out"),
|
|
264
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(result.success).toBe(true);
|
|
268
|
+
expect(result.outputs.some((output) => output.path.endsWith(".css"))).toBe(false);
|
|
269
|
+
expect(await readFile(path.join(root, "out/index.html"), "utf8")).not.toContain("stylesheet");
|
|
270
|
+
});
|
|
190
271
|
});
|
|
191
272
|
|
|
192
273
|
describe("SsrBaseArtifactBuilder", () => {
|
|
@@ -64,7 +64,7 @@ export class PagesBundleBuilder {
|
|
|
64
64
|
define: this.#define(),
|
|
65
65
|
plugins: [
|
|
66
66
|
PagesBundleBuilder.createPagesEntryPlugin(entrySource),
|
|
67
|
-
PagesBundleBuilder.
|
|
67
|
+
PagesBundleBuilder.createCssStubPlugin(),
|
|
68
68
|
PagesBundleBuilder.createServerUseClientFetchPlugin(),
|
|
69
69
|
await createExternalizeFrameworkPlugin({ app: this.#app, extra: akanConfig.externalLibs }),
|
|
70
70
|
akanConfig.barrelImports.length > 0
|
|
@@ -137,9 +137,9 @@ export class PagesBundleBuilder {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
static
|
|
140
|
+
static createCssStubPlugin(): BunPlugin {
|
|
141
141
|
return {
|
|
142
|
-
name: "akan-
|
|
142
|
+
name: "akan-css-stub",
|
|
143
143
|
setup(build) {
|
|
144
144
|
build.onLoad({ filter: /\.css$/ }, () => ({
|
|
145
145
|
contents: "",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
|
-
import ts from "typescript";
|
|
4
2
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
3
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
5
4
|
|
|
6
5
|
export class PagesEntrySourceGenerator {
|
|
7
6
|
#pageEntries: PageEntry[];
|
|
@@ -22,101 +21,25 @@ export class PagesEntrySourceGenerator {
|
|
|
22
21
|
return `export const pages = {\n${lines.join("\n")}\n};\n`;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
static generateStatic(pageEntries: PageEntry[]): string {
|
|
26
|
-
return new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
24
|
+
static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
|
|
25
|
+
return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
generateStatic(): string {
|
|
28
|
+
async generateStatic(): Promise<string> {
|
|
30
29
|
const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
|
|
31
30
|
const specifier = PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
|
|
32
31
|
return `import * as page${index} from ${JSON.stringify(specifier)};`;
|
|
33
32
|
});
|
|
34
|
-
const entries =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const entries = await Promise.all(
|
|
34
|
+
this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
|
|
35
|
+
const isAsyncDefault = await AsyncDefaultExportDetector.detect(moduleAbsPath);
|
|
36
|
+
return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
38
39
|
return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
|
|
39
40
|
}
|
|
41
|
+
|
|
40
42
|
static #toImportSpecifier(moduleAbsPath: string): string {
|
|
41
43
|
return path.resolve(moduleAbsPath).split(path.sep).join("/");
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
static #hasAsyncDefaultExport(moduleAbsPath: string): boolean {
|
|
45
|
-
try {
|
|
46
|
-
const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
|
|
47
|
-
const sourceFile = ts.createSourceFile(
|
|
48
|
-
moduleAbsPath,
|
|
49
|
-
source,
|
|
50
|
-
ts.ScriptTarget.Latest,
|
|
51
|
-
true,
|
|
52
|
-
PagesEntrySourceGenerator.#scriptKind(moduleAbsPath),
|
|
53
|
-
);
|
|
54
|
-
return PagesEntrySourceGenerator.#sourceFileHasAsyncDefaultExport(sourceFile);
|
|
55
|
-
} catch {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
static #sourceFileHasAsyncDefaultExport(sourceFile: ts.SourceFile): boolean {
|
|
61
|
-
const asyncBindings = new Map<string, boolean>();
|
|
62
|
-
let defaultIdentifier: string | null = null;
|
|
63
|
-
|
|
64
|
-
for (const statement of sourceFile.statements) {
|
|
65
|
-
if (ts.isFunctionDeclaration(statement)) {
|
|
66
|
-
if (PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
|
|
67
|
-
return PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
|
|
68
|
-
}
|
|
69
|
-
if (statement.name) {
|
|
70
|
-
asyncBindings.set(
|
|
71
|
-
statement.name.text,
|
|
72
|
-
PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword),
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (ts.isVariableStatement(statement)) {
|
|
79
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
80
|
-
if (!ts.isIdentifier(declaration.name)) continue;
|
|
81
|
-
asyncBindings.set(
|
|
82
|
-
declaration.name.text,
|
|
83
|
-
PagesEntrySourceGenerator.#isAsyncFunctionExpression(declaration.initializer),
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (ts.isExportAssignment(statement)) {
|
|
90
|
-
if (PagesEntrySourceGenerator.#isAsyncFunctionExpression(statement.expression)) return true;
|
|
91
|
-
if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
96
|
-
const exportClause = statement.exportClause;
|
|
97
|
-
for (const specifier of exportClause.elements) {
|
|
98
|
-
if (specifier.name.text !== "default") continue;
|
|
99
|
-
defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
static #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
|
|
108
|
-
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
static #isAsyncFunctionExpression(node?: ts.Expression): boolean {
|
|
112
|
-
return Boolean(
|
|
113
|
-
node &&
|
|
114
|
-
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
115
|
-
PagesEntrySourceGenerator.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
static #scriptKind(moduleAbsPath: string): ts.ScriptKind {
|
|
120
|
-
return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
121
|
-
}
|
|
122
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.76",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@langchain/openai": "^1.4.6",
|
|
46
46
|
"@tailwindcss/node": "^4.3.0",
|
|
47
47
|
"@trapezedev/project": "^7.1.4",
|
|
48
|
-
"akanjs": "3.0.0-alpha.
|
|
48
|
+
"akanjs": "3.0.0-alpha.76",
|
|
49
49
|
"chalk": "^5.6.2",
|
|
50
50
|
"commander": "^14.0.3",
|
|
51
51
|
"dayjs": "^1.11.20",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type ts from "typescript";
|
|
4
|
+
|
|
5
|
+
type TypeScript = typeof ts;
|
|
6
|
+
|
|
7
|
+
export class AsyncDefaultExportDetector {
|
|
8
|
+
static #typescriptLoad: Promise<TypeScript> | undefined;
|
|
9
|
+
|
|
10
|
+
// `typescript` costs ~70 MB resident and this detector is reached from the cli entry through the root
|
|
11
|
+
// layout generator, so the compiler loads on first use rather than at import (`entryModuleGraph.test.ts`).
|
|
12
|
+
static #loadTypescript(): Promise<TypeScript> {
|
|
13
|
+
AsyncDefaultExportDetector.#typescriptLoad ??= import("typescript").then(
|
|
14
|
+
(mod) => (mod.default ?? mod) as TypeScript,
|
|
15
|
+
);
|
|
16
|
+
return AsyncDefaultExportDetector.#typescriptLoad;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async detect(moduleAbsPath: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
const typescript = await AsyncDefaultExportDetector.#loadTypescript();
|
|
22
|
+
const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
|
|
23
|
+
const sourceFile = typescript.createSourceFile(
|
|
24
|
+
moduleAbsPath,
|
|
25
|
+
source,
|
|
26
|
+
typescript.ScriptTarget.Latest,
|
|
27
|
+
true,
|
|
28
|
+
AsyncDefaultExportDetector.#scriptKind(typescript, moduleAbsPath),
|
|
29
|
+
);
|
|
30
|
+
return new AsyncDefaultExportDetector(typescript).detectInSourceFile(sourceFile);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#ts: TypeScript;
|
|
37
|
+
|
|
38
|
+
constructor(typescript: TypeScript) {
|
|
39
|
+
this.#ts = typescript;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
detectInSourceFile(sourceFile: ts.SourceFile): boolean {
|
|
43
|
+
const ts = this.#ts;
|
|
44
|
+
const asyncBindings = new Map<string, boolean>();
|
|
45
|
+
let defaultIdentifier: string | null = null;
|
|
46
|
+
|
|
47
|
+
for (const statement of sourceFile.statements) {
|
|
48
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
49
|
+
if (this.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
|
|
50
|
+
return this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
|
|
51
|
+
}
|
|
52
|
+
if (statement.name) {
|
|
53
|
+
asyncBindings.set(statement.name.text, this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword));
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ts.isVariableStatement(statement)) {
|
|
59
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
60
|
+
if (!ts.isIdentifier(declaration.name)) continue;
|
|
61
|
+
asyncBindings.set(declaration.name.text, this.#isAsyncFunctionExpression(declaration.initializer));
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ts.isExportAssignment(statement)) {
|
|
67
|
+
if (this.#isAsyncFunctionExpression(statement.expression)) return true;
|
|
68
|
+
if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
73
|
+
const exportClause = statement.exportClause;
|
|
74
|
+
for (const specifier of exportClause.elements) {
|
|
75
|
+
if (specifier.name.text !== "default") continue;
|
|
76
|
+
defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
|
|
85
|
+
const ts = this.#ts;
|
|
86
|
+
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#isAsyncFunctionExpression(node?: ts.Expression): boolean {
|
|
90
|
+
const ts = this.#ts;
|
|
91
|
+
return Boolean(
|
|
92
|
+
node &&
|
|
93
|
+
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
94
|
+
this.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static #scriptKind(typescript: TypeScript, moduleAbsPath: string): ts.ScriptKind {
|
|
99
|
+
return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx")
|
|
100
|
+
? typescript.ScriptKind.TSX
|
|
101
|
+
: typescript.ScriptKind.TS;
|
|
102
|
+
}
|
|
103
|
+
}
|