@tschk/moonshine-compiler 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
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.1",
19
- "@tschk/moonshine-router": "^0.3.1"
18
+ "@tschk/moonshine-framework": "^0.3.3",
19
+ "@tschk/moonshine-router": "^0.3.3"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "1.3.14",
package/src/build.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
- import type { RouteArtifact } from "@tschk/moonshine-framework";
3
+ import { PUBLIC_DIR, type RouteArtifact } from "@tschk/moonshine-framework";
4
4
  import { toPosix } from "./path.js";
5
5
 
6
6
  export type BundleAsset = {
@@ -125,13 +125,14 @@ function toOutRelative(outDir: string, outputPath: string): string {
125
125
  async function buildBundleAssets(
126
126
  outDir: string,
127
127
  outputPaths: string[],
128
+ urlBase?: string,
128
129
  ): Promise<BundleAsset[]> {
129
130
  const assets: BundleAsset[] = [];
130
131
  for (const outputPath of outputPaths) {
131
132
  const file = toOutRelative(outDir, outputPath);
132
133
  assets.push({
133
134
  file,
134
- path: `/${file}`,
135
+ path: `/${toOutRelative(urlBase ?? outDir, outputPath)}`,
135
136
  integrity: await hashFile(outputPath),
136
137
  });
137
138
  }
@@ -159,6 +160,11 @@ export async function buildBundles(options: {
159
160
  const distDir = resolve(options.outDir, "dist");
160
161
  await mkdir(distDir, { recursive: true });
161
162
 
163
+ // Client output goes in its own directory because that directory is what the
164
+ // server exposes over HTTP; the server bundle must never sit inside it.
165
+ const publicDir = resolve(options.outDir, PUBLIC_DIR);
166
+ await mkdir(publicDir, { recursive: true });
167
+
162
168
  const serverOutputs = await runBuild(
163
169
  serverEntry,
164
170
  distDir,
@@ -166,11 +172,15 @@ export async function buildBundles(options: {
166
172
  "bun",
167
173
  );
168
174
  const clientOutputs = clientEntry
169
- ? await runBuild(clientEntry, distDir, options.root, "browser")
175
+ ? await runBuild(clientEntry, publicDir, options.root, "browser")
170
176
  : [];
171
177
 
172
178
  const serverAssets = await buildBundleAssets(options.outDir, serverOutputs);
173
- const clientAssets = await buildBundleAssets(options.outDir, clientOutputs);
179
+ const clientAssets = await buildBundleAssets(
180
+ options.outDir,
181
+ clientOutputs,
182
+ publicDir,
183
+ );
174
184
 
175
185
  return {
176
186
  server: serverAssets,
package/src/discover.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { dirname, basename, join, relative, resolve } from "node:path";
3
3
  import type { RouteDefinition, RenderMode } from "@tschk/moonshine-framework";
4
- import { mergeRoutes } from "./inherit.js";
4
+ import { mergeRoutes, type RouteConvention } from "./inherit.js";
5
5
  import { toPosix } from "./path.js";
6
6
 
7
+ export type { RouteConvention } from "./inherit.js";
8
+
7
9
  export type DiscoverOptions = {
8
10
  routesDir: string;
9
11
  programmatic?: RouteDefinition[];
12
+ /**
13
+ * File-system routing dialect. `moonshine` is the native `src/routes` layout;
14
+ * the two `next-*` values read an adopted Next app in place.
15
+ */
16
+ convention?: RouteConvention;
10
17
  };
11
18
 
12
19
  export function segmentToPattern(segment: string): string {
@@ -43,15 +50,109 @@ async function* walkFiles(dir: string): AsyncGenerator<string> {
43
50
  }
44
51
  }
45
52
 
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
+ const ROUTE_EXT = /\.(tsx?|jsx?|mjs)$/;
54
+
55
+ /** Segments the App Router strips from the URL, or refuses to route at all. */
56
+ function isRouteGroup(segment: string): boolean {
57
+ return /^\([^)]*\)$/.test(segment);
58
+ }
59
+
60
+ function isPrivateSegment(segment: string): boolean {
61
+ return segment.startsWith("_") || segment.startsWith("@");
62
+ }
63
+
64
+ /** Directory chain of an App Router file, as URL pattern plus a stable id. */
65
+ function appRouterPath(dir: string): { path: string; id: string } | null {
66
+ const parts = dir === "." ? [] : dir.split("/").filter(Boolean);
67
+ const pattern: string[] = [];
68
+ for (const part of parts) {
69
+ if (isPrivateSegment(part)) return null;
70
+ if (isRouteGroup(part)) continue;
71
+ pattern.push(segmentToPattern(part));
53
72
  }
73
+ return {
74
+ path: pattern.length ? `/${pattern.join("/")}` : "/",
75
+ id: parts.length ? parts.join("/") : "index",
76
+ };
77
+ }
78
+
79
+ /**
80
+ * `app/**\/page.tsx` and `app/**\/route.ts`. Every other App Router special
81
+ * (`layout`, `loading`, `template`, `not-found`, `error`, `default`) is either
82
+ * picked up by layout inheritance or has no moonshine equivalent.
83
+ */
84
+ function discoverNextApp(
85
+ files: string[],
86
+ routesDir: string,
87
+ ): RouteDefinition[] {
88
+ const raw: RouteDefinition[] = [];
89
+ for (const file of files) {
90
+ const rel = toPosix(relative(routesDir, file));
91
+ const name = basename(rel);
92
+ if (!ROUTE_EXT.test(name)) continue;
93
+ const base = name.replace(ROUTE_EXT, "");
94
+ if (base !== "page" && base !== "route") continue;
95
+
96
+ const located = appRouterPath(dirname(rel));
97
+ if (!located) continue;
98
+
99
+ const mode: RenderMode | undefined = base === "route" ? "api" : undefined;
100
+ raw.push({
101
+ id: base === "route" ? `${located.id}#route` : located.id,
102
+ path: located.path,
103
+ file,
104
+ ...(mode && { mode }),
105
+ });
106
+ }
107
+ return raw;
108
+ }
54
109
 
110
+ /** Files the Pages Router treats as framework hooks rather than routes. */
111
+ const PAGES_RESERVED = new Set([
112
+ "_app",
113
+ "_document",
114
+ "_error",
115
+ "middleware",
116
+ "_middleware",
117
+ ]);
118
+
119
+ /** `pages/**\/*.tsx`, with `pages/api/**` mapped onto moonshine's api mode. */
120
+ function discoverNextPages(
121
+ files: string[],
122
+ routesDir: string,
123
+ ): RouteDefinition[] {
124
+ const raw: RouteDefinition[] = [];
125
+ for (const file of files) {
126
+ const rel = toPosix(relative(routesDir, file));
127
+ const name = basename(rel);
128
+ if (!ROUTE_EXT.test(name)) continue;
129
+ if (/\.(test|spec|d)\.[jt]sx?$/.test(name)) continue;
130
+ const base = name.replace(ROUTE_EXT, "");
131
+ if (PAGES_RESERVED.has(base)) continue;
132
+
133
+ const dir = dirname(rel);
134
+ const parts = dir === "." ? [] : dir.split("/").filter(Boolean);
135
+ if (parts.some(isPrivateSegment)) continue;
136
+
137
+ const segments = parts.map(segmentToPattern);
138
+ if (base !== "index") segments.push(segmentToPattern(base));
139
+ const path = segments.length ? `/${segments.join("/")}` : "/";
140
+ const mode: RenderMode | undefined = parts[0] === "api" ? "api" : undefined;
141
+
142
+ raw.push({
143
+ id: dir === "." ? base : `${dir}/${base}`,
144
+ path,
145
+ file,
146
+ ...(mode && { mode }),
147
+ });
148
+ }
149
+ return raw;
150
+ }
151
+
152
+ function discoverMoonshine(
153
+ files: string[],
154
+ routesDir: string,
155
+ ): RouteDefinition[] {
55
156
  const fileSet = new Set(files);
56
157
  const raw: RouteDefinition[] = [];
57
158
 
@@ -88,18 +189,41 @@ export async function discoverRoutes(
88
189
  const mode: RenderMode | undefined =
89
190
  suffix === "server" ? "api" : undefined;
90
191
 
91
- const route: RouteDefinition = {
192
+ raw.push({
92
193
  id,
93
194
  path: routePath,
94
195
  file,
95
196
  ...(dataFile && { dataFile }),
96
197
  ...(mode && { mode }),
97
- };
198
+ });
199
+ }
200
+
201
+ return raw;
202
+ }
98
203
 
99
- raw.push(route);
204
+ export async function discoverRoutes(
205
+ options: DiscoverOptions,
206
+ ): Promise<RouteDefinition[]> {
207
+ const routesDir = resolve(options.routesDir);
208
+ const convention = options.convention ?? "moonshine";
209
+ const files: string[] = [];
210
+ for await (const file of walkFiles(routesDir)) {
211
+ files.push(file);
100
212
  }
101
213
 
102
- const merged = mergeRoutes(raw, options.programmatic ?? [], routesDir);
214
+ const raw =
215
+ convention === "next-app"
216
+ ? discoverNextApp(files, routesDir)
217
+ : convention === "next-pages"
218
+ ? discoverNextPages(files, routesDir)
219
+ : discoverMoonshine(files, routesDir);
220
+
221
+ const merged = mergeRoutes(
222
+ raw,
223
+ options.programmatic ?? [],
224
+ routesDir,
225
+ convention,
226
+ );
103
227
  merged.sort(
104
228
  (a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id),
105
229
  );
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  export {
8
8
  discoverRoutes,
9
9
  type DiscoverOptions,
10
+ type RouteConvention,
10
11
  segmentToPattern,
11
12
  } from "./discover.js";
12
13
  export { mergeRoutes } from "./inherit.js";
package/src/inherit.ts CHANGED
@@ -6,20 +6,52 @@ import { toPosix } from "./path.js";
6
6
 
7
7
  type SpecialKind = "layout" | "error" | "middleware";
8
8
 
9
+ /**
10
+ * Moonshine names co-located specials `+layout.tsx`; the Next App Router names
11
+ * them `layout.tsx`. Only the prefix differs, so one lookup serves both.
12
+ */
13
+ export type RouteConvention = "moonshine" | "next-app" | "next-pages";
14
+
15
+ const specialPrefix: Readonly<Record<RouteConvention, string | null>> = {
16
+ moonshine: "+",
17
+ "next-app": "",
18
+ // The Pages Router has no per-directory specials; `_app` wraps everything.
19
+ "next-pages": null,
20
+ };
21
+
22
+ /**
23
+ * The App Router's `error.tsx` takes `{ error, reset }` and its root
24
+ * `middleware.ts` runs on the edge, and neither matches the moonshine
25
+ * contract, so only layouts are inherited under that convention.
26
+ */
27
+ const specialKinds: Readonly<Record<RouteConvention, SpecialKind[]>> = {
28
+ moonshine: ["layout", "error", "middleware"],
29
+ "next-app": ["layout"],
30
+ "next-pages": [],
31
+ };
32
+
9
33
  function findSpecial(
10
34
  routesDir: string,
11
35
  dir: string,
12
36
  kind: SpecialKind,
37
+ convention: RouteConvention,
13
38
  ): string | undefined {
14
- const base = join(routesDir, dir, `+${kind}`);
15
- for (const ext of [".tsx", ".ts"]) {
39
+ const prefix = specialPrefix[convention];
40
+ if (prefix === null) return undefined;
41
+ if (!specialKinds[convention].includes(kind)) return undefined;
42
+ const base = join(routesDir, dir, `${prefix}${kind}`);
43
+ for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
16
44
  const candidate = `${base}${ext}`;
17
45
  if (existsSync(candidate)) return candidate;
18
46
  }
19
47
  return undefined;
20
48
  }
21
49
 
22
- function applyInheritance(routes: RouteDefinition[], routesDir: string): void {
50
+ function applyInheritance(
51
+ routes: RouteDefinition[],
52
+ routesDir: string,
53
+ convention: RouteConvention,
54
+ ): void {
23
55
  const resolvedDir = resolve(routesDir);
24
56
  for (const route of routes) {
25
57
  if (!route.file) continue;
@@ -38,11 +70,11 @@ function applyInheritance(routes: RouteDefinition[], routesDir: string): void {
38
70
  let errorBoundary: string | undefined;
39
71
 
40
72
  for (const ancestor of ancestors) {
41
- const layout = findSpecial(resolvedDir, ancestor, "layout");
73
+ const layout = findSpecial(resolvedDir, ancestor, "layout", convention);
42
74
  if (layout) layouts.push(layout);
43
- const mw = findSpecial(resolvedDir, ancestor, "middleware");
75
+ const mw = findSpecial(resolvedDir, ancestor, "middleware", convention);
44
76
  if (mw) middleware.push(mw);
45
- const err = findSpecial(resolvedDir, ancestor, "error");
77
+ const err = findSpecial(resolvedDir, ancestor, "error", convention);
46
78
  if (err) errorBoundary = err;
47
79
  }
48
80
 
@@ -56,6 +88,7 @@ export function mergeRoutes(
56
88
  filesystem: RouteDefinition[],
57
89
  programmatic: RouteDefinition[] = [],
58
90
  routesDir?: string,
91
+ convention: RouteConvention = "moonshine",
59
92
  ): RouteDefinition[] {
60
93
  const map = new Map<string, RouteDefinition>();
61
94
  for (const route of filesystem) {
@@ -70,7 +103,7 @@ export function mergeRoutes(
70
103
  }
71
104
  }
72
105
  const merged = [...map.values()];
73
- if (routesDir) applyInheritance(merged, routesDir);
106
+ if (routesDir) applyInheritance(merged, routesDir, convention);
74
107
  createRouteGraph(merged);
75
108
  return merged;
76
109
  }
package/src/manifest.ts CHANGED
@@ -11,6 +11,7 @@ import { analyzeModule } from "./analyze.js";
11
11
  import { buildBundles } from "./build.js";
12
12
  import { classifyRoute } from "./classify.js";
13
13
  import { discoverRoutes } from "./discover.js";
14
+ import type { RouteConvention } from "./inherit.js";
14
15
  import { toPosix } from "./path.js";
15
16
 
16
17
  export type BuildOptions = {
@@ -19,6 +20,7 @@ export type BuildOptions = {
19
20
  outDir?: string;
20
21
  programmatic?: RouteDefinition[];
21
22
  runtime?: RuntimeTarget;
23
+ convention?: RouteConvention;
22
24
  };
23
25
 
24
26
  function toProjectRelative(projectDir: string, p: string): string {
@@ -94,6 +96,7 @@ export async function buildProject(
94
96
  const rawRoutes = await discoverRoutes({
95
97
  routesDir,
96
98
  programmatic: options.programmatic,
99
+ ...(options.convention && { convention: options.convention }),
97
100
  });
98
101
 
99
102
  const buildRoutes: RouteArtifact[] = [];
@@ -48,14 +48,19 @@ describe("buildProject", () => {
48
48
  ]);
49
49
  expect(manifest.routes.every((r) => r.runtime === "bun")).toBe(true);
50
50
  expect(manifest.entries.server).toBe("dist/server.js");
51
- expect(manifest.entries.client).toBe("/dist/client.js");
51
+ expect(manifest.entries.client).toBe("/client.js");
52
52
  expect(manifest.capabilities).toEqual(["islands", "streaming"]);
53
+ // Client assets build into public/ because that is the only directory the
54
+ // server exposes; the server bundle must stay outside it.
53
55
  expect(manifest.assets.map((a) => a.file)).toEqual([
54
- "dist/client.js",
55
56
  "dist/server.js",
57
+ "public/client.js",
56
58
  ]);
57
59
  for (const asset of manifest.assets) {
58
- expect(asset.path).toBe(`/${asset.file}`);
60
+ const served = asset.file.startsWith("public/");
61
+ expect(asset.path).toBe(
62
+ served ? `/${asset.file.slice("public/".length)}` : `/${asset.file}`,
63
+ );
59
64
  expect(asset.integrity).toMatch(/^sha256-[A-Za-z0-9+/]+=?$/);
60
65
  }
61
66