@sigil-dev/grimoire 0.7.6 → 0.7.7

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 (41) hide show
  1. package/index.ts +35 -34
  2. package/package.json +8 -6
  3. package/preload.js +3 -2
  4. package/server.ts +13 -13
  5. package/src/client/head.ts +29 -29
  6. package/src/client/router.ts +290 -224
  7. package/src/dev/compile-module.ts +173 -0
  8. package/src/dev/effect-registry.ts +23 -0
  9. package/src/dev/graph.ts +114 -0
  10. package/src/dev/hmr-client.ts +158 -0
  11. package/src/dev/hmr-server.ts +187 -0
  12. package/src/dev/loader.ts +47 -0
  13. package/src/dev/paths.ts +14 -0
  14. package/src/dev/runtime-bundle.ts +49 -0
  15. package/src/dev/watcher.ts +44 -0
  16. package/src/integrations/vite.ts +73 -72
  17. package/src/rendering/hydrate.ts +120 -81
  18. package/src/rendering/index.ts +296 -199
  19. package/src/rendering/ssrPlugin.ts +67 -53
  20. package/src/routing/manifest-gen.ts +42 -39
  21. package/src/routing/router.ts +109 -106
  22. package/src/routing/scanner.ts +141 -135
  23. package/src/routing/transform-routes.ts +101 -101
  24. package/src/server/build.ts +239 -147
  25. package/src/server/coordinator.ts +306 -306
  26. package/src/server/index.ts +260 -50
  27. package/src/server/worker.ts +59 -59
  28. package/src/typegen/index.ts +356 -353
  29. package/src/types.ts +270 -269
  30. package/test/context.test.ts +52 -52
  31. package/test/hydration.test.ts +119 -119
  32. package/test/middleware.test.ts +223 -223
  33. package/test/rendering.test.ts +579 -425
  34. package/test/routing.test.ts +81 -83
  35. package/test/scanning.test.ts +200 -181
  36. package/test/scope.test.ts +24 -8
  37. package/test/server.test.ts +249 -229
  38. package/test/streaming.test.ts +125 -106
  39. package/test/transform-routes.test.ts +84 -84
  40. package/test/typegen.test.ts +35 -25
  41. package/tsconfig.json +1 -0
@@ -1,353 +1,356 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { mkdir } from "node:fs/promises";
3
- import { dirname, isAbsolute, join, relative } from "node:path";
4
- import type { RouteFile, RouteTree } from "../routing/scanner.ts";
5
-
6
- export interface TypegenConfig {
7
- projectRoot: string;
8
- routesDir: string;
9
- outDir: string; // absolute path to .grimoire/types
10
- }
11
-
12
- export interface RouteGroup {
13
- dir: string;
14
- paramNames: string[];
15
- restParamNames: string[];
16
- page?: RouteFile;
17
- pageServer?: RouteFile;
18
- layout?: RouteFile;
19
- layoutServer?: RouteFile;
20
- server?: RouteFile;
21
- error?: RouteFile;
22
- }
23
-
24
- // On Windows, relative() returns the absolute `to` path when src and dest are on
25
- // different drives. Detect that and fall back to routes-dir-relative so join()
26
- // doesn't produce an invalid double-rooted path like "D:\out\C:\Users\...".
27
- function safeRelativeDir(
28
- projectRoot: string,
29
- routesDir: string,
30
- groupDir: string,
31
- ): string {
32
- const rel = relative(projectRoot, groupDir);
33
- return isAbsolute(rel) ? relative(routesDir, groupDir) : rel;
34
- }
35
-
36
- export function groupByDirectory(tree: RouteTree): Map<string, RouteGroup> {
37
- const groups = new Map<string, RouteGroup>();
38
-
39
- // Only process + convention files — simple files don't get $types
40
- const files: RouteFile[] = [
41
- ...tree.routes.filter((f) => f.type !== "simple"),
42
- ...tree.layouts,
43
- ...tree.servers,
44
- ...tree.errors,
45
- ];
46
-
47
- for (const file of files) {
48
- const dir = dirname(file.filePath);
49
- if (!groups.has(dir)) {
50
- groups.set(dir, {
51
- dir,
52
- paramNames: file.paramNames,
53
- restParamNames: file.restParamNames,
54
- });
55
- }
56
- const g = groups.get(dir)!;
57
- if (file.paramNames.length > g.paramNames.length) {
58
- g.paramNames = file.paramNames;
59
- }
60
- if (file.restParamNames.length > g.restParamNames.length) {
61
- g.restParamNames = file.restParamNames;
62
- }
63
- switch (file.type) {
64
- case "page":
65
- g.page = file;
66
- break;
67
- case "pageServer":
68
- g.pageServer = file;
69
- break;
70
- case "layout":
71
- g.layout = file;
72
- break;
73
- case "layoutServer":
74
- g.layoutServer = file;
75
- break;
76
- case "server":
77
- g.server = file;
78
- break;
79
- case "error":
80
- g.error = file;
81
- break;
82
- }
83
- }
84
-
85
- return groups;
86
- }
87
-
88
- export function toImportPath(from: string, to: string): string {
89
- return relative(from, to)
90
- .replace(/\\/g, "/")
91
- .replace(/\.tsx?$/, ".js");
92
- }
93
-
94
- export function buildParams(paramNames: string[], restParamNames: string[] = []): string {
95
- const all = [...paramNames, ...restParamNames];
96
- if (all.length === 0) return "{}";
97
- const props = all.map((p) => ` ${p}: string;`).join("\n");
98
- return `{\n${props}\n}`;
99
- }
100
-
101
- function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
102
- const lines: string[] = [
103
- "// Auto-generated by @sigil-dev/grimoire — do not edit",
104
- "",
105
- ];
106
-
107
- const paramsType = buildParams(group.paramNames, group.restParamNames);
108
-
109
- // --- Params ---
110
- lines.push(`export type Params = ${paramsType};`, "");
111
-
112
- // --- PageData from +page.server.ts ---
113
- if (group.pageServer) {
114
- const imp = toImportPath(outFileDir, group.pageServer.filePath);
115
- // Use `typeof import(...)` (not `import type * as NS`) so the module type
116
- // can be used in `keyof` and indexed access without TS2709.
117
- lines.push(
118
- `type _PS = typeof import("${imp}");`,
119
- `export type PageData = "load" extends keyof _PS`,
120
- ` ? Awaited<ReturnType<_PS["load"]>>`,
121
- ` : Record<string, never>;`,
122
- "",
123
- );
124
- lines.push(
125
- `export type PageProps = { data: PageData; params: Params };`,
126
- "",
127
- );
128
- // PageServerLoad annotate parameter, NOT return type, to preserve inference
129
- lines.push(
130
- `/** Annotate the load() parameter only — not the return type — or PageData loses its concrete keys. */`,
131
- `export type PageServerLoad = (ctx: {`,
132
- ` params: Params;`,
133
- ` request: Request;`,
134
- ` url: URL;`,
135
- ` locals: App.Locals;`,
136
- `}) => Record<string, unknown> | Promise<Record<string, unknown>>;`,
137
- "",
138
- );
139
- // Actions all exports except load and default
140
- lines.push(
141
- `type _ActionKeys = Exclude<keyof _PS, "load" | "default">;`,
142
- `export type Actions = {`,
143
- ` [K in _ActionKeys]: (ctx: {`,
144
- ` params: Params;`,
145
- ` request: Request;`,
146
- ` url: URL;`,
147
- ` locals: App.Locals;`,
148
- ` }) => unknown | Promise<unknown>;`,
149
- `};`,
150
- "",
151
- );
152
- } else {
153
- lines.push(
154
- `export type PageData = Record<string, never>;`,
155
- `export type PageProps = { data: PageData; params: Params };`,
156
- `export type PageServerLoad = never;`,
157
- `export type Actions = Record<string, never>;`,
158
- "",
159
- );
160
- }
161
-
162
- // --- LayoutData from +layout.server.ts ---
163
- if (group.layoutServer) {
164
- const imp = toImportPath(outFileDir, group.layoutServer.filePath);
165
- lines.push(
166
- `type _LS = typeof import("${imp}");`,
167
- `export type LayoutData = "load" extends keyof _LS`,
168
- ` ? Awaited<ReturnType<_LS["load"]>>`,
169
- ` : Record<string, never>;`,
170
- "",
171
- );
172
- } else {
173
- lines.push(`export type LayoutData = Record<string, never>;`);
174
- }
175
-
176
- lines.push(
177
- `export type LayoutProps = { data: LayoutData; params: Params; children?: unknown };`,
178
- `export type LayoutServerLoad = (ctx: {`,
179
- ` params: Params;`,
180
- ` request: Request;`,
181
- ` url: URL;`,
182
- ` locals: App.Locals;`,
183
- `}) => Record<string, unknown> | Promise<Record<string, unknown>>;`,
184
- "",
185
- );
186
-
187
- // --- +server.ts API route handlers ---
188
- if (group.server) {
189
- const imp = toImportPath(outFileDir, group.server.filePath);
190
- lines.push(
191
- `type _SRV = typeof import("${imp}");`,
192
- `type _HttpMethods = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";`,
193
- `type _ServerKeys = Extract<keyof _SRV, _HttpMethods>;`,
194
- `export type ServerHandlers = {`,
195
- ` [K in _ServerKeys]: (ctx: {`,
196
- ` params: Params;`,
197
- ` request: Request;`,
198
- ` url: URL;`,
199
- ` locals: App.Locals;`,
200
- ` }) => Response | Promise<Response>;`,
201
- `};`,
202
- ``,
203
- `export type UpgradeContext = {`,
204
- ` request: Request;`,
205
- ` params: Params;`,
206
- ` url: URL;`,
207
- ` locals: App.Locals;`,
208
- `};`,
209
- ``,
210
- `type _UpgradeReturn = "upgrade" extends keyof _SRV`,
211
- ` ? _SRV["upgrade"] extends (...args: any[]) => any`,
212
- ` ? Awaited<ReturnType<_SRV["upgrade"]>>`,
213
- ` : {}`,
214
- ` : {};`,
215
- `export type WsData = { params: Params } & Omit<_UpgradeReturn, "__handler">;`,
216
- ``,
217
- `export type WebSocketHandler = {`,
218
- ` open?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
219
- ` message?(ws: ServerWebSocket<WsData>, data: string | Buffer): void | Promise<void>;`,
220
- ` close?(ws: ServerWebSocket<WsData>, code: number, reason?: string): void | Promise<void>;`,
221
- ` drain?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
222
- `};`,
223
- "",
224
- );
225
- }
226
-
227
- // --- +error.tsx ---
228
- if (group.error) {
229
- lines.push(
230
- `export type ErrorProps = { status: number; message: string };`,
231
- "",
232
- );
233
- }
234
-
235
- return lines.join("\n");
236
- }
237
-
238
- function generateAmbient(): string {
239
- return [
240
- "// Auto-generated by @sigil-dev/grimoire — do not edit",
241
- "",
242
- "declare namespace App {",
243
- " // Extend this interface in your app's src/app.d.ts to add typed locals",
244
- " interface Locals extends Record<string, unknown> {}",
245
- "}",
246
- "",
247
- "// Available in all route files without import.",
248
- "// Params default to Record<string,string>; for per-route typed params import from './$types'.",
249
- "// Note: using these globals widens return types — PageData loses concrete keys.",
250
- "// For precise data inference annotate load() with PageServerLoad from './$types'.",
251
- "",
252
- "type PageServerLoad<",
253
- " P extends Record<string, string> = Record<string, string>",
254
- "> = (ctx: {",
255
- " params: P;",
256
- " request: Request;",
257
- " url: URL;",
258
- " locals: App.Locals;",
259
- "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
260
- "",
261
- "type LayoutServerLoad<",
262
- " P extends Record<string, string> = Record<string, string>",
263
- "> = (ctx: {",
264
- " params: P;",
265
- " request: Request;",
266
- " url: URL;",
267
- " locals: App.Locals;",
268
- "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
269
- "",
270
- "type RequestHandler<",
271
- " P extends Record<string, string> = Record<string, string>",
272
- "> = (ctx: {",
273
- " params: P;",
274
- " request: Request;",
275
- " url: URL;",
276
- " locals: App.Locals;",
277
- "}) => Response | Promise<Response>;",
278
- "",
279
- "type PageProps<",
280
- " D extends Record<string, unknown> = Record<string, unknown>,",
281
- " P extends Record<string, string> = Record<string, string>",
282
- "> = { data: D; params: P };",
283
- "",
284
- "type LayoutProps<",
285
- " D extends Record<string, unknown> = Record<string, unknown>,",
286
- " P extends Record<string, string> = Record<string, string>",
287
- "> = { data: D; params: P; children?: unknown };",
288
- "",
289
- "type ErrorProps = { status: number; message: string };",
290
- "",
291
- "type UpgradeContext<",
292
- " P extends Record<string, string> = Record<string, string>",
293
- "> = {",
294
- " params: P;",
295
- " request: Request;",
296
- " url: URL;",
297
- " locals: App.Locals;",
298
- "};",
299
- "",
300
- ].join("\n");
301
- }
302
-
303
- function generateTsConfig(): string {
304
- return JSON.stringify(
305
- {
306
- compilerOptions: {
307
- // Merges ".." (project root) and "./types" (.grimoire/types) into one
308
- // virtual root so `import from './$types'` resolves without path aliases.
309
- rootDirs: ["..", "./types"],
310
- },
311
- include: ["types/**/*.d.ts"],
312
- },
313
- null,
314
- 2,
315
- );
316
- }
317
-
318
- export async function generateTypes(
319
- tree: RouteTree,
320
- config: TypegenConfig,
321
- ): Promise<void> {
322
- const { projectRoot, outDir } = config;
323
-
324
- // Skip rm — on Windows, rm+mkdir in quick succession can race and leave dirs
325
- // in an unusable state. Stale type files are harmless; we overwrite what we need.
326
- await mkdir(outDir, { recursive: true }).catch(() => {});
327
-
328
- const groups = groupByDirectory(tree);
329
-
330
- for (const group of groups.values()) {
331
- const routeRelDir = safeRelativeDir(
332
- projectRoot,
333
- config.routesDir,
334
- group.dir,
335
- );
336
- const outFileDir = join(outDir, routeRelDir);
337
- // Use mkdirSync — async mkdir on Windows/Bun can report success before
338
- // the directory is actually visible to subsequent fs calls
339
- mkdirSync(outFileDir, { recursive: true });
340
-
341
- const content = generateTypesForGroup(group, outFileDir);
342
- writeFileSync(join(outFileDir, "$types.d.ts"), content, "utf-8");
343
- }
344
-
345
- writeFileSync(join(outDir, "ambient.d.ts"), generateAmbient(), "utf-8");
346
-
347
- const grimoireDir = dirname(outDir);
348
- writeFileSync(
349
- join(grimoireDir, "tsconfig.generated.json"),
350
- generateTsConfig(),
351
- "utf-8",
352
- );
353
- }
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import type { RouteFile, RouteTree } from "../routing/scanner.ts";
5
+
6
+ export interface TypegenConfig {
7
+ projectRoot: string;
8
+ routesDir: string;
9
+ outDir: string; // absolute path to .grimoire/types
10
+ }
11
+
12
+ export interface RouteGroup {
13
+ dir: string;
14
+ paramNames: string[];
15
+ restParamNames: string[];
16
+ page?: RouteFile;
17
+ pageServer?: RouteFile;
18
+ layout?: RouteFile;
19
+ layoutServer?: RouteFile;
20
+ server?: RouteFile;
21
+ error?: RouteFile;
22
+ }
23
+
24
+ // On Windows, relative() returns the absolute `to` path when src and dest are on
25
+ // different drives. Detect that and fall back to routes-dir-relative so join()
26
+ // doesn't produce an invalid double-rooted path like "D:\out\C:\Users\...".
27
+ function safeRelativeDir(
28
+ projectRoot: string,
29
+ routesDir: string,
30
+ groupDir: string,
31
+ ): string {
32
+ const rel = relative(projectRoot, groupDir);
33
+ return isAbsolute(rel) ? relative(routesDir, groupDir) : rel;
34
+ }
35
+
36
+ export function groupByDirectory(tree: RouteTree): Map<string, RouteGroup> {
37
+ const groups = new Map<string, RouteGroup>();
38
+
39
+ // Only process + convention files — simple files don't get $types
40
+ const files: RouteFile[] = [
41
+ ...tree.routes.filter((f) => f.type !== "simple"),
42
+ ...tree.layouts,
43
+ ...tree.servers,
44
+ ...tree.errors,
45
+ ];
46
+
47
+ for (const file of files) {
48
+ const dir = dirname(file.filePath);
49
+ if (!groups.has(dir)) {
50
+ groups.set(dir, {
51
+ dir,
52
+ paramNames: file.paramNames,
53
+ restParamNames: file.restParamNames,
54
+ });
55
+ }
56
+ const g = groups.get(dir)!;
57
+ if (file.paramNames.length > g.paramNames.length) {
58
+ g.paramNames = file.paramNames;
59
+ }
60
+ if (file.restParamNames.length > g.restParamNames.length) {
61
+ g.restParamNames = file.restParamNames;
62
+ }
63
+ switch (file.type) {
64
+ case "page":
65
+ g.page = file;
66
+ break;
67
+ case "pageServer":
68
+ g.pageServer = file;
69
+ break;
70
+ case "layout":
71
+ g.layout = file;
72
+ break;
73
+ case "layoutServer":
74
+ g.layoutServer = file;
75
+ break;
76
+ case "server":
77
+ g.server = file;
78
+ break;
79
+ case "error":
80
+ g.error = file;
81
+ break;
82
+ }
83
+ }
84
+
85
+ return groups;
86
+ }
87
+
88
+ export function toImportPath(from: string, to: string): string {
89
+ return relative(from, to)
90
+ .replace(/\\/g, "/")
91
+ .replace(/\.tsx?$/, ".js");
92
+ }
93
+
94
+ export function buildParams(
95
+ paramNames: string[],
96
+ restParamNames: string[] = [],
97
+ ): string {
98
+ const all = [...paramNames, ...restParamNames];
99
+ if (all.length === 0) return "{}";
100
+ const props = all.map((p) => ` ${p}: string;`).join("\n");
101
+ return `{\n${props}\n}`;
102
+ }
103
+
104
+ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
105
+ const lines: string[] = [
106
+ "// Auto-generated by @sigil-dev/grimoire — do not edit",
107
+ "",
108
+ ];
109
+
110
+ const paramsType = buildParams(group.paramNames, group.restParamNames);
111
+
112
+ // --- Params ---
113
+ lines.push(`export type Params = ${paramsType};`, "");
114
+
115
+ // --- PageData from +page.server.ts ---
116
+ if (group.pageServer) {
117
+ const imp = toImportPath(outFileDir, group.pageServer.filePath);
118
+ // Use `typeof import(...)` (not `import type * as NS`) so the module type
119
+ // can be used in `keyof` and indexed access without TS2709.
120
+ lines.push(
121
+ `type _PS = typeof import("${imp}");`,
122
+ `export type PageData = "load" extends keyof _PS`,
123
+ ` ? Awaited<ReturnType<_PS["load"]>>`,
124
+ ` : Record<string, never>;`,
125
+ "",
126
+ );
127
+ lines.push(
128
+ `export type PageProps = { data: PageData; params: Params };`,
129
+ "",
130
+ );
131
+ // PageServerLoad — annotate parameter, NOT return type, to preserve inference
132
+ lines.push(
133
+ `/** Annotate the load() parameter only — not the return type — or PageData loses its concrete keys. */`,
134
+ `export type PageServerLoad = (ctx: {`,
135
+ ` params: Params;`,
136
+ ` request: Request;`,
137
+ ` url: URL;`,
138
+ ` locals: App.Locals;`,
139
+ `}) => Record<string, unknown> | Promise<Record<string, unknown>>;`,
140
+ "",
141
+ );
142
+ // Actions all exports except load and default
143
+ lines.push(
144
+ `type _ActionKeys = Exclude<keyof _PS, "load" | "default">;`,
145
+ `export type Actions = {`,
146
+ ` [K in _ActionKeys]: (ctx: {`,
147
+ ` params: Params;`,
148
+ ` request: Request;`,
149
+ ` url: URL;`,
150
+ ` locals: App.Locals;`,
151
+ ` }) => unknown | Promise<unknown>;`,
152
+ `};`,
153
+ "",
154
+ );
155
+ } else {
156
+ lines.push(
157
+ `export type PageData = Record<string, never>;`,
158
+ `export type PageProps = { data: PageData; params: Params };`,
159
+ `export type PageServerLoad = never;`,
160
+ `export type Actions = Record<string, never>;`,
161
+ "",
162
+ );
163
+ }
164
+
165
+ // --- LayoutData from +layout.server.ts ---
166
+ if (group.layoutServer) {
167
+ const imp = toImportPath(outFileDir, group.layoutServer.filePath);
168
+ lines.push(
169
+ `type _LS = typeof import("${imp}");`,
170
+ `export type LayoutData = "load" extends keyof _LS`,
171
+ ` ? Awaited<ReturnType<_LS["load"]>>`,
172
+ ` : Record<string, never>;`,
173
+ "",
174
+ );
175
+ } else {
176
+ lines.push(`export type LayoutData = Record<string, never>;`);
177
+ }
178
+
179
+ lines.push(
180
+ `export type LayoutProps = { data: LayoutData; params: Params; children?: unknown };`,
181
+ `export type LayoutServerLoad = (ctx: {`,
182
+ ` params: Params;`,
183
+ ` request: Request;`,
184
+ ` url: URL;`,
185
+ ` locals: App.Locals;`,
186
+ `}) => Record<string, unknown> | Promise<Record<string, unknown>>;`,
187
+ "",
188
+ );
189
+
190
+ // --- +server.ts API route handlers ---
191
+ if (group.server) {
192
+ const imp = toImportPath(outFileDir, group.server.filePath);
193
+ lines.push(
194
+ `type _SRV = typeof import("${imp}");`,
195
+ `type _HttpMethods = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";`,
196
+ `type _ServerKeys = Extract<keyof _SRV, _HttpMethods>;`,
197
+ `export type ServerHandlers = {`,
198
+ ` [K in _ServerKeys]: (ctx: {`,
199
+ ` params: Params;`,
200
+ ` request: Request;`,
201
+ ` url: URL;`,
202
+ ` locals: App.Locals;`,
203
+ ` }) => Response | Promise<Response>;`,
204
+ `};`,
205
+ ``,
206
+ `export type UpgradeContext = {`,
207
+ ` request: Request;`,
208
+ ` params: Params;`,
209
+ ` url: URL;`,
210
+ ` locals: App.Locals;`,
211
+ `};`,
212
+ ``,
213
+ `type _UpgradeReturn = "upgrade" extends keyof _SRV`,
214
+ ` ? _SRV["upgrade"] extends (...args: any[]) => any`,
215
+ ` ? Awaited<ReturnType<_SRV["upgrade"]>>`,
216
+ ` : {}`,
217
+ ` : {};`,
218
+ `export type WsData = { params: Params } & Omit<_UpgradeReturn, "__handler">;`,
219
+ ``,
220
+ `export type WebSocketHandler = {`,
221
+ ` open?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
222
+ ` message?(ws: ServerWebSocket<WsData>, data: string | Buffer): void | Promise<void>;`,
223
+ ` close?(ws: ServerWebSocket<WsData>, code: number, reason?: string): void | Promise<void>;`,
224
+ ` drain?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
225
+ `};`,
226
+ "",
227
+ );
228
+ }
229
+
230
+ // --- +error.tsx ---
231
+ if (group.error) {
232
+ lines.push(
233
+ `export type ErrorProps = { status: number; message: string; error: unknown; route: string };`,
234
+ "",
235
+ );
236
+ }
237
+
238
+ return lines.join("\n");
239
+ }
240
+
241
+ function generateAmbient(): string {
242
+ return [
243
+ "// Auto-generated by @sigil-dev/grimoire do not edit",
244
+ "",
245
+ "declare namespace App {",
246
+ " // Extend this interface in your app's src/app.d.ts to add typed locals",
247
+ " interface Locals extends Record<string, unknown> {}",
248
+ "}",
249
+ "",
250
+ "// Available in all route files without import.",
251
+ "// Params default to Record<string,string>; for per-route typed params import from './$types'.",
252
+ "// Note: using these globals widens return types — PageData loses concrete keys.",
253
+ "// For precise data inference annotate load() with PageServerLoad from './$types'.",
254
+ "",
255
+ "type PageServerLoad<",
256
+ " P extends Record<string, string> = Record<string, string>",
257
+ "> = (ctx: {",
258
+ " params: P;",
259
+ " request: Request;",
260
+ " url: URL;",
261
+ " locals: App.Locals;",
262
+ "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
263
+ "",
264
+ "type LayoutServerLoad<",
265
+ " P extends Record<string, string> = Record<string, string>",
266
+ "> = (ctx: {",
267
+ " params: P;",
268
+ " request: Request;",
269
+ " url: URL;",
270
+ " locals: App.Locals;",
271
+ "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
272
+ "",
273
+ "type RequestHandler<",
274
+ " P extends Record<string, string> = Record<string, string>",
275
+ "> = (ctx: {",
276
+ " params: P;",
277
+ " request: Request;",
278
+ " url: URL;",
279
+ " locals: App.Locals;",
280
+ "}) => Response | Promise<Response>;",
281
+ "",
282
+ "type PageProps<",
283
+ " D extends Record<string, unknown> = Record<string, unknown>,",
284
+ " P extends Record<string, string> = Record<string, string>",
285
+ "> = { data: D; params: P };",
286
+ "",
287
+ "type LayoutProps<",
288
+ " D extends Record<string, unknown> = Record<string, unknown>,",
289
+ " P extends Record<string, string> = Record<string, string>",
290
+ "> = { data: D; params: P; children?: unknown };",
291
+ "",
292
+ "type ErrorProps = { status: number; message: string; error: unknown; route: string };",
293
+ "",
294
+ "type UpgradeContext<",
295
+ " P extends Record<string, string> = Record<string, string>",
296
+ "> = {",
297
+ " params: P;",
298
+ " request: Request;",
299
+ " url: URL;",
300
+ " locals: App.Locals;",
301
+ "};",
302
+ "",
303
+ ].join("\n");
304
+ }
305
+
306
+ function generateTsConfig(): string {
307
+ return JSON.stringify(
308
+ {
309
+ compilerOptions: {
310
+ // Merges ".." (project root) and "./types" (.grimoire/types) into one
311
+ // virtual root so `import from './$types'` resolves without path aliases.
312
+ rootDirs: ["..", "./types"],
313
+ },
314
+ include: ["types/**/*.d.ts"],
315
+ },
316
+ null,
317
+ 2,
318
+ );
319
+ }
320
+
321
+ export async function generateTypes(
322
+ tree: RouteTree,
323
+ config: TypegenConfig,
324
+ ): Promise<void> {
325
+ const { projectRoot, outDir } = config;
326
+
327
+ // Skip rm — on Windows, rm+mkdir in quick succession can race and leave dirs
328
+ // in an unusable state. Stale type files are harmless; we overwrite what we need.
329
+ await mkdir(outDir, { recursive: true }).catch(() => {});
330
+
331
+ const groups = groupByDirectory(tree);
332
+
333
+ for (const group of groups.values()) {
334
+ const routeRelDir = safeRelativeDir(
335
+ projectRoot,
336
+ config.routesDir,
337
+ group.dir,
338
+ );
339
+ const outFileDir = join(outDir, routeRelDir);
340
+ // Use mkdirSync — async mkdir on Windows/Bun can report success before
341
+ // the directory is actually visible to subsequent fs calls
342
+ mkdirSync(outFileDir, { recursive: true });
343
+
344
+ const content = generateTypesForGroup(group, outFileDir);
345
+ writeFileSync(join(outFileDir, "$types.d.ts"), content, "utf-8");
346
+ }
347
+
348
+ writeFileSync(join(outDir, "ambient.d.ts"), generateAmbient(), "utf-8");
349
+
350
+ const grimoireDir = dirname(outDir);
351
+ writeFileSync(
352
+ join(grimoireDir, "tsconfig.generated.json"),
353
+ generateTsConfig(),
354
+ "utf-8",
355
+ );
356
+ }