@tschk/moonshine-compiler 0.3.5 → 0.4.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/package.json +3 -3
- package/src/analyze.ts +64 -17
- package/src/build.ts +26 -0
- package/src/discover.ts +13 -10
- package/src/inherit.ts +39 -6
- package/src/manifest.ts +9 -2
- package/test/analyze.test.ts +146 -0
- package/test/classify.test.ts +18 -0
- package/test/fixtures/modes/near-miss.tsx +5 -0
- package/test/fixtures/modes/renamed-source.tsx +5 -0
- package/test/fixtures/modes/request-utils.ts +3 -0
- package/test/fixtures/modes/ssr-helpers.ts +5 -0
- package/test/fixtures/project/src/routes/client.client.tsx +1 -1
- package/test/inherit.test.ts +50 -0
- package/test/path.test.ts +30 -0
- package/test/server-bundle-support-modules.test.ts +68 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"test": "bun test"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@tschk/moonshine-framework": "^0.3.
|
|
19
|
-
"@tschk/moonshine-router": "^0.3.
|
|
18
|
+
"@tschk/moonshine-framework": "^0.3.6",
|
|
19
|
+
"@tschk/moonshine-router": "^0.3.6"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "1.3.14",
|
package/src/analyze.ts
CHANGED
|
@@ -22,12 +22,16 @@ const HTTP_METHODS = [
|
|
|
22
22
|
"HEAD",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
-
function getSourceFile(
|
|
25
|
+
function getSourceFile(
|
|
26
|
+
file: string,
|
|
27
|
+
program?: ts.Program,
|
|
28
|
+
sourceText?: string,
|
|
29
|
+
): ts.SourceFile {
|
|
26
30
|
if (program) {
|
|
27
31
|
const source = program.getSourceFile(file);
|
|
28
32
|
if (source) return source;
|
|
29
33
|
}
|
|
30
|
-
const text = readFileSync(file, "utf8");
|
|
34
|
+
const text = sourceText ?? readFileSync(file, "utf8");
|
|
31
35
|
const scriptKind = file.endsWith(".tsx")
|
|
32
36
|
? ts.ScriptKind.TSX
|
|
33
37
|
: ts.ScriptKind.TS;
|
|
@@ -48,14 +52,6 @@ function isUseClientDirective(source: ts.SourceFile, node: ts.Node): boolean {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
function isExportedName(node: ts.Node, name: string): boolean {
|
|
51
|
-
const flags = ts.getCombinedModifierFlags(node as ts.Declaration);
|
|
52
|
-
if (!(flags & ts.ModifierFlags.Export)) return false;
|
|
53
|
-
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
|
|
54
|
-
return node.name?.text === name;
|
|
55
|
-
}
|
|
56
|
-
if (ts.isVariableDeclaration(node)) {
|
|
57
|
-
if (ts.isIdentifier(node.name)) return node.name.text === name;
|
|
58
|
-
}
|
|
59
55
|
if (
|
|
60
56
|
ts.isExportDeclaration(node) &&
|
|
61
57
|
node.exportClause &&
|
|
@@ -65,6 +61,16 @@ function isExportedName(node: ts.Node, name: string): boolean {
|
|
|
65
61
|
if (elem.name.text === name) return true;
|
|
66
62
|
}
|
|
67
63
|
}
|
|
64
|
+
|
|
65
|
+
const flags = ts.getCombinedModifierFlags(node as ts.Declaration);
|
|
66
|
+
if (!(flags & ts.ModifierFlags.Export)) return false;
|
|
67
|
+
|
|
68
|
+
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
|
|
69
|
+
return node.name?.text === name;
|
|
70
|
+
}
|
|
71
|
+
if (ts.isVariableDeclaration(node)) {
|
|
72
|
+
if (ts.isIdentifier(node.name)) return node.name.text === name;
|
|
73
|
+
}
|
|
68
74
|
return false;
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -76,14 +82,51 @@ function isExportedHandler(node: ts.Node): boolean {
|
|
|
76
82
|
return false;
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
/** Module names that mean "this route reads per-request state". */
|
|
86
|
+
const REQUEST_BOUND_MODULES = new Set(["cookies", "headers", "request"]);
|
|
87
|
+
|
|
88
|
+
/** Bindings that mean the same thing whatever module they came from. */
|
|
89
|
+
const REQUEST_BOUND_BINDINGS = new Set([
|
|
90
|
+
"cookies",
|
|
91
|
+
"headers",
|
|
92
|
+
"draftMode",
|
|
93
|
+
"request",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The last path segment, lowercased, with any extension removed:
|
|
98
|
+
* `"./lib/Request.ts"` -> `"request"`, `"next/headers"` -> `"headers"`.
|
|
99
|
+
*/
|
|
100
|
+
function moduleTailName(specifier: string): string {
|
|
101
|
+
const tail = specifier.toLowerCase().split("/").pop() ?? "";
|
|
102
|
+
return tail.replace(/\.[cm]?[jt]sx?$/, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function importedBindingNames(node: ts.ImportDeclaration): string[] {
|
|
106
|
+
const bindings = node.importClause?.namedBindings;
|
|
107
|
+
if (!bindings || !ts.isNamedImports(bindings)) return [];
|
|
108
|
+
return bindings.elements.map(
|
|
109
|
+
(element) => (element.propertyName ?? element.name).text,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether an import pulls in per-request state, which forces the route to `ssr`.
|
|
115
|
+
*
|
|
116
|
+
* This compared the whole specifier with `includes`, so `./lib/request-utils`,
|
|
117
|
+
* `./headers-config` or any path with a matching substring anywhere demoted a
|
|
118
|
+
* prerenderable route to per-request rendering — silently, since the route
|
|
119
|
+
* still worked. The segment is now compared whole, and the imported binding
|
|
120
|
+
* names are checked too, so `import { cookies } from "@acme/ssr"` is caught
|
|
121
|
+
* even though nothing in that specifier says so.
|
|
122
|
+
*/
|
|
79
123
|
function isRequestBoundImport(node: ts.Node): boolean {
|
|
80
124
|
if (!ts.isImportDeclaration(node)) return false;
|
|
81
125
|
if (!ts.isStringLiteral(node.moduleSpecifier)) return false;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
text.includes("request")
|
|
126
|
+
if (REQUEST_BOUND_MODULES.has(moduleTailName(node.moduleSpecifier.text)))
|
|
127
|
+
return true;
|
|
128
|
+
return importedBindingNames(node).some((name) =>
|
|
129
|
+
REQUEST_BOUND_BINDINGS.has(name),
|
|
87
130
|
);
|
|
88
131
|
}
|
|
89
132
|
|
|
@@ -119,8 +162,12 @@ function getFileSuffix(file: string): "server" | "client" | undefined {
|
|
|
119
162
|
return undefined;
|
|
120
163
|
}
|
|
121
164
|
|
|
122
|
-
export function analyzeModule(
|
|
123
|
-
|
|
165
|
+
export function analyzeModule(
|
|
166
|
+
file: string,
|
|
167
|
+
program?: ts.Program,
|
|
168
|
+
sourceText?: string,
|
|
169
|
+
): ModuleFacts {
|
|
170
|
+
const source = getSourceFile(file, program, sourceText);
|
|
124
171
|
const suffix = getFileSuffix(file);
|
|
125
172
|
const facts: ModuleFacts = {
|
|
126
173
|
clientBoundary: suffix === "client",
|
package/src/build.ts
CHANGED
|
@@ -63,6 +63,32 @@ function generateServerEntry(
|
|
|
63
63
|
: ""
|
|
64
64
|
}`);
|
|
65
65
|
}
|
|
66
|
+
// Layouts, middleware, and error boundaries are route dependencies the server
|
|
67
|
+
// renders through, so they belong in the bundle alongside the pages. Without
|
|
68
|
+
// them the renderer falls back to `await import(file)` on their SOURCE paths,
|
|
69
|
+
// which only resolves where the source tree sits next to the build output —
|
|
70
|
+
// never in a deployed image, and never on a runtime that cannot resolve a
|
|
71
|
+
// dynamic import at all. A page would render locally and 500 in production.
|
|
72
|
+
const supportFiles: string[] = [];
|
|
73
|
+
for (const route of routes) {
|
|
74
|
+
for (const file of [
|
|
75
|
+
...(route.layouts ?? []),
|
|
76
|
+
...(route.middleware ?? []),
|
|
77
|
+
...(route.errorBoundary ? [route.errorBoundary] : []),
|
|
78
|
+
]) {
|
|
79
|
+
if (file && !supportFiles.includes(file)) supportFiles.push(file);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i < supportFiles.length; i++) {
|
|
83
|
+
const file = supportFiles[i]!;
|
|
84
|
+
imports.push(
|
|
85
|
+
`import * as support_${i} from "${importPath(buildDir, file)}";`,
|
|
86
|
+
);
|
|
87
|
+
// Registered unwrapped: these export a component or middleware, not a route
|
|
88
|
+
// module with loader/action semantics.
|
|
89
|
+
moduleBodys.push(` "${toPosix(file)}": support_${i}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
return `${imports.join("\n")}\n\n${moduleHelpers.join("\n")}\n\nconst ${routes.map((_, i) => `module_${i} = makeModule_${i}(route_${i}, ${routes[i]!.dataFile ? `data_${i}` : "undefined"})`).join(";\nconst ")};\n\nexport const routes = {\n${bodies.join(",\n")}\n};\n\nexport const modules = {\n${moduleBodys.join(",\n")}\n};\n`;
|
|
67
93
|
}
|
|
68
94
|
|
package/src/discover.ts
CHANGED
|
@@ -153,15 +153,24 @@ function discoverMoonshine(
|
|
|
153
153
|
files: string[],
|
|
154
154
|
routesDir: string,
|
|
155
155
|
): RouteDefinition[] {
|
|
156
|
-
const fileSet = new Set(files);
|
|
157
156
|
const raw: RouteDefinition[] = [];
|
|
157
|
+
const dataFiles = new Map<string, string>();
|
|
158
|
+
const routeFiles: string[] = [];
|
|
158
159
|
|
|
159
160
|
for (const file of files) {
|
|
161
|
+
if (isDataFile(file)) {
|
|
162
|
+
const prefix = file.slice(0, file.lastIndexOf(".data."));
|
|
163
|
+
if (!dataFiles.has(prefix)) dataFiles.set(prefix, file);
|
|
164
|
+
} else {
|
|
165
|
+
routeFiles.push(file);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const file of routeFiles) {
|
|
160
170
|
const rel = toPosix(relative(routesDir, file));
|
|
161
171
|
const dir = dirname(rel);
|
|
162
172
|
const name = basename(rel);
|
|
163
173
|
|
|
164
|
-
if (isDataFile(name)) continue;
|
|
165
174
|
if (name.startsWith("+")) continue;
|
|
166
175
|
|
|
167
176
|
const parsed = parseRouteFile(name);
|
|
@@ -177,14 +186,8 @@ function discoverMoonshine(
|
|
|
177
186
|
? `/${[dirPattern, basePattern].filter(Boolean).join("/")}`
|
|
178
187
|
: "/";
|
|
179
188
|
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
for (const ext of [".data.ts", ".data.tsx"]) {
|
|
183
|
-
const candidate = join(parent, `${base}${ext}`);
|
|
184
|
-
if (fileSet.has(candidate)) return candidate;
|
|
185
|
-
}
|
|
186
|
-
return undefined;
|
|
187
|
-
})();
|
|
189
|
+
const prefix = file.slice(0, file.length - name.length) + base;
|
|
190
|
+
const dataFile = dataFiles.get(prefix);
|
|
188
191
|
|
|
189
192
|
const mode: RenderMode | undefined =
|
|
190
193
|
suffix === "server" ? "api" : undefined;
|
package/src/inherit.ts
CHANGED
|
@@ -35,15 +35,29 @@ function findSpecial(
|
|
|
35
35
|
dir: string,
|
|
36
36
|
kind: SpecialKind,
|
|
37
37
|
convention: RouteConvention,
|
|
38
|
+
cache: Map<string, string | undefined>,
|
|
38
39
|
): string | undefined {
|
|
40
|
+
const cacheKey = `${dir}\0${kind}`;
|
|
41
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
42
|
+
|
|
39
43
|
const prefix = specialPrefix[convention];
|
|
40
|
-
if (prefix === null)
|
|
41
|
-
|
|
44
|
+
if (prefix === null) {
|
|
45
|
+
cache.set(cacheKey, undefined);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (!specialKinds[convention].includes(kind)) {
|
|
49
|
+
cache.set(cacheKey, undefined);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
42
52
|
const base = join(routesDir, dir, `${prefix}${kind}`);
|
|
43
53
|
for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
|
|
44
54
|
const candidate = `${base}${ext}`;
|
|
45
|
-
if (existsSync(candidate))
|
|
55
|
+
if (existsSync(candidate)) {
|
|
56
|
+
cache.set(cacheKey, candidate);
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
46
59
|
}
|
|
60
|
+
cache.set(cacheKey, undefined);
|
|
47
61
|
return undefined;
|
|
48
62
|
}
|
|
49
63
|
|
|
@@ -53,6 +67,7 @@ function applyInheritance(
|
|
|
53
67
|
convention: RouteConvention,
|
|
54
68
|
): void {
|
|
55
69
|
const resolvedDir = resolve(routesDir);
|
|
70
|
+
const cache = new Map<string, string | undefined>();
|
|
56
71
|
for (const route of routes) {
|
|
57
72
|
if (!route.file) continue;
|
|
58
73
|
const rel = toPosix(relative(resolvedDir, resolve(route.file)));
|
|
@@ -70,11 +85,29 @@ function applyInheritance(
|
|
|
70
85
|
let errorBoundary: string | undefined;
|
|
71
86
|
|
|
72
87
|
for (const ancestor of ancestors) {
|
|
73
|
-
const layout = findSpecial(
|
|
88
|
+
const layout = findSpecial(
|
|
89
|
+
resolvedDir,
|
|
90
|
+
ancestor,
|
|
91
|
+
"layout",
|
|
92
|
+
convention,
|
|
93
|
+
cache,
|
|
94
|
+
);
|
|
74
95
|
if (layout) layouts.push(layout);
|
|
75
|
-
const mw = findSpecial(
|
|
96
|
+
const mw = findSpecial(
|
|
97
|
+
resolvedDir,
|
|
98
|
+
ancestor,
|
|
99
|
+
"middleware",
|
|
100
|
+
convention,
|
|
101
|
+
cache,
|
|
102
|
+
);
|
|
76
103
|
if (mw) middleware.push(mw);
|
|
77
|
-
const err = findSpecial(
|
|
104
|
+
const err = findSpecial(
|
|
105
|
+
resolvedDir,
|
|
106
|
+
ancestor,
|
|
107
|
+
"error",
|
|
108
|
+
convention,
|
|
109
|
+
cache,
|
|
110
|
+
);
|
|
78
111
|
if (err) errorBoundary = err;
|
|
79
112
|
}
|
|
80
113
|
|
package/src/manifest.ts
CHANGED
|
@@ -99,9 +99,16 @@ export async function buildProject(
|
|
|
99
99
|
...(options.convention && { convention: options.convention }),
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
const rawRouteTexts = await Promise.all(
|
|
103
|
+
rawRoutes.map((route) =>
|
|
104
|
+
readFile(route.file, "utf8").catch(() => undefined),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
|
|
102
108
|
const buildRoutes: RouteArtifact[] = [];
|
|
103
|
-
for (
|
|
104
|
-
const
|
|
109
|
+
for (let i = 0; i < rawRoutes.length; i++) {
|
|
110
|
+
const route = rawRoutes[i];
|
|
111
|
+
const facts = analyzeModule(route.file, undefined, rawRouteTexts[i]);
|
|
105
112
|
const decision = classifyRoute({ explicit: route.mode, facts });
|
|
106
113
|
buildRoutes.push({
|
|
107
114
|
...route,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { analyzeModule } from "../src/analyze.js";
|
|
4
|
+
|
|
5
|
+
function analyzeSource(sourceCode: string, filename = "test.tsx") {
|
|
6
|
+
const program = {
|
|
7
|
+
getSourceFile: (file: string) => {
|
|
8
|
+
if (file === filename) {
|
|
9
|
+
return ts.createSourceFile(
|
|
10
|
+
filename,
|
|
11
|
+
sourceCode,
|
|
12
|
+
ts.ScriptTarget.Latest,
|
|
13
|
+
true,
|
|
14
|
+
filename.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
},
|
|
19
|
+
} as unknown as ts.Program;
|
|
20
|
+
|
|
21
|
+
return analyzeModule(filename, program);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("analyzeModule", () => {
|
|
25
|
+
test("detects clientBoundary from 'use client' directive", () => {
|
|
26
|
+
const facts = analyzeSource(
|
|
27
|
+
"'use client';\nexport default function App() {}",
|
|
28
|
+
);
|
|
29
|
+
expect(facts.clientBoundary).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("detects clientBoundary from file suffix", () => {
|
|
33
|
+
const facts = analyzeSource(
|
|
34
|
+
"export default function App() {}",
|
|
35
|
+
"app.client.tsx",
|
|
36
|
+
);
|
|
37
|
+
expect(facts.clientBoundary).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("detects serverOnly from file suffix", () => {
|
|
41
|
+
const facts = analyzeSource(
|
|
42
|
+
"export default function App() {}",
|
|
43
|
+
"app.server.tsx",
|
|
44
|
+
);
|
|
45
|
+
expect(facts.serverOnly).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("detects exportsHandler from 'handler' export", () => {
|
|
49
|
+
const facts = analyzeSource("export function handler() {}");
|
|
50
|
+
expect(facts.exportsHandler).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("detects exportsHandler from HTTP method exports", () => {
|
|
54
|
+
expect(analyzeSource("export const GET = () => {}").exportsHandler).toBe(
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
expect(analyzeSource("export function POST() {}").exportsHandler).toBe(
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
expect(analyzeSource("export { myFunc as PUT }").exportsHandler).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("does not detect exportsHandler for non-exported methods", () => {
|
|
64
|
+
const facts = analyzeSource("function GET() {}");
|
|
65
|
+
expect(facts.exportsHandler).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("detects exportsLoader", () => {
|
|
69
|
+
const facts = analyzeSource("export const loader = async () => {}");
|
|
70
|
+
expect(facts.exportsLoader).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("detects exportsAction", () => {
|
|
74
|
+
const facts = analyzeSource("export function action() {}");
|
|
75
|
+
expect(facts.exportsAction).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("detects requestBound from specific module imports", () => {
|
|
79
|
+
expect(
|
|
80
|
+
analyzeSource("import { anything } from 'next/headers'").requestBound,
|
|
81
|
+
).toBe(true);
|
|
82
|
+
expect(
|
|
83
|
+
analyzeSource("import { something } from './cookies.ts'").requestBound,
|
|
84
|
+
).toBe(true);
|
|
85
|
+
expect(analyzeSource("import { xyz } from 'request'").requestBound).toBe(
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("detects requestBound from specific binding imports", () => {
|
|
91
|
+
expect(
|
|
92
|
+
analyzeSource("import { cookies } from 'any-module'").requestBound,
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect(
|
|
95
|
+
analyzeSource("import { headers, other } from 'some-module'")
|
|
96
|
+
.requestBound,
|
|
97
|
+
).toBe(true);
|
|
98
|
+
expect(
|
|
99
|
+
analyzeSource("import { draftMode } from 'utils'").requestBound,
|
|
100
|
+
).toBe(true);
|
|
101
|
+
expect(analyzeSource("import { request } from 'core'").requestBound).toBe(
|
|
102
|
+
true,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("does not detect requestBound for unrelated imports", () => {
|
|
107
|
+
const facts = analyzeSource("import { useState } from 'react'");
|
|
108
|
+
expect(facts.requestBound).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("detects interactive from JSX event attributes", () => {
|
|
112
|
+
const facts = analyzeSource(
|
|
113
|
+
"export default function Btn() { return <button onClick={() => {}}>Click</button> }",
|
|
114
|
+
);
|
|
115
|
+
expect(facts.interactive).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("detects interactive from island calls", () => {
|
|
119
|
+
expect(analyzeSource("island(Component)").interactive).toBe(true);
|
|
120
|
+
expect(analyzeSource("myLib.island(Component)").interactive).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("detects unresolvedDynamicImport", () => {
|
|
124
|
+
const facts = analyzeSource("const moduleName = 'fs'; import(moduleName);");
|
|
125
|
+
expect(facts.unresolvedDynamicImport).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("does not detect unresolvedDynamicImport for string literals", () => {
|
|
129
|
+
const facts = analyzeSource("import('fs');");
|
|
130
|
+
expect(facts.unresolvedDynamicImport).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("returns default facts for empty module", () => {
|
|
134
|
+
const facts = analyzeSource("");
|
|
135
|
+
expect(facts).toEqual({
|
|
136
|
+
clientBoundary: false,
|
|
137
|
+
serverOnly: false,
|
|
138
|
+
exportsHandler: false,
|
|
139
|
+
exportsLoader: false,
|
|
140
|
+
exportsAction: false,
|
|
141
|
+
requestBound: false,
|
|
142
|
+
interactive: false,
|
|
143
|
+
unresolvedDynamicImport: false,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
package/test/classify.test.ts
CHANGED
|
@@ -46,6 +46,24 @@ describe("classifyRoute", () => {
|
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
test("a module whose name merely contains 'request' is not request-bound", () => {
|
|
50
|
+
// `./request-utils` demoted this route to ssr because the specifier was
|
|
51
|
+
// compared with `includes`. Nothing broke, it just stopped prerendering.
|
|
52
|
+
const facts = analyzeModule(fixture("near-miss.tsx"));
|
|
53
|
+
expect(facts.requestBound).toBe(false);
|
|
54
|
+
expect(classifyRoute({ facts }).mode).not.toBe("ssr");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("request-bound bindings are caught whatever module they came from", () => {
|
|
58
|
+
// The specifier says nothing about cookies; the binding does.
|
|
59
|
+
const facts = analyzeModule(fixture("renamed-source.tsx"));
|
|
60
|
+
expect(facts.requestBound).toBe(true);
|
|
61
|
+
expect(classifyRoute({ facts })).toEqual({
|
|
62
|
+
mode: "ssr",
|
|
63
|
+
reason: "uses request-time server data",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
49
67
|
test("selects island for interactive child", () => {
|
|
50
68
|
const facts = analyzeModule(fixture("interactive.tsx"));
|
|
51
69
|
expect(facts.interactive).toBe(true);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Reads process.env deliberately: browsers have no `process`, so this is the
|
|
2
2
|
// shape that used to throw `ReferenceError: process is not defined` while the
|
|
3
3
|
// client entry evaluated, leaving the page blank with no error boundary.
|
|
4
|
-
const apiBase = process.env.MOONSHINE_TEST_API_BASE ??
|
|
4
|
+
const apiBase = process.env.MOONSHINE_TEST_API_BASE ?? "unset";
|
|
5
5
|
|
|
6
6
|
export default function ClientPage() {
|
|
7
7
|
return <h1>client {apiBase}</h1>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeRoutes } from "../src/inherit";
|
|
3
|
+
import type { RouteDefinition } from "@tschk/moonshine-framework";
|
|
4
|
+
|
|
5
|
+
export interface InheritableLayout {
|
|
6
|
+
id: string;
|
|
7
|
+
layouts: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("mergeRoutes", () => {
|
|
11
|
+
test("combines nested route definitions with inheritable layouts", () => {
|
|
12
|
+
const routes: RouteDefinition[] = [
|
|
13
|
+
{ id: "index", path: "/", file: "index.tsx" },
|
|
14
|
+
{ id: "about", path: "/about", file: "about.tsx" },
|
|
15
|
+
{ id: "nested/index", path: "/nested", file: "nested/index.tsx" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const layouts: InheritableLayout[] = [
|
|
19
|
+
{ id: "index", layouts: ["layout.tsx"] },
|
|
20
|
+
{ id: "about", layouts: ["layout.tsx"] },
|
|
21
|
+
{ id: "nested/index", layouts: ["layout.tsx", "nested/layout.tsx"] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const merged = mergeRoutes(routes, layouts as unknown as RouteDefinition[]);
|
|
25
|
+
|
|
26
|
+
expect(merged.length).toBe(3);
|
|
27
|
+
|
|
28
|
+
const indexRoute = merged.find((r) => r.id === "index");
|
|
29
|
+
expect(indexRoute?.layouts).toEqual(["layout.tsx"]);
|
|
30
|
+
|
|
31
|
+
const aboutRoute = merged.find((r) => r.id === "about");
|
|
32
|
+
expect(aboutRoute?.layouts).toEqual(["layout.tsx"]);
|
|
33
|
+
|
|
34
|
+
const nestedRoute = merged.find((r) => r.id === "nested/index");
|
|
35
|
+
expect(nestedRoute?.layouts).toEqual(["layout.tsx", "nested/layout.tsx"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("handles empty layouts gracefully", () => {
|
|
39
|
+
const routes: RouteDefinition[] = [
|
|
40
|
+
{ id: "index", path: "/", file: "index.tsx" },
|
|
41
|
+
];
|
|
42
|
+
const layouts: InheritableLayout[] = [];
|
|
43
|
+
|
|
44
|
+
const merged = mergeRoutes(routes, layouts as unknown as RouteDefinition[]);
|
|
45
|
+
|
|
46
|
+
expect(merged.length).toBe(1);
|
|
47
|
+
const indexRoute = merged.find((r) => r.id === "index");
|
|
48
|
+
expect(indexRoute?.layouts).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { toPosix } from "../src/path";
|
|
3
|
+
|
|
4
|
+
describe("toPosix", () => {
|
|
5
|
+
test("converts Windows paths to POSIX paths", () => {
|
|
6
|
+
expect(toPosix("C:\\Users\\John\\Documents\\file.txt")).toBe(
|
|
7
|
+
"C:/Users/John/Documents/file.txt",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("preserves already correct POSIX paths", () => {
|
|
12
|
+
expect(toPosix("/usr/local/bin/node")).toBe("/usr/local/bin/node");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("handles empty strings", () => {
|
|
16
|
+
expect(toPosix("")).toBe("");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("handles mixed slashes", () => {
|
|
20
|
+
expect(toPosix("foo\\bar/baz\\qux")).toBe("foo/bar/baz/qux");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("handles consecutive backslashes", () => {
|
|
24
|
+
expect(toPosix("\\\\server\\share\\folder")).toBe("//server/share/folder");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("handles trailing and leading backslashes", () => {
|
|
28
|
+
expect(toPosix("\\foo\\bar\\")).toBe("/foo/bar/");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { buildProject } from "../src/manifest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The server bundle must carry every module the server renders through, not
|
|
8
|
+
* just the page.
|
|
9
|
+
*
|
|
10
|
+
* A page's layout is loaded during server rendering. When it is missing from
|
|
11
|
+
* `modules`, the renderer falls back to `await import(file)` on the layout's
|
|
12
|
+
* SOURCE path. That resolves in a checkout, where the source sits beside the
|
|
13
|
+
* build output, and fails in a deployed image that ships only the build — so
|
|
14
|
+
* the route renders locally and returns 500 in production. It also fails
|
|
15
|
+
* outright on runtimes that cannot resolve a dynamic import.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const projectDir = resolve(import.meta.dir, "fixtures", "project");
|
|
19
|
+
const outDir = join(projectDir, ".moonshine");
|
|
20
|
+
|
|
21
|
+
function clean() {
|
|
22
|
+
if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterAll(clean);
|
|
26
|
+
|
|
27
|
+
describe("server bundle support modules", () => {
|
|
28
|
+
test("registers the layouts its routes render through", async () => {
|
|
29
|
+
const manifest = await buildProject({ projectDir });
|
|
30
|
+
|
|
31
|
+
const layouts = [
|
|
32
|
+
...new Set(manifest.routes.flatMap((route) => route.layouts ?? [])),
|
|
33
|
+
];
|
|
34
|
+
expect(layouts.length).toBeGreaterThan(0);
|
|
35
|
+
|
|
36
|
+
const { modules } = await import(join(outDir, "dist", "server.js"));
|
|
37
|
+
// The bundle keys modules by their build-time absolute path, while the
|
|
38
|
+
// manifest stores them relative to the project.
|
|
39
|
+
for (const layout of layouts) {
|
|
40
|
+
expect(Object.keys(modules)).toContain(join(projectDir, layout));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a registered layout is the real module, not a route wrapper", async () => {
|
|
45
|
+
const manifest = await buildProject({ projectDir });
|
|
46
|
+
const layout = join(
|
|
47
|
+
projectDir,
|
|
48
|
+
manifest.routes.flatMap((route) => route.layouts ?? [])[0]!,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const { modules } = await import(join(outDir, "dist", "server.js"));
|
|
52
|
+
|
|
53
|
+
// Layouts export a component; wrapping them in loader/action semantics
|
|
54
|
+
// would make the renderer read `default` off the wrong object.
|
|
55
|
+
expect(modules[layout]).toBeDefined();
|
|
56
|
+
expect(typeof modules[layout].default).toBe("function");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("still registers pages by id and file", async () => {
|
|
60
|
+
const manifest = await buildProject({ projectDir });
|
|
61
|
+
const route = manifest.routes[0]!;
|
|
62
|
+
|
|
63
|
+
const { modules } = await import(join(outDir, "dist", "server.js"));
|
|
64
|
+
|
|
65
|
+
expect(modules[route.id]).toBeDefined();
|
|
66
|
+
expect(modules[join(projectDir, route.file)]).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
});
|