@sigil-dev/grimoire 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.grimoire/_routes.dom.js +4 -0
- package/.grimoire/_routes.hydrate.js +4 -0
- package/.grimoire/_routes.ts +4 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +6 -0
- package/.grimoire/types/api/hello/$types.d.ts +29 -0
- package/README.md +1 -0
- package/index.ts +22 -0
- package/package.json +36 -0
- package/public/__grimoire__/client.js +86 -0
- package/public/__grimoire__/hydrate.js +101 -0
- package/src/client-router.ts +77 -0
- package/src/client.ts +4 -0
- package/src/context.ts +10 -0
- package/src/cookie-utils.ts +66 -0
- package/src/enhance.ts +97 -0
- package/src/error.ts +52 -0
- package/src/fail.ts +41 -0
- package/src/head.ts +27 -0
- package/src/headers.ts +114 -0
- package/src/hooks.ts +93 -0
- package/src/hydrate.ts +22 -0
- package/src/manifest-gen.ts +26 -0
- package/src/plugins.ts +25 -0
- package/src/redirect.ts +35 -0
- package/src/renderer.ts +142 -0
- package/src/router.ts +94 -0
- package/src/scanner.ts +97 -0
- package/src/scope.ts +22 -0
- package/src/server.ts +318 -0
- package/src/ssrPlugin.ts +26 -0
- package/src/sync.ts +18 -0
- package/src/transform-routes.ts +90 -0
- package/src/typegen.ts +263 -0
- package/src/types.ts +85 -0
- package/src/vite-plugin.ts +72 -0
- package/test/context.test.ts +52 -0
- package/test/fail.test.ts +46 -0
- package/test/headers.test.ts +96 -0
- package/test/hydration.test.ts +119 -0
- package/test/middleware.test.ts +217 -0
- package/test/preload.ts +5 -0
- package/test/redirect-error.test.ts +112 -0
- package/test/rendering.test.ts +172 -0
- package/test/routing.test.ts +45 -0
- package/test/scanning.test.ts +55 -0
- package/test/scope.test.ts +164 -0
- package/test/server.test.ts +30 -0
- package/test/streaming.test.ts +132 -0
- package/test/transform-routes.test.ts +84 -0
- package/test/typegen.test.ts +652 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, sep } from "node:path";
|
|
6
|
+
import type { RouteFile } from "../src/scanner";
|
|
7
|
+
import { scanRoutes } from "../src/scanner";
|
|
8
|
+
import {
|
|
9
|
+
buildParams,
|
|
10
|
+
generateTypes,
|
|
11
|
+
groupByDirectory,
|
|
12
|
+
toImportPath,
|
|
13
|
+
} from "../src/typegen";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// toImportPath
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
describe("toImportPath", () => {
|
|
20
|
+
test(".ts extension becomes .js", () => {
|
|
21
|
+
const result = toImportPath("/a/b", "/a/b/c/file.ts");
|
|
22
|
+
expect(result).toBe("c/file.js");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test(".tsx extension becomes .js", () => {
|
|
26
|
+
const result = toImportPath("/a/b", "/a/b/c/file.tsx");
|
|
27
|
+
expect(result).toBe("c/file.js");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("produces ../ prefixes for parent directories", () => {
|
|
31
|
+
const result = toImportPath(
|
|
32
|
+
"/project/.grimoire/types/src/routes/blog/[slug]",
|
|
33
|
+
"/project/src/routes/blog/[slug]/+page.server.ts",
|
|
34
|
+
);
|
|
35
|
+
// From .grimoire/types/src/routes/blog/[slug] up 6 levels to /project/, then down
|
|
36
|
+
expect(result).toBe(
|
|
37
|
+
"../../../../../../src/routes/blog/[slug]/+page.server.js",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("never contains backslashes regardless of platform separator", () => {
|
|
42
|
+
// Construct paths using the OS separator to exercise the replace logic
|
|
43
|
+
// from: C:\project\.grimoire\types\src\routes (4 levels below C:\project)
|
|
44
|
+
// to: C:\project\src\routes\+page.server.ts
|
|
45
|
+
// → 4 "../" to escape back to C:\project, then down to src/routes/file
|
|
46
|
+
const from = ["C:", "project", ".grimoire", "types", "src", "routes"].join(
|
|
47
|
+
sep,
|
|
48
|
+
);
|
|
49
|
+
const to = ["C:", "project", "src", "routes", "+page.server.ts"].join(sep);
|
|
50
|
+
const result = toImportPath(from, to);
|
|
51
|
+
expect(result).not.toContain("\\");
|
|
52
|
+
expect(result).toBe("../../../../src/routes/+page.server.js");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("sibling file produces just the filename", () => {
|
|
56
|
+
const result = toImportPath("/a/b/c", "/a/b/c/file.ts");
|
|
57
|
+
expect(result).toBe("file.js");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// buildParams
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe("buildParams", () => {
|
|
66
|
+
test("empty array returns {}", () => {
|
|
67
|
+
expect(buildParams([])).toBe("{}");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("single param", () => {
|
|
71
|
+
expect(buildParams(["id"])).toBe("{\n id: string;\n}");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("multiple params", () => {
|
|
75
|
+
expect(buildParams(["org", "repo"])).toBe(
|
|
76
|
+
"{\n org: string;\n repo: string;\n}",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("produces valid TypeScript type syntax", () => {
|
|
81
|
+
const result = buildParams(["slug"]);
|
|
82
|
+
expect(result).toContain("slug: string;");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// groupByDirectory
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
function makeRouteFile(
|
|
91
|
+
overrides: Partial<RouteFile> & { filePath: string; type: RouteFile["type"] },
|
|
92
|
+
): RouteFile {
|
|
93
|
+
return {
|
|
94
|
+
path: "/",
|
|
95
|
+
clientPath: "/src/routes/+page.tsx",
|
|
96
|
+
paramNames: [],
|
|
97
|
+
...overrides,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("groupByDirectory", () => {
|
|
102
|
+
test("simple files are excluded", () => {
|
|
103
|
+
const tree = {
|
|
104
|
+
routes: [
|
|
105
|
+
makeRouteFile({ filePath: "/app/routes/index.tsx", type: "simple" }),
|
|
106
|
+
makeRouteFile({ filePath: "/app/routes/about.tsx", type: "simple" }),
|
|
107
|
+
],
|
|
108
|
+
layouts: [],
|
|
109
|
+
servers: [],
|
|
110
|
+
errors: [],
|
|
111
|
+
};
|
|
112
|
+
const groups = groupByDirectory(tree);
|
|
113
|
+
expect(groups.size).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("+page.tsx and +page.server.ts in the same directory form one group", () => {
|
|
117
|
+
const tree = {
|
|
118
|
+
routes: [
|
|
119
|
+
makeRouteFile({
|
|
120
|
+
filePath: "/app/routes/blog/[slug]/+page.tsx",
|
|
121
|
+
type: "page",
|
|
122
|
+
paramNames: ["slug"],
|
|
123
|
+
}),
|
|
124
|
+
makeRouteFile({
|
|
125
|
+
filePath: "/app/routes/blog/[slug]/+page.server.ts",
|
|
126
|
+
type: "pageServer",
|
|
127
|
+
paramNames: ["slug"],
|
|
128
|
+
}),
|
|
129
|
+
],
|
|
130
|
+
layouts: [],
|
|
131
|
+
servers: [],
|
|
132
|
+
errors: [],
|
|
133
|
+
};
|
|
134
|
+
const groups = groupByDirectory(tree);
|
|
135
|
+
expect(groups.size).toBe(1);
|
|
136
|
+
const group = groups.get("/app/routes/blog/[slug]")!;
|
|
137
|
+
expect(group).toBeDefined();
|
|
138
|
+
expect(group.page?.filePath).toBe("/app/routes/blog/[slug]/+page.tsx");
|
|
139
|
+
expect(group.pageServer?.filePath).toBe(
|
|
140
|
+
"/app/routes/blog/[slug]/+page.server.ts",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("+layout files go into their own group separate from +page files", () => {
|
|
145
|
+
const tree = {
|
|
146
|
+
routes: [
|
|
147
|
+
makeRouteFile({ filePath: "/app/routes/+page.tsx", type: "page" }),
|
|
148
|
+
],
|
|
149
|
+
layouts: [
|
|
150
|
+
makeRouteFile({ filePath: "/app/routes/+layout.tsx", type: "layout" }),
|
|
151
|
+
],
|
|
152
|
+
servers: [],
|
|
153
|
+
errors: [],
|
|
154
|
+
};
|
|
155
|
+
const groups = groupByDirectory(tree);
|
|
156
|
+
expect(groups.size).toBe(1);
|
|
157
|
+
const group = groups.get("/app/routes")!;
|
|
158
|
+
expect(group.page).toBeDefined();
|
|
159
|
+
expect(group.layout).toBeDefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("+server.ts creates a group with server field set", () => {
|
|
163
|
+
const tree = {
|
|
164
|
+
routes: [],
|
|
165
|
+
layouts: [],
|
|
166
|
+
servers: [
|
|
167
|
+
makeRouteFile({
|
|
168
|
+
filePath: "/app/routes/api/hello/+server.ts",
|
|
169
|
+
type: "server",
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
errors: [],
|
|
173
|
+
};
|
|
174
|
+
const groups = groupByDirectory(tree);
|
|
175
|
+
expect(groups.size).toBe(1);
|
|
176
|
+
expect(groups.get("/app/routes/api/hello")!.server).toBeDefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("+error.tsx creates a group with error field set", () => {
|
|
180
|
+
const tree = {
|
|
181
|
+
routes: [],
|
|
182
|
+
layouts: [],
|
|
183
|
+
servers: [],
|
|
184
|
+
errors: [
|
|
185
|
+
makeRouteFile({
|
|
186
|
+
filePath: "/app/routes/+error.tsx",
|
|
187
|
+
type: "error",
|
|
188
|
+
}),
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
const groups = groupByDirectory(tree);
|
|
192
|
+
expect(groups.size).toBe(1);
|
|
193
|
+
expect(groups.get("/app/routes")!.error).toBeDefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("paramNames taken from the file with most params", () => {
|
|
197
|
+
const tree = {
|
|
198
|
+
routes: [
|
|
199
|
+
makeRouteFile({
|
|
200
|
+
filePath: "/app/routes/[org]/[repo]/+page.tsx",
|
|
201
|
+
type: "page",
|
|
202
|
+
paramNames: ["org", "repo"],
|
|
203
|
+
}),
|
|
204
|
+
makeRouteFile({
|
|
205
|
+
filePath: "/app/routes/[org]/[repo]/+page.server.ts",
|
|
206
|
+
type: "pageServer",
|
|
207
|
+
paramNames: ["org", "repo"],
|
|
208
|
+
}),
|
|
209
|
+
],
|
|
210
|
+
layouts: [],
|
|
211
|
+
servers: [],
|
|
212
|
+
errors: [],
|
|
213
|
+
};
|
|
214
|
+
const groups = groupByDirectory(tree);
|
|
215
|
+
const group = groups.values().next().value!;
|
|
216
|
+
expect(group.paramNames).toEqual(["org", "repo"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("simple files mixed with +page files: only +page files contribute to groups", () => {
|
|
220
|
+
const tree = {
|
|
221
|
+
routes: [
|
|
222
|
+
makeRouteFile({ filePath: "/app/routes/about.tsx", type: "simple" }),
|
|
223
|
+
makeRouteFile({
|
|
224
|
+
filePath: "/app/routes/blog/+page.tsx",
|
|
225
|
+
type: "page",
|
|
226
|
+
}),
|
|
227
|
+
],
|
|
228
|
+
layouts: [],
|
|
229
|
+
servers: [],
|
|
230
|
+
errors: [],
|
|
231
|
+
};
|
|
232
|
+
const groups = groupByDirectory(tree);
|
|
233
|
+
expect(groups.size).toBe(1);
|
|
234
|
+
expect(groups.has("/app/routes/blog")).toBe(true);
|
|
235
|
+
expect(groups.has("/app/routes")).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// generateTypes — integration (file system)
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
let tmpDir: string;
|
|
244
|
+
|
|
245
|
+
beforeAll(async () => {
|
|
246
|
+
tmpDir = join(tmpdir(), `grimoire-typegen-${Date.now()}`);
|
|
247
|
+
await mkdir(join(tmpDir, "src", "routes", "blog", "[slug]"), {
|
|
248
|
+
recursive: true,
|
|
249
|
+
});
|
|
250
|
+
await mkdir(join(tmpDir, "src", "routes", "api", "posts"), {
|
|
251
|
+
recursive: true,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Layout at root
|
|
255
|
+
await writeFile(
|
|
256
|
+
join(tmpDir, "src", "routes", "+layout.tsx"),
|
|
257
|
+
"export default () => null;",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// Page-only route (no server)
|
|
261
|
+
await writeFile(
|
|
262
|
+
join(tmpDir, "src", "routes", "blog", "+page.tsx"),
|
|
263
|
+
"export default () => null;",
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Route with +page.server.ts that has a load function
|
|
267
|
+
await writeFile(
|
|
268
|
+
join(tmpDir, "src", "routes", "blog", "[slug]", "+page.tsx"),
|
|
269
|
+
"export default () => null;",
|
|
270
|
+
);
|
|
271
|
+
await writeFile(
|
|
272
|
+
join(tmpDir, "src", "routes", "blog", "[slug]", "+page.server.ts"),
|
|
273
|
+
`export async function load({ params }: { params: { slug: string } }) {
|
|
274
|
+
return { post: { title: "Hello", views: 42 } };
|
|
275
|
+
}
|
|
276
|
+
export async function DELETE({ request }: { request: Request }) {
|
|
277
|
+
return {};
|
|
278
|
+
}`,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Route with +layout.server.ts
|
|
282
|
+
await writeFile(
|
|
283
|
+
join(tmpDir, "src", "routes", "blog", "+layout.server.ts"),
|
|
284
|
+
`export async function load() { return { nav: ["Home", "Blog"] }; }`,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// API route (+server.ts)
|
|
288
|
+
await writeFile(
|
|
289
|
+
join(tmpDir, "src", "routes", "api", "posts", "+server.ts"),
|
|
290
|
+
`export async function GET() { return Response.json([]); }
|
|
291
|
+
export async function POST() { return Response.json({}); }`,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Error page
|
|
295
|
+
await writeFile(
|
|
296
|
+
join(tmpDir, "src", "routes", "+error.tsx"),
|
|
297
|
+
"export default () => null;",
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Simple file (should be ignored)
|
|
301
|
+
await writeFile(
|
|
302
|
+
join(tmpDir, "src", "routes", "about.tsx"),
|
|
303
|
+
"export default () => null;",
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
afterAll(async () => {
|
|
308
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
async function runGenerate() {
|
|
312
|
+
const routesDir = join(tmpDir, "src", "routes");
|
|
313
|
+
const tree = await scanRoutes(routesDir, tmpDir);
|
|
314
|
+
await generateTypes(tree, {
|
|
315
|
+
projectRoot: tmpDir,
|
|
316
|
+
routesDir,
|
|
317
|
+
outDir: join(tmpDir, ".grimoire", "types"),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function readGenerated(routeRelPath: string) {
|
|
322
|
+
const p = join(tmpDir, ".grimoire", "types", routeRelPath, "$types.d.ts");
|
|
323
|
+
return readFile(p, "utf-8");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
describe("generateTypes — output structure", () => {
|
|
327
|
+
test("creates ambient.d.ts with App.Locals namespace", async () => {
|
|
328
|
+
await runGenerate();
|
|
329
|
+
const ambient = await readFile(
|
|
330
|
+
join(tmpDir, ".grimoire", "types", "ambient.d.ts"),
|
|
331
|
+
"utf-8",
|
|
332
|
+
);
|
|
333
|
+
expect(ambient).toContain("declare namespace App");
|
|
334
|
+
expect(ambient).toContain("interface Locals");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("creates tsconfig.generated.json with rootDirs", async () => {
|
|
338
|
+
const config = JSON.parse(
|
|
339
|
+
await readFile(
|
|
340
|
+
join(tmpDir, ".grimoire", "tsconfig.generated.json"),
|
|
341
|
+
"utf-8",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
expect(config.compilerOptions.rootDirs).toContain("..");
|
|
345
|
+
expect(config.compilerOptions.rootDirs).toContain("./types");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("generates $types.d.ts for each route directory with + files", async () => {
|
|
349
|
+
expect(
|
|
350
|
+
existsSync(
|
|
351
|
+
join(tmpDir, ".grimoire", "types", "src", "routes", "$types.d.ts"),
|
|
352
|
+
),
|
|
353
|
+
).toBe(true);
|
|
354
|
+
expect(
|
|
355
|
+
existsSync(
|
|
356
|
+
join(
|
|
357
|
+
tmpDir,
|
|
358
|
+
".grimoire",
|
|
359
|
+
"types",
|
|
360
|
+
"src",
|
|
361
|
+
"routes",
|
|
362
|
+
"blog",
|
|
363
|
+
"$types.d.ts",
|
|
364
|
+
),
|
|
365
|
+
),
|
|
366
|
+
).toBe(true);
|
|
367
|
+
expect(
|
|
368
|
+
existsSync(
|
|
369
|
+
join(
|
|
370
|
+
tmpDir,
|
|
371
|
+
".grimoire",
|
|
372
|
+
"types",
|
|
373
|
+
"src",
|
|
374
|
+
"routes",
|
|
375
|
+
"blog",
|
|
376
|
+
"[slug]",
|
|
377
|
+
"$types.d.ts",
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("does NOT generate $types.d.ts for simple (non-+ convention) files", async () => {
|
|
384
|
+
// about.tsx is a simple file in src/routes/ — there's no $types for it
|
|
385
|
+
// (src/routes/ does get $types from +layout.tsx, but it's not created for about.tsx alone)
|
|
386
|
+
// Confirm that about/ directory itself has no $types
|
|
387
|
+
expect(
|
|
388
|
+
existsSync(
|
|
389
|
+
join(
|
|
390
|
+
tmpDir,
|
|
391
|
+
".grimoire",
|
|
392
|
+
"types",
|
|
393
|
+
"src",
|
|
394
|
+
"routes",
|
|
395
|
+
"about",
|
|
396
|
+
"$types.d.ts",
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("generateTypes — route without +page.server.ts", () => {
|
|
404
|
+
test("PageData = Record<string, never>", async () => {
|
|
405
|
+
const content = await readGenerated("src/routes/blog");
|
|
406
|
+
expect(content).toContain("export type PageData = Record<string, never>;");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("PageServerLoad = never", async () => {
|
|
410
|
+
const content = await readGenerated("src/routes/blog");
|
|
411
|
+
expect(content).toContain("export type PageServerLoad = never;");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("Actions = Record<string, never>", async () => {
|
|
415
|
+
const content = await readGenerated("src/routes/blog");
|
|
416
|
+
expect(content).toContain("export type Actions = Record<string, never>;");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("PageProps contains params", async () => {
|
|
420
|
+
const content = await readGenerated("src/routes/blog");
|
|
421
|
+
expect(content).toContain("export type PageProps = { params: Params };");
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("generateTypes — route with +page.server.ts", () => {
|
|
426
|
+
test("uses typeof import() for the server module", async () => {
|
|
427
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
428
|
+
expect(content).toMatch(/type _PS = typeof import\("/);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("import path ends with .js", async () => {
|
|
432
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
433
|
+
const match = content.match(/typeof import\("([^"]+)"\)/);
|
|
434
|
+
expect(match?.[1]).toMatch(/\.js$/);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("import path contains only forward slashes", async () => {
|
|
438
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
439
|
+
const match = content.match(/typeof import\("([^"]+)"\)/);
|
|
440
|
+
expect(match?.[1]).not.toContain("\\");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("PageData inferred from load() via ReturnType", async () => {
|
|
444
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
445
|
+
expect(content).toContain(
|
|
446
|
+
'export type PageData = "load" extends keyof _PS',
|
|
447
|
+
);
|
|
448
|
+
expect(content).toContain('Awaited<ReturnType<_PS["load"]>>');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("PageProps = PageData & { params: Params }", async () => {
|
|
452
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
453
|
+
expect(content).toContain(
|
|
454
|
+
"export type PageProps = PageData & { params: Params };",
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("Actions excludes load and default from keys", async () => {
|
|
459
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
460
|
+
expect(content).toContain(
|
|
461
|
+
'type _ActionKeys = Exclude<keyof _PS, "load" | "default">;',
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("Params has slug typed as string", async () => {
|
|
466
|
+
const content = await readGenerated("src/routes/blog/[slug]");
|
|
467
|
+
expect(content).toContain("slug: string;");
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("generateTypes — +layout.server.ts", () => {
|
|
472
|
+
test("LayoutData inferred from load() via ReturnType", async () => {
|
|
473
|
+
const content = await readGenerated("src/routes/blog");
|
|
474
|
+
expect(content).toMatch(/type _LS = typeof import\("/);
|
|
475
|
+
expect(content).toContain(
|
|
476
|
+
'export type LayoutData = "load" extends keyof _LS',
|
|
477
|
+
);
|
|
478
|
+
expect(content).toContain('Awaited<ReturnType<_LS["load"]>>');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("LayoutProps includes children", async () => {
|
|
482
|
+
const content = await readGenerated("src/routes/blog");
|
|
483
|
+
expect(content).toContain("children?: unknown");
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("generateTypes — +server.ts (API route)", () => {
|
|
488
|
+
test("generates ServerHandlers type", async () => {
|
|
489
|
+
const content = await readGenerated("src/routes/api/posts");
|
|
490
|
+
expect(content).toContain("export type ServerHandlers");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("uses typeof import() for server module", async () => {
|
|
494
|
+
const content = await readGenerated("src/routes/api/posts");
|
|
495
|
+
expect(content).toMatch(/type _SRV = typeof import\("/);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("extracts HTTP method keys", async () => {
|
|
499
|
+
const content = await readGenerated("src/routes/api/posts");
|
|
500
|
+
expect(content).toContain("_HttpMethods");
|
|
501
|
+
expect(content).toContain("_ServerKeys");
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("generateTypes — +error.tsx", () => {
|
|
506
|
+
test("generates ErrorProps type", async () => {
|
|
507
|
+
const content = await readGenerated("src/routes");
|
|
508
|
+
expect(content).toContain(
|
|
509
|
+
"export type ErrorProps = { status: number; message: string };",
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe("generateTypes — clears stale output", () => {
|
|
515
|
+
test("removes files from a deleted route on re-run", async () => {
|
|
516
|
+
// First run already happened in beforeAll via runGenerate()
|
|
517
|
+
// Write a stale file manually
|
|
518
|
+
const staleDir = join(
|
|
519
|
+
tmpDir,
|
|
520
|
+
".grimoire",
|
|
521
|
+
"types",
|
|
522
|
+
"src",
|
|
523
|
+
"routes",
|
|
524
|
+
"old-route",
|
|
525
|
+
);
|
|
526
|
+
await mkdir(staleDir, { recursive: true });
|
|
527
|
+
await writeFile(join(staleDir, "$types.d.ts"), "// stale");
|
|
528
|
+
|
|
529
|
+
// Re-run
|
|
530
|
+
await runGenerate();
|
|
531
|
+
|
|
532
|
+
// Stale file should be gone
|
|
533
|
+
expect(existsSync(join(staleDir, "$types.d.ts"))).toBe(false);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// tsc integration — proves the generated types actually type-check
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
describe("tsc integration", () => {
|
|
542
|
+
let tscDir: string;
|
|
543
|
+
|
|
544
|
+
beforeAll(async () => {
|
|
545
|
+
tscDir = join(tmpdir(), `grimoire-tsc-${Date.now()}`);
|
|
546
|
+
const routeDir = join(tscDir, "src", "routes", "blog", "[slug]");
|
|
547
|
+
await mkdir(routeDir, { recursive: true });
|
|
548
|
+
|
|
549
|
+
// Minimal +page.server.ts whose return type flows through to PageProps
|
|
550
|
+
await writeFile(
|
|
551
|
+
join(routeDir, "+page.server.ts"),
|
|
552
|
+
`export async function load({ params }: { params: { slug: string } }) {
|
|
553
|
+
return { post: { title: "Hello", views: 42 } };
|
|
554
|
+
}`,
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
// File that uses the generated types — must live in the route dir for ./$types to resolve
|
|
558
|
+
await writeFile(
|
|
559
|
+
join(routeDir, "check.ts"),
|
|
560
|
+
`import type { PageProps } from "./$types";
|
|
561
|
+
declare const props: PageProps;
|
|
562
|
+
const _title: string = props.post.title;
|
|
563
|
+
const _views: number = props.post.views;
|
|
564
|
+
const _slug: string = props.params.slug;
|
|
565
|
+
export {};`,
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// tsconfig for this mini-project
|
|
569
|
+
await writeFile(
|
|
570
|
+
join(tscDir, "tsconfig.json"),
|
|
571
|
+
JSON.stringify(
|
|
572
|
+
{
|
|
573
|
+
extends: "./.grimoire/tsconfig.generated.json",
|
|
574
|
+
compilerOptions: {
|
|
575
|
+
target: "ESNext",
|
|
576
|
+
module: "ESNext",
|
|
577
|
+
moduleResolution: "bundler",
|
|
578
|
+
strict: true,
|
|
579
|
+
skipLibCheck: true,
|
|
580
|
+
noEmit: true,
|
|
581
|
+
},
|
|
582
|
+
include: ["src", ".grimoire/types"],
|
|
583
|
+
},
|
|
584
|
+
null,
|
|
585
|
+
2,
|
|
586
|
+
),
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// Generate types
|
|
590
|
+
const routesDir = join(tscDir, "src", "routes");
|
|
591
|
+
const tree = await scanRoutes(routesDir, tscDir);
|
|
592
|
+
await generateTypes(tree, {
|
|
593
|
+
projectRoot: tscDir,
|
|
594
|
+
routesDir,
|
|
595
|
+
outDir: join(tscDir, ".grimoire", "types"),
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
afterAll(async () => {
|
|
600
|
+
await rm(tscDir, { recursive: true, force: true });
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
function runTsc(cwd: string) {
|
|
604
|
+
return Bun.spawnSync(
|
|
605
|
+
[process.execPath, "x", "tsc", "--noEmit", "--project", "tsconfig.json"],
|
|
606
|
+
{ cwd },
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
test("check.ts with correct types passes tsc", () => {
|
|
611
|
+
const result = runTsc(tscDir);
|
|
612
|
+
// Ignore bun package-download messages; only care about exit code
|
|
613
|
+
expect(result.exitCode).toBe(0);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("accessing a non-existent property fails tsc", async () => {
|
|
617
|
+
// Add a file with a wrong property access
|
|
618
|
+
const routeDir = join(tscDir, "src", "routes", "blog", "[slug]");
|
|
619
|
+
await writeFile(
|
|
620
|
+
join(routeDir, "bad.ts"),
|
|
621
|
+
`import type { PageProps } from "./$types";
|
|
622
|
+
declare const props: PageProps;
|
|
623
|
+
const _bad = props.post.bogus;
|
|
624
|
+
export {};`,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const result = runTsc(tscDir);
|
|
628
|
+
expect(result.exitCode).not.toBe(0);
|
|
629
|
+
|
|
630
|
+
// Clean up so subsequent test runs are clean
|
|
631
|
+
const { unlink } = await import("node:fs/promises");
|
|
632
|
+
await unlink(join(routeDir, "bad.ts"));
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("params properties are typed as string", async () => {
|
|
636
|
+
// Assigning params.slug to a number should fail
|
|
637
|
+
const routeDir = join(tscDir, "src", "routes", "blog", "[slug]");
|
|
638
|
+
await writeFile(
|
|
639
|
+
join(routeDir, "wrong-param-type.ts"),
|
|
640
|
+
`import type { PageProps } from "./$types";
|
|
641
|
+
declare const props: PageProps;
|
|
642
|
+
const _n: number = props.params.slug;
|
|
643
|
+
export {};`,
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
const result = runTsc(tscDir);
|
|
647
|
+
expect(result.exitCode).not.toBe(0);
|
|
648
|
+
|
|
649
|
+
const { unlink } = await import("node:fs/promises");
|
|
650
|
+
await unlink(join(routeDir, "wrong-param-type.ts"));
|
|
651
|
+
});
|
|
652
|
+
});
|