@tschk/moonshine-compiler 0.4.0 → 0.4.4

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 (51) hide show
  1. package/README.md +43 -0
  2. package/package.json +10 -6
  3. package/src/build.ts +535 -75
  4. package/src/compile-graph.ts +215 -0
  5. package/src/compile-output.ts +91 -0
  6. package/src/crepus-source.ts +116 -0
  7. package/src/discover.ts +29 -2
  8. package/src/inherit.ts +7 -4
  9. package/src/manifest.ts +156 -12
  10. package/src/path.ts +16 -0
  11. package/src/source.ts +8 -0
  12. package/test/analyze.test.ts +0 -146
  13. package/test/classify.test.ts +0 -99
  14. package/test/client-process-env.test.ts +0 -73
  15. package/test/discover.test.ts +0 -76
  16. package/test/fixtures/modes/api.ts +0 -3
  17. package/test/fixtures/modes/client.client.tsx +0 -3
  18. package/test/fixtures/modes/interactive.tsx +0 -3
  19. package/test/fixtures/modes/loader.ts +0 -5
  20. package/test/fixtures/modes/near-miss.tsx +0 -5
  21. package/test/fixtures/modes/renamed-source.tsx +0 -5
  22. package/test/fixtures/modes/request-utils.ts +0 -3
  23. package/test/fixtures/modes/request.ts +0 -1
  24. package/test/fixtures/modes/ssr-helpers.ts +0 -5
  25. package/test/fixtures/modes/static.tsx +0 -3
  26. package/test/fixtures/modes/unresolved.ts +0 -5
  27. package/test/fixtures/project/src/routes/+error.tsx +0 -3
  28. package/test/fixtures/project/src/routes/+layout.tsx +0 -3
  29. package/test/fixtures/project/src/routes/+middleware.ts +0 -3
  30. package/test/fixtures/project/src/routes/about.data.ts +0 -7
  31. package/test/fixtures/project/src/routes/about.tsx +0 -7
  32. package/test/fixtures/project/src/routes/api/health.ts +0 -3
  33. package/test/fixtures/project/src/routes/client.client.tsx +0 -8
  34. package/test/fixtures/project/src/routes/counter.tsx +0 -3
  35. package/test/fixtures/project/src/routes/docs/[...path].tsx +0 -3
  36. package/test/fixtures/project/src/routes/index.tsx +0 -3
  37. package/test/fixtures/routes/+error.tsx +0 -3
  38. package/test/fixtures/routes/+layout.tsx +0 -3
  39. package/test/fixtures/routes/+middleware.ts +0 -3
  40. package/test/fixtures/routes/about.tsx +0 -3
  41. package/test/fixtures/routes/account/[[tab]].tsx +0 -3
  42. package/test/fixtures/routes/api/health.server.ts +0 -7
  43. package/test/fixtures/routes/blog/[slug].data.ts +0 -7
  44. package/test/fixtures/routes/blog/[slug].tsx +0 -3
  45. package/test/fixtures/routes/docs/[...path].tsx +0 -3
  46. package/test/fixtures/routes/index.tsx +0 -3
  47. package/test/inherit.test.ts +0 -50
  48. package/test/manifest.test.ts +0 -96
  49. package/test/path.test.ts +0 -30
  50. package/test/server-bundle-support-modules.test.ts +0 -68
  51. package/tsconfig.json +0 -4
@@ -1,3 +0,0 @@
1
- export default function ClientPage() {
2
- return <h1>client</h1>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function Counter() {
2
- return <button onClick={() => {}}>+</button>;
3
- }
@@ -1,5 +0,0 @@
1
- import { request } from "./request";
2
-
3
- export function loader() {
4
- return request.url;
5
- }
@@ -1,5 +0,0 @@
1
- import { formatRequestId } from "./request-utils";
2
-
3
- export default function Page() {
4
- return <p>{formatRequestId("abc")}</p>;
5
- }
@@ -1,5 +0,0 @@
1
- import { cookies } from "./ssr-helpers";
2
-
3
- export default function Page() {
4
- return <p>{cookies().get("session")?.value}</p>;
5
- }
@@ -1,3 +0,0 @@
1
- export function formatRequestId(value: string): string {
2
- return value.trim();
3
- }
@@ -1 +0,0 @@
1
- export const request = new Request("http://example.com");
@@ -1,5 +0,0 @@
1
- export function cookies(): {
2
- get(name: string): { value: string } | undefined;
3
- } {
4
- return { get: () => ({ value: "session" }) };
5
- }
@@ -1,3 +0,0 @@
1
- export default function Page() {
2
- return <h1>static</h1>;
3
- }
@@ -1,5 +0,0 @@
1
- export default async function Page() {
2
- const name = "x";
3
- const mod = await import(`./${name}`);
4
- return mod;
5
- }
@@ -1,3 +0,0 @@
1
- export default function RootError() {
2
- return <p>error</p>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function RootLayout({ children }: { children: unknown }) {
2
- return <>{children}</>;
3
- }
@@ -1,3 +0,0 @@
1
- export function middleware(_context: unknown, next: () => unknown): unknown {
2
- return next();
3
- }
@@ -1,7 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
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
- }
@@ -1,3 +0,0 @@
1
- export function GET(_request: Request) {
2
- return Response.json({ status: "ok" });
3
- }
@@ -1,8 +0,0 @@
1
- // Reads process.env deliberately: browsers have no `process`, so this is the
2
- // shape that used to throw `ReferenceError: process is not defined` while the
3
- // client entry evaluated, leaving the page blank with no error boundary.
4
- const apiBase = process.env.MOONSHINE_TEST_API_BASE ?? "unset";
5
-
6
- export default function ClientPage() {
7
- return <h1>client {apiBase}</h1>;
8
- }
@@ -1,3 +0,0 @@
1
- export default function Counter() {
2
- return <button onClick={() => {}}>+</button>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function DocsCatchAllPage() {
2
- return <h1>docs</h1>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function IndexPage() {
2
- return <h1>home</h1>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function RootError() {
2
- return <p>error</p>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function RootLayout({ children }: { children: unknown }) {
2
- return <>{children}</>;
3
- }
@@ -1,3 +0,0 @@
1
- export function middleware(_context: unknown, next: () => unknown): unknown {
2
- return next();
3
- }
@@ -1,3 +0,0 @@
1
- export default function AboutPage() {
2
- return <h1>about</h1>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function AccountPage() {
2
- return <h1>account</h1>;
3
- }
@@ -1,7 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
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
- }
@@ -1,3 +0,0 @@
1
- export default function BlogPostPage({ data }: { data: unknown }) {
2
- return <h1>{String(data)}</h1>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function DocsCatchAllPage() {
2
- return <h1>docs</h1>;
3
- }
@@ -1,3 +0,0 @@
1
- export default function IndexPage() {
2
- return <h1>home</h1>;
3
- }
@@ -1,50 +0,0 @@
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
- });
@@ -1,96 +0,0 @@
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("/client.js");
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.
55
- expect(manifest.assets.map((a) => a.file)).toEqual([
56
- "dist/server.js",
57
- "public/client.js",
58
- ]);
59
- for (const asset of manifest.assets) {
60
- const served = asset.file.startsWith("public/");
61
- expect(asset.path).toBe(
62
- served ? `/${asset.file.slice("public/".length)}` : `/${asset.file}`,
63
- );
64
- expect(asset.integrity).toMatch(/^sha256-[A-Za-z0-9+/]+=?$/);
65
- }
66
-
67
- const fromDisk = await readManifest(join(outDir, "manifest.json"));
68
- expect(fromDisk.version).toBe(1);
69
- expect(fromDisk.routes.map((r) => r.path)).toEqual(
70
- manifest.routes.map((r) => r.path),
71
- );
72
-
73
- const text = JSON.stringify(manifest);
74
- expect(text).not.toMatch(/\\\\/);
75
- expect(text).not.toMatch(/^\//);
76
- expect(text).not.toMatch(/process\.env/);
77
-
78
- for (const route of manifest.routes) {
79
- expect(route.file.startsWith("src/")).toBe(true);
80
- if (route.dataFile) expect(route.dataFile.startsWith("src/")).toBe(true);
81
- if (route.layouts) {
82
- for (const l of route.layouts) {
83
- expect(l.startsWith("src/")).toBe(true);
84
- }
85
- }
86
- if (route.middleware) {
87
- for (const m of route.middleware) {
88
- expect(m.startsWith("src/")).toBe(true);
89
- }
90
- }
91
- if (route.errorBoundary) {
92
- expect(route.errorBoundary.startsWith("src/")).toBe(true);
93
- }
94
- }
95
- });
96
- });
package/test/path.test.ts DELETED
@@ -1,30 +0,0 @@
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
- });
@@ -1,68 +0,0 @@
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
- });
package/tsconfig.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "include": ["src/**/*", "test/**/*"]
4
- }