@akanjs/devkit 3.0.0-alpha.99 → 3.0.0-beta.1
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/artifact/implicitRootLayout.test.ts +3 -1
- package/artifact/implicitRootLayout.ts +14 -7
- package/executors.test.ts +1 -3
- package/executors.ts +1 -0
- package/frontendBuild/fontOptimizer.test.ts +75 -2
- package/frontendBuild/fontOptimizer.ts +61 -9
- package/integration/devStabilityHarness.ts +11 -9
- package/package.json +2 -2
- package/routeSourceValidator.test.ts +63 -0
- package/routeSourceValidator.ts +123 -16
- package/subspace.test.ts +15 -0
- package/subspace.ts +9 -2
- package/transforms/asyncDefaultExportDetector.ts +2 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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);
|
|
@@ -17,15 +17,16 @@ const makeApp = async (layoutSource: string) => {
|
|
|
17
17
|
await mkdir(path.join(cwdPath, "public/fonts"), { recursive: true });
|
|
18
18
|
await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutSource);
|
|
19
19
|
await Bun.write(path.join(cwdPath, "public/fonts/Assistant-Regular.woff2"), Bun.file(SOURCE_FONT));
|
|
20
|
+
const warnings: string[] = [];
|
|
20
21
|
const app = {
|
|
21
22
|
cwdPath,
|
|
22
23
|
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
23
24
|
workspace: { workspaceRoot: root },
|
|
24
25
|
getPageKeys: async () => ["./_layout.tsx"],
|
|
25
26
|
verbose: () => undefined,
|
|
26
|
-
logger: { warn: () =>
|
|
27
|
+
logger: { warn: (message: string) => warnings.push(message) },
|
|
27
28
|
} as unknown as App;
|
|
28
|
-
return { app, cwdPath };
|
|
29
|
+
return { app, cwdPath, warnings };
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const layoutWith = (extra = "") => `
|
|
@@ -40,6 +41,22 @@ export default function Layout() {
|
|
|
40
41
|
}
|
|
41
42
|
`;
|
|
42
43
|
|
|
44
|
+
const chainLayoutWith = (declaration: string) => `
|
|
45
|
+
import { rootLayout } from "akanjs/client";
|
|
46
|
+
|
|
47
|
+
export default rootLayout()
|
|
48
|
+
.fonts(${declaration})
|
|
49
|
+
.theme("dark")
|
|
50
|
+
.render(({ children }) => children);
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const chainFontEntry = (extra = "") => `{
|
|
54
|
+
name: "Assistant",${extra}
|
|
55
|
+
paths: [{ src: "/fonts/Assistant-Regular.woff2", weight: 400 }],
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
const chainFontList = (extra = "") => `[\n ${chainFontEntry(extra)},\n ]`;
|
|
59
|
+
|
|
43
60
|
const optimize = (app: App) => new FontOptimizer(app, "start").optimize();
|
|
44
61
|
|
|
45
62
|
afterEach(async () => {
|
|
@@ -109,3 +126,59 @@ describe("FontOptimizer cache", () => {
|
|
|
109
126
|
expect(result.files).toEqual([]);
|
|
110
127
|
});
|
|
111
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("FontOptimizer discovery", () => {
|
|
131
|
+
test("reads the .fonts() stage of a rootLayout() chain", async () => {
|
|
132
|
+
const { app, warnings } = await makeApp(chainLayoutWith(chainFontList()));
|
|
133
|
+
|
|
134
|
+
const result = await optimize(app);
|
|
135
|
+
|
|
136
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
137
|
+
expect(result.files).toHaveLength(1);
|
|
138
|
+
expect(result.css).toContain("@font-face");
|
|
139
|
+
expect(warnings).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("keeps reading the legacy fonts export", async () => {
|
|
143
|
+
const { app } = await makeApp(layoutWith());
|
|
144
|
+
const result = await optimize(app);
|
|
145
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
146
|
+
expect(result.files).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("picks up a config field the chain declares, so the cache key sees it", async () => {
|
|
150
|
+
const { app, cwdPath } = await makeApp(chainLayoutWith(chainFontList()));
|
|
151
|
+
const first = await optimize(app);
|
|
152
|
+
|
|
153
|
+
await writeFile(
|
|
154
|
+
path.join(cwdPath, "page/_layout.tsx"),
|
|
155
|
+
chainLayoutWith(chainFontList(`\n className: "font-brand",`)),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const second = await optimize(app);
|
|
159
|
+
expect(second.css).not.toBe(first.css);
|
|
160
|
+
expect(second.css).toContain(".font-brand");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// A list the build cannot read subsets nothing while the runtime still preloads /_akan/fonts, so the 404s
|
|
164
|
+
// have to be announced at build time rather than found in a browser console.
|
|
165
|
+
test("warns when the chain is handed a font list it cannot read", async () => {
|
|
166
|
+
const { app, warnings } = await makeApp(chainLayoutWith("brandFonts"));
|
|
167
|
+
|
|
168
|
+
const result = await optimize(app);
|
|
169
|
+
|
|
170
|
+
expect(result.fonts).toEqual([]);
|
|
171
|
+
expect(result.files).toEqual([]);
|
|
172
|
+
expect(warnings).toHaveLength(1);
|
|
173
|
+
expect(warnings[0]).toContain("page/_layout.tsx");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("warns about an entry it cannot read and keeps the ones it can", async () => {
|
|
177
|
+
const { app, warnings } = await makeApp(chainLayoutWith(`[...brandFonts, ${chainFontEntry()}]`));
|
|
178
|
+
|
|
179
|
+
const result = await optimize(app);
|
|
180
|
+
|
|
181
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
182
|
+
expect(warnings).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -152,7 +152,7 @@ export class FontOptimizer {
|
|
|
152
152
|
const file = Bun.file(filePath);
|
|
153
153
|
if (!(await file.exists())) return;
|
|
154
154
|
const source = await file.text();
|
|
155
|
-
// A
|
|
155
|
+
// A `.fonts()` stage or a `fonts` declaration cannot exist in text that never mentions it, and parsing the
|
|
156
156
|
// route files that never declare one is what a cached optimize() otherwise spends its time on.
|
|
157
157
|
if (!source.includes("fonts")) return;
|
|
158
158
|
fonts.push(...this.#extractFontsExport(source, filePath));
|
|
@@ -186,23 +186,75 @@ export class FontOptimizer {
|
|
|
186
186
|
if (faceCss.length > 0) this.#cssParts.push(...faceCss, this.#buildRootVariableRule(font));
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The font list of a route file, from the `.fonts([…])` stage of its default-exported `rootLayout()` chain
|
|
191
|
+
* or from the legacy `export const fonts`. Only an inline literal is readable, because the build enumerates
|
|
192
|
+
* routes without evaluating them — and a list it cannot read is warned about rather than skipped in silence:
|
|
193
|
+
* the runtime still emits `/_akan/fonts` preloads for fonts nothing subset, and every one of them 404s.
|
|
194
|
+
*/
|
|
189
195
|
#extractFontsExport(source: string, filePath: string): ReactFont[] {
|
|
190
196
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
191
|
-
const
|
|
197
|
+
const declaration = this.#findFontsDeclaration(sourceFile);
|
|
198
|
+
if (!declaration) return [];
|
|
199
|
+
const value = this.#literalToValue(declaration);
|
|
200
|
+
const entries: unknown[] = Array.isArray(value) ? value : [];
|
|
201
|
+
const fonts = entries.filter((entry): entry is ReactFont => this.#isReadableFont(entry));
|
|
202
|
+
if (fonts.length !== entries.length || !Array.isArray(value))
|
|
203
|
+
this.#app.logger.warn(
|
|
204
|
+
`[font] ${path.relative(this.#app.cwdPath, filePath)} declares fonts the build cannot read without evaluating the module — write the list inline, or nothing is subset for it and every /_akan/fonts request 404s`,
|
|
205
|
+
);
|
|
206
|
+
return fonts.map((font) => this.#withFontDefaults(font));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#isReadableFont(value: unknown): value is ReactFont {
|
|
210
|
+
if (!value || typeof value !== "object") return false;
|
|
211
|
+
const font = value as Partial<ReactFont>;
|
|
212
|
+
return typeof font.name === "string" && Array.isArray(font.paths);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#findFontsDeclaration(sourceFile: ts.SourceFile): ts.Expression | null {
|
|
192
216
|
for (const statement of sourceFile.statements) {
|
|
217
|
+
if (ts.isExportAssignment(statement)) {
|
|
218
|
+
if (statement.isExportEquals) continue;
|
|
219
|
+
const stage = this.#findChainStageArgument(statement.expression, "fonts");
|
|
220
|
+
if (stage) return stage;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
193
223
|
if (!ts.isVariableStatement(statement)) continue;
|
|
194
224
|
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
|
195
|
-
|
|
196
|
-
if (!isExported) continue;
|
|
225
|
+
if (!modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
197
226
|
for (const declaration of statement.declarationList.declarations) {
|
|
198
227
|
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "fonts") continue;
|
|
199
|
-
|
|
200
|
-
if (Array.isArray(value)) {
|
|
201
|
-
fonts.push(...(value as ReactFont[]).map((font) => this.#withFontDefaults(font)));
|
|
202
|
-
}
|
|
228
|
+
if (declaration.initializer) return declaration.initializer;
|
|
203
229
|
}
|
|
204
230
|
}
|
|
205
|
-
return
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Walks a `rootLayout()` chain from its outermost call inward, so the stage that wins at runtime is the one read. */
|
|
235
|
+
#findChainStageArgument(expression: ts.Expression, stageName: string): ts.Expression | null {
|
|
236
|
+
let node = this.#unwrapExpression(expression);
|
|
237
|
+
let argument: ts.Expression | null = null;
|
|
238
|
+
while (node && ts.isCallExpression(node)) {
|
|
239
|
+
const callee = this.#unwrapExpression(node.expression);
|
|
240
|
+
if (!callee) return null;
|
|
241
|
+
if (ts.isIdentifier(callee)) return callee.text === "rootLayout" ? argument : null;
|
|
242
|
+
if (!ts.isPropertyAccessExpression(callee)) return null;
|
|
243
|
+
if (callee.name.text === stageName) argument ??= node.arguments[0] ?? null;
|
|
244
|
+
node = this.#unwrapExpression(callee.expression);
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#unwrapExpression(expression?: ts.Expression): ts.Expression | undefined {
|
|
250
|
+
let current = expression;
|
|
251
|
+
while (
|
|
252
|
+
current &&
|
|
253
|
+
(ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current))
|
|
254
|
+
) {
|
|
255
|
+
current = current.expression;
|
|
256
|
+
}
|
|
257
|
+
return current;
|
|
206
258
|
}
|
|
207
259
|
|
|
208
260
|
#literalToValue(node: ts.Node): unknown {
|
|
@@ -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
|
|
162
|
+
import { layout } from "akanjs/client";
|
|
163
163
|
|
|
164
|
-
export default
|
|
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 {
|
|
171
|
+
`import { page } from "akanjs/client";
|
|
172
|
+
import { marker } from "../common/marker";
|
|
172
173
|
import { ClientMarker } from "../ui/ClientMarker";
|
|
173
174
|
|
|
174
|
-
export default
|
|
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 {
|
|
191
|
+
`import { page } from "akanjs/client";
|
|
192
|
+
import { ClientMarker } from "../../ui/ClientMarker";
|
|
191
193
|
|
|
192
|
-
export default
|
|
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-
|
|
3
|
+
"version": "3.0.0-beta.1",
|
|
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-
|
|
47
|
+
"akanjs": "3.0.0-beta.1",
|
|
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
|
+
});
|
package/routeSourceValidator.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
102
|
-
|
|
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 (
|
package/subspace.test.ts
CHANGED
|
@@ -202,6 +202,21 @@ describe("Subspace push", () => {
|
|
|
202
202
|
expect(manifest.akan.source.hash).toMatch(/^[0-9a-f]{32}$/);
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
test("ships a library file the workspace deleted, instead of reading it off the clone's stale index", async () => {
|
|
206
|
+
const { wsRoot, bareRoot, workRoot, subspace } = await makeMirror("moved-served", "moved-private", "moved-kit");
|
|
207
|
+
expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
|
|
208
|
+
|
|
209
|
+
await rm(path.join(wsRoot, "libs/moved-kit/common/helper.ts"));
|
|
210
|
+
await write(path.join(wsRoot, "libs/moved-kit/common/renamed.ts"), "export const helper = 1;\n");
|
|
211
|
+
await git(wsRoot, ["add", "-A"]);
|
|
212
|
+
await git(wsRoot, ["commit", "--quiet", "-m", "move the helper"]);
|
|
213
|
+
|
|
214
|
+
expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
|
|
215
|
+
const subspaceRoot = await cloneSubspace(workRoot, bareRoot, "moved-check");
|
|
216
|
+
expect(await FileSys.fileExists(path.join(subspaceRoot, "libs/moved-kit/common/helper.ts"))).toBe(false);
|
|
217
|
+
expect(await FileSys.fileExists(path.join(subspaceRoot, "libs/moved-kit/common/renamed.ts"))).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
205
220
|
test("is idempotent — a second push with no workspace change is skipped", async () => {
|
|
206
221
|
const { subspace } = await makeMirror("idem-served", "idem-private", "idem-kit");
|
|
207
222
|
expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
|
package/subspace.ts
CHANGED
|
@@ -232,7 +232,7 @@ export class Subspace {
|
|
|
232
232
|
}
|
|
233
233
|
const clone = new Executor(`subspace-${this.name}`, this.#clonePath);
|
|
234
234
|
await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
|
|
235
|
-
await clone.spawn("git", ["checkout", "--quiet", "-B", branch, `origin/${branch}`]);
|
|
235
|
+
await clone.spawn("git", ["checkout", "--quiet", "-f", "-B", branch, `origin/${branch}`]);
|
|
236
236
|
await clone.spawn("git", ["clean", "-qfd"]);
|
|
237
237
|
return clone;
|
|
238
238
|
}
|
|
@@ -462,10 +462,17 @@ export class Subspace {
|
|
|
462
462
|
"--",
|
|
463
463
|
`libs/${lib}`,
|
|
464
464
|
]);
|
|
465
|
-
const
|
|
465
|
+
const candidates = listed
|
|
466
466
|
.split("\0")
|
|
467
467
|
.filter((file) => !!file && !this.#isSubspaceOwned(file))
|
|
468
468
|
.sort();
|
|
469
|
+
//* `--cached` reads the clone's index, which the slice replaced the members without updating — so it still
|
|
470
|
+
//* names every file the workspace has deleted or moved since the last push, and opening one throws. The
|
|
471
|
+
//* index is only reconciled by the `git add -A` a push commits with, long after the stamp is written.
|
|
472
|
+
const present = await Promise.all(
|
|
473
|
+
candidates.map(async (file) => await FileSys.entryExists(path.join(this.#clonePath, file))),
|
|
474
|
+
);
|
|
475
|
+
const files = candidates.filter((_, index) => present[index]);
|
|
469
476
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
470
477
|
for (const file of files) {
|
|
471
478
|
const content = await FileSys.readText(path.join(this.#clonePath, file));
|
|
@@ -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
|
}
|