@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.
- package/akanConfig/akanConfig.ts +3 -0
- package/applicationBuildRunner.ts +8 -0
- package/executors.test.ts +290 -1
- package/executors.ts +210 -59
- package/fileSys.ts +7 -1
- package/frontendBuild/fontOptimizer.ts +10 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +22 -13
- package/package.json +2 -2
- package/packageExportsMap.ts +1 -1
- package/routeSourceValidator.ts +51 -1
- package/scanInfo.ts +4 -0
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
|
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}
|
|
1549
|
+
if (await this.exists(`${this.workspace.workspaceRoot}/libs/${dep}/${facet}`)) targetDeps.push(dep);
|
|
1412
1550
|
}
|
|
1413
|
-
|
|
1414
|
-
await
|
|
1415
|
-
await Promise.all(
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
213
|
-
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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",
|
package/packageExportsMap.ts
CHANGED
|
@@ -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(
|
|
70
|
+
return target.replace(/\*/g, subpath.slice(prefix.length, subpath.length - suffix.length));
|
|
71
71
|
}
|
|
72
72
|
return null;
|
|
73
73
|
}
|
package/routeSourceValidator.ts
CHANGED
|
@@ -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) => {
|