@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.
@@ -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
- export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
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
- <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>
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
- export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
268
- return <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;
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 artifact = await this.#loadCsrArtifact();
33
- const csrBasePaths = [...akanConfig.basePaths];
34
- const htmlEntries = csrBasePaths.length > 0 ? csrBasePaths : ["index"];
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(path.join(this.#app.cwdPath, ".akan/generated/csr"), { recursive: true });
37
- const generatedHtmlFiles = Object.fromEntries(htmlEntries.map((basePath) => this.#createHtmlFile(basePath)));
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: Object.keys(generatedHtmlFiles),
42
- files: {
43
- ...generatedHtmlFiles,
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(artifact.cssAssets ?? {});
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
- `${this.#app.cwdPath}/.akan/generated/csr/${filename}`,
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="./csr.tsx"></script>
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 #loadCsrArtifact(): Promise<Pick<BaseBuildArtifact, "cssAssets">> {
109
- const artifactDir = path.join(
110
- this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
111
- ".akan/artifact",
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, { cssUrl: string; cssRelPath: string }>): Promise<void> {
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 = path.basename(htmlPath, ".html") === "index" ? "" : path.basename(htmlPath, ".html");
126
- const inlined = await this.#inlineHtmlAssets(await htmlFile.text(), htmlPath, cssAssets[basePath]);
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
- for (const filePath of cssFiles) await unlink(filePath).catch(() => undefined);
133
- const remainingJs = await this.#listOutputFiles((filePath) => filePath.endsWith(".js"));
134
- const remainingCss = await this.#listOutputFiles((filePath) => filePath.endsWith(".css"));
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?: { cssUrl: string; cssRelPath: string },
145
- ): Promise<{ html: string; jsFiles: string[]; cssFiles: 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
- const bundledCss = (
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 matches = [...html.matchAll(/<\/head\s*>/gi)];
199
- const bodyStart = html.search(/<body(?:\s|>)/i);
200
- const headEnd = matches
201
- .filter((match) => match.index !== undefined && (bodyStart === -1 || match.index < bodyStart))
202
- .at(-1);
203
- if (!headEnd || headEnd.index === undefined) return `${snippet}\n${html}`;
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 collectStylesheetHrefs(html: string): string[] {
212
- const linkRe = /<link\b(?=[^>]*\brel=["']stylesheet["'])(?=[^>]*\bhref=["']([^"']+)["'])[^>]*>/gi;
213
- return [...html.matchAll(linkRe)].map((match) => match[1]).filter((href): href is string => !!href);
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.createServerCssStubPlugin()],
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 and strips external stylesheet links", () => {
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.createServerCssStubPlugin(),
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 createServerCssStubPlugin(): BunPlugin {
140
+ static createCssStubPlugin(): BunPlugin {
141
141
  return {
142
- name: "akan-server-css-stub",
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 = this.#pageEntries.map(({ key, moduleAbsPath }, index) => {
35
- const isAsyncDefault = PagesEntrySourceGenerator.#hasAsyncDefaultExport(moduleAbsPath);
36
- return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
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.74",
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.74",
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
+ }