@akanjs/devkit 3.0.0-alpha.50 → 3.0.0-alpha.52

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 2.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [6d58c7e]
8
+ - Updated dependencies [11aa655]
9
+ - Updated dependencies [25d5b15]
10
+ - Updated dependencies [11aa655]
11
+ - Updated dependencies [11aa655]
12
+ - Updated dependencies [25d5b15]
13
+ - Updated dependencies [42cf7a2]
14
+ - Updated dependencies [25d5b15]
15
+ - Updated dependencies [04cb46d]
16
+ - akanjs@2.4.2
17
+
3
18
  ## 2.4.1
4
19
 
5
20
  ### Patch Changes
@@ -55,7 +55,7 @@ interface RootBoundary {
55
55
  segments: string[];
56
56
  }
57
57
 
58
- function getRootBoundarySegments(key: string): string[] | null {
58
+ export function getRootBoundarySegments(key: string): string[] | null {
59
59
  const match = LAYOUT_KEY_RE.exec(key);
60
60
  if (!match) return null;
61
61
  const prefix = match[1]?.replace(/\/$/, "");
@@ -76,7 +76,7 @@ function implicitDictionaryMacroAbsPath(appCwdPath: string): string {
76
76
  return path.join(path.resolve(appCwdPath), IMPLICIT_DICT_DIR, "useDict.ts");
77
77
  }
78
78
 
79
- function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
79
+ export function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
80
80
  const firstVisibleIndex = segments.findIndex((segment) => !/^\(.+\)$/.test(segment));
81
81
  if (firstVisibleIndex === -1) return segments.length <= 1;
82
82
  if (segments.slice(firstVisibleIndex + 1).some((segment) => /^\(.+\)$/.test(segment))) return false;
package/biome.base.json CHANGED
@@ -178,6 +178,10 @@
178
178
  "includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
179
179
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
180
180
  },
181
+ {
182
+ "includes": ["{apps,libs}/**/ui/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
183
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-async-component-in-ui.grit"]
184
+ },
181
185
  {
182
186
  "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
183
187
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
package/executors.test.ts CHANGED
@@ -621,6 +621,64 @@ describe("Workspace and app executor environment contracts", () => {
621
621
  });
622
622
  });
623
623
 
624
+ describe("root layout source validation during page key discovery", () => {
625
+ const makeRouteValidationApp = async (appName: string, config: string, files: Record<string, string>) => {
626
+ const root = await makeTempRoot();
627
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
628
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
629
+ process.env.AKAN_PUBLIC_ENV = "local";
630
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
631
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
632
+ await writeFile(path.join(root, `apps/${appName}/akan.config.ts`), config);
633
+ for (const [file, source] of Object.entries(files)) {
634
+ const filePath = path.join(root, `apps/${appName}/page`, file);
635
+ await mkdir(path.dirname(filePath), { recursive: true });
636
+ await writeFile(filePath, source);
637
+ }
638
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
639
+ return AppExecutor.from(workspace, appName);
640
+ };
641
+ const wsConnectLayout = [
642
+ "export const wsConnect = false;",
643
+ "export default function Layout({ children }) { return children; }",
644
+ "",
645
+ ].join("\n");
646
+
647
+ test("accepts wsConnect on a configured base-path root layout", async () => {
648
+ process.env.AKAN_PUBLIC_BASE_PATHS = "web,admin";
649
+ const app = await makeRouteValidationApp(
650
+ "base-path-root-ws",
651
+ [
652
+ "export default {",
653
+ ' routes: [{ basePath: "web", domains: {} }, { basePath: "admin", domains: {} }],',
654
+ "};",
655
+ "",
656
+ ].join("\n"),
657
+ { "admin/_layout.tsx": wsConnectLayout },
658
+ );
659
+
660
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./admin/_layout.tsx"]);
661
+ });
662
+
663
+ test("rejects wsConnect on a nested layout", async () => {
664
+ const app = await makeRouteValidationApp(
665
+ "nested-layout-ws",
666
+ 'export default { routes: [{ basePath: "admin", domains: {} }] };\n',
667
+ { "admin/users/_layout.tsx": wsConnectLayout },
668
+ );
669
+
670
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(/unsupported export "wsConnect"/);
671
+ });
672
+
673
+ test("accepts wsConnect on a grouped root layout", async () => {
674
+ const app = await makeRouteValidationApp("grouped-root-ws", "export default {};\n", {
675
+ "(docs)/_layout.tsx": wsConnectLayout,
676
+ });
677
+
678
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./(docs)/_layout.tsx"]);
679
+ });
680
+ });
681
+
624
682
  test("accepts metadata route exports during page key discovery", async () => {
625
683
  const root = await makeTempRoot();
626
684
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
package/executors.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  upsertAgentBlock,
41
41
  } from "./agentsIndex";
42
42
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
43
+ import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
43
44
  import { FileSys } from "./fileSys";
44
45
  import { getDirname } from "./getDirname";
45
46
  import { Linter } from "./linter";
@@ -1469,7 +1470,8 @@ export class AppExecutor extends SysExecutor {
1469
1470
  );
1470
1471
  }
1471
1472
  if (!owner) owners.set(routeId, { absPath, fromLib });
1472
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1473
+ const layoutSegments = getRootBoundarySegments(key);
1474
+ const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
1473
1475
  const routeSource = await Bun.file(absPath).text();
1474
1476
  const validator = await AppExecutor.#getRouteSourceValidator();
1475
1477
  if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
@@ -0,0 +1,35 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // React has no async client component: an async body is a server-only shape, so a `ui/` component that
5
+ // awaits stops rendering the moment any client parent imports it — and `ui/` is written to be reachable
6
+ // from both sides. It also moves the load below the route, where the framework can no longer start it
7
+ // before the first byte. Awaiting belongs to `page/**`, which passes the resolved data down as props.
8
+ //
9
+ // Component-ness is a PascalCase regex on the binding, the same test `no-model-type-in-util-zone.grit`
10
+ // uses. The declarator regex anchors `async` to the initializer position (`[^=]*` cannot cross an `=`),
11
+ // so an async handler declared inside a synchronous component is not read as the component's own body;
12
+ // `not within JsFunctionBody()` drops every nested declaration for the same reason. Out of scope by
13
+ // construction, since neither is a named binding at statement level: an inline `onClick={async () => …}`
14
+ // and an async member of an object literal.
15
+ JsIdentifierBinding() as $name where {
16
+ $name <: r"[A-Z][A-Za-z0-9_$]*",
17
+ not $name <: within JsFunctionBody(),
18
+ not $name <: within JsFormalParameter(),
19
+ or {
20
+ $name <: within JsVariableDeclarator() as $decl where {
21
+ $decl <: r"[A-Z][A-Za-z0-9_$]*\s*(?::[^=]*)?=\s*async[\s\S]*"
22
+ },
23
+ $name <: within JsFunctionDeclaration() as $fn where {
24
+ $fn <: r"(?:export\s+)?(?:default\s+)?async\s+function[\s\S]*"
25
+ },
26
+ $name <: within JsFunctionExportDefaultDeclaration() as $default where {
27
+ $default <: r"(?:export\s+)?(?:default\s+)?async\s+function[\s\S]*"
28
+ }
29
+ },
30
+ register_diagnostic(
31
+ span = $name,
32
+ message = "An async component belongs in page/. React has no async client component, so a ui/ component that awaits breaks as soon as a client parent renders it, and the route can no longer start the load before the first byte. Await in the page — or hand an unawaited fetch.* to a Zone as an init / view prop — and take the resolved data as a prop.",
33
+ severity = "error"
34
+ )
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.50",
3
+ "version": "3.0.0-alpha.52",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "@langchain/openai": "^1.4.6",
46
46
  "@tailwindcss/node": "^4.3.0",
47
47
  "@trapezedev/project": "^7.1.4",
48
- "akanjs": "3.0.0-alpha.50",
48
+ "akanjs": "3.0.0-alpha.52",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",