@akanjs/devkit 2.4.1 → 2.4.2-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.
@@ -182,6 +182,8 @@ export class AkanAppConfig implements AppConfigResult {
182
182
  /** True only when the app's akan.config.ts explicitly declares a `mobile` section (vs. the synthesized default). */
183
183
  hasMobileConfig: boolean;
184
184
  secrets: string[];
185
+ /** Raw setting; resolved against the app's lib deps at sync time (see `AppExecutor.syncPages`). */
186
+ syncPageLibs: string[] | boolean;
185
187
  baseDevEnv: BaseDevEnv;
186
188
  libs: string[];
187
189
  /** Live-only: plugins declared in this app's `akan.config.ts` (never serialized). */
@@ -219,6 +221,7 @@ export class AkanAppConfig implements AppConfigResult {
219
221
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
220
222
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
221
223
  this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
224
+ this.syncPageLibs = (config?.syncPageLibs as string[] | boolean | undefined) ?? false;
222
225
  this.hasMobileConfig = Boolean(config.mobile);
223
226
  this.mobile = this.#resolveMobileConfig(config.mobile);
224
227
  this.docker = this.#makeDockerContent(config?.docker ?? {});
@@ -272,6 +272,13 @@ void run().catch((error) => {
272
272
  async #writeTypecheckTsconfig({ incremental = true }: TypecheckOptions = {}) {
273
273
  const typecheckDir = path.join(this.#app.cwdPath, ".akan", "typecheck");
274
274
  await mkdir(typecheckDir, { recursive: true });
275
+ //* TypeScript's `include` globs do not cross a symlink, so synced lib pages need their real path.
276
+ const libPageIncludes = (await this.#app.getPageRoots())
277
+ .filter((root) => root.keyPrefix)
278
+ .flatMap((root) => {
279
+ const rel = path.relative(typecheckDir, root.realDir).split(path.sep).join("/");
280
+ return [`${rel}/**/*.ts`, `${rel}/**/*.tsx`];
281
+ });
275
282
  const tsconfig = {
276
283
  extends: "../../tsconfig.json",
277
284
  compilerOptions: {
@@ -285,6 +292,7 @@ void run().catch((error) => {
285
292
  "../../client.ts",
286
293
  "../../page/**/*.ts",
287
294
  "../../page/**/*.tsx",
295
+ ...libPageIncludes,
288
296
  "../../../../pkgs/akanjs/*/types/**/*.d.ts",
289
297
  ],
290
298
  references: [],
package/executors.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AkanAppConfig } from "./akanConfig";
@@ -21,6 +21,8 @@ const writeJson = async (filePath: string, value: object) => {
21
21
  await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
22
22
  };
23
23
 
24
+ const PAGE_SOURCE = "export default function Page() {\n return null;\n}\n";
25
+
24
26
  const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
25
27
  name: "fixture",
26
28
  version: "1.0.0",
@@ -271,6 +273,293 @@ describe("Workspace and app executor environment contracts", () => {
271
273
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
272
274
  });
273
275
 
276
+ describe("syncPages", () => {
277
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
278
+ const makeAppWithLibPages = async (
279
+ appName: string,
280
+ { config = "export default {};\n", libs = { shared: ["about"] } as Record<string, string[] | null> } = {},
281
+ ) => {
282
+ const root = await makeTempRoot();
283
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
284
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
285
+ process.env.AKAN_PUBLIC_ENV = "local";
286
+ process.env.PORT_OFFSET = "0";
287
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
288
+ for (const [lib, routes] of Object.entries(libs)) {
289
+ await mkdir(path.join(root, "libs", lib), { recursive: true });
290
+ for (const route of routes ?? []) {
291
+ await mkdir(path.join(root, "libs", lib, "page", route), { recursive: true });
292
+ await writeFile(path.join(root, "libs", lib, "page", route, "_index.tsx"), PAGE_SOURCE);
293
+ }
294
+ }
295
+ await mkdir(path.join(root, "apps", appName, "page"), { recursive: true });
296
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), config);
297
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
298
+ return { root, app: AppExecutor.from(workspace, appName), appRoot: path.join(root, "apps", appName) };
299
+ };
300
+
301
+ test("links every lib dep that ships a page folder when enabled with true", async () => {
302
+ const { app, appRoot } = await makeAppWithLibPages("pages-true", {
303
+ config: "export default { syncPageLibs: true };\n",
304
+ libs: { shared: ["about"], util: null },
305
+ });
306
+ expect(await app.syncPages(["shared", "util"])).toBe(true);
307
+
308
+ const link = path.join(appRoot, "page/(libs)/(shared)");
309
+ expect((await lstat(link)).isSymbolicLink()).toBe(true);
310
+ expect(await lstat(path.join(appRoot, "page/(libs)/(util)")).catch(() => null)).toBeNull();
311
+ expect(await app.getPageKeys({ refresh: true })).toEqual(["./(libs)/(shared)/about/_index.tsx"]);
312
+ });
313
+
314
+ test("is a no-op when the links already match the config", async () => {
315
+ const { app } = await makeAppWithLibPages("pages-noop", {
316
+ config: "export default { syncPageLibs: ['shared'] };\n",
317
+ });
318
+ expect(await app.syncPages(["shared"])).toBe(true);
319
+ expect(await app.syncPages(["shared"])).toBe(false);
320
+ });
321
+
322
+ test("removes the synced page folder when disabled", async () => {
323
+ const { app, appRoot } = await makeAppWithLibPages("pages-disable", {
324
+ config: "export default { syncPageLibs: true };\n",
325
+ });
326
+ await app.syncPages(["shared"]);
327
+ expect((await lstat(path.join(appRoot, "page/(libs)/(shared)"))).isSymbolicLink()).toBe(true);
328
+
329
+ await writeFile(path.join(appRoot, "akan.config.ts"), "export default { syncPageLibs: false };\n");
330
+ await app.getConfig({ refresh: true });
331
+ expect(await app.syncPages(["shared"])).toBe(true);
332
+ expect(await lstat(path.join(appRoot, "page/(libs)")).catch(() => null)).toBeNull();
333
+ });
334
+
335
+ test("clears a link whose lib page folder was deleted, and keeps the workspace walkable", async () => {
336
+ const { root, app, appRoot } = await makeAppWithLibPages("pages-dangling", {
337
+ config: "export default { syncPageLibs: true };\n",
338
+ });
339
+ await app.syncPages(["shared"]);
340
+ await rm(path.join(root, "libs/shared/page"), { recursive: true, force: true });
341
+
342
+ // A dangling link sits 3 levels under apps/, which is inside the workspace app scan's walk.
343
+ expect(await app.workspace.getApps()).toEqual(["pages-dangling"]);
344
+ expect(await app.syncPages(["shared"])).toBe(true);
345
+ expect(await lstat(path.join(appRoot, "page/(libs)")).catch(() => null)).toBeNull();
346
+ });
347
+
348
+ test("rejects a lib the app does not depend on, and one without a page folder", async () => {
349
+ const { app } = await makeAppWithLibPages("pages-unknown", {
350
+ config: "export default { syncPageLibs: ['missing'] };\n",
351
+ });
352
+ await expect(app.syncPages(["shared"])).rejects.toThrow("does not depend on it");
353
+
354
+ const { app: noPage } = await makeAppWithLibPages("pages-nopage", {
355
+ config: "export default { syncPageLibs: ['util'] };\n",
356
+ libs: { util: null },
357
+ });
358
+ await expect(noPage.syncPages(["util"])).rejects.toThrow("libs/util/page does not exist");
359
+ });
360
+
361
+ test("links into every basePath when the app declares subRoutes", async () => {
362
+ const { app, appRoot } = await makeAppWithLibPages("pages-baseroutes", {
363
+ config: [
364
+ "export default {",
365
+ " syncPageLibs: true,",
366
+ ' routes: [{ basePath: "admin", domains: {} }, { basePath: "shop", domains: {} }],',
367
+ "};",
368
+ "",
369
+ ].join("\n"),
370
+ });
371
+ await app.syncPages(["shared"]);
372
+
373
+ expect((await lstat(path.join(appRoot, "page/admin/(libs)/(shared)"))).isSymbolicLink()).toBe(true);
374
+ expect((await lstat(path.join(appRoot, "page/shop/(libs)/(shared)"))).isSymbolicLink()).toBe(true);
375
+ expect(await app.getPageKeys({ refresh: true })).toEqual([
376
+ "./admin/(libs)/(shared)/about/_index.tsx",
377
+ "./shop/(libs)/(shared)/about/_index.tsx",
378
+ ]);
379
+ });
380
+
381
+ test("rejects a lib route that collides with an app route", async () => {
382
+ const { app, appRoot } = await makeAppWithLibPages("pages-collide", {
383
+ config: "export default { syncPageLibs: true };\n",
384
+ });
385
+ await mkdir(path.join(appRoot, "page/(marketing)/about"), { recursive: true });
386
+ await writeFile(path.join(appRoot, "page/(marketing)/about/_index.tsx"), PAGE_SOURCE);
387
+ await app.syncPages(["shared"]);
388
+
389
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('duplicate page route "/:lang/about"');
390
+ });
391
+
392
+ test("rejects two libs that mount the same route", async () => {
393
+ const { app } = await makeAppWithLibPages("pages-collide-libs", {
394
+ config: "export default { syncPageLibs: true };\n",
395
+ libs: { shared: ["about"], social: ["about"] },
396
+ });
397
+ await app.syncPages(["shared", "social"]);
398
+
399
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('duplicate page route "/:lang/about"');
400
+ });
401
+ });
402
+
403
+ describe("syncAssets", () => {
404
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
405
+ const makeAppWithLibAssets = async (appName: string) => {
406
+ const root = await makeTempRoot();
407
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
408
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
409
+ process.env.AKAN_PUBLIC_ENV = "local";
410
+ process.env.PORT_OFFSET = "0";
411
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
412
+ await mkdir(path.join(root, "libs/shared/public"), { recursive: true });
413
+ await writeFile(path.join(root, "libs/shared/public/logo.png"), "logo");
414
+ await mkdir(path.join(root, "libs/shared/private"), { recursive: true });
415
+ await writeFile(path.join(root, "libs/shared/private/rules.json"), "{}");
416
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
417
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), "export default {};\n");
418
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
419
+ return { root, app: AppExecutor.from(workspace, appName), appRoot: path.join(root, "apps", appName) };
420
+ };
421
+
422
+ test("links lib assets into the app instead of copying them", async () => {
423
+ const { app, appRoot } = await makeAppWithLibAssets("assets-link");
424
+ await app.syncAssets(["shared"]);
425
+
426
+ const publicLink = path.join(appRoot, "public/libs/shared");
427
+ const privateLink = path.join(appRoot, "private/libs/shared");
428
+ expect((await lstat(publicLink)).isSymbolicLink()).toBe(true);
429
+ expect((await lstat(privateLink)).isSymbolicLink()).toBe(true);
430
+ expect(await readFile(path.join(publicLink, "logo.png"), "utf8")).toBe("logo");
431
+ expect(await readFile(path.join(privateLink, "rules.json"), "utf8")).toBe("{}");
432
+ if (process.platform !== "win32") expect(path.isAbsolute(await readlink(publicLink))).toBe(false);
433
+ });
434
+
435
+ test("drops links for deps that no longer ship assets", async () => {
436
+ const { app, appRoot } = await makeAppWithLibAssets("assets-drop");
437
+ await app.syncAssets(["shared"]);
438
+ await app.syncAssets([]);
439
+
440
+ expect(await lstat(path.join(appRoot, "public/libs")).catch(() => null)).toBeNull();
441
+ expect(await lstat(path.join(appRoot, "private/libs")).catch(() => null)).toBeNull();
442
+ });
443
+
444
+ test("removes a link whose target disappeared", async () => {
445
+ const { root, app, appRoot } = await makeAppWithLibAssets("assets-dangling");
446
+ await app.syncAssets(["shared"]);
447
+ await rm(path.join(root, "libs/shared/public"), { recursive: true, force: true });
448
+
449
+ const publicLink = path.join(appRoot, "public/libs/shared");
450
+ await app.removeDir(publicLink);
451
+ expect(await lstat(publicLink).catch(() => null)).toBeNull();
452
+ });
453
+
454
+ test("removing a linked dir with a trailing separator keeps the lib source", async () => {
455
+ const { root, app, appRoot } = await makeAppWithLibAssets("assets-trailing");
456
+ await app.syncAssets(["shared"]);
457
+
458
+ const publicLink = path.join(appRoot, "public/libs/shared");
459
+ await app.removeDir(`${publicLink}${path.sep}`);
460
+ expect(await lstat(publicLink).catch(() => null)).toBeNull();
461
+ expect(await readFile(path.join(root, "libs/shared/public/logo.png"), "utf8")).toBe("logo");
462
+ });
463
+
464
+ test("materializes linked lib assets into dist on build", async () => {
465
+ const { root, app } = await makeAppWithLibAssets("assets-dist");
466
+ await app.syncAssets(["shared"]);
467
+ await app.prepareCommand("build");
468
+
469
+ const distPublicLib = path.join(root, "dist/apps/assets-dist/public/libs/shared");
470
+ expect((await lstat(distPublicLib)).isSymbolicLink()).toBe(false);
471
+ expect(await readFile(path.join(distPublicLib, "logo.png"), "utf8")).toBe("logo");
472
+ expect(await readFile(path.join(root, "dist/apps/assets-dist/private/libs/shared/rules.json"), "utf8")).toBe(
473
+ "{}",
474
+ );
475
+ });
476
+ });
477
+
478
+ describe("devOnly routes", () => {
479
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
480
+ const makeAppWithRoutes = async (appName: string, routes: Record<string, string>) => {
481
+ const root = await makeTempRoot();
482
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
483
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
484
+ process.env.AKAN_PUBLIC_ENV = "local";
485
+ process.env.PORT_OFFSET = "0";
486
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
487
+ await mkdir(path.join(root, "apps", appName, "page"), { recursive: true });
488
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), "export default {};\n");
489
+ for (const [rel, source] of Object.entries(routes)) {
490
+ const filePath = path.join(root, "apps", appName, "page", rel);
491
+ await mkdir(path.dirname(filePath), { recursive: true });
492
+ await writeFile(filePath, source);
493
+ }
494
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
495
+ return { root, app: AppExecutor.from(workspace, appName) };
496
+ };
497
+ const devOnlyPage = `export const pageConfig = { devOnly: true };\n${PAGE_SOURCE}`;
498
+ const layout = "export default function Layout({ children }) { return children; }\n";
499
+ const devOnlyLayout = `export const pageConfig = { devOnly: true };\n${layout}`;
500
+
501
+ test("keeps dev-only routes outside of a build", async () => {
502
+ const { app } = await makeAppWithRoutes("devonly-start", {
503
+ "_index.tsx": PAGE_SOURCE,
504
+ "debug/_index.tsx": devOnlyPage,
505
+ });
506
+
507
+ expect(await app.getPageKeys({ refresh: true })).toEqual(["./_index.tsx", "./debug/_index.tsx"]);
508
+ });
509
+
510
+ test("drops a dev-only page from the build", async () => {
511
+ const { app } = await makeAppWithRoutes("devonly-page", {
512
+ "_index.tsx": PAGE_SOURCE,
513
+ "debug/_index.tsx": devOnlyPage,
514
+ });
515
+ await app.prepareCommand("build");
516
+
517
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx"]);
518
+ });
519
+
520
+ test("drops a dev-only layout together with every route under it", async () => {
521
+ const { app } = await makeAppWithRoutes("devonly-layout", {
522
+ "_index.tsx": PAGE_SOURCE,
523
+ "(dev)/_layout.tsx": devOnlyLayout,
524
+ "(dev)/debug/_index.tsx": PAGE_SOURCE,
525
+ "(dev)/debug/deep/_index.tsx": PAGE_SOURCE,
526
+ "keep/_index.tsx": PAGE_SOURCE,
527
+ });
528
+ await app.prepareCommand("build");
529
+
530
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx", "./keep/_index.tsx"]);
531
+ });
532
+
533
+ test("treats devOnly: false as a normal route", async () => {
534
+ const { app } = await makeAppWithRoutes("devonly-false", {
535
+ "_index.tsx": `export const pageConfig = { devOnly: false, cache: true };\n${PAGE_SOURCE}`,
536
+ });
537
+ await app.prepareCommand("build");
538
+
539
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx"]);
540
+ });
541
+
542
+ test("rejects a devOnly value the build cannot read statically", async () => {
543
+ const { app } = await makeAppWithRoutes("devonly-dynamic", {
544
+ "_index.tsx": `export const pageConfig = { devOnly: process.env.NODE_ENV !== "production" };\n${PAGE_SOURCE}`,
545
+ });
546
+
547
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
548
+ "pageConfig.devOnly must be a literal true or false",
549
+ );
550
+ });
551
+
552
+ test("reads devOnly through a satisfies annotation", async () => {
553
+ const { app } = await makeAppWithRoutes("devonly-satisfies", {
554
+ "_index.tsx": PAGE_SOURCE,
555
+ "debug/_index.tsx": `export const pageConfig = { devOnly: true } satisfies { devOnly: boolean };\n${PAGE_SOURCE}`,
556
+ });
557
+ await app.prepareCommand("build");
558
+
559
+ expect(await app.getPageKeys()).toEqual(["./_index.tsx"]);
560
+ });
561
+ });
562
+
274
563
  describe("getDevPort", () => {
275
564
  const makeWorkspaceWithApps = async (names: string[]) => {
276
565
  const root = await makeTempRoot();
package/executors.ts CHANGED
@@ -8,7 +8,16 @@ import {
8
8
  spawn,
9
9
  } from "node:child_process";
10
10
  import { readFileSync } from "node:fs";
11
- import { copyFile, mkdir, readdir as readDirEntries, stat } from "node:fs/promises";
11
+ import {
12
+ copyFile,
13
+ cp as cpEntry,
14
+ mkdir,
15
+ readdir as readDirEntries,
16
+ realpath,
17
+ rm,
18
+ stat,
19
+ symlink,
20
+ } from "node:fs/promises";
12
21
  import path from "node:path";
13
22
  import { pathToFileURL } from "node:url";
14
23
  import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
@@ -33,6 +42,15 @@ import { Spinner } from "./spinner";
33
42
  import type { TypeChecker } from "./typeChecker";
34
43
  import type { FileContent, PackageJson, TsConfigJson } from "./types";
35
44
 
45
+ export interface PageRoot {
46
+ /** App-relative location of the route files, i.e. the symlink for a synced lib. */
47
+ dir: string;
48
+ /** Where the files actually live, which is what a file watcher reports. */
49
+ realDir: string;
50
+ /** Prefix that turns a `dir`-relative path into an app page key (empty for the app's own `page`). */
51
+ keyPrefix: string;
52
+ }
53
+
36
54
  const staticTemplateFileExtensions = new Set([
37
55
  ".avif",
38
56
  ".bmp",
@@ -389,8 +407,10 @@ export class Executor {
389
407
  return this;
390
408
  }
391
409
  async removeDir(dirPath: string) {
392
- const readPath = this.getPath(dirPath);
393
- if (await FileSys.dirExists(readPath)) await $`rm -rf ${readPath}`;
410
+ //* XXX: `path.join(…, ".")` drops a trailing separator — `rm -rf link/` resolves through a symlink
411
+ //* and wipes its target, while `rm -rf link` only unlinks it.
412
+ const readPath = path.join(this.getPath(dirPath), ".");
413
+ if (await FileSys.entryExists(readPath)) await rm(readPath, { recursive: true, force: true });
394
414
  this.logger.verbose(`Remove directory ${readPath}`);
395
415
  return this;
396
416
  }
@@ -435,13 +455,16 @@ export class Executor {
435
455
  const readPath = this.getPath(filePath);
436
456
  return await FileSys.readJson<object>(readPath);
437
457
  }
438
- async cp(srcPath: string, destPath: string) {
458
+ async cp(srcPath: string, destPath: string, { dereference = false }: { dereference?: boolean } = {}) {
439
459
  const src = this.getPath(srcPath);
440
460
  const dest = this.getPath(destPath);
441
461
  if (!(await FileSys.exists(src))) return;
442
462
  const isDirectory = (await stat(src)).isDirectory();
443
463
  if (!(await FileSys.exists(dest)) && isDirectory) await mkdir(dest, { recursive: true });
444
- await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
464
+ //* `cp -r` keeps symlinks on GNU coreutils but follows them on macOS, so anything that must land as
465
+ //* real files regardless of platform has to say so explicitly.
466
+ if (dereference) await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
467
+ else await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
445
468
  }
446
469
  log(msg: string) {
447
470
  this.logger.info(msg);
@@ -890,7 +913,9 @@ export class WorkspaceExecutor extends Executor {
890
913
  dirs.map(async (dir) => {
891
914
  if (AVOID_DIRS.includes(dir)) return;
892
915
  const dirPath = path.join(dirname, dir);
893
- if ((await stat(dirPath)).isDirectory()) {
916
+ //* A dangling symlink (e.g. a synced lib page whose source was deleted) must not fail the walk —
917
+ //* this runs for `getApps`, so throwing here would break every command until it is repaired.
918
+ if (await FileSys.dirExists(dirPath)) {
894
919
  const hasTargetFile = await FileSys.fileExists(path.join(dirPath, targetFilename));
895
920
  if (hasTargetFile) results.push(`${prefix}${dir}`);
896
921
  if (maxDepth > 0) await getDirs(dirPath, maxDepth - 1, results, `${prefix}${dir}/`);
@@ -1299,11 +1324,17 @@ export class AppExecutor extends SysExecutor {
1299
1324
  };
1300
1325
  Object.assign(process.env, routeEnv);
1301
1326
  if (type === "build") {
1327
+ //* `scanSync` already read the route set, and it reads it unfiltered — dev-only routes are still
1328
+ //* generated against and typechecked. Drop the cache so the build phases re-read it without them.
1329
+ this.#excludeDevOnlyPages = true;
1330
+ this.#pageKeys = null;
1302
1331
  if (await this.exists(this.dist.cwdPath)) await this.dist.exec(`rm -rf ${this.dist.cwdPath}`);
1303
1332
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1333
+ //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1334
+ //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1304
1335
  await Promise.all([
1305
- this.cp("private", `${this.dist.cwdPath}/private`),
1306
- this.cp("public", `${this.dist.cwdPath}/public`),
1336
+ this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1337
+ this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1307
1338
  ]);
1308
1339
  } else await this.removeDir(".akan");
1309
1340
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
@@ -1351,75 +1382,192 @@ export class AppExecutor extends SysExecutor {
1351
1382
  }
1352
1383
 
1353
1384
  #pageKeys: string[] | null = null;
1385
+ /** Set once `prepareCommand("build")` runs, so every later consumer of the route set agrees on it. */
1386
+ #excludeDevOnlyPages = false;
1354
1387
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1355
1388
  if (this.#pageKeys && !refresh) return this.#pageKeys;
1356
1389
  const akanConfig = await this.getConfig();
1357
1390
  const glob = new Bun.Glob("**/*");
1358
1391
  const pageKeys: string[] = [];
1359
- const pageDir = `${this.cwdPath}/page`;
1360
- if (!(await FileSys.dirExists(pageDir))) {
1361
- this.#pageKeys = [];
1362
- return this.#pageKeys;
1363
- }
1364
- for await (const rel of glob.scan({
1365
- cwd: pageDir,
1366
- absolute: false,
1367
- onlyFiles: true,
1368
- })) {
1369
- const segments = rel.split(path.sep);
1370
- if (segments.some((s) => s === "node_modules")) continue;
1371
- const posix = segments.join("/");
1372
- const absPath = path.join(pageDir, posix);
1373
- validatePageSourceFile(posix, { filePath: absPath });
1374
- if (!isRouteSourceFile(posix)) continue;
1375
- const key = `./${posix}`;
1376
- validateSubRoutePageKey(key, akanConfig.basePaths, {
1377
- appName: this.name,
1378
- filePath: absPath,
1379
- });
1380
- const parsed = parseRouteModuleKey(key);
1381
- if (parsed.isInternalRootLayout) {
1382
- throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
1392
+ const owners = new Map<string, { absPath: string; fromLib: boolean }>();
1393
+ const devOnlyKeys = new Set<string>();
1394
+ const devOnlyDirs: string[] = [];
1395
+ for (const root of await this.getPageRoots()) {
1396
+ if (!(await FileSys.dirExists(root.dir))) continue;
1397
+ for await (const rel of glob.scan({
1398
+ cwd: root.dir,
1399
+ absolute: false,
1400
+ onlyFiles: true,
1401
+ })) {
1402
+ const segments = rel.split(path.sep);
1403
+ if (segments.some((s) => s === "node_modules")) continue;
1404
+ const posix = `${root.keyPrefix}${segments.join("/")}`;
1405
+ const absPath = path.join(root.dir, ...segments);
1406
+ validatePageSourceFile(posix, { filePath: absPath });
1407
+ if (!isRouteSourceFile(posix)) continue;
1408
+ const key = `./${posix}`;
1409
+ validateSubRoutePageKey(key, akanConfig.basePaths, {
1410
+ appName: this.name,
1411
+ filePath: absPath,
1412
+ });
1413
+ const parsed = parseRouteModuleKey(key);
1414
+ if (parsed.isInternalRootLayout) {
1415
+ throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
1416
+ }
1417
+ const fromLib = !!root.keyPrefix;
1418
+ const routeId = `${parsed.kind}:${parsed.pattern}`;
1419
+ const owner = owners.get(routeId);
1420
+ //* App-owned routes have always been allowed to collide (two groups, one pattern); only report a
1421
+ //* collision once a synced lib is involved, where neither side can see the other.
1422
+ if (owner && (owner.fromLib || fromLib)) {
1423
+ throw new Error(
1424
+ `[route-convention] duplicate ${parsed.kind} route "${parsed.pattern}" in app "${this.name}":\n- ${owner.absPath}\n- ${absPath}`,
1425
+ );
1426
+ }
1427
+ if (!owner) owners.set(routeId, { absPath, fromLib });
1428
+ const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1429
+ const routeSource = await Bun.file(absPath).text();
1430
+ const validator = await AppExecutor.#getRouteSourceValidator();
1431
+ if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
1432
+ else {
1433
+ const info = validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, {
1434
+ rootLayout: isRootLayout,
1435
+ });
1436
+ if (info.devOnly) {
1437
+ devOnlyKeys.add(key);
1438
+ //* A layout owns its directory, so a dev-only one takes the whole subtree with it — leaving its
1439
+ //* pages behind would ship them stripped of the chrome they were written under.
1440
+ if (parsed.kind === "layout") devOnlyDirs.push(key.replace(/[^/]+$/, ""));
1441
+ }
1442
+ }
1443
+ pageKeys.push(key);
1383
1444
  }
1384
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1385
- const routeSource = await Bun.file(absPath).text();
1386
- const validator = await AppExecutor.#getRouteSourceValidator();
1387
- if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
1388
- else validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
1389
- pageKeys.push(key);
1390
1445
  }
1391
1446
  pageKeys.sort();
1392
- this.#pageKeys = pageKeys;
1447
+ this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
1393
1448
  return this.#pageKeys;
1394
1449
  }
1450
+ #dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
1451
+ if (!devOnlyKeys.size) return pageKeys;
1452
+ const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
1453
+ const dropped = pageKeys.filter(isDevOnly);
1454
+ this.log(`[route] excluded ${dropped.length} dev-only route file(s) from the build: ${dropped.join(", ")}`);
1455
+ return pageKeys.filter((key) => !isDevOnly(key));
1456
+ }
1457
+ /**
1458
+ * Every directory that contributes route files, as `page`-relative key prefixes. Lib roots are the
1459
+ * symlinks `syncPages` created, so route keys stay app-relative while `realDir` is what a file watcher
1460
+ * reports. Enumeration never crosses a symlink (neither Bun's glob nor TypeScript's `include` does),
1461
+ * which is why linked page folders have to be listed here instead of found by walking `page`.
1462
+ */
1463
+ async getPageRoots(): Promise<PageRoot[]> {
1464
+ const akanConfig = await this.getConfig();
1465
+ const pageDir = `${this.cwdPath}/page`;
1466
+ const roots: PageRoot[] = [{ dir: pageDir, realDir: pageDir, keyPrefix: "" }];
1467
+ for (const parent of AppExecutor.#pageLibParents(akanConfig.basePaths)) {
1468
+ const libsDir = `${pageDir}/${parent}${AppExecutor.#pageLibsDir}`;
1469
+ const entries = await readDirEntries(libsDir).catch(() => [] as string[]);
1470
+ for (const entry of entries.sort()) {
1471
+ const dir = `${libsDir}/${entry}`;
1472
+ if (!(await FileSys.dirExists(dir))) continue;
1473
+ roots.push({ dir, realDir: await realpath(dir), keyPrefix: `${parent}${AppExecutor.#pageLibsDir}/${entry}/` });
1474
+ }
1475
+ }
1476
+ return roots;
1477
+ }
1395
1478
  setPageKeys(pageKeys: string[]) {
1396
1479
  this.#pageKeys = pageKeys;
1397
1480
  }
1398
1481
 
1399
- async syncAssets(libDeps: string[]) {
1400
- const projectPublicPath = `${this.cwdPath}/public`;
1401
- const projectAssetsPath = `${this.cwdPath}/private`;
1402
- const projectPublicLibPath = `${projectPublicPath}/libs`;
1403
- const projectAssetsLibPath = `${projectAssetsPath}/libs`;
1404
- await Promise.all([this.removeDir(projectPublicLibPath), this.removeDir(projectAssetsLibPath)]);
1405
- const targetPublicDeps = [] as string[];
1406
- for (const dep of libDeps) {
1407
- if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/public`)) targetPublicDeps.push(dep);
1482
+ static readonly #pageLibsDir = "(libs)";
1483
+ /** Where `(libs)` may live: once at the page root, or once per basePath when the app declares subRoutes. */
1484
+ static #pageLibParents(basePaths: Iterable<string>): string[] {
1485
+ const parents = [...basePaths].map((basePath) => `${basePath}/`);
1486
+ return parents.length ? parents : [""];
1487
+ }
1488
+ /** Returns whether the linked page set changed, which is what makes the app's route keys stale. */
1489
+ async syncPages(libDeps: string[]): Promise<boolean> {
1490
+ const akanConfig = await this.getConfig();
1491
+ const parents = AppExecutor.#pageLibParents(akanConfig.basePaths);
1492
+ const libs = await this.#resolvePageLibs(akanConfig.syncPageLibs, libDeps);
1493
+ //* Listed rather than taken from `getPageRoots`, which drops links whose target is gone — those are
1494
+ //* exactly the ones a sync has to clean up.
1495
+ const linked = (
1496
+ await Promise.all(
1497
+ parents.map(async (parent) => {
1498
+ const libsDir = `${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`;
1499
+ const entries = await readDirEntries(libsDir).catch(() => [] as string[]);
1500
+ return entries.map((entry) => `${parent}${AppExecutor.#pageLibsDir}/${entry}/`);
1501
+ }),
1502
+ )
1503
+ ).flat();
1504
+ const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
1505
+ if (linked.sort().join(",") === wanted.sort().join(",")) return false;
1506
+ await Promise.all(
1507
+ parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
1508
+ );
1509
+ for (const parent of parents) {
1510
+ if (!libs.length) break;
1511
+ const libsDir = `${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`;
1512
+ await this.mkdir(libsDir);
1513
+ await Promise.all(
1514
+ libs.map((lib) =>
1515
+ AppExecutor.#linkLibAsset(`${this.workspace.workspaceRoot}/libs/${lib}/page`, `${libsDir}/(${lib})`),
1516
+ ),
1517
+ );
1518
+ }
1519
+ this.#pageKeys = null;
1520
+ return true;
1521
+ }
1522
+ async #resolvePageLibs(syncPageLibs: string[] | boolean, libDeps: string[]): Promise<string[]> {
1523
+ if (!syncPageLibs) return [];
1524
+ const hasPageDir = async (lib: string) =>
1525
+ await FileSys.dirExists(`${this.workspace.workspaceRoot}/libs/${lib}/page`);
1526
+ if (syncPageLibs === true) {
1527
+ const libs: string[] = [];
1528
+ for (const lib of libDeps) if (await hasPageDir(lib)) libs.push(lib);
1529
+ return libs;
1408
1530
  }
1409
- const targetAssetsDeps = [] as string[];
1531
+ for (const lib of syncPageLibs) {
1532
+ if (!libDeps.includes(lib))
1533
+ throw new Error(
1534
+ `[syncPageLibs] app "${this.name}" lists lib "${lib}" but does not depend on it (deps: ${libDeps.join(", ") || "none"})`,
1535
+ );
1536
+ if (!(await hasPageDir(lib)))
1537
+ throw new Error(`[syncPageLibs] app "${this.name}" lists lib "${lib}" but libs/${lib}/page does not exist`);
1538
+ }
1539
+ return [...syncPageLibs];
1540
+ }
1541
+ async syncAssets(libDeps: string[]) {
1542
+ await Promise.all((["public", "private"] as const).map((facet) => this.#syncLibAssets(facet, libDeps)));
1543
+ }
1544
+ async #syncLibAssets(facet: "public" | "private", libDeps: string[]) {
1545
+ const libLinkPath = `${this.cwdPath}/${facet}/libs`;
1546
+ await this.removeDir(libLinkPath);
1547
+ const targetDeps = [] as string[];
1410
1548
  for (const dep of libDeps) {
1411
- if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/private`)) targetAssetsDeps.push(dep);
1549
+ if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/${facet}`)) targetDeps.push(dep);
1412
1550
  }
1413
- await Promise.all(targetPublicDeps.map((dep) => this.mkdir(`${projectPublicLibPath}/${dep}`)));
1414
- await Promise.all(targetAssetsDeps.map((dep) => this.mkdir(`${projectAssetsLibPath}/${dep}`)));
1415
- await Promise.all([
1416
- ...targetPublicDeps.map((dep) =>
1417
- this.cp(`${this.workspace.workspaceRoot}/libs/${dep}/public`, `${projectPublicLibPath}/${dep}`),
1418
- ),
1419
- ...targetAssetsDeps.map((dep) =>
1420
- this.cp(`${this.workspace.workspaceRoot}/libs/${dep}/private`, `${projectAssetsLibPath}/${dep}`),
1551
+ if (!targetDeps.length) return;
1552
+ await this.mkdir(libLinkPath);
1553
+ await Promise.all(
1554
+ targetDeps.map((dep) =>
1555
+ AppExecutor.#linkLibAsset(`${this.workspace.workspaceRoot}/libs/${dep}/${facet}`, `${libLinkPath}/${dep}`),
1421
1556
  ),
1422
- ]);
1557
+ );
1558
+ }
1559
+ static async #linkLibAsset(targetPath: string, linkPath: string) {
1560
+ //* A relative link keeps working when the workspace is mounted at another path (containers, CI);
1561
+ //* Windows junctions are the exception and resolve their target as an absolute path.
1562
+ const isWindows = process.platform === "win32";
1563
+ try {
1564
+ const target = isWindows ? targetPath : path.relative(path.dirname(linkPath), targetPath);
1565
+ await symlink(target, linkPath, isWindows ? "junction" : "dir");
1566
+ } catch (error) {
1567
+ if (!isWindows) throw error;
1568
+ await mkdir(linkPath, { recursive: true });
1569
+ await cpEntry(targetPath, linkPath, { recursive: true, dereference: true, force: true });
1570
+ }
1423
1571
  }
1424
1572
  async scanSync({ refresh = false, write = true }: { refresh?: boolean; write?: boolean } = {}) {
1425
1573
  const scanInfo = (await this.scan({
@@ -1428,6 +1576,9 @@ export class AppExecutor extends SysExecutor {
1428
1576
  writeLib: write,
1429
1577
  })) as AppInfo;
1430
1578
  if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
1579
+ //* `scan` read the routes off the page tree this sync may be about to change, so re-read them.
1580
+ if (write && (await this.syncPages(scanInfo.getScanResult().libDeps)))
1581
+ scanInfo.setRoutes(await this.getPageKeys({ refresh: true }));
1431
1582
  if (write) await this.#runPluginSyncAssets();
1432
1583
  return scanInfo;
1433
1584
  }
package/fileSys.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { stat } from "node:fs/promises";
1
+ import { lstat, stat } from "node:fs/promises";
2
2
  import { Logger } from "akanjs/common";
3
3
 
4
4
  export class FileSys {
@@ -16,6 +16,12 @@ export class FileSys {
16
16
  .then(() => true)
17
17
  .catch(() => false);
18
18
  }
19
+ //* Unlike `exists`, this reports a symlink whose target is gone, so stale links can be cleaned up.
20
+ static async entryExists(path: string) {
21
+ return await lstat(path)
22
+ .then(() => true)
23
+ .catch(() => false);
24
+ }
19
25
  static async readText(path: string) {
20
26
  return await Bun.file(path).text();
21
27
  }
@@ -388,12 +388,20 @@ export class FontOptimizer {
388
388
  }
389
389
 
390
390
  async #collectAutoSubsetText() {
391
- const roots = ["page", "ui"].map((dir) => path.join(this.#app.cwdPath, dir));
391
+ //* Synced lib pages hold app-visible text too, and a glob never crosses the symlink that mounts them.
392
+ const libPageRoots = (await this.#app.getPageRoots()).filter((root) => root.keyPrefix).map((root) => root.dir);
393
+ const roots = [...["page", "ui"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
392
394
  const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,html,md}");
393
395
  const parts: string[] = [];
394
396
  await Promise.all(
395
397
  roots.map(async (root) => {
396
- if (!(await Bun.file(root).exists())) return;
398
+ if (
399
+ !(await stat(root).then(
400
+ (entry) => entry.isDirectory(),
401
+ () => false,
402
+ ))
403
+ )
404
+ return;
397
405
  for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
398
406
  parts.push(await Bun.file(filePath).text());
399
407
  }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  // (~40MB) into a process that then holds them for the whole dev session. Phase 2 moved css compilation
6
6
  // into the batch worker, so this process has no use for them — `entryModuleGraph.test.ts` keeps it that way.
7
7
  import type { App } from "@akanjs/devkit/commandDecorators";
8
- import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
8
+ import { AppExecutor, type PageRoot, WorkspaceExecutor } from "@akanjs/devkit/executors";
9
9
  import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
10
10
  import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
11
11
  import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
@@ -209,23 +209,31 @@ class IncrementalBuilder {
209
209
  get shuttingDown(): boolean {
210
210
  return this.#shuttingDown;
211
211
  }
212
- batchTouchesPagesTree(appDir: string, batch: ChangeBatch): boolean {
213
- const absAppDir = path.resolve(appDir);
212
+ //* Watch events name the file's real path, so synced lib pages are matched by `realDir` while their
213
+ //* page key still carries the app-relative `(libs)/(<lib>)` prefix.
214
+ static #matchPageRoot(roots: PageRoot[], abs: string): PageRoot | null {
215
+ for (const root of roots) {
216
+ const absRoot = path.resolve(root.realDir);
217
+ if (abs === absRoot || abs.startsWith(`${absRoot}${path.sep}`)) return root;
218
+ }
219
+ return null;
220
+ }
221
+ batchTouchesPagesTree(roots: PageRoot[], batch: ChangeBatch): boolean {
214
222
  for (const f of batch.files) {
215
223
  const abs = path.resolve(f);
216
- if (!abs.startsWith(`${absAppDir}${path.sep}`) && abs !== absAppDir) continue;
224
+ if (!IncrementalBuilder.#matchPageRoot(roots, abs)) continue;
217
225
  if (/\.(tsx|ts|jsx|js)$/.test(abs)) return true;
218
226
  }
219
227
  return false;
220
228
  }
221
- async batchMayChangePageKeys(appDir: string, batch: ChangeBatch): Promise<boolean> {
222
- const absAppDir = path.resolve(appDir);
229
+ async batchMayChangePageKeys(roots: PageRoot[], batch: ChangeBatch): Promise<boolean> {
223
230
  const pageKeys = new Set((await this.#app.getPageKeys()).map((key) => path.normalize(key)));
224
231
  for (const f of batch.files) {
225
232
  const abs = path.resolve(f);
226
- if (!abs.startsWith(`${absAppDir}${path.sep}`) && abs !== absAppDir) continue;
233
+ const root = IncrementalBuilder.#matchPageRoot(roots, abs);
234
+ if (!root) continue;
227
235
  if (!/\.(tsx|ts|jsx|js)$/.test(abs)) continue;
228
- const rel = path.normalize(path.relative(absAppDir, abs));
236
+ const rel = path.normalize(`${root.keyPrefix}${path.relative(path.resolve(root.realDir), abs)}`);
229
237
  if (!(await Bun.file(abs).exists()) || !pageKeys.has(rel)) return true;
230
238
  }
231
239
  return false;
@@ -265,13 +273,13 @@ class IncrementalBuilder {
265
273
  }, 150);
266
274
  }
267
275
  async installWatcher() {
268
- const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
276
+ const artifactDir = this.#artifactDir;
269
277
  const roots = await new WatchRootResolver(this.#app).resolve();
270
278
  const watcher = new HmrWatcher({
271
279
  roots,
272
280
  logger: this.#logger,
273
281
  onBatch: async (batch: ChangeBatch) => {
274
- await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
282
+ await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(artifactDir, batch));
275
283
  },
276
284
  });
277
285
  await watcher.start();
@@ -279,7 +287,7 @@ class IncrementalBuilder {
279
287
  this.#logger.verbose(`watching ${roots.length} roots`);
280
288
  }
281
289
 
282
- async #handleWatchBatch(appDir: string, artifactDir: string, batch: ChangeBatch) {
290
+ async #handleWatchBatch(artifactDir: string, batch: ChangeBatch) {
283
291
  const rawKinds = new Set(batch.kinds);
284
292
  if (rawKinds.size === 0) return;
285
293
  const generation = ++this.#generation;
@@ -330,11 +338,12 @@ class IncrementalBuilder {
330
338
  this.#logger.verbose(`client rebuild skipped; devPlan actions=${devPlan.actions.join(",") || "(none)"}`);
331
339
  }
332
340
 
333
- if (kinds.includes("code") && rebuildClient && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
341
+ const pageRoots = await this.#app.getPageRoots();
342
+ if (kinds.includes("code") && rebuildClient && (await this.batchMayChangePageKeys(pageRoots, expandedBatch))) {
334
343
  const started = Date.now();
335
344
  await this.#app.getPageKeys({ refresh: true });
336
345
  this.#logger.verbose(`pageKeys updated, app pageKeys are refreshed (${Date.now() - started}ms)`);
337
- } else if (kinds.includes("code") && rebuildClient && this.batchTouchesPagesTree(appDir, expandedBatch)) {
346
+ } else if (kinds.includes("code") && rebuildClient && this.batchTouchesPagesTree(pageRoots, expandedBatch)) {
338
347
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
339
348
  }
340
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.1",
3
+ "version": "2.4.2-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "2.4.1",
47
+ "akanjs": "2.4.2-rc.0",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",
@@ -67,7 +67,7 @@ export class PackageExportsMap {
67
67
  for (const { prefix, suffix, target } of this.#patterns) {
68
68
  if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
69
69
  if (subpath.length < prefix.length + suffix.length) continue;
70
- return target.replace("*", subpath.slice(prefix.length, subpath.length - suffix.length));
70
+ return target.replace(/\*/g, subpath.slice(prefix.length, subpath.length - suffix.length));
71
71
  }
72
72
  return null;
73
73
  }
@@ -1,5 +1,11 @@
1
1
  import ts from "typescript";
2
2
 
3
+ /** What the build needs out of a route module without evaluating it. */
4
+ export interface RouteSourceInfo {
5
+ /** `pageConfig.devOnly === true`, read straight off the AST. */
6
+ devOnly: boolean;
7
+ }
8
+
3
9
  /**
4
10
  * Static enforcement of the `page/` route conventions, split out of `executors.ts` so that importing
5
11
  * an executor does not pull `typescript` (+65MB resident) into the module graph. Both validators need
@@ -53,7 +59,7 @@ export class RouteSourceValidator {
53
59
  filePath: string,
54
60
  kind: "page" | "layout",
55
61
  options: { rootLayout?: boolean } = {},
56
- ) {
62
+ ): RouteSourceInfo {
57
63
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
58
64
  const allowed =
59
65
  kind === "page"
@@ -117,6 +123,50 @@ export class RouteSourceValidator {
117
123
  if (exported.has("metadata") && exported.has("generateMetadata")) {
118
124
  throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
119
125
  }
126
+ return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
127
+ }
128
+
129
+ /**
130
+ * `devOnly` decides whether the route exists in the production build at all, so it is read from the
131
+ * source rather than from an evaluated module — the build never imports route files to enumerate them.
132
+ * That is why only a literal is accepted: anything the parser cannot settle would otherwise ship a
133
+ * route the author believed was excluded.
134
+ */
135
+ static #readDevOnly(sourceFile: ts.SourceFile, filePath: string): boolean {
136
+ for (const statement of sourceFile.statements) {
137
+ if (!ts.isVariableStatement(statement)) continue;
138
+ const isExported = ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
139
+ if (!isExported) continue;
140
+ for (const declaration of statement.declarationList.declarations) {
141
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "pageConfig") continue;
142
+ const initializer = RouteSourceValidator.#unwrapExpression(declaration.initializer);
143
+ if (!initializer || !ts.isObjectLiteralExpression(initializer)) continue;
144
+ for (const property of initializer.properties) {
145
+ if (!ts.isPropertyAssignment(property)) continue;
146
+ const name = property.name;
147
+ const key = ts.isIdentifier(name) ? name.text : ts.isStringLiteral(name) ? name.text : null;
148
+ if (key !== "devOnly") continue;
149
+ const value = RouteSourceValidator.#unwrapExpression(property.initializer);
150
+ if (value?.kind === ts.SyntaxKind.TrueKeyword) return true;
151
+ if (value?.kind === ts.SyntaxKind.FalseKeyword) return false;
152
+ throw new Error(
153
+ `[route-convention] pageConfig.devOnly must be a literal true or false in ${filePath} — the build reads it without evaluating the module`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
161
+ static #unwrapExpression(expression?: ts.Expression): ts.Expression | undefined {
162
+ let current = expression;
163
+ while (
164
+ current &&
165
+ (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current))
166
+ ) {
167
+ current = current.expression;
168
+ }
169
+ return current;
120
170
  }
121
171
 
122
172
  /**
package/scanInfo.ts CHANGED
@@ -422,6 +422,10 @@ export class AppInfo extends ScanInfo {
422
422
  return this.scanResult as AppScanResult;
423
423
  }
424
424
 
425
+ setRoutes(routes: string[]) {
426
+ (this.scanResult as AppScanResult).routes = routes;
427
+ }
428
+
425
429
  static async #getAllLibDeps(exec: AppExecutor, libDeps: string[], libSet = new Set<string>()) {
426
430
  await Promise.all(
427
431
  libDeps.map(async (libName) => {