@tschk/moonshine-compiler 0.4.1 → 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.
- package/README.md +43 -0
- package/package.json +10 -6
- package/src/build.ts +535 -75
- package/src/compile-graph.ts +215 -0
- package/src/compile-output.ts +91 -0
- package/src/crepus-source.ts +116 -0
- package/src/discover.ts +29 -2
- package/src/inherit.ts +7 -4
- package/src/manifest.ts +156 -12
- package/src/path.ts +16 -0
- package/src/source.ts +8 -0
- package/test/analyze.test.ts +0 -146
- package/test/classify.test.ts +0 -99
- package/test/client-process-env.test.ts +0 -73
- package/test/discover.test.ts +0 -76
- package/test/fixtures/modes/api.ts +0 -3
- package/test/fixtures/modes/client.client.tsx +0 -3
- package/test/fixtures/modes/interactive.tsx +0 -3
- package/test/fixtures/modes/loader.ts +0 -5
- package/test/fixtures/modes/near-miss.tsx +0 -5
- package/test/fixtures/modes/renamed-source.tsx +0 -5
- package/test/fixtures/modes/request-utils.ts +0 -3
- package/test/fixtures/modes/request.ts +0 -1
- package/test/fixtures/modes/ssr-helpers.ts +0 -5
- package/test/fixtures/modes/static.tsx +0 -3
- package/test/fixtures/modes/unresolved.ts +0 -5
- package/test/fixtures/project/src/routes/+error.tsx +0 -3
- package/test/fixtures/project/src/routes/+layout.tsx +0 -3
- package/test/fixtures/project/src/routes/+middleware.ts +0 -3
- package/test/fixtures/project/src/routes/about.data.ts +0 -7
- package/test/fixtures/project/src/routes/about.tsx +0 -7
- package/test/fixtures/project/src/routes/api/health.ts +0 -3
- package/test/fixtures/project/src/routes/client.client.tsx +0 -8
- package/test/fixtures/project/src/routes/counter.tsx +0 -3
- package/test/fixtures/project/src/routes/docs/[...path].tsx +0 -3
- package/test/fixtures/project/src/routes/index.tsx +0 -3
- package/test/fixtures/routes/+error.tsx +0 -3
- package/test/fixtures/routes/+layout.tsx +0 -3
- package/test/fixtures/routes/+middleware.ts +0 -3
- package/test/fixtures/routes/about.tsx +0 -3
- package/test/fixtures/routes/account/[[tab]].tsx +0 -3
- package/test/fixtures/routes/api/health.server.ts +0 -7
- package/test/fixtures/routes/blog/[slug].data.ts +0 -7
- package/test/fixtures/routes/blog/[slug].tsx +0 -3
- package/test/fixtures/routes/docs/[...path].tsx +0 -3
- package/test/fixtures/routes/index.tsx +0 -3
- package/test/inherit.test.ts +0 -50
- package/test/manifest.test.ts +0 -96
- package/test/path.test.ts +0 -30
- package/test/server-bundle-support-modules.test.ts +0 -68
- package/tsconfig.json +0 -4
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, posix, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type CrepusGraphTarget = "moonshine-dom" | "moonshine-ssr";
|
|
5
|
+
export type CrepusGraph = {
|
|
6
|
+
entry: string;
|
|
7
|
+
files: Array<{ path: string; source: string }>;
|
|
8
|
+
};
|
|
9
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
10
|
+
const componentExtensions = [".svelte", ".vue"];
|
|
11
|
+
export function validateNativeRuntimeImport(specifier: string): void {
|
|
12
|
+
if (
|
|
13
|
+
/^(?:(?:react|react-dom|solid-js|svelte|vue)(?:\/|$)|@vue\/(?:reactivity|runtime-core|runtime-dom|shared|server-renderer)(?:\/|$))/.test(
|
|
14
|
+
specifier,
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Unsupported native runtime import ${JSON.stringify(specifier)}; Crepus must lower host primitives to Moonshine or reject them`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
function fail(message: string): never {
|
|
22
|
+
throw new Error(`Invalid Crepus module graph: ${message}`);
|
|
23
|
+
}
|
|
24
|
+
function relativePath(value: unknown): value is string {
|
|
25
|
+
return (
|
|
26
|
+
typeof value === "string" &&
|
|
27
|
+
value.length > 0 &&
|
|
28
|
+
!value.includes("\\") &&
|
|
29
|
+
!value.includes(String.fromCharCode(0)) &&
|
|
30
|
+
!/^(?:\/|[a-z]:|file:)/i.test(value) &&
|
|
31
|
+
value
|
|
32
|
+
.split("/")
|
|
33
|
+
.every((part) => part !== "" && part !== "." && part !== "..")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
async function regularFile(directory: string, path: string): Promise<string> {
|
|
37
|
+
const absolute = resolve(directory, path);
|
|
38
|
+
let current = resolve(directory);
|
|
39
|
+
const directoryStat = await lstat(current);
|
|
40
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
|
|
41
|
+
fail("graph directory must be a real directory");
|
|
42
|
+
const parts = path.split("/");
|
|
43
|
+
for (let index = 0; index < parts.length; index++) {
|
|
44
|
+
current = join(current, parts[index]!);
|
|
45
|
+
const stat = await lstat(current);
|
|
46
|
+
if (stat.isSymbolicLink()) fail(`symlink: ${path}`);
|
|
47
|
+
if (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())
|
|
48
|
+
fail(`not a regular file: ${path}`);
|
|
49
|
+
}
|
|
50
|
+
return readFile(absolute, "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read only the explicitly inventoried, target-specific compiler module graph. */
|
|
54
|
+
export async function readCrepusGraph(
|
|
55
|
+
directory: string,
|
|
56
|
+
target: CrepusGraphTarget,
|
|
57
|
+
): Promise<CrepusGraph | undefined> {
|
|
58
|
+
const manifestPath = "crepus-module-manifest.json";
|
|
59
|
+
try {
|
|
60
|
+
await lstat(join(directory, manifestPath));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const manifest = JSON.parse(await regularFile(directory, manifestPath));
|
|
66
|
+
const entry = `CrepusEmit.${target}.ts`;
|
|
67
|
+
const prefix = `modules/${target}/`;
|
|
68
|
+
if (
|
|
69
|
+
!manifest ||
|
|
70
|
+
manifest.version !== 1 ||
|
|
71
|
+
manifest.target !== target ||
|
|
72
|
+
manifest.entry !== entry ||
|
|
73
|
+
!relativePath(manifest.sourceEntry) ||
|
|
74
|
+
![...extensions, ...componentExtensions].includes(
|
|
75
|
+
posix.extname(manifest.sourceEntry).toLowerCase(),
|
|
76
|
+
) ||
|
|
77
|
+
!Array.isArray(manifest.files)
|
|
78
|
+
)
|
|
79
|
+
fail("unsupported manifest schema, version, target or entry");
|
|
80
|
+
const paths = new Set<string>();
|
|
81
|
+
for (const path of manifest.files) {
|
|
82
|
+
if (
|
|
83
|
+
!relativePath(path) ||
|
|
84
|
+
(path !== entry && !path.startsWith(prefix)) ||
|
|
85
|
+
!extensions.includes(posix.extname(path).toLowerCase())
|
|
86
|
+
)
|
|
87
|
+
fail(`unsupported file path: ${String(path)}`);
|
|
88
|
+
if (paths.has(path)) fail(`duplicate file: ${path}`);
|
|
89
|
+
paths.add(path);
|
|
90
|
+
}
|
|
91
|
+
const sourceModule =
|
|
92
|
+
manifest.sourceModule === undefined
|
|
93
|
+
? prefix +
|
|
94
|
+
(componentExtensions.includes(
|
|
95
|
+
posix.extname(manifest.sourceEntry).toLowerCase(),
|
|
96
|
+
)
|
|
97
|
+
? manifest.sourceEntry + ".ts"
|
|
98
|
+
: manifest.sourceEntry)
|
|
99
|
+
: manifest.sourceModule;
|
|
100
|
+
if (
|
|
101
|
+
!relativePath(sourceModule) ||
|
|
102
|
+
!sourceModule.startsWith(prefix) ||
|
|
103
|
+
!paths.has(entry) ||
|
|
104
|
+
!paths.has(sourceModule)
|
|
105
|
+
)
|
|
106
|
+
fail("entry or sourceEntry is not listed");
|
|
107
|
+
if (manifest.assets !== undefined && !Array.isArray(manifest.assets))
|
|
108
|
+
fail("assets must be an array");
|
|
109
|
+
const assets = new Map<string, string>();
|
|
110
|
+
for (const path of manifest.assets ?? []) {
|
|
111
|
+
if (
|
|
112
|
+
!relativePath(path) ||
|
|
113
|
+
!path.startsWith(prefix) ||
|
|
114
|
+
posix.extname(path) !== ".css" ||
|
|
115
|
+
paths.has(path) ||
|
|
116
|
+
assets.has(path)
|
|
117
|
+
)
|
|
118
|
+
fail(`unsupported or duplicate CSS asset: ${String(path)}`);
|
|
119
|
+
assets.set(path, await regularFile(directory, path));
|
|
120
|
+
}
|
|
121
|
+
await validateCSSAssets(assets, prefix, directory);
|
|
122
|
+
const files: CrepusGraph["files"] = [];
|
|
123
|
+
let sourceReferenced = false;
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
const source = await regularFile(directory, path);
|
|
126
|
+
const loader = posix.extname(path).slice(1).toLowerCase() as
|
|
127
|
+
"ts" | "tsx" | "js" | "jsx";
|
|
128
|
+
const transpiler = new Bun.Transpiler({ loader });
|
|
129
|
+
transpiler.transformSync(source);
|
|
130
|
+
for (const dependency of transpiler.scan(source).imports) {
|
|
131
|
+
const specifier = dependency.path;
|
|
132
|
+
validateNativeRuntimeImport(specifier);
|
|
133
|
+
if (
|
|
134
|
+
specifier.includes("\\") ||
|
|
135
|
+
specifier.includes(String.fromCharCode(0)) ||
|
|
136
|
+
/^(?:\/|[a-z]:|file:)/i.test(specifier)
|
|
137
|
+
)
|
|
138
|
+
fail(`unsupported import ${JSON.stringify(specifier)} in ${path}`);
|
|
139
|
+
if (!specifier.startsWith(".")) continue;
|
|
140
|
+
const base = posix.normalize(posix.join(posix.dirname(path), specifier));
|
|
141
|
+
if (!base.startsWith(prefix))
|
|
142
|
+
fail(`import escapes target modules: ${specifier}`);
|
|
143
|
+
const candidates = assets.has(base)
|
|
144
|
+
? [base]
|
|
145
|
+
: posix.extname(base)
|
|
146
|
+
? [base]
|
|
147
|
+
: extensions.flatMap((extension) => [
|
|
148
|
+
base + extension,
|
|
149
|
+
`${base}/index${extension}`,
|
|
150
|
+
]);
|
|
151
|
+
const matches = candidates.filter(
|
|
152
|
+
(candidate) => paths.has(candidate) || assets.has(candidate),
|
|
153
|
+
);
|
|
154
|
+
if (matches.length !== 1)
|
|
155
|
+
fail(
|
|
156
|
+
`missing or ambiguous listed import ${JSON.stringify(specifier)} in ${path}`,
|
|
157
|
+
);
|
|
158
|
+
if (path === entry && matches[0] === sourceModule)
|
|
159
|
+
sourceReferenced = true;
|
|
160
|
+
}
|
|
161
|
+
files.push({ path, source });
|
|
162
|
+
}
|
|
163
|
+
// Legacy manifests cannot identify allocator-remapped SFC entry modules.
|
|
164
|
+
if (manifest.sourceModule !== undefined && !sourceReferenced)
|
|
165
|
+
fail("entry does not reference sourceEntry module");
|
|
166
|
+
files.push(...Array.from(assets, ([path, source]) => ({ path, source })));
|
|
167
|
+
return { entry, files };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Parse CSS with Bun while resolving exclusively from the already checked inventory. */
|
|
171
|
+
async function validateCSSAssets(
|
|
172
|
+
assets: Map<string, string>,
|
|
173
|
+
prefix: string,
|
|
174
|
+
directory: string,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
if (!assets.size) return;
|
|
177
|
+
const root = await realpath(directory);
|
|
178
|
+
const result = await Bun.build({
|
|
179
|
+
root,
|
|
180
|
+
entrypoints: [...assets.keys()].map((path) => join(root, path)),
|
|
181
|
+
plugins: [
|
|
182
|
+
{
|
|
183
|
+
name: "crepus-css-inventory",
|
|
184
|
+
setup(build) {
|
|
185
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
186
|
+
if (args.kind === "entry-point-build") {
|
|
187
|
+
const path = posix.relative(root, args.path);
|
|
188
|
+
if (!assets.has(path)) throw new Error("Unlisted CSS entry");
|
|
189
|
+
return { path, namespace: "crepus-css" };
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
args.kind !== "import-rule" ||
|
|
193
|
+
!args.path.startsWith(".") ||
|
|
194
|
+
args.path.includes("\\") ||
|
|
195
|
+
args.path.includes("\0")
|
|
196
|
+
)
|
|
197
|
+
throw new Error(`Unsupported CSS reference: ${args.path}`);
|
|
198
|
+
const path = posix.normalize(
|
|
199
|
+
posix.join(posix.dirname(args.importer), args.path),
|
|
200
|
+
);
|
|
201
|
+
if (!path.startsWith(prefix) || !assets.has(path))
|
|
202
|
+
throw new Error(`Unlisted CSS import: ${args.path}`);
|
|
203
|
+
return { path, namespace: "crepus-css" };
|
|
204
|
+
});
|
|
205
|
+
build.onLoad({ filter: /.*/, namespace: "crepus-css" }, (args) => ({
|
|
206
|
+
contents: assets.get(args.path)!,
|
|
207
|
+
loader: "css",
|
|
208
|
+
}));
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
if (!result.success)
|
|
214
|
+
fail(`CSS validation failed: ${result.logs.map(String).join("; ")}`);
|
|
215
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
12
|
+
import type { CrepusGraph } from "./compile-graph";
|
|
13
|
+
|
|
14
|
+
async function directory(path: string): Promise<void> {
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(path);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
19
|
+
}
|
|
20
|
+
const info = await lstat(path);
|
|
21
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
22
|
+
throw new Error(`Unsafe compiled module directory: ${path}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Publish immutable compiler bytes before atomically switching the public entry. */
|
|
26
|
+
export async function publishCrepusGraph(
|
|
27
|
+
output: string,
|
|
28
|
+
graph: CrepusGraph,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const hash = createHash("sha256");
|
|
31
|
+
hash.update(JSON.stringify([graph.entry, graph.files]));
|
|
32
|
+
const digest = hash.digest("hex");
|
|
33
|
+
const parent = dirname(output);
|
|
34
|
+
const generated = join(parent, ".moonshine-compiled");
|
|
35
|
+
const source = join(generated, basename(output));
|
|
36
|
+
await directory(generated);
|
|
37
|
+
await directory(source);
|
|
38
|
+
const destination = join(source, digest);
|
|
39
|
+
const staged = await mkdtemp(join(source, ".pending-"));
|
|
40
|
+
try {
|
|
41
|
+
for (const file of graph.files) {
|
|
42
|
+
const path = join(staged, file.path);
|
|
43
|
+
await mkdir(dirname(path), { recursive: true });
|
|
44
|
+
await writeFile(path, file.source);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await rename(staged, destination);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (
|
|
50
|
+
!["EEXIST", "ENOTEMPTY"].includes(
|
|
51
|
+
(error as NodeJS.ErrnoException).code ?? "",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
// A pre-existing digest is reusable only when every referenced byte matches.
|
|
57
|
+
const rootInfo = await lstat(destination);
|
|
58
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
59
|
+
throw new Error(`Unsafe compiled module directory: ${destination}`);
|
|
60
|
+
for (const file of graph.files) {
|
|
61
|
+
const parts = file.path.split("/");
|
|
62
|
+
let path = destination;
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
path = join(path, part);
|
|
65
|
+
if ((await lstat(path)).isSymbolicLink())
|
|
66
|
+
throw new Error(`Unsafe compiled module path: ${path}`);
|
|
67
|
+
}
|
|
68
|
+
if ((await readFile(path, "utf8")) !== file.source)
|
|
69
|
+
throw new Error(`Compiled module contents differ: ${path}`);
|
|
70
|
+
}
|
|
71
|
+
const entry = graph.files.find((file) => file.path === graph.entry)!;
|
|
72
|
+
const hasDefault = new Bun.Transpiler({ loader: "ts" })
|
|
73
|
+
.scan(entry.source)
|
|
74
|
+
.exports.includes("default");
|
|
75
|
+
const specifier = JSON.stringify(
|
|
76
|
+
"./" +
|
|
77
|
+
relative(parent, join(destination, graph.entry)).split(sep).join("/"),
|
|
78
|
+
);
|
|
79
|
+
const facade = `// Generated by moonshine compile; compiler modules are preserved unchanged.\nexport * from ${specifier};\n${hasDefault ? `export { default } from ${specifier};\n` : ""}`;
|
|
80
|
+
const pending = await mkdtemp(join(parent, ".moonshine-entry-"));
|
|
81
|
+
try {
|
|
82
|
+
const path = join(pending, "entry.ts");
|
|
83
|
+
await writeFile(path, facade);
|
|
84
|
+
await rename(path, output);
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(pending, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
await rm(staged, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import {
|
|
4
|
+
basename,
|
|
5
|
+
dirname,
|
|
6
|
+
isAbsolute,
|
|
7
|
+
join,
|
|
8
|
+
relative,
|
|
9
|
+
resolve,
|
|
10
|
+
sep,
|
|
11
|
+
} from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
readCrepusGraph,
|
|
14
|
+
validateNativeRuntimeImport,
|
|
15
|
+
type CrepusGraph,
|
|
16
|
+
} from "./compile-graph";
|
|
17
|
+
|
|
18
|
+
export function requireCrepusCompiler(compiler?: string): string {
|
|
19
|
+
const executable = Bun.which(compiler ?? "crepus");
|
|
20
|
+
if (!executable)
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Crepus compiler not found: ${compiler ?? "crepus"}; install a compiler with native emission or pass --compiler <path>`,
|
|
23
|
+
);
|
|
24
|
+
return executable;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Delegate source parsing/lowering; never writes inside the source project. */
|
|
28
|
+
export async function emitCrepusSource(
|
|
29
|
+
abs: string,
|
|
30
|
+
target: "moonshine-dom" | "moonshine-ssr" | "react",
|
|
31
|
+
compiler?: string,
|
|
32
|
+
site?: string,
|
|
33
|
+
): Promise<{
|
|
34
|
+
source: string;
|
|
35
|
+
ir?: { version?: number; root?: unknown[] };
|
|
36
|
+
graph?: CrepusGraph;
|
|
37
|
+
}> {
|
|
38
|
+
const executable = requireCrepusCompiler(compiler);
|
|
39
|
+
const root = resolve(site ?? dirname(abs));
|
|
40
|
+
const entry = relative(root, abs);
|
|
41
|
+
if (
|
|
42
|
+
!entry ||
|
|
43
|
+
entry === ".." ||
|
|
44
|
+
entry.startsWith(`..${sep}`) ||
|
|
45
|
+
isAbsolute(entry)
|
|
46
|
+
)
|
|
47
|
+
throw new Error("Source must be a file inside --site");
|
|
48
|
+
const temporary = await mkdtemp(join(tmpdir(), "moonshine-compile-"));
|
|
49
|
+
try {
|
|
50
|
+
const child = Bun.spawn(
|
|
51
|
+
[
|
|
52
|
+
executable,
|
|
53
|
+
"web",
|
|
54
|
+
"build",
|
|
55
|
+
"--site",
|
|
56
|
+
root,
|
|
57
|
+
"--entry",
|
|
58
|
+
entry.split(sep).join("/"),
|
|
59
|
+
"--out-dir",
|
|
60
|
+
temporary,
|
|
61
|
+
"--emit",
|
|
62
|
+
target,
|
|
63
|
+
],
|
|
64
|
+
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
|
65
|
+
);
|
|
66
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
67
|
+
child.exited,
|
|
68
|
+
new Response(child.stdout).text(),
|
|
69
|
+
new Response(child.stderr).text(),
|
|
70
|
+
]);
|
|
71
|
+
if (code !== 0)
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Crepus compilation failed (exit ${code}): ${stderr || stdout}`,
|
|
74
|
+
);
|
|
75
|
+
const generated = join(
|
|
76
|
+
temporary,
|
|
77
|
+
`CrepusEmit.${target}.` + (target === "react" ? "tsx" : "ts"),
|
|
78
|
+
);
|
|
79
|
+
try {
|
|
80
|
+
await access(generated);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Crepus compiler did not emit expected file: ${basename(generated)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const graph =
|
|
87
|
+
target === "react" ? undefined : await readCrepusGraph(temporary, target);
|
|
88
|
+
const source = await readFile(generated, "utf8");
|
|
89
|
+
const transpiler = new Bun.Transpiler({
|
|
90
|
+
loader: target === "react" ? "tsx" : "ts",
|
|
91
|
+
});
|
|
92
|
+
transpiler.transformSync(source);
|
|
93
|
+
for (const dependency of transpiler.scan(source).imports) {
|
|
94
|
+
if (target !== "react") validateNativeRuntimeImport(dependency.path);
|
|
95
|
+
if (
|
|
96
|
+
!graph &&
|
|
97
|
+
/^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[a-z]:|file:)/i.test(dependency.path)
|
|
98
|
+
) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Crepus emitted unsupported filesystem import ${JSON.stringify(dependency.path)}; companion modules/assets require an upstream asset contract`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let ir: { version?: number; root?: unknown[] } | undefined;
|
|
105
|
+
try {
|
|
106
|
+
ir = JSON.parse(
|
|
107
|
+
await readFile(join(temporary, "crepus-view-ir.json"), "utf8"),
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
111
|
+
}
|
|
112
|
+
return { source, ir, ...(graph && { graph }) };
|
|
113
|
+
} finally {
|
|
114
|
+
await rm(temporary, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/discover.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type DiscoverOptions = {
|
|
|
11
11
|
programmatic?: RouteDefinition[];
|
|
12
12
|
/**
|
|
13
13
|
* File-system routing dialect. `moonshine` is the native `src/routes` layout;
|
|
14
|
-
*
|
|
14
|
+
* `next-*` and `waku` read adopted framework routes in place.
|
|
15
15
|
*/
|
|
16
16
|
convention?: RouteConvention;
|
|
17
17
|
};
|
|
@@ -149,6 +149,31 @@ function discoverNextPages(
|
|
|
149
149
|
return raw;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/** Waku pages and `_api` endpoints; underscore specials are not pages. */
|
|
153
|
+
function discoverWaku(files: string[], routesDir: string): RouteDefinition[] {
|
|
154
|
+
const raw: RouteDefinition[] = [];
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const rel = toPosix(relative(routesDir, file));
|
|
157
|
+
const name = basename(rel);
|
|
158
|
+
if (!ROUTE_EXT.test(name) || name.startsWith("_")) continue;
|
|
159
|
+
if (/\.(test|spec|d)\.[jt]sx?$/.test(name)) continue;
|
|
160
|
+
const base = name.replace(ROUTE_EXT, "");
|
|
161
|
+
const parts = rel.split("/").slice(0, -1);
|
|
162
|
+
const api = parts[0] === "_api";
|
|
163
|
+
const directories = api ? parts.slice(1) : parts;
|
|
164
|
+
if (directories.some((part) => part.startsWith("_"))) continue;
|
|
165
|
+
const segments = directories.map(segmentToPattern);
|
|
166
|
+
if (base !== "index") segments.push(segmentToPattern(base));
|
|
167
|
+
raw.push({
|
|
168
|
+
id: rel.replace(ROUTE_EXT, ""),
|
|
169
|
+
path: segments.length ? `/${segments.join("/")}` : "/",
|
|
170
|
+
file,
|
|
171
|
+
...(api && { mode: "api" as const }),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return raw;
|
|
175
|
+
}
|
|
176
|
+
|
|
152
177
|
function discoverMoonshine(
|
|
153
178
|
files: string[],
|
|
154
179
|
routesDir: string,
|
|
@@ -219,7 +244,9 @@ export async function discoverRoutes(
|
|
|
219
244
|
? discoverNextApp(files, routesDir)
|
|
220
245
|
: convention === "next-pages"
|
|
221
246
|
? discoverNextPages(files, routesDir)
|
|
222
|
-
:
|
|
247
|
+
: convention === "waku"
|
|
248
|
+
? discoverWaku(files, routesDir)
|
|
249
|
+
: discoverMoonshine(files, routesDir);
|
|
223
250
|
|
|
224
251
|
const merged = mergeRoutes(
|
|
225
252
|
raw,
|
package/src/inherit.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import type { RouteDefinition } from "@tschk/moonshine-framework";
|
|
4
|
-
import { createRouteGraph } from "@tschk/moonshine-router";
|
|
4
|
+
import { createRouteGraph } from "@tschk/moonshine-router/graph";
|
|
5
5
|
import { toPosix } from "./path.js";
|
|
6
6
|
|
|
7
7
|
type SpecialKind = "layout" | "error" | "middleware";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Moonshine names co-located specials `+layout.tsx`; the Next App Router names
|
|
11
|
-
* them `layout.tsx`. Only the prefix differs
|
|
11
|
+
* them `layout.tsx`, and Waku uses `_layout.tsx`. Only the prefix differs.
|
|
12
12
|
*/
|
|
13
|
-
export type RouteConvention = "moonshine" | "next-app" | "next-pages";
|
|
13
|
+
export type RouteConvention = "moonshine" | "next-app" | "next-pages" | "waku";
|
|
14
14
|
|
|
15
15
|
const specialPrefix: Readonly<Record<RouteConvention, string | null>> = {
|
|
16
16
|
moonshine: "+",
|
|
17
|
+
waku: "_",
|
|
17
18
|
"next-app": "",
|
|
18
19
|
// The Pages Router has no per-directory specials; `_app` wraps everything.
|
|
19
20
|
"next-pages": null,
|
|
@@ -26,6 +27,7 @@ const specialPrefix: Readonly<Record<RouteConvention, string | null>> = {
|
|
|
26
27
|
*/
|
|
27
28
|
const specialKinds: Readonly<Record<RouteConvention, SpecialKind[]>> = {
|
|
28
29
|
moonshine: ["layout", "error", "middleware"],
|
|
30
|
+
waku: ["layout"],
|
|
29
31
|
"next-app": ["layout"],
|
|
30
32
|
"next-pages": [],
|
|
31
33
|
};
|
|
@@ -69,7 +71,8 @@ function applyInheritance(
|
|
|
69
71
|
const resolvedDir = resolve(routesDir);
|
|
70
72
|
const cache = new Map<string, string | undefined>();
|
|
71
73
|
for (const route of routes) {
|
|
72
|
-
if (!route.file)
|
|
74
|
+
if (!route.file || (convention === "waku" && route.mode === "api"))
|
|
75
|
+
continue;
|
|
73
76
|
const rel = toPosix(relative(resolvedDir, resolve(route.file)));
|
|
74
77
|
const dir = dirname(rel);
|
|
75
78
|
const parts = dir === "." ? [] : dir.split("/").filter(Boolean);
|