@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tschk/moonshine-compiler",
3
- "version": "0.3.5",
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.5",
19
- "@tschk/moonshine-router": "^0.3.5"
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(file: string, program?: ts.Program): ts.SourceFile {
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
- const text = node.moduleSpecifier.text.toLowerCase();
83
- return (
84
- text.includes("cookies") ||
85
- text.includes("headers") ||
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(file: string, program?: ts.Program): ModuleFacts {
123
- const source = getSourceFile(file, program);
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 dataFile = (() => {
181
- const parent = dirname(file);
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) return undefined;
41
- if (!specialKinds[convention].includes(kind)) return undefined;
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)) return 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(resolvedDir, ancestor, "layout", convention);
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(resolvedDir, ancestor, "middleware", convention);
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(resolvedDir, ancestor, "error", convention);
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 (const route of rawRoutes) {
104
- const facts = analyzeModule(route.file);
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
+ });
@@ -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);
@@ -0,0 +1,5 @@
1
+ import { formatRequestId } from "./request-utils";
2
+
3
+ export default function Page() {
4
+ return <p>{formatRequestId("abc")}</p>;
5
+ }
@@ -0,0 +1,5 @@
1
+ import { cookies } from "./ssr-helpers";
2
+
3
+ export default function Page() {
4
+ return <p>{cookies().get("session")?.value}</p>;
5
+ }
@@ -0,0 +1,3 @@
1
+ export function formatRequestId(value: string): string {
2
+ return value.trim();
3
+ }
@@ -0,0 +1,5 @@
1
+ export function cookies(): {
2
+ get(name: string): { value: string } | undefined;
3
+ } {
4
+ return { get: () => ({ value: "session" }) };
5
+ }
@@ -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 ?? 'unset';
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
+ });