@akanjs/devkit 2.3.0 → 2.3.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.
@@ -336,7 +336,7 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
336
336
  };
337
337
 
338
338
  programCommand.action(async (...args: unknown[]) => {
339
- Logger.rawLog();
339
+ if (!targetMeta.targetOption.stdio) Logger.rawLog();
340
340
  const cmdArgs = args.slice(0, args.length - 2);
341
341
  const opt = args[args.length - 2] as Record<string, unknown>;
342
342
  const commandArgs = [] as unknown[];
@@ -364,7 +364,7 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
364
364
 
365
365
  try {
366
366
  await targetMeta.handler.call(cmd, ...commandArgs);
367
- Logger.rawLog();
367
+ if (!targetMeta.targetOption.stdio) Logger.rawLog();
368
368
  } catch (e) {
369
369
  printCliError(e);
370
370
  throw e;
@@ -28,4 +28,5 @@ export interface TargetOption {
28
28
  devOnly?: boolean;
29
29
  desc?: string;
30
30
  runsOnWorkspaceRoot?: boolean;
31
+ stdio?: boolean;
31
32
  }
package/executors.test.ts CHANGED
@@ -256,6 +256,98 @@ describe("Workspace and app executor environment contracts", () => {
256
256
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
257
257
  });
258
258
 
259
+ test("accepts metadata route exports during page key discovery", async () => {
260
+ const root = await makeTempRoot();
261
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
262
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
263
+ process.env.AKAN_PUBLIC_ENV = "local";
264
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
265
+ await mkdir(path.join(root, "apps/demo/page/docs"), { recursive: true });
266
+ await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
267
+ await writeFile(
268
+ path.join(root, "apps/demo/page/_layout.tsx"),
269
+ [
270
+ "export const head = null;",
271
+ "export const metadata = { title: 'Root' };",
272
+ "export default function Layout({ children }) { return children; }",
273
+ "",
274
+ ].join("\n"),
275
+ );
276
+ await writeFile(
277
+ path.join(root, "apps/demo/page/docs/_layout.tsx"),
278
+ [
279
+ "export async function generateMetadata() { return { title: 'Docs' }; }",
280
+ "export default function Layout({ children }) { return children; }",
281
+ "",
282
+ ].join("\n"),
283
+ );
284
+ await writeFile(
285
+ path.join(root, "apps/demo/page/docs/intro.tsx"),
286
+ ["export const metadata = { title: 'Intro' };", "export default function Page() { return null; }", ""].join("\n"),
287
+ );
288
+
289
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
290
+ const app = AppExecutor.from(workspace, "demo");
291
+
292
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual([
293
+ "./_layout.tsx",
294
+ "./docs/_layout.tsx",
295
+ "./docs/intro.tsx",
296
+ ]);
297
+ });
298
+
299
+ test("rejects conflicting metadata route exports during page key discovery", async () => {
300
+ const root = await makeTempRoot();
301
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
302
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
303
+ process.env.AKAN_PUBLIC_ENV = "local";
304
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
305
+ await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
306
+ await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
307
+ await writeFile(
308
+ path.join(root, "apps/demo/page/conflict.tsx"),
309
+ [
310
+ "export const metadata = { title: 'Conflict' };",
311
+ "export function generateMetadata() { return { title: 'Conflict' }; }",
312
+ "export default function Page() { return null; }",
313
+ "",
314
+ ].join("\n"),
315
+ );
316
+
317
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
318
+ const app = AppExecutor.from(workspace, "demo");
319
+
320
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
321
+ "metadata and generateMetadata cannot both be exported",
322
+ );
323
+ });
324
+
325
+ test("rejects mixed head and metadata route export channels during page key discovery", async () => {
326
+ const root = await makeTempRoot();
327
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
328
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
329
+ process.env.AKAN_PUBLIC_ENV = "local";
330
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
331
+ await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
332
+ await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
333
+ await writeFile(
334
+ path.join(root, "apps/demo/page/mixed.tsx"),
335
+ [
336
+ "export const head = null;",
337
+ "export const metadata = { title: 'Mixed' };",
338
+ "export default function Page() { return null; }",
339
+ "",
340
+ ].join("\n"),
341
+ );
342
+
343
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
344
+ const app = AppExecutor.from(workspace, "demo");
345
+
346
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
347
+ "head/generateHead and metadata/generateMetadata cannot both be exported",
348
+ );
349
+ });
350
+
259
351
  test("assigns start command ports from sorted app order", async () => {
260
352
  const root = await makeTempRoot();
261
353
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
package/executors.ts CHANGED
@@ -151,11 +151,21 @@ const parseEnvFile = (envPath: string): Record<string, string> => {
151
151
  return env;
152
152
  };
153
153
 
154
- const PAGE_ROUTE_EXPORTS = new Set(["default", "pageConfig", "head", "generateHead", "Loading"]);
154
+ const PAGE_ROUTE_EXPORTS = new Set([
155
+ "default",
156
+ "pageConfig",
157
+ "head",
158
+ "metadata",
159
+ "generateHead",
160
+ "generateMetadata",
161
+ "Loading",
162
+ ]);
155
163
  const ROOT_LAYOUT_EXPORTS = new Set([
156
164
  "default",
157
165
  "head",
166
+ "metadata",
158
167
  "generateHead",
168
+ "generateMetadata",
159
169
  "fonts",
160
170
  "manifest",
161
171
  "theme",
@@ -166,7 +176,16 @@ const ROOT_LAYOUT_EXPORTS = new Set([
166
176
  "NotFound",
167
177
  "Error",
168
178
  ]);
169
- const LAYOUT_ROUTE_EXPORTS = new Set(["default", "head", "generateHead", "Loading", "NotFound", "Error"]);
179
+ const LAYOUT_ROUTE_EXPORTS = new Set([
180
+ "default",
181
+ "head",
182
+ "metadata",
183
+ "generateHead",
184
+ "generateMetadata",
185
+ "Loading",
186
+ "NotFound",
187
+ "Error",
188
+ ]);
170
189
 
171
190
  function validateRouteSourceExports(
172
191
  source: string,
@@ -221,6 +240,18 @@ function validateRouteSourceExports(
221
240
  if (exported.has("head") && exported.has("generateHead")) {
222
241
  throw new Error(`[route-convention] head and generateHead cannot both be exported in ${filePath}`);
223
242
  }
243
+ if (
244
+ !options.rootLayout &&
245
+ (exported.has("head") || exported.has("generateHead")) &&
246
+ (exported.has("metadata") || exported.has("generateMetadata"))
247
+ ) {
248
+ throw new Error(
249
+ `[route-convention] head/generateHead and metadata/generateMetadata cannot both be exported in ${filePath}`,
250
+ );
251
+ }
252
+ if (exported.has("metadata") && exported.has("generateMetadata")) {
253
+ throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
254
+ }
224
255
  }
225
256
 
226
257
  export class Executor {
@@ -8,6 +8,7 @@ import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
8
  import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
10
10
  import { HmrChangeClassifier } from "./hmrChangeClassifier";
11
+ import { PagesBundleBuilder } from "./pagesBundleBuilder";
11
12
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
12
13
  import { RoutesManifestArtifactSerializer } from "./routesManifestArtifactSerializer";
13
14
  import { prepareCssAsset } from "./ssrBaseArtifactBuilder";
@@ -95,6 +96,32 @@ describe("PagesEntrySourceGenerator", () => {
95
96
  });
96
97
  });
97
98
 
99
+ describe("PagesBundleBuilder", () => {
100
+ test("stubs CSS imports in the server pages bundle", async () => {
101
+ const root = await makeTempRoot();
102
+ const entry = path.join(root, "entry.tsx");
103
+ const css = path.join(root, "styles.css");
104
+ const outdir = path.join(root, "out");
105
+ await write(entry, ['import "./styles.css";', "export const marker = 1;", ""].join("\n"));
106
+ await write(
107
+ css,
108
+ ['@plugin "daisyui" {', " themes: false;", "}", "@theme {", " --color-primary: red;", "}", ""].join("\n"),
109
+ );
110
+
111
+ const result = await Bun.build({
112
+ entrypoints: [entry],
113
+ outdir,
114
+ target: "bun",
115
+ format: "esm",
116
+ plugins: [PagesBundleBuilder.createServerCssStubPlugin()],
117
+ });
118
+
119
+ expect(result.success).toBe(true);
120
+ expect(result.logs).toEqual([]);
121
+ expect(result.outputs.some((output) => output.kind === "entry-point")).toBe(true);
122
+ });
123
+ });
124
+
98
125
  describe("CsrArtifactBuilder", () => {
99
126
  test("replaces module script src with inline script", async () => {
100
127
  const html = [
@@ -64,6 +64,7 @@ export class PagesBundleBuilder {
64
64
  define: this.#define(),
65
65
  plugins: [
66
66
  PagesBundleBuilder.createPagesEntryPlugin(entrySource),
67
+ PagesBundleBuilder.createServerCssStubPlugin(),
67
68
  await createExternalizeFrameworkPlugin({ app: this.#app, extra: akanConfig.externalLibs }),
68
69
  akanConfig.barrelImports.length > 0
69
70
  ? await createBarrelImportsPlugin(this.#app, {
@@ -134,4 +135,16 @@ export class PagesBundleBuilder {
134
135
  },
135
136
  };
136
137
  }
138
+
139
+ static createServerCssStubPlugin(): BunPlugin {
140
+ return {
141
+ name: "akan-server-css-stub",
142
+ setup(build) {
143
+ build.onLoad({ filter: /\.css$/ }, () => ({
144
+ contents: "",
145
+ loader: "js",
146
+ }));
147
+ },
148
+ };
149
+ }
137
150
  }
@@ -8,6 +8,7 @@ import { ClientEntriesBundler } from "./clientEntriesBundler";
8
8
  import { CssCompiler } from "./cssCompiler";
9
9
  import { FontOptimizer } from "./fontOptimizer";
10
10
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
11
+ import { RouteClientBuilder } from "./routeClientBuilder";
11
12
  import { VENDOR_SPECIFIERS, type VendorSpecifier } from "./vendorSpecifiers";
12
13
 
13
14
  export interface BuildSsrBaseArtifactResult {
@@ -37,7 +38,8 @@ export class SsrBaseArtifactBuilder {
37
38
 
38
39
  async build(): Promise<BuildSsrBaseArtifactResult> {
39
40
  const akanConfig = await this.#app.getConfig();
40
- const { rscClientUrl, vendorMap } = await this.#buildRuntimeClientEntries();
41
+ const { rscClientUrl, rscRuntimeClientManifest, rscRuntimeSsrManifest, vendorMap } =
42
+ await this.#buildRuntimeClientEntries();
41
43
  const pageKeys = await this.#app.getPageKeys();
42
44
  this.#app.verbose(`[base-artifact] discovered ${pageKeys.length} route files under ${this.#app.cwdPath}/page`);
43
45
 
@@ -58,6 +60,8 @@ export class SsrBaseArtifactBuilder {
58
60
 
59
61
  const artifact: BaseBuildArtifact = {
60
62
  rscClientUrl,
63
+ rscRuntimeClientManifest,
64
+ rscRuntimeSsrManifest,
61
65
  vendorMap,
62
66
  cssAssets,
63
67
  pagesBundlePath:
@@ -80,21 +84,61 @@ export class SsrBaseArtifactBuilder {
80
84
  return { artifact, seedIndex, cssCompiler, optimizedFonts };
81
85
  }
82
86
 
83
- async #buildRuntimeClientEntries(): Promise<{ rscClientUrl: string; vendorMap: Record<VendorSpecifier, string> }> {
87
+ async #buildRuntimeClientEntries(): Promise<
88
+ Pick<BaseBuildArtifact, "rscClientUrl" | "rscRuntimeClientManifest" | "rscRuntimeSsrManifest"> & {
89
+ vendorMap: Record<VendorSpecifier, string>;
90
+ }
91
+ > {
84
92
  const akanServerPath = await this.#resolveAkanServerPath();
85
93
  const rscClientEntry = `${akanServerPath}/rscClient.tsx`;
94
+ const rscSegmentOutletEntry = `${akanServerPath}/rscSegmentOutlet.tsx`;
86
95
  const vendorEntries = VENDOR_SPECIFIERS.map((specifier) => ({
87
96
  specifier,
88
97
  absPath: `${akanServerPath}/vendor/${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`,
89
98
  }));
90
- const entries = [rscClientEntry, ...vendorEntries.map((v) => v.absPath)];
99
+ const entries = [rscClientEntry, rscSegmentOutletEntry, ...vendorEntries.map((v) => v.absPath)];
91
100
  const clientBundle = await new ClientEntriesBundler({ app: this.#app, entries, command: this.#command }).bundle();
101
+ const ssrBundle = await new ClientEntriesBundler({
102
+ app: this.#app,
103
+ entries: [rscSegmentOutletEntry],
104
+ ...RouteClientBuilder.resolveSsrClientExternalOptions(this.#command),
105
+ outputSubdir: "client-ssr",
106
+ command: this.#command,
107
+ }).bundle();
92
108
  const rscClientUrl = clientBundle.entryUrlsByAbsPath.get(rscClientEntry) ?? "";
109
+ const rscRuntimeSsrManifest = {
110
+ moduleLoading: null,
111
+ moduleMap: Object.fromEntries(
112
+ Object.entries(clientBundle.manifest)
113
+ .map(([key, row]) => {
114
+ const ssrOutput = ssrBundle.entryOutputAbsByAbsPath.get(rscSegmentOutletEntry);
115
+ if (
116
+ !ssrOutput ||
117
+ key !== `${clientBundle.clientReferenceIdByAbsPath.get(rscSegmentOutletEntry)}#${row.name}`
118
+ ) {
119
+ return null;
120
+ }
121
+ return [
122
+ row.id,
123
+ { [row.name]: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: row.name, async: true } },
124
+ ];
125
+ })
126
+ .filter(
127
+ (entry): entry is [string, Record<string, { id: string; chunks: string[]; name: string; async: true }>] =>
128
+ Boolean(entry),
129
+ ),
130
+ ),
131
+ };
93
132
  const vendorMap = Object.fromEntries(
94
133
  vendorEntries.map(({ specifier, absPath }) => [specifier, clientBundle.entryUrlsByAbsPath.get(absPath) ?? ""]),
95
134
  ) as Record<VendorSpecifier, string>;
96
135
  this.#app.verbose(`[base-artifact] rscClientUrl=${rscClientUrl} vendors=${Object.keys(vendorMap).length}`);
97
- return { rscClientUrl, vendorMap };
136
+ return {
137
+ rscClientUrl,
138
+ rscRuntimeClientManifest: clientBundle.manifest,
139
+ rscRuntimeSsrManifest,
140
+ vendorMap,
141
+ };
98
142
  }
99
143
  async #resolveAkanServerPath() {
100
144
  const candidates: string[] = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.0",
3
+ "version": "2.3.1-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.0",
35
+ "akanjs": "2.3.1-rc.0",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "^5.5.20",