@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.
Files changed (52) hide show
  1. package/.grimoire/_routes.dom.js +4 -0
  2. package/.grimoire/_routes.hydrate.js +4 -0
  3. package/.grimoire/_routes.ts +4 -0
  4. package/.grimoire/tsconfig.generated.json +11 -0
  5. package/.grimoire/types/ambient.d.ts +6 -0
  6. package/.grimoire/types/api/hello/$types.d.ts +29 -0
  7. package/README.md +1 -0
  8. package/index.ts +22 -0
  9. package/package.json +36 -0
  10. package/public/__grimoire__/client.js +86 -0
  11. package/public/__grimoire__/hydrate.js +101 -0
  12. package/src/client-router.ts +77 -0
  13. package/src/client.ts +4 -0
  14. package/src/context.ts +10 -0
  15. package/src/cookie-utils.ts +66 -0
  16. package/src/enhance.ts +97 -0
  17. package/src/error.ts +52 -0
  18. package/src/fail.ts +41 -0
  19. package/src/head.ts +27 -0
  20. package/src/headers.ts +114 -0
  21. package/src/hooks.ts +93 -0
  22. package/src/hydrate.ts +22 -0
  23. package/src/manifest-gen.ts +26 -0
  24. package/src/plugins.ts +25 -0
  25. package/src/redirect.ts +35 -0
  26. package/src/renderer.ts +142 -0
  27. package/src/router.ts +94 -0
  28. package/src/scanner.ts +97 -0
  29. package/src/scope.ts +22 -0
  30. package/src/server.ts +318 -0
  31. package/src/ssrPlugin.ts +26 -0
  32. package/src/sync.ts +18 -0
  33. package/src/transform-routes.ts +90 -0
  34. package/src/typegen.ts +263 -0
  35. package/src/types.ts +85 -0
  36. package/src/vite-plugin.ts +72 -0
  37. package/test/context.test.ts +52 -0
  38. package/test/fail.test.ts +46 -0
  39. package/test/headers.test.ts +96 -0
  40. package/test/hydration.test.ts +119 -0
  41. package/test/middleware.test.ts +217 -0
  42. package/test/preload.ts +5 -0
  43. package/test/redirect-error.test.ts +112 -0
  44. package/test/rendering.test.ts +172 -0
  45. package/test/routing.test.ts +45 -0
  46. package/test/scanning.test.ts +55 -0
  47. package/test/scope.test.ts +164 -0
  48. package/test/server.test.ts +30 -0
  49. package/test/streaming.test.ts +132 -0
  50. package/test/transform-routes.test.ts +84 -0
  51. package/test/typegen.test.ts +652 -0
  52. 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
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "Preserve",
4
+ "moduleResolution": "bundler",
5
+ "lib": ["ESNext", "DOM"]
6
+ }
7
+ }