@tschk/moonshine-compiler 0.3.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.
Files changed (41) hide show
  1. package/README.md +27 -0
  2. package/package.json +25 -0
  3. package/src/analyze.ts +150 -0
  4. package/src/build.ts +183 -0
  5. package/src/classify.ts +37 -0
  6. package/src/discover.ts +107 -0
  7. package/src/index.ts +13 -0
  8. package/src/inherit.ts +76 -0
  9. package/src/manifest.ts +174 -0
  10. package/src/path.ts +3 -0
  11. package/test/classify.test.ts +81 -0
  12. package/test/discover.test.ts +76 -0
  13. package/test/fixtures/modes/api.ts +3 -0
  14. package/test/fixtures/modes/client.client.tsx +3 -0
  15. package/test/fixtures/modes/interactive.tsx +3 -0
  16. package/test/fixtures/modes/loader.ts +5 -0
  17. package/test/fixtures/modes/request.ts +1 -0
  18. package/test/fixtures/modes/static.tsx +3 -0
  19. package/test/fixtures/modes/unresolved.ts +5 -0
  20. package/test/fixtures/project/src/routes/+error.tsx +3 -0
  21. package/test/fixtures/project/src/routes/+layout.tsx +3 -0
  22. package/test/fixtures/project/src/routes/+middleware.ts +3 -0
  23. package/test/fixtures/project/src/routes/about.data.ts +7 -0
  24. package/test/fixtures/project/src/routes/about.tsx +7 -0
  25. package/test/fixtures/project/src/routes/api/health.ts +3 -0
  26. package/test/fixtures/project/src/routes/client.client.tsx +3 -0
  27. package/test/fixtures/project/src/routes/counter.tsx +3 -0
  28. package/test/fixtures/project/src/routes/docs/[...path].tsx +3 -0
  29. package/test/fixtures/project/src/routes/index.tsx +3 -0
  30. package/test/fixtures/routes/+error.tsx +3 -0
  31. package/test/fixtures/routes/+layout.tsx +3 -0
  32. package/test/fixtures/routes/+middleware.ts +3 -0
  33. package/test/fixtures/routes/about.tsx +3 -0
  34. package/test/fixtures/routes/account/[[tab]].tsx +3 -0
  35. package/test/fixtures/routes/api/health.server.ts +7 -0
  36. package/test/fixtures/routes/blog/[slug].data.ts +7 -0
  37. package/test/fixtures/routes/blog/[slug].tsx +3 -0
  38. package/test/fixtures/routes/docs/[...path].tsx +3 -0
  39. package/test/fixtures/routes/index.tsx +3 -0
  40. package/test/manifest.test.ts +91 -0
  41. package/tsconfig.json +4 -0
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # `@tschk/moonshine-compiler`
2
+
3
+ Filesystem route discovery, static analysis, bundling, and build manifest
4
+ output.
5
+
6
+ ```ts
7
+ import { buildProject, discoverRoutes } from "@tschk/moonshine-compiler";
8
+
9
+ const manifest = await buildProject({
10
+ projectDir: process.cwd(),
11
+ runtime: "bun",
12
+ });
13
+
14
+ const routes = await discoverRoutes({
15
+ routesDir: "./src/routes",
16
+ });
17
+ ```
18
+
19
+ Features:
20
+
21
+ - `discoverRoutes` maps `src/routes` files to route patterns.
22
+ - `analyzeModule` reads source syntax to infer route facts.
23
+ - `classifyRoute` picks `static`, `ssr`, `island`, `spa`, or `api`.
24
+ - `buildProject` runs `Bun.build` and writes `.moonshine/manifest.json`.
25
+
26
+ See [docs/ROUTING.md](../../docs/ROUTING.md) and
27
+ [docs/MANIFEST.md](../../docs/MANIFEST.md).
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@tschk/moonshine-compiler",
3
+ "version": "0.3.1",
4
+ "license": "ISC",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "format": "bunx prettier --check .",
13
+ "lint": "bunx oxlint .",
14
+ "typecheck": "bunx tsc --noEmit",
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "@tschk/moonshine-framework": "^0.3.1",
19
+ "@tschk/moonshine-router": "^0.3.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "1.3.14",
23
+ "typescript": "^5.8.2"
24
+ }
25
+ }
package/src/analyze.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ts from "typescript";
3
+
4
+ export type ModuleFacts = {
5
+ clientBoundary: boolean;
6
+ serverOnly: boolean;
7
+ exportsHandler: boolean;
8
+ exportsLoader: boolean;
9
+ exportsAction: boolean;
10
+ requestBound: boolean;
11
+ interactive: boolean;
12
+ unresolvedDynamicImport: boolean;
13
+ };
14
+
15
+ const HTTP_METHODS = [
16
+ "GET",
17
+ "POST",
18
+ "PUT",
19
+ "DELETE",
20
+ "PATCH",
21
+ "OPTIONS",
22
+ "HEAD",
23
+ ];
24
+
25
+ function getSourceFile(file: string, program?: ts.Program): ts.SourceFile {
26
+ if (program) {
27
+ const source = program.getSourceFile(file);
28
+ if (source) return source;
29
+ }
30
+ const text = readFileSync(file, "utf8");
31
+ const scriptKind = file.endsWith(".tsx")
32
+ ? ts.ScriptKind.TSX
33
+ : ts.ScriptKind.TS;
34
+ return ts.createSourceFile(
35
+ file,
36
+ text,
37
+ ts.ScriptTarget.Latest,
38
+ true,
39
+ scriptKind,
40
+ );
41
+ }
42
+
43
+ function isUseClientDirective(source: ts.SourceFile, node: ts.Node): boolean {
44
+ if (!ts.isExpressionStatement(node)) return false;
45
+ if (node.parent !== source) return false;
46
+ if (!ts.isStringLiteral(node.expression)) return false;
47
+ return node.expression.text === "use client";
48
+ }
49
+
50
+ 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
+ if (
60
+ ts.isExportDeclaration(node) &&
61
+ node.exportClause &&
62
+ ts.isNamedExports(node.exportClause)
63
+ ) {
64
+ for (const elem of node.exportClause.elements) {
65
+ if (elem.name.text === name) return true;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function isExportedHandler(node: ts.Node): boolean {
72
+ if (isExportedName(node, "handler")) return true;
73
+ for (const method of HTTP_METHODS) {
74
+ if (isExportedName(node, method)) return true;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ function isRequestBoundImport(node: ts.Node): boolean {
80
+ if (!ts.isImportDeclaration(node)) return false;
81
+ 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")
87
+ );
88
+ }
89
+
90
+ function isNonLiteralDynamicImport(node: ts.Node): boolean {
91
+ if (!ts.isCallExpression(node)) return false;
92
+ if (node.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
93
+ const arg = node.arguments[0];
94
+ return arg !== undefined && !ts.isStringLiteral(arg);
95
+ }
96
+
97
+ function isJsxEventAttribute(node: ts.Node): boolean {
98
+ if (!ts.isJsxAttribute(node)) return false;
99
+ const name = ts.isIdentifier(node.name) ? node.name.text : undefined;
100
+ return !!name && /^on[A-Z]/.test(name);
101
+ }
102
+
103
+ function isIslandCall(node: ts.Node): boolean {
104
+ if (!ts.isCallExpression(node)) return false;
105
+ const expr = node.expression;
106
+ if (ts.isIdentifier(expr)) return expr.text === "island";
107
+ if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) {
108
+ return expr.name.text === "island";
109
+ }
110
+ return false;
111
+ }
112
+
113
+ function getFileSuffix(file: string): "server" | "client" | undefined {
114
+ const lower = file.toLowerCase();
115
+ if (lower.endsWith(".server.ts") || lower.endsWith(".server.tsx"))
116
+ return "server";
117
+ if (lower.endsWith(".client.ts") || lower.endsWith(".client.tsx"))
118
+ return "client";
119
+ return undefined;
120
+ }
121
+
122
+ export function analyzeModule(file: string, program?: ts.Program): ModuleFacts {
123
+ const source = getSourceFile(file, program);
124
+ const suffix = getFileSuffix(file);
125
+ const facts: ModuleFacts = {
126
+ clientBoundary: suffix === "client",
127
+ serverOnly: suffix === "server",
128
+ exportsHandler: false,
129
+ exportsLoader: false,
130
+ exportsAction: false,
131
+ requestBound: false,
132
+ interactive: false,
133
+ unresolvedDynamicImport: false,
134
+ };
135
+
136
+ function visit(node: ts.Node) {
137
+ if (isUseClientDirective(source, node)) facts.clientBoundary = true;
138
+ if (isExportedHandler(node)) facts.exportsHandler = true;
139
+ if (isExportedName(node, "loader")) facts.exportsLoader = true;
140
+ if (isExportedName(node, "action")) facts.exportsAction = true;
141
+ if (isRequestBoundImport(node)) facts.requestBound = true;
142
+ if (isNonLiteralDynamicImport(node)) facts.unresolvedDynamicImport = true;
143
+ if (isJsxEventAttribute(node)) facts.interactive = true;
144
+ if (isIslandCall(node)) facts.interactive = true;
145
+ ts.forEachChild(node, visit);
146
+ }
147
+
148
+ visit(source);
149
+ return facts;
150
+ }
package/src/build.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import type { RouteArtifact } from "@tschk/moonshine-framework";
4
+ import { toPosix } from "./path.js";
5
+
6
+ export type BundleAsset = {
7
+ file: string;
8
+ path: string;
9
+ integrity: string;
10
+ };
11
+
12
+ export type BundleOutput = {
13
+ server: BundleAsset[];
14
+ client: BundleAsset[];
15
+ entryFiles: {
16
+ server: string;
17
+ client?: string;
18
+ };
19
+ };
20
+
21
+ function importPath(from: string, to: string): string {
22
+ const rel = toPosix(relative(from, to));
23
+ return rel.startsWith(".") ? rel : `./${rel}`;
24
+ }
25
+
26
+ function generateServerEntry(
27
+ buildDir: string,
28
+ routes: RouteArtifact[],
29
+ ): string {
30
+ const imports: string[] = [];
31
+ const bodies: string[] = [];
32
+ const moduleHelpers: string[] = [];
33
+ const moduleBodys: string[] = [];
34
+ for (let i = 0; i < routes.length; i++) {
35
+ const route = routes[i]!;
36
+ const modulePath = importPath(buildDir, route.file);
37
+ imports.push(`import * as route_${i} from "${modulePath}";`);
38
+ if (route.dataFile) {
39
+ const dataPath = importPath(buildDir, route.dataFile);
40
+ imports.push(`import * as data_${i} from "${dataPath}";`);
41
+ }
42
+ const dataRef = route.dataFile ? `data_${i}` : "undefined";
43
+ bodies.push(` "${route.path}": { module: route_${i}, data: ${dataRef} }`);
44
+ moduleHelpers.push(`function makeModule_${i}(mod, data) {
45
+ const m = { ...mod, ...data };
46
+ if (!m.loader && typeof mod.GET === "function") {
47
+ m.loader = (ctx) => mod.GET(ctx.request);
48
+ }
49
+ if (!m.action && typeof mod.POST === "function") {
50
+ m.action = (ctx) => mod.POST(ctx.request);
51
+ }
52
+ if (!m.loader && !m.action && typeof mod.handler === "function") {
53
+ m.loader = (ctx) => mod.handler(ctx.request);
54
+ }
55
+ return m;
56
+ }`);
57
+ const moduleVar = `module_${i}`;
58
+ moduleBodys.push(` "${route.id}": ${moduleVar},
59
+ "${toPosix(route.file)}": ${moduleVar}${
60
+ route.dataFile
61
+ ? `,
62
+ "${toPosix(route.dataFile)}": ${moduleVar}`
63
+ : ""
64
+ }`);
65
+ }
66
+ 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
+ }
68
+
69
+ function generateClientEntry(
70
+ buildDir: string,
71
+ routes: RouteArtifact[],
72
+ ): string | undefined {
73
+ const clientRoutes = routes.filter(
74
+ (r) => r.mode === "island" || r.mode === "spa",
75
+ );
76
+ if (clientRoutes.length === 0) return undefined;
77
+ const imports: string[] = [];
78
+ const body: string[] = [];
79
+ let index = 0;
80
+ for (const route of clientRoutes) {
81
+ const modulePath = importPath(buildDir, route.file);
82
+ imports.push(`import route_${index} from "${modulePath}";`);
83
+ body.push(` "${route.path}": route_${index}`);
84
+ index++;
85
+ }
86
+ return `${imports.join("\n")}\n\nexport const islands = {\n${body.join(",\n")}\n};\n`;
87
+ }
88
+
89
+ async function hashFile(path: string): Promise<string> {
90
+ const file = Bun.file(path);
91
+ const hasher = new Bun.CryptoHasher("sha256");
92
+ hasher.update(await file.bytes());
93
+ return `sha256-${hasher.digest("base64")}`;
94
+ }
95
+
96
+ async function runBuild(
97
+ entry: string,
98
+ outdir: string,
99
+ root: string,
100
+ target: "bun" | "browser",
101
+ ): Promise<string[]> {
102
+ const result = await Bun.build({
103
+ entrypoints: [entry],
104
+ outdir,
105
+ root,
106
+ target,
107
+ format: "esm",
108
+ naming: "[name].js",
109
+ splitting: false,
110
+ sourcemap: "none",
111
+ });
112
+ if (!result.success) {
113
+ const messages = result.logs.map((log) => log.message).join("\n");
114
+ throw new Error(`Bun build failed for ${target}: ${messages}`);
115
+ }
116
+ return result.outputs
117
+ .filter((output) => output.kind !== "sourcemap")
118
+ .map((output) => output.path);
119
+ }
120
+
121
+ function toOutRelative(outDir: string, outputPath: string): string {
122
+ return toPosix(relative(outDir, outputPath));
123
+ }
124
+
125
+ async function buildBundleAssets(
126
+ outDir: string,
127
+ outputPaths: string[],
128
+ ): Promise<BundleAsset[]> {
129
+ const assets: BundleAsset[] = [];
130
+ for (const outputPath of outputPaths) {
131
+ const file = toOutRelative(outDir, outputPath);
132
+ assets.push({
133
+ file,
134
+ path: `/${file}`,
135
+ integrity: await hashFile(outputPath),
136
+ });
137
+ }
138
+ return assets;
139
+ }
140
+
141
+ export async function buildBundles(options: {
142
+ root: string;
143
+ outDir: string;
144
+ routes: RouteArtifact[];
145
+ }): Promise<BundleOutput> {
146
+ const buildDir = resolve(options.outDir, ".build");
147
+ await mkdir(buildDir, { recursive: true });
148
+
149
+ const serverEntry = join(buildDir, "server.ts");
150
+ await writeFile(serverEntry, generateServerEntry(buildDir, options.routes));
151
+
152
+ const clientCode = generateClientEntry(buildDir, options.routes);
153
+ let clientEntry: string | undefined;
154
+ if (clientCode) {
155
+ clientEntry = join(buildDir, "client.ts");
156
+ await writeFile(clientEntry, clientCode);
157
+ }
158
+
159
+ const distDir = resolve(options.outDir, "dist");
160
+ await mkdir(distDir, { recursive: true });
161
+
162
+ const serverOutputs = await runBuild(
163
+ serverEntry,
164
+ distDir,
165
+ options.root,
166
+ "bun",
167
+ );
168
+ const clientOutputs = clientEntry
169
+ ? await runBuild(clientEntry, distDir, options.root, "browser")
170
+ : [];
171
+
172
+ const serverAssets = await buildBundleAssets(options.outDir, serverOutputs);
173
+ const clientAssets = await buildBundleAssets(options.outDir, clientOutputs);
174
+
175
+ return {
176
+ server: serverAssets,
177
+ client: clientAssets,
178
+ entryFiles: {
179
+ server: serverAssets[0]?.file ?? "",
180
+ client: clientAssets[0]?.path,
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,37 @@
1
+ import type { ModuleFacts } from "./analyze.js";
2
+
3
+ export type { ModuleFacts } from "./analyze.js";
4
+
5
+ export type ModeDecision = {
6
+ mode: "static" | "ssr" | "island" | "spa" | "api";
7
+ reason: string;
8
+ };
9
+
10
+ export type ClassificationInput = {
11
+ explicit?: "auto" | "static" | "ssr" | "island" | "spa" | "api";
12
+ facts: ModuleFacts;
13
+ };
14
+
15
+ export function classifyRoute(input: ClassificationInput): ModeDecision {
16
+ if (input.explicit && input.explicit !== "auto")
17
+ return { mode: input.explicit, reason: "explicit route configuration" };
18
+ if (input.facts.exportsHandler)
19
+ return { mode: "api", reason: "exports request handler" };
20
+ if (input.facts.clientBoundary)
21
+ return { mode: "spa", reason: "route root is a client boundary" };
22
+ if (
23
+ input.facts.requestBound ||
24
+ input.facts.serverOnly ||
25
+ input.facts.exportsLoader ||
26
+ input.facts.exportsAction
27
+ )
28
+ return { mode: "ssr", reason: "uses request-time server data" };
29
+ if (input.facts.interactive)
30
+ return { mode: "island", reason: "contains an interactive client subtree" };
31
+ if (!input.facts.unresolvedDynamicImport)
32
+ return { mode: "static", reason: "deterministic build-time route" };
33
+ return {
34
+ mode: "ssr",
35
+ reason: "dynamic behavior could not be resolved safely",
36
+ };
37
+ }
@@ -0,0 +1,107 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, basename, join, relative, resolve } from "node:path";
3
+ import type { RouteDefinition, RenderMode } from "@tschk/moonshine-framework";
4
+ import { mergeRoutes } from "./inherit.js";
5
+ import { toPosix } from "./path.js";
6
+
7
+ export type DiscoverOptions = {
8
+ routesDir: string;
9
+ programmatic?: RouteDefinition[];
10
+ };
11
+
12
+ export function segmentToPattern(segment: string): string {
13
+ if (/^\[\.\.\.[^\]]*\]$/.test(segment)) return `*${segment.slice(4, -1)}`;
14
+ if (/^\[\[[^\]]+\]\]$/.test(segment)) return `:${segment.slice(2, -2)}?`;
15
+ if (/^\[[^\]]+\]$/.test(segment)) return `:${segment.slice(1, -1)}`;
16
+ return segment;
17
+ }
18
+
19
+ type FileSuffix = "server" | "client" | undefined;
20
+
21
+ function parseRouteFile(
22
+ name: string,
23
+ ): { base: string; suffix: FileSuffix } | null {
24
+ const server = /^(.+)\.server\.(ts|tsx)$/.exec(name);
25
+ if (server) return { base: server[1]!, suffix: "server" };
26
+ const client = /^(.+)\.client\.(ts|tsx)$/.exec(name);
27
+ if (client) return { base: client[1]!, suffix: "client" };
28
+ const route = /^(.+)\.(ts|tsx)$/.exec(name);
29
+ if (route) return { base: route[1]!, suffix: undefined };
30
+ return null;
31
+ }
32
+
33
+ function isDataFile(name: string): boolean {
34
+ return /\.data\.(ts|tsx)$/.test(name);
35
+ }
36
+
37
+ async function* walkFiles(dir: string): AsyncGenerator<string> {
38
+ const entries = await readdir(dir, { withFileTypes: true });
39
+ for (const entry of entries) {
40
+ const full = join(dir, entry.name);
41
+ if (entry.isDirectory()) yield* walkFiles(full);
42
+ else if (entry.isFile()) yield full;
43
+ }
44
+ }
45
+
46
+ export async function discoverRoutes(
47
+ options: DiscoverOptions,
48
+ ): Promise<RouteDefinition[]> {
49
+ const routesDir = resolve(options.routesDir);
50
+ const files: string[] = [];
51
+ for await (const file of walkFiles(routesDir)) {
52
+ files.push(file);
53
+ }
54
+
55
+ const fileSet = new Set(files);
56
+ const raw: RouteDefinition[] = [];
57
+
58
+ for (const file of files) {
59
+ const rel = toPosix(relative(routesDir, file));
60
+ const dir = dirname(rel);
61
+ const name = basename(rel);
62
+
63
+ if (isDataFile(name)) continue;
64
+ if (name.startsWith("+")) continue;
65
+
66
+ const parsed = parseRouteFile(name);
67
+ if (!parsed) continue;
68
+
69
+ const { base, suffix } = parsed;
70
+ const id = dir === "." ? base : `${dir}/${base}`;
71
+ const dirParts = dir === "." ? [] : dir.split("/").filter(Boolean);
72
+ const dirPattern = dirParts.map(segmentToPattern).join("/");
73
+ const basePattern = base === "index" ? "" : segmentToPattern(base);
74
+ const routePath =
75
+ dirPattern || basePattern
76
+ ? `/${[dirPattern, basePattern].filter(Boolean).join("/")}`
77
+ : "/";
78
+
79
+ const dataFile = (() => {
80
+ const parent = dirname(file);
81
+ for (const ext of [".data.ts", ".data.tsx"]) {
82
+ const candidate = join(parent, `${base}${ext}`);
83
+ if (fileSet.has(candidate)) return candidate;
84
+ }
85
+ return undefined;
86
+ })();
87
+
88
+ const mode: RenderMode | undefined =
89
+ suffix === "server" ? "api" : undefined;
90
+
91
+ const route: RouteDefinition = {
92
+ id,
93
+ path: routePath,
94
+ file,
95
+ ...(dataFile && { dataFile }),
96
+ ...(mode && { mode }),
97
+ };
98
+
99
+ raw.push(route);
100
+ }
101
+
102
+ const merged = mergeRoutes(raw, options.programmatic ?? [], routesDir);
103
+ merged.sort(
104
+ (a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id),
105
+ );
106
+ return merged;
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { analyzeModule, type ModuleFacts } from "./analyze.js";
2
+ export {
3
+ classifyRoute,
4
+ type ClassificationInput,
5
+ type ModeDecision,
6
+ } from "./classify.js";
7
+ export {
8
+ discoverRoutes,
9
+ type DiscoverOptions,
10
+ segmentToPattern,
11
+ } from "./discover.js";
12
+ export { mergeRoutes } from "./inherit.js";
13
+ export { buildProject, readManifest, type BuildOptions } from "./manifest.js";
package/src/inherit.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import type { RouteDefinition } from "@tschk/moonshine-framework";
4
+ import { createRouteGraph } from "@tschk/moonshine-router";
5
+ import { toPosix } from "./path.js";
6
+
7
+ type SpecialKind = "layout" | "error" | "middleware";
8
+
9
+ function findSpecial(
10
+ routesDir: string,
11
+ dir: string,
12
+ kind: SpecialKind,
13
+ ): string | undefined {
14
+ const base = join(routesDir, dir, `+${kind}`);
15
+ for (const ext of [".tsx", ".ts"]) {
16
+ const candidate = `${base}${ext}`;
17
+ if (existsSync(candidate)) return candidate;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ function applyInheritance(routes: RouteDefinition[], routesDir: string): void {
23
+ const resolvedDir = resolve(routesDir);
24
+ for (const route of routes) {
25
+ if (!route.file) continue;
26
+ const rel = toPosix(relative(resolvedDir, resolve(route.file)));
27
+ const dir = dirname(rel);
28
+ const parts = dir === "." ? [] : dir.split("/").filter(Boolean);
29
+ const ancestors: string[] = [""];
30
+ let acc = "";
31
+ for (const part of parts) {
32
+ acc = acc ? `${acc}/${part}` : part;
33
+ ancestors.push(acc);
34
+ }
35
+
36
+ const layouts: string[] = [];
37
+ const middleware: string[] = [];
38
+ let errorBoundary: string | undefined;
39
+
40
+ for (const ancestor of ancestors) {
41
+ const layout = findSpecial(resolvedDir, ancestor, "layout");
42
+ if (layout) layouts.push(layout);
43
+ const mw = findSpecial(resolvedDir, ancestor, "middleware");
44
+ if (mw) middleware.push(mw);
45
+ const err = findSpecial(resolvedDir, ancestor, "error");
46
+ if (err) errorBoundary = err;
47
+ }
48
+
49
+ if (layouts.length) route.layouts = layouts;
50
+ if (middleware.length) route.middleware = middleware;
51
+ if (errorBoundary) route.errorBoundary = errorBoundary;
52
+ }
53
+ }
54
+
55
+ export function mergeRoutes(
56
+ filesystem: RouteDefinition[],
57
+ programmatic: RouteDefinition[] = [],
58
+ routesDir?: string,
59
+ ): RouteDefinition[] {
60
+ const map = new Map<string, RouteDefinition>();
61
+ for (const route of filesystem) {
62
+ map.set(route.id, route);
63
+ }
64
+ for (const route of programmatic) {
65
+ const existing = map.get(route.id);
66
+ if (existing) {
67
+ map.set(route.id, { ...existing, ...route });
68
+ } else {
69
+ map.set(route.id, route);
70
+ }
71
+ }
72
+ const merged = [...map.values()];
73
+ if (routesDir) applyInheritance(merged, routesDir);
74
+ createRouteGraph(merged);
75
+ return merged;
76
+ }
@@ -0,0 +1,174 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import type {
4
+ MoonshineManifest,
5
+ RouteArtifact,
6
+ RouteDefinition,
7
+ RuntimeTarget,
8
+ } from "@tschk/moonshine-framework";
9
+ import { MANIFEST_VERSION } from "@tschk/moonshine-framework";
10
+ import { analyzeModule } from "./analyze.js";
11
+ import { buildBundles } from "./build.js";
12
+ import { classifyRoute } from "./classify.js";
13
+ import { discoverRoutes } from "./discover.js";
14
+ import { toPosix } from "./path.js";
15
+
16
+ export type BuildOptions = {
17
+ projectDir: string;
18
+ routesDir?: string;
19
+ outDir?: string;
20
+ programmatic?: RouteDefinition[];
21
+ runtime?: RuntimeTarget;
22
+ };
23
+
24
+ function toProjectRelative(projectDir: string, p: string): string {
25
+ const rel = toPosix(relative(projectDir, resolve(p)));
26
+ return rel.startsWith("..") ? toPosix(p) : rel || ".";
27
+ }
28
+
29
+ async function resolveFrameworkVersion(): Promise<string> {
30
+ const url = import.meta.resolve("@tschk/moonshine-framework");
31
+ const entryPath = new URL(url).pathname;
32
+ const pkgPath = entryPath.split("/").slice(0, -2).join("/") + "/package.json";
33
+ const pkg = (await Bun.file(pkgPath).json()) as { version: string };
34
+ return pkg.version;
35
+ }
36
+
37
+ function sortedStringify(value: unknown): string {
38
+ const text = JSON.stringify(
39
+ value,
40
+ (_key, v) => {
41
+ if (v === undefined) return undefined;
42
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return v;
43
+ const sorted: Record<string, unknown> = {};
44
+ for (const k of Object.keys(v as Record<string, unknown>).sort()) {
45
+ sorted[k] = (v as Record<string, unknown>)[k];
46
+ }
47
+ return sorted;
48
+ },
49
+ 2,
50
+ );
51
+ return `${text}\n`;
52
+ }
53
+
54
+ function deriveCapabilities(
55
+ routes: RouteArtifact[],
56
+ ): MoonshineManifest["capabilities"] {
57
+ const capabilities: Set<MoonshineManifest["capabilities"][number]> =
58
+ new Set();
59
+ capabilities.add("streaming");
60
+ for (const route of routes) {
61
+ if (route.mode === "island" || route.mode === "spa") {
62
+ capabilities.add("islands");
63
+ }
64
+ if (route.runtime === "cloudflare" || route.runtime === "vercel-edge") {
65
+ capabilities.add("edge");
66
+ }
67
+ if (route.cache?.revalidate !== undefined) {
68
+ capabilities.add("revalidation");
69
+ }
70
+ }
71
+ return [...capabilities].sort();
72
+ }
73
+
74
+ function makeStaticOutput(route: RouteArtifact): string | undefined {
75
+ if (route.mode !== "static") return undefined;
76
+ if (route.path.includes("*")) return undefined;
77
+ const slug =
78
+ route.path === "/"
79
+ ? "index"
80
+ : route.path.replace(/^\/+/, "").replace(/\//g, "-");
81
+ return `static/${slug}.html`;
82
+ }
83
+
84
+ export async function buildProject(
85
+ options: BuildOptions,
86
+ ): Promise<MoonshineManifest> {
87
+ const projectDir = resolve(options.projectDir);
88
+ const routesDir = resolve(
89
+ options.routesDir ?? join(projectDir, "src", "routes"),
90
+ );
91
+ const outDir = resolve(options.outDir ?? join(projectDir, ".moonshine"));
92
+ const runtime = options.runtime ?? "bun";
93
+
94
+ const rawRoutes = await discoverRoutes({
95
+ routesDir,
96
+ programmatic: options.programmatic,
97
+ });
98
+
99
+ const buildRoutes: RouteArtifact[] = [];
100
+ for (const route of rawRoutes) {
101
+ const facts = analyzeModule(route.file);
102
+ const decision = classifyRoute({ explicit: route.mode, facts });
103
+ buildRoutes.push({
104
+ ...route,
105
+ mode: decision.mode,
106
+ runtime: route.runtime ?? runtime,
107
+ decision: decision.reason,
108
+ clientEntries: [],
109
+ serverEntry: undefined,
110
+ staticOutput: makeStaticOutput({
111
+ ...route,
112
+ mode: decision.mode,
113
+ } as RouteArtifact),
114
+ } as RouteArtifact);
115
+ }
116
+
117
+ await rm(outDir, { recursive: true, force: true });
118
+ await mkdir(outDir, { recursive: true });
119
+
120
+ const bundles = await buildBundles({
121
+ root: projectDir,
122
+ outDir,
123
+ routes: buildRoutes,
124
+ });
125
+ const clientFile = bundles.entryFiles.client;
126
+
127
+ for (const route of buildRoutes) {
128
+ if ((route.mode === "island" || route.mode === "spa") && clientFile) {
129
+ route.clientEntries = [clientFile];
130
+ }
131
+ route.serverEntry = bundles.entryFiles.server;
132
+ }
133
+
134
+ const allAssets = [...bundles.server, ...bundles.client].sort((a, b) =>
135
+ a.file.localeCompare(b.file),
136
+ );
137
+
138
+ const manifest: MoonshineManifest = {
139
+ version: MANIFEST_VERSION,
140
+ frameworkVersion: await resolveFrameworkVersion(),
141
+ routes: buildRoutes.map((route) => ({
142
+ ...route,
143
+ file: toProjectRelative(projectDir, route.file),
144
+ ...(route.dataFile && {
145
+ dataFile: toProjectRelative(projectDir, route.dataFile),
146
+ }),
147
+ ...(route.layouts && {
148
+ layouts: route.layouts.map((l) => toProjectRelative(projectDir, l)),
149
+ }),
150
+ ...(route.middleware && {
151
+ middleware: route.middleware.map((m) =>
152
+ toProjectRelative(projectDir, m),
153
+ ),
154
+ }),
155
+ ...(route.errorBoundary && {
156
+ errorBoundary: toProjectRelative(projectDir, route.errorBoundary),
157
+ }),
158
+ })) as RouteArtifact[],
159
+ assets: allAssets,
160
+ entries: {
161
+ ...(bundles.entryFiles.server && { server: bundles.entryFiles.server }),
162
+ ...(clientFile && { client: clientFile }),
163
+ },
164
+ capabilities: deriveCapabilities(buildRoutes),
165
+ };
166
+
167
+ await writeFile(join(outDir, "manifest.json"), sortedStringify(manifest));
168
+ return manifest;
169
+ }
170
+
171
+ export async function readManifest(path: string): Promise<MoonshineManifest> {
172
+ const text = await readFile(resolve(path), "utf8");
173
+ return JSON.parse(text) as MoonshineManifest;
174
+ }
package/src/path.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function toPosix(p: string): string {
2
+ return p.replace(/\\/g, "/");
3
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { analyzeModule, classifyRoute } from "../src";
4
+
5
+ const fixtureDir = join(import.meta.dir, "fixtures", "modes");
6
+
7
+ function fixture(name: string): string {
8
+ return join(fixtureDir, name);
9
+ }
10
+
11
+ describe("classifyRoute", () => {
12
+ test("honors explicit override", () => {
13
+ const facts = analyzeModule(fixture("static.tsx"));
14
+ const decision = classifyRoute({ explicit: "island", facts });
15
+ expect(decision).toEqual({
16
+ mode: "island",
17
+ reason: "explicit route configuration",
18
+ });
19
+ });
20
+
21
+ test("selects api for handler export", () => {
22
+ const facts = analyzeModule(fixture("api.ts"));
23
+ expect(facts.exportsHandler).toBe(true);
24
+ expect(classifyRoute({ facts })).toEqual({
25
+ mode: "api",
26
+ reason: "exports request handler",
27
+ });
28
+ });
29
+
30
+ test("selects spa for root client boundary", () => {
31
+ const facts = analyzeModule(fixture("client.client.tsx"));
32
+ expect(facts.clientBoundary).toBe(true);
33
+ expect(classifyRoute({ facts })).toEqual({
34
+ mode: "spa",
35
+ reason: "route root is a client boundary",
36
+ });
37
+ });
38
+
39
+ test("selects ssr for request-bound loader", () => {
40
+ const facts = analyzeModule(fixture("loader.ts"));
41
+ expect(facts.requestBound).toBe(true);
42
+ expect(facts.exportsLoader).toBe(true);
43
+ expect(classifyRoute({ facts })).toEqual({
44
+ mode: "ssr",
45
+ reason: "uses request-time server data",
46
+ });
47
+ });
48
+
49
+ test("selects island for interactive child", () => {
50
+ const facts = analyzeModule(fixture("interactive.tsx"));
51
+ expect(facts.interactive).toBe(true);
52
+ expect(classifyRoute({ facts })).toEqual({
53
+ mode: "island",
54
+ reason: "contains an interactive client subtree",
55
+ });
56
+ });
57
+
58
+ test("selects static for deterministic module", () => {
59
+ const facts = analyzeModule(fixture("static.tsx"));
60
+ expect(classifyRoute({ facts })).toEqual({
61
+ mode: "static",
62
+ reason: "deterministic build-time route",
63
+ });
64
+ });
65
+
66
+ test("falls back to ssr for unresolved dynamic import", () => {
67
+ const facts = analyzeModule(fixture("unresolved.ts"));
68
+ expect(facts.unresolvedDynamicImport).toBe(true);
69
+ expect(classifyRoute({ facts })).toEqual({
70
+ mode: "ssr",
71
+ reason: "dynamic behavior could not be resolved safely",
72
+ });
73
+ });
74
+
75
+ test("produces deterministic output", () => {
76
+ const path = fixture("static.tsx");
77
+ const first = classifyRoute({ facts: analyzeModule(path) });
78
+ const second = classifyRoute({ facts: analyzeModule(path) });
79
+ expect(JSON.stringify(first)).toBe(JSON.stringify(second));
80
+ });
81
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { discoverRoutes } from "../src/discover";
4
+
5
+ const fixtureDir = join(import.meta.dir, "fixtures", "routes");
6
+
7
+ function layoutPath() {
8
+ return join(fixtureDir, "+layout.tsx");
9
+ }
10
+
11
+ function middlewarePath() {
12
+ return join(fixtureDir, "+middleware.ts");
13
+ }
14
+
15
+ function errorPath() {
16
+ return join(fixtureDir, "+error.tsx");
17
+ }
18
+
19
+ describe("discoverRoutes", () => {
20
+ test("discovers exact route IDs and paths", async () => {
21
+ const routes = await discoverRoutes({ routesDir: fixtureDir });
22
+ expect(routes.map((r) => r.id)).toEqual([
23
+ "index",
24
+ "about",
25
+ "account/[[tab]]",
26
+ "api/health",
27
+ "blog/[slug]",
28
+ "docs/[...path]",
29
+ ]);
30
+ expect(routes.map((r) => r.path)).toEqual([
31
+ "/",
32
+ "/about",
33
+ "/account/:tab?",
34
+ "/api/health",
35
+ "/blog/:slug",
36
+ "/docs/*path",
37
+ ]);
38
+ });
39
+
40
+ test("inherits layout, middleware, and nearest error boundary", async () => {
41
+ const routes = await discoverRoutes({ routesDir: fixtureDir });
42
+ for (const route of routes) {
43
+ expect(route.layouts).toEqual([layoutPath()]);
44
+ expect(route.middleware).toEqual([middlewarePath()]);
45
+ expect(route.errorBoundary).toBe(errorPath());
46
+ }
47
+ });
48
+
49
+ test("pairs a route with its data file", async () => {
50
+ const routes = await discoverRoutes({ routesDir: fixtureDir });
51
+ const blog = routes.find((r) => r.id === "blog/[slug]");
52
+ expect(blog).toBeDefined();
53
+ expect(blog!.dataFile).toBe(join(fixtureDir, "blog", "[slug].data.ts"));
54
+ });
55
+
56
+ test("marks server files as API mode", async () => {
57
+ const routes = await discoverRoutes({ routesDir: fixtureDir });
58
+ const api = routes.find((r) => r.id === "api/health");
59
+ expect(api).toBeDefined();
60
+ expect(api!.mode).toBe("api");
61
+ });
62
+
63
+ test("produces deterministic sorted output", async () => {
64
+ const first = await discoverRoutes({ routesDir: fixtureDir });
65
+ const second = await discoverRoutes({ routesDir: fixtureDir });
66
+ expect(JSON.stringify(first)).toBe(JSON.stringify(second));
67
+ expect(first.map((r) => r.path)).toEqual([
68
+ "/",
69
+ "/about",
70
+ "/account/:tab?",
71
+ "/api/health",
72
+ "/blog/:slug",
73
+ "/docs/*path",
74
+ ]);
75
+ });
76
+ });
@@ -0,0 +1,3 @@
1
+ export function GET(_request: Request) {
2
+ return Response.json({ ok: true });
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function ClientPage() {
2
+ return <h1>client</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function Counter() {
2
+ return <button onClick={() => {}}>+</button>;
3
+ }
@@ -0,0 +1,5 @@
1
+ import { request } from "./request";
2
+
3
+ export function loader() {
4
+ return request.url;
5
+ }
@@ -0,0 +1 @@
1
+ export const request = new Request("http://example.com");
@@ -0,0 +1,3 @@
1
+ export default function Page() {
2
+ return <h1>static</h1>;
3
+ }
@@ -0,0 +1,5 @@
1
+ export default async function Page() {
2
+ const name = "x";
3
+ const mod = await import(`./${name}`);
4
+ return mod;
5
+ }
@@ -0,0 +1,3 @@
1
+ export default function RootError() {
2
+ return <p>error</p>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function RootLayout({ children }: { children: unknown }) {
2
+ return <>{children}</>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function middleware(_context: unknown, next: () => unknown): unknown {
2
+ return next();
3
+ }
@@ -0,0 +1,7 @@
1
+ export function loader({ request }: { request: Request }) {
2
+ return new URL(request.url).pathname;
3
+ }
4
+
5
+ export function action({ request }: { request: Request }) {
6
+ return new URL(request.url).pathname;
7
+ }
@@ -0,0 +1,7 @@
1
+ export function loader() {
2
+ return { url: "/about" };
3
+ }
4
+
5
+ export default function AboutPage({ data }: { data: { url: string } }) {
6
+ return <h1>{data.url}</h1>;
7
+ }
@@ -0,0 +1,3 @@
1
+ export function GET(_request: Request) {
2
+ return Response.json({ status: "ok" });
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function ClientPage() {
2
+ return <h1>client</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function Counter() {
2
+ return <button onClick={() => {}}>+</button>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function DocsCatchAllPage() {
2
+ return <h1>docs</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function IndexPage() {
2
+ return <h1>home</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function RootError() {
2
+ return <p>error</p>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function RootLayout({ children }: { children: unknown }) {
2
+ return <>{children}</>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function middleware(_context: unknown, next: () => unknown): unknown {
2
+ return next();
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function AboutPage() {
2
+ return <h1>about</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function AccountPage() {
2
+ return <h1>account</h1>;
3
+ }
@@ -0,0 +1,7 @@
1
+ export function GET(_request: Request) {
2
+ return Response.json({ status: "ok" });
3
+ }
4
+
5
+ export function POST(_request: Request) {
6
+ return Response.json({ status: "ok" });
7
+ }
@@ -0,0 +1,7 @@
1
+ export function loader({ request }: { request: Request }) {
2
+ return new URL(request.url).pathname;
3
+ }
4
+
5
+ export function action({ request }: { request: Request }) {
6
+ return new URL(request.url).pathname;
7
+ }
@@ -0,0 +1,3 @@
1
+ export default function BlogPostPage({ data }: { data: unknown }) {
2
+ return <h1>{String(data)}</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function DocsCatchAllPage() {
2
+ return <h1>docs</h1>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function IndexPage() {
2
+ return <h1>home</h1>;
3
+ }
@@ -0,0 +1,91 @@
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, readManifest } from "../src/manifest";
5
+
6
+ const projectDir = resolve(import.meta.dir, "fixtures", "project");
7
+ const outDir = join(projectDir, ".moonshine");
8
+
9
+ function clean() {
10
+ if (existsSync(outDir)) {
11
+ rmSync(outDir, { recursive: true, force: true });
12
+ }
13
+ }
14
+
15
+ afterAll(clean);
16
+
17
+ describe("buildProject", () => {
18
+ test("emits a versioned, deterministic manifest with relative paths", async () => {
19
+ const manifest = await buildProject({
20
+ projectDir,
21
+ });
22
+
23
+ expect(manifest.version).toBe(1);
24
+ expect(manifest.frameworkVersion).toMatch(/^\d+\.\d+\.\d+/);
25
+ expect(manifest.routes.map((r) => r.path)).toEqual([
26
+ "/",
27
+ "/about",
28
+ "/api/health",
29
+ "/client",
30
+ "/counter",
31
+ "/docs/*path",
32
+ ]);
33
+ expect(manifest.routes.map((r) => r.mode)).toEqual([
34
+ "static",
35
+ "ssr",
36
+ "api",
37
+ "spa",
38
+ "island",
39
+ "static",
40
+ ]);
41
+ expect(manifest.routes.map((r) => r.decision)).toEqual([
42
+ "deterministic build-time route",
43
+ "uses request-time server data",
44
+ "exports request handler",
45
+ "route root is a client boundary",
46
+ "contains an interactive client subtree",
47
+ "deterministic build-time route",
48
+ ]);
49
+ expect(manifest.routes.every((r) => r.runtime === "bun")).toBe(true);
50
+ expect(manifest.entries.server).toBe("dist/server.js");
51
+ expect(manifest.entries.client).toBe("/dist/client.js");
52
+ expect(manifest.capabilities).toEqual(["islands", "streaming"]);
53
+ expect(manifest.assets.map((a) => a.file)).toEqual([
54
+ "dist/client.js",
55
+ "dist/server.js",
56
+ ]);
57
+ for (const asset of manifest.assets) {
58
+ expect(asset.path).toBe(`/${asset.file}`);
59
+ expect(asset.integrity).toMatch(/^sha256-[A-Za-z0-9+/]+=?$/);
60
+ }
61
+
62
+ const fromDisk = await readManifest(join(outDir, "manifest.json"));
63
+ expect(fromDisk.version).toBe(1);
64
+ expect(fromDisk.routes.map((r) => r.path)).toEqual(
65
+ manifest.routes.map((r) => r.path),
66
+ );
67
+
68
+ const text = JSON.stringify(manifest);
69
+ expect(text).not.toMatch(/\\\\/);
70
+ expect(text).not.toMatch(/^\//);
71
+ expect(text).not.toMatch(/process\.env/);
72
+
73
+ for (const route of manifest.routes) {
74
+ expect(route.file.startsWith("src/")).toBe(true);
75
+ if (route.dataFile) expect(route.dataFile.startsWith("src/")).toBe(true);
76
+ if (route.layouts) {
77
+ for (const l of route.layouts) {
78
+ expect(l.startsWith("src/")).toBe(true);
79
+ }
80
+ }
81
+ if (route.middleware) {
82
+ for (const m of route.middleware) {
83
+ expect(m.startsWith("src/")).toBe(true);
84
+ }
85
+ }
86
+ if (route.errorBoundary) {
87
+ expect(route.errorBoundary.startsWith("src/")).toBe(true);
88
+ }
89
+ }
90
+ });
91
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*", "test/**/*"]
4
+ }