@akanjs/devkit 3.0.0-alpha.103 → 3.0.0-alpha.105

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.
@@ -50,7 +50,9 @@ describe("resolveSsrPageEntries", () => {
50
50
  expect(groupedRoot?.seedAbsPaths).toContain(groupedLayoutPath);
51
51
 
52
52
  const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
53
- expect(generatedSource).toContain('import * as inheritedLayout from "../../../page/_layout.tsx";');
53
+ // Both user modules go through the route resolver, so a `rootLayout()` chain and the legacy exports read alike.
54
+ expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
55
+ expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
54
56
  expect(generatedSource).not.toContain("<System.Provider");
55
57
  expect(generatedSource).toContain("export async function generateMetadata(props: PageProps)");
56
58
  expect(generatedSource).toContain("if (userLayout.generateMetadata) return userLayout.generateMetadata(props);");
@@ -185,13 +185,17 @@ async function writeGeneratedRootLayoutFile(opts: {
185
185
  const clientImport = opts.includeStInit
186
186
  ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
187
187
  : `import "@apps/${opts.appName}/client";\n`;
188
+ // Both user modules go through `resolveRouteModule`: a `rootLayout()` chain and the legacy named exports read
189
+ // the same afterwards, and this file never has to know which shape the app wrote.
190
+ const inheritedLabel = inheritedSourceAbsPath ? path.relative(opts.appCwdPath, inheritedSourceAbsPath) : "";
188
191
  const inheritedImport = inheritedSourceSpecifier
189
- ? `import * as inheritedLayout from ${JSON.stringify(inheritedSourceSpecifier)};\n`
190
- : "const inheritedLayout = {};\n";
192
+ ? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)}).module as LayoutModule;\n`
193
+ : "const inheritedLayout: LayoutModule = {};\n";
191
194
  const prefix = routePrefixForSegments(opts.boundary.segments);
195
+ const userLabel = opts.boundary.sourceAbsPath ? path.relative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
192
196
  const userImport = sourceSpecifier
193
- ? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
194
- : "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
197
+ ? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)}).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
198
+ : "const UserLayout = ({ children }: LayoutProps) => children;\nconst userLayout: LayoutModule = {};\n";
195
199
  const isAsyncUserLayout = opts.boundary.sourceAbsPath
196
200
  ? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
197
201
  : false;
@@ -212,8 +216,9 @@ async function writeGeneratedRootLayoutFile(opts: {
212
216
  const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
213
217
  const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
214
218
  const source = opts.includeSystemProvider
215
- ? `import type { LayoutProps, PageProps } from "akanjs/client";
216
- import { loadFonts } from "akanjs/client";
219
+ ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
220
+ import { loadFonts, resolveRouteModule } from "akanjs/client";
221
+ import type { ReactNode } from "react";
217
222
  import { System } from "akanjs/ui";
218
223
  import { env } from "@apps/${opts.appName}/env/env.client";
219
224
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
@@ -264,7 +269,9 @@ ${layoutBinding} return (
264
269
  );
265
270
  }
266
271
  `
267
- : `import type { LayoutProps, PageProps } from "akanjs/client";
272
+ : `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
273
+ import { resolveRouteModule } from "akanjs/client";
274
+ import type { ReactNode } from "react";
268
275
  ${inheritedImport}${userImport}
269
276
  export async function generateHead(props: PageProps) {
270
277
  if (userLayout.generateHead) return userLayout.generateHead(props);
package/executors.test.ts CHANGED
@@ -595,9 +595,7 @@ describe("Workspace and app executor environment contracts", () => {
595
595
  "_index.tsx": `export const pageConfig = { devOnly: process.env.NODE_ENV !== "production" };\n${PAGE_SOURCE}`,
596
596
  });
597
597
 
598
- await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
599
- "pageConfig.devOnly must be a literal true or false",
600
- );
598
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow("devOnly must be a literal true or false");
601
599
  });
602
600
 
603
601
  test("reads devOnly through a satisfies annotation", async () => {
package/executors.ts CHANGED
@@ -1548,6 +1548,7 @@ export class AppExecutor extends SysExecutor {
1548
1548
  else {
1549
1549
  const info = validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, {
1550
1550
  rootLayout: isRootLayout,
1551
+ pattern: parsed.pattern,
1551
1552
  });
1552
1553
  if (info.devOnly) {
1553
1554
  devOnlyKeys.add(key);
@@ -159,19 +159,20 @@ export const server = new AkanServer("${this.appName}", {
159
159
  this.writeFile(
160
160
  "page/_layout.tsx",
161
161
  `import "./styles.css";
162
- import type { LayoutProps } from "akanjs/client";
162
+ import { layout } from "akanjs/client";
163
163
 
164
- export default function Layout({ children }: LayoutProps) {
164
+ export default layout().render(({ children }) => {
165
165
  return <>{children}</>;
166
- }
166
+ });
167
167
  `,
168
168
  ),
169
169
  this.writeFile(
170
170
  "page/_index.tsx",
171
- `import { marker } from "../common/marker";
171
+ `import { page } from "akanjs/client";
172
+ import { marker } from "../common/marker";
172
173
  import { ClientMarker } from "../ui/ClientMarker";
173
174
 
174
- export default function Page() {
175
+ export default page().render(() => {
175
176
  return (
176
177
  <main>
177
178
  <h1>Dev stability fixture</h1>
@@ -179,7 +180,7 @@ export default function Page() {
179
180
  <ClientMarker />
180
181
  </main>
181
182
  );
182
- }
183
+ });
183
184
  `,
184
185
  ),
185
186
  // A second route exists so a test can ask for a page the backend has *never* built, at a moment of
@@ -187,16 +188,17 @@ export default function Page() {
187
188
  // request of a test is the only one that reaches the builder at all.
188
189
  this.writeFile(
189
190
  "page/second/_index.tsx",
190
- `import { ClientMarker } from "../../ui/ClientMarker";
191
+ `import { page } from "akanjs/client";
192
+ import { ClientMarker } from "../../ui/ClientMarker";
191
193
 
192
- export default function Page() {
194
+ export default page().render(() => {
193
195
  return (
194
196
  <main>
195
197
  <h1>Second route</h1>
196
198
  <ClientMarker />
197
199
  </main>
198
200
  );
199
- }
201
+ });
200
202
  `,
201
203
  ),
202
204
  this.writeFile(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.103",
3
+ "version": "3.0.0-alpha.105",
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": "3.0.0-alpha.103",
47
+ "akanjs": "3.0.0-alpha.105",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "dayjs": "^1.11.20",
@@ -39,3 +39,66 @@ describe("RouteSourceValidator", () => {
39
39
  });
40
40
  });
41
41
  });
42
+
43
+ describe("RouteSourceValidator route chains", () => {
44
+ const chain = (source: string, kind: "page" | "layout", pattern: string) =>
45
+ RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
46
+
47
+ test("reads params, prompt and devOnly off a page() chain without evaluating it", () => {
48
+ const source = [
49
+ 'import { page } from "akanjs/client";',
50
+ "export default page()",
51
+ ' .param("projectId", ID, { desc: "The project." })',
52
+ ' .search("status", String)',
53
+ " .config({ transition: 'stack', devOnly: true })",
54
+ ' .prompt("briefProject", "Brief one project.")',
55
+ " .render(async ({ projectId }) => null);",
56
+ ].join("\n");
57
+ expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
58
+ devOnly: true,
59
+ chain: { kind: "page", params: ["projectId"], prompt: "briefProject" },
60
+ });
61
+ });
62
+
63
+ test("names a [segment] the page did not declare and a .param() the path lacks", () => {
64
+ expect(() => chain("export default page().render(() => null);", "page", "/:lang/org/:orgId")).toThrow(
65
+ 'sits under [orgId] but declares no .param("orgId")',
66
+ );
67
+ expect(() => chain('export default page().param("orgId", ID).render(() => null);', "page", "/:lang/org")).toThrow(
68
+ 'declares .param("orgId") but no [orgId] segment is in its path',
69
+ );
70
+ expect(() =>
71
+ chain('export default page().param("lang", String).render(() => null);', "page", "/:lang/org"),
72
+ ).toThrow('declares .param("lang"), which every route receives undeclared');
73
+ // A layout may leave a segment undeclared.
74
+ expect(() =>
75
+ chain("export default layout().render(({ children }) => children);", "layout", "/:lang/org/:orgId"),
76
+ ).not.toThrow();
77
+ });
78
+
79
+ test("refuses named exports beside a chain, a chain in the wrong file kind, and a computed name", () => {
80
+ expect(() =>
81
+ chain(["export default page().render(() => null);", "export const pageConfig = {};"].join("\n"), "page", "/"),
82
+ ).toThrow("exports pageConfig beside its page() chain");
83
+ expect(() => chain("export default layout().render(() => null);", "page", "/")).toThrow(
84
+ "is a page file but exports layout()",
85
+ );
86
+ expect(() => chain("export default page().render(() => null);", "layout", "/")).toThrow(
87
+ "is a layout file but exports page()",
88
+ );
89
+ expect(() => chain("export default page().param(name, ID).render(() => null);", "page", "/:lang/:x")).toThrow(
90
+ ".param() takes a string literal first",
91
+ );
92
+ expect(() => chain("export default page().config({ devOnly: flag }).render(() => null);", "page", "/")).toThrow(
93
+ "devOnly must be a literal true or false",
94
+ );
95
+ });
96
+
97
+ test("still reads the legacy shape as before", () => {
98
+ const source = [
99
+ "export default function Page() { return null; }",
100
+ "export const pageConfig = { devOnly: false };",
101
+ ].join("\n");
102
+ expect(chain(source, "page", "/")).toEqual({ devOnly: false });
103
+ });
104
+ });
@@ -3,10 +3,27 @@ import ts from "typescript";
3
3
 
4
4
  /** What the build needs out of a route module without evaluating it. */
5
5
  export interface RouteSourceInfo {
6
- /** `pageConfig.devOnly === true`, read straight off the AST. */
6
+ /** `pageConfig.devOnly === true`, or `.config({ devOnly: true })` on a chain, read straight off the AST. */
7
7
  devOnly: boolean;
8
+ /** Present when the default export is a `page()` / `layout()` / `rootLayout()` chain. */
9
+ chain?: RouteSourceChain;
8
10
  }
9
11
 
12
+ export interface RouteSourceChain {
13
+ kind: "page" | "layout" | "rootLayout";
14
+ /** The `.param()` names, in chain order. */
15
+ params: string[];
16
+ /** The `.prompt()` name, when the page publishes itself as an MCP prompt. */
17
+ prompt?: string;
18
+ }
19
+
20
+ interface ChainStage {
21
+ name: string;
22
+ args: ts.NodeArray<ts.Expression>;
23
+ }
24
+
25
+ const chainRoots = new Set(["page", "layout", "rootLayout"]);
26
+
10
27
  /**
11
28
  * Static enforcement of the `page/` route conventions, split out of `executors.ts` so that importing
12
29
  * an executor does not pull `typescript` (+65MB resident) into the module graph. Both validators need
@@ -21,7 +38,7 @@ export class RouteSourceValidator {
21
38
  source: string,
22
39
  filePath: string,
23
40
  kind: "page" | "layout",
24
- options: { rootLayout?: boolean } = {},
41
+ options: { rootLayout?: boolean; pattern?: string } = {},
25
42
  ): RouteSourceInfo {
26
43
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
27
44
  const allowed = getRouteExports(kind, { rootLayout: options.rootLayout });
@@ -81,7 +98,91 @@ export class RouteSourceValidator {
81
98
  if (exported.has("metadata") && exported.has("generateMetadata")) {
82
99
  throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
83
100
  }
84
- return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
101
+ const chain = RouteSourceValidator.#readChain(sourceFile, filePath);
102
+ if (!chain) return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
103
+ const named = [...exported].filter((name) => name !== "default");
104
+ if (named.length)
105
+ throw new Error(
106
+ `[route-convention] ${filePath} exports ${named.join(", ")} beside its ${chain.kind}() chain — every route setting is a stage of the chain`,
107
+ );
108
+ if (kind === "page" && chain.kind !== "page")
109
+ throw new Error(`[route-convention] ${filePath} is a page file but exports ${chain.kind}()`);
110
+ if (kind === "layout" && chain.kind === "page")
111
+ throw new Error(`[route-convention] ${filePath} is a layout file but exports page()`);
112
+ if (options.pattern) RouteSourceValidator.#assertChainParams(chain, options.pattern, filePath);
113
+ return {
114
+ devOnly: chain.devOnly,
115
+ chain: { kind: chain.kind, params: chain.params, ...(chain.prompt ? { prompt: chain.prompt } : {}) },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * The default export as a `page()` / `layout()` / `rootLayout()` chain, or null when the module is the legacy
121
+ * shape. Read off the source for the same reason `devOnly` is: the build enumerates routes without importing
122
+ * them, and `akan sync` should name a `[projectId]` folder whose page declares no `.param("projectId")` before
123
+ * the first request does.
124
+ */
125
+ static #readChain(sourceFile: ts.SourceFile, filePath: string) {
126
+ for (const statement of sourceFile.statements) {
127
+ if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
128
+ let node = RouteSourceValidator.#unwrapExpression(statement.expression);
129
+ const stages: ChainStage[] = [];
130
+ while (node && ts.isCallExpression(node)) {
131
+ const callee = node.expression;
132
+ if (ts.isPropertyAccessExpression(callee)) {
133
+ stages.unshift({ name: callee.name.text, args: node.arguments });
134
+ node = callee.expression;
135
+ continue;
136
+ }
137
+ if (ts.isIdentifier(callee) && chainRoots.has(callee.text))
138
+ return RouteSourceValidator.#chainOf(callee.text as RouteSourceChain["kind"], stages, filePath);
139
+ return null;
140
+ }
141
+ return null;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
147
+ const params: string[] = [];
148
+ let prompt: string | undefined;
149
+ let devOnly = false;
150
+ const literal = (stage: ChainStage) => {
151
+ const first = stage.args[0];
152
+ if (!first || !ts.isStringLiteralLike(first))
153
+ throw new Error(
154
+ `[route-convention] .${stage.name}() takes a string literal first in ${filePath} — the build reads it without evaluating the module`,
155
+ );
156
+ return first.text;
157
+ };
158
+ for (const stage of stages) {
159
+ if (stage.name === "param") params.push(literal(stage));
160
+ else if (stage.name === "prompt") prompt = literal(stage);
161
+ else if (stage.name === "config")
162
+ devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
163
+ }
164
+ return { kind, params, prompt, devOnly };
165
+ }
166
+
167
+ /** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
168
+ static #assertChainParams(chain: { kind: string; params: string[] }, pattern: string, filePath: string) {
169
+ const inPath = pattern
170
+ .split("/")
171
+ .filter((part) => part.startsWith(":"))
172
+ .map((part) => part.slice(1))
173
+ .filter((name) => name !== "lang");
174
+ if (chain.params.includes("lang"))
175
+ throw new Error(`[route-convention] ${filePath} declares .param("lang"), which every route receives undeclared`);
176
+ const unknown = chain.params.find((name) => !inPath.includes(name));
177
+ if (unknown)
178
+ throw new Error(
179
+ `[route-convention] ${filePath} declares .param("${unknown}") but no [${unknown}] segment is in its path`,
180
+ );
181
+ const undeclared = inPath.find((name) => !chain.params.includes(name));
182
+ if (chain.kind === "page" && undeclared)
183
+ throw new Error(
184
+ `[route-convention] ${filePath} sits under [${undeclared}] but declares no .param("${undeclared}") — a page reads only what it declares`,
185
+ );
85
186
  }
86
187
 
87
188
  /**
@@ -98,24 +199,30 @@ export class RouteSourceValidator {
98
199
  for (const declaration of statement.declarationList.declarations) {
99
200
  if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "pageConfig") continue;
100
201
  const initializer = RouteSourceValidator.#unwrapExpression(declaration.initializer);
101
- if (!initializer || !ts.isObjectLiteralExpression(initializer)) continue;
102
- for (const property of initializer.properties) {
103
- if (!ts.isPropertyAssignment(property)) continue;
104
- const name = property.name;
105
- const key = ts.isIdentifier(name) ? name.text : ts.isStringLiteral(name) ? name.text : null;
106
- if (key !== "devOnly") continue;
107
- const value = RouteSourceValidator.#unwrapExpression(property.initializer);
108
- if (value?.kind === ts.SyntaxKind.TrueKeyword) return true;
109
- if (value?.kind === ts.SyntaxKind.FalseKeyword) return false;
110
- throw new Error(
111
- `[route-convention] pageConfig.devOnly must be a literal true or false in ${filePath} — the build reads it without evaluating the module`,
112
- );
113
- }
202
+ if (initializer && ts.isObjectLiteralExpression(initializer))
203
+ return RouteSourceValidator.#devOnlyOf(initializer, filePath);
114
204
  }
115
205
  }
116
206
  return false;
117
207
  }
118
208
 
209
+ static #devOnlyOf(config: ts.Expression | undefined, filePath: string): boolean {
210
+ if (!config || !ts.isObjectLiteralExpression(config)) return false;
211
+ for (const property of config.properties) {
212
+ if (!ts.isPropertyAssignment(property)) continue;
213
+ const name = property.name;
214
+ const key = ts.isIdentifier(name) ? name.text : ts.isStringLiteral(name) ? name.text : null;
215
+ if (key !== "devOnly") continue;
216
+ const value = RouteSourceValidator.#unwrapExpression(property.initializer);
217
+ if (value?.kind === ts.SyntaxKind.TrueKeyword) return true;
218
+ if (value?.kind === ts.SyntaxKind.FalseKeyword) return false;
219
+ throw new Error(
220
+ `[route-convention] devOnly must be a literal true or false in ${filePath} — the build reads it without evaluating the module`,
221
+ );
222
+ }
223
+ return false;
224
+ }
225
+
119
226
  static #unwrapExpression(expression?: ts.Expression): ts.Expression | undefined {
120
227
  let current = expression;
121
228
  while (
@@ -65,6 +65,8 @@ export class AsyncDefaultExportDetector {
65
65
 
66
66
  if (ts.isExportAssignment(statement)) {
67
67
  if (this.#isAsyncFunctionExpression(statement.expression)) return true;
68
+ // `export default layout()….render(fn)`: the loader unfolds the chain into an async adapter whatever `fn` is.
69
+ if (ts.isCallExpression(statement.expression)) return true;
68
70
  if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
69
71
  continue;
70
72
  }