@sigil-dev/grimoire 0.7.5 → 0.7.6

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 (50) hide show
  1. package/.grimoire/_routes.dom.js +8 -0
  2. package/.grimoire/_routes.hydrate.js +8 -0
  3. package/.grimoire/tsconfig.generated.json +11 -0
  4. package/.grimoire/types/ambient.d.ts +59 -0
  5. package/.grimoire/types/api/hello/$types.d.ts +50 -0
  6. package/.grimoire/types/api/items/$types.d.ts +50 -0
  7. package/.grimoire/types/echo/$types.d.ts +50 -0
  8. package/.grimoire/types/env-private.d.ts +5 -0
  9. package/.grimoire/types/env-public.d.ts +5 -0
  10. package/.grimoire/types/mixed/$types.d.ts +50 -0
  11. package/.grimoire/types/params/[docId]/$types.d.ts +52 -0
  12. package/.grimoire/types/reject/$types.d.ts +50 -0
  13. package/index.ts +34 -34
  14. package/package.json +8 -4
  15. package/preload.js +2 -0
  16. package/public/__grimoire__/hydrate.js +585 -0
  17. package/public/__grimoire__/index.js +490 -0
  18. package/src/client/head.ts +29 -0
  19. package/src/client/router.ts +224 -76
  20. package/src/env/index.ts +25 -0
  21. package/src/env/plugin.ts +13 -0
  22. package/src/env/private.ts +5 -0
  23. package/src/env/public.ts +7 -0
  24. package/src/env/typegen.ts +51 -0
  25. package/src/integrations/vite.ts +72 -72
  26. package/src/rendering/head.ts +22 -2
  27. package/src/rendering/hydrate.ts +81 -27
  28. package/src/rendering/index.ts +199 -186
  29. package/src/rendering/ssrPlugin.ts +53 -47
  30. package/src/routing/manifest-gen.ts +39 -26
  31. package/src/routing/router.ts +106 -98
  32. package/src/routing/scanner.ts +135 -129
  33. package/src/routing/transform-routes.ts +101 -101
  34. package/src/server/build.ts +147 -90
  35. package/src/server/coordinator.ts +306 -297
  36. package/src/server/hooks.ts +24 -3
  37. package/src/server/index.ts +144 -70
  38. package/src/server/worker.ts +59 -59
  39. package/src/typegen/index.ts +353 -340
  40. package/src/types.ts +269 -260
  41. package/test/context.test.ts +52 -52
  42. package/test/hydration.test.ts +119 -119
  43. package/test/middleware.test.ts +223 -221
  44. package/test/rendering.test.ts +425 -425
  45. package/test/routing.test.ts +83 -45
  46. package/test/scanning.test.ts +181 -169
  47. package/test/server.test.ts +229 -229
  48. package/test/streaming.test.ts +106 -106
  49. package/test/transform-routes.test.ts +84 -84
  50. package/test/typegen.test.ts +19 -1
@@ -1,425 +1,425 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdir, rm, writeFile } from "fs/promises";
3
- import { tmpdir as osTmpDir } from "os";
4
- import { join } from "path";
5
- import { renderRoute } from "../src/rendering";
6
- import { matchRoute } from "../src/routing/router";
7
- import { scanRoutes } from "../src/routing/scanner";
8
-
9
- /** Read a streaming Response body into a single string */
10
- async function readStream(res: Response): Promise<string> {
11
- const reader = res.body!.getReader();
12
- const parts: string[] = [];
13
- while (true) {
14
- const { done, value } = await reader.read();
15
- if (done) break;
16
- // Bun ReadableStream chunks can be string, Uint8Array, or other
17
- parts.push(String(value));
18
- }
19
- return parts.join("");
20
- }
21
-
22
- let tmpDir: string;
23
-
24
- beforeAll(async () => {
25
- tmpDir = join(osTmpDir(), `grimoire-render-${Date.now()}`);
26
- await mkdir(join(tmpDir, "blog"), { recursive: true });
27
-
28
- // simple static page
29
- await writeFile(
30
- join(tmpDir, "index.tsx"),
31
- `
32
- export default function Home() {
33
- return <h1>Hello Grimoire</h1>;
34
- }
35
- `,
36
- );
37
-
38
- // page with load function
39
- await writeFile(
40
- join(tmpDir, "blog", "+page.server.ts"),
41
- `
42
- export async function load({ params }) {
43
- return { title: 'My Blog' };
44
- }
45
- `,
46
- );
47
-
48
- await writeFile(
49
- join(tmpDir, "blog", "+page.tsx"),
50
- `
51
- export default function Blog({ data }) {
52
- return <h1>{data.title}</h1>;
53
- }
54
- `,
55
- );
56
-
57
- // layout
58
- await writeFile(
59
- join(tmpDir, "+layout.tsx"),
60
- `
61
- export default function Layout({ children }) {
62
- return <div class="app" innerHTML={children}></div>;
63
- }
64
- `,
65
- );
66
-
67
- // root error page — renders status and message
68
- await writeFile(
69
- join(tmpDir, "+error.tsx"),
70
- `
71
- export default function ErrorPage({ status, message }) {
72
- return <div class="error"><span class="status">{status}</span><span class="msg">{message}</span></div>;
73
- }
74
- `,
75
- );
76
-
77
- // page whose load throws redirect — use inline object so no grimoire import needed from /tmp
78
- await mkdir(join(tmpDir, "redirect-page"), { recursive: true });
79
- await writeFile(
80
- join(tmpDir, "redirect-page", "+page.server.ts"),
81
- `export async function load() { throw { __redirect: true, status: 302, location: "/login" }; }`,
82
- );
83
- await writeFile(
84
- join(tmpDir, "redirect-page", "+page.tsx"),
85
- `export default function P() { return <div>never</div>; }`,
86
- );
87
-
88
- // page whose load throws error — same pattern
89
- await mkdir(join(tmpDir, "error-page"), { recursive: true });
90
- await writeFile(
91
- join(tmpDir, "error-page", "+page.server.ts"),
92
- `export async function load() { throw { __error: true, status: 404, message: "Not found" }; }`,
93
- );
94
- await writeFile(
95
- join(tmpDir, "error-page", "+page.tsx"),
96
- `export default function P() { return <div>never</div>; }`,
97
- );
98
-
99
- // page whose load returns locals.tag (tests locals passthrough)
100
- await mkdir(join(tmpDir, "locals-test"), { recursive: true });
101
- await writeFile(
102
- join(tmpDir, "locals-test", "+page.server.ts"),
103
- `
104
- export async function load({ locals }) { return { tag: locals.tag ?? "none" }; }
105
- `,
106
- );
107
- await writeFile(
108
- join(tmpDir, "locals-test", "+page.tsx"),
109
- `export default function P({ data }) { return <div class="tag">{data.tag}</div>; }`,
110
- );
111
-
112
- // layout.server test — nested layout with server data
113
- await mkdir(join(tmpDir, "dashboard"), { recursive: true });
114
- await writeFile(
115
- join(tmpDir, "dashboard", "+layout.server.ts"),
116
- `export async function load() { return { user: "admin" }; }`,
117
- );
118
- await writeFile(
119
- join(tmpDir, "dashboard", "+layout.tsx"),
120
- `export default function DashLayout({ data, children }) {
121
- return <div class="dash" data-user={data.user} innerHTML={children}></div>;
122
- }`,
123
- );
124
- await writeFile(
125
- join(tmpDir, "dashboard", "index.tsx"),
126
- `export default function Dash() { return <p>dash content</p>; }`,
127
- );
128
-
129
- await mkdir(join(tmpDir, "section", "nested"), { recursive: true });
130
- await writeFile(
131
- join(tmpDir, "section", "+layout.tsx"),
132
- `export default function SectionLayout({ children }) {
133
- return <div class="section" innerHTML={children}></div>;
134
- }`,
135
- );
136
- await writeFile(
137
- join(tmpDir, "section", "nested", "+page.tsx"),
138
- `export default function NestedPage() { return <p class="nested-content">nested</p>; }`,
139
- );
140
-
141
- // triple nesting — root + section + deep
142
- await mkdir(join(tmpDir, "section", "deep", "page"), { recursive: true });
143
- await writeFile(
144
- join(tmpDir, "section", "deep", "+layout.tsx"),
145
- `export default function DeepLayout({ children }) {
146
- return <div class="deep" innerHTML={children}></div>;
147
- }`,
148
- );
149
- await writeFile(
150
- join(tmpDir, "section", "deep", "page", "+page.tsx"),
151
- `export default function DeepPage() { return <p class="deep-content">deep</p>; }`,
152
- );
153
- });
154
-
155
- afterAll(async () => {
156
- await rm(tmpDir, { recursive: true });
157
- });
158
-
159
- describe("renderRoute", () => {
160
- test("nested layouts stack from root to innermost", async () => {
161
- const tree = await scanRoutes(tmpDir);
162
- const matched = matchRoute(tree, new URL("http://localhost/section/nested"));
163
- const res = await renderRoute(
164
- matched!,
165
- new Request("http://localhost/section/nested"),
166
- );
167
- const html = await readStream(res);
168
-
169
- // root layout wraps section layout wraps page
170
- expect(html).toContain('class="app"'); // root layout
171
- expect(html).toContain('class="section"'); // section layout
172
- expect(html).toContain('class="nested-content"'); // page
173
-
174
- // root must appear before section in the output
175
- const rootIdx = html.indexOf('class="app"');
176
- const sectionIdx = html.indexOf('class="section"');
177
- const pageIdx = html.indexOf('class="nested-content"');
178
- expect(rootIdx).toBeLessThan(sectionIdx);
179
- expect(sectionIdx).toBeLessThan(pageIdx);
180
- });
181
-
182
- test("triple nested layouts render in correct order", async () => {
183
- const tree = await scanRoutes(tmpDir);
184
- const matched = matchRoute(
185
- tree,
186
- new URL("http://localhost/section/deep/page"),
187
- );
188
- const res = await renderRoute(
189
- matched!,
190
- new Request("http://localhost/section/deep/page"),
191
- );
192
- const html = await readStream(res);
193
-
194
- expect(html).toContain('class="app"'); // root
195
- expect(html).toContain('class="section"'); // section
196
- expect(html).toContain('class="deep"'); // deep
197
- expect(html).toContain('class="deep-content"'); // page
198
-
199
- const rootIdx = html.indexOf('class="app"');
200
- const sectionIdx = html.indexOf('class="section"');
201
- const deepIdx = html.indexOf('class="deep"');
202
- const pageIdx = html.indexOf('class="deep-content"');
203
- expect(rootIdx).toBeLessThan(sectionIdx);
204
- expect(sectionIdx).toBeLessThan(deepIdx);
205
- expect(deepIdx).toBeLessThan(pageIdx);
206
- });
207
-
208
- test("page with no ancestor section layout only gets root layout", async () => {
209
- const tree = await scanRoutes(tmpDir);
210
- const matched = matchRoute(tree, new URL("http://localhost/blog"));
211
- const res = await renderRoute(
212
- matched!,
213
- new Request("http://localhost/blog"),
214
- );
215
- const html = await readStream(res);
216
-
217
- expect(html).toContain('class="app"'); // root layout
218
- expect(html).not.toContain('class="section"'); // no section layout
219
- expect(html).not.toContain('class="dash"'); // no dashboard layout
220
- });
221
-
222
- test("layout.server.ts data reaches the correct layout in a nested chain", async () => {
223
- const tree = await scanRoutes(tmpDir);
224
- const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
225
- const res = await renderRoute(
226
- matched!,
227
- new Request("http://localhost/dashboard"),
228
- );
229
- const html = await readStream(res);
230
-
231
- // root layout wraps dashboard layout
232
- expect(html).toContain('class="app"');
233
- expect(html).toContain('class="dash"');
234
- expect(html).toContain('data-user="admin"');
235
-
236
- const rootIdx = html.indexOf('class="app"');
237
- const dashIdx = html.indexOf('class="dash"');
238
- expect(rootIdx).toBeLessThan(dashIdx);
239
- });
240
-
241
- test("renders static page", async () => {
242
- const tree = await scanRoutes(tmpDir);
243
- const matched = matchRoute(tree, new URL("http://localhost/"));
244
- const res = await renderRoute(matched!, new Request("http://localhost/"));
245
- const html = await readStream(res);
246
- expect(html).toContain("<h1>Hello Grimoire</h1>");
247
- expect(html).toContain("<!DOCTYPE html>");
248
- });
249
-
250
- test("renders page with load data", async () => {
251
- const tree = await scanRoutes(tmpDir);
252
- const matched = matchRoute(tree, new URL("http://localhost/blog"));
253
- console.log("matched.route.type:", matched?.route.type);
254
- console.log("matched.pageServer:", matched?.pageServer?.filePath);
255
- const res = await renderRoute(
256
- matched!,
257
- new Request("http://localhost/blog"),
258
- );
259
- const html = await readStream(res);
260
- expect(html).toContain("<!--g-->My Blog<!--/g-->");
261
- });
262
-
263
- test("wraps page in layout", async () => {
264
- const tree = await scanRoutes(tmpDir);
265
- const matched = matchRoute(tree, new URL("http://localhost/"));
266
- const res = await renderRoute(matched!, new Request("http://localhost/"));
267
- const html = await readStream(res);
268
- expect(html).toContain('class="app"');
269
- expect(html).toContain("<h1>Hello Grimoire</h1>");
270
- });
271
-
272
- test("returns 404 shape for unmatched route", async () => {
273
- const tree = await scanRoutes(tmpDir);
274
- const matched = matchRoute(tree, new URL("http://localhost/nonexistent"));
275
- expect(matched).toBeNull();
276
- });
277
-
278
- test("injects grimoire state script", async () => {
279
- const tree = await scanRoutes(tmpDir);
280
- const matched = matchRoute(tree, new URL("http://localhost/"));
281
- const res = await renderRoute(matched!, new Request("http://localhost/"));
282
- const html = await readStream(res);
283
- expect(html).toContain("__grimoire_state__");
284
- });
285
-
286
- test("component output is not escaped", async () => {
287
- await writeFile(
288
- join(tmpDir, "inner.tsx"),
289
- `
290
- export default function Inner() {
291
- return <strong>bold</strong>;
292
- }
293
- `,
294
- );
295
- await writeFile(
296
- join(tmpDir, "outer.tsx"),
297
- `
298
- import Inner from './inner';
299
- export default function Outer() {
300
- return <div><Inner /></div>;
301
- }
302
- `,
303
- );
304
-
305
- const tree = await scanRoutes(tmpDir);
306
- const matched = matchRoute(tree, new URL("http://localhost/outer"));
307
- const res = await renderRoute(
308
- matched!,
309
- new Request("http://localhost/outer"),
310
- );
311
- const html = await readStream(res);
312
-
313
- // should contain actual HTML tags, not escaped versions
314
- expect(html).toContain("<strong>bold</strong>");
315
- expect(html).not.toContain("&lt;strong&gt;");
316
- });
317
-
318
- test("user data is escaped", async () => {
319
- await writeFile(
320
- join(tmpDir, "xss.tsx"),
321
- `
322
- export default function XSS() {
323
- const userInput = '<script>alert("xss")</script>';
324
- return <div>{userInput}</div>;
325
- }
326
- `,
327
- );
328
-
329
- const tree = await scanRoutes(tmpDir);
330
- const matched = matchRoute(tree, new URL("http://localhost/xss"));
331
- const res = await renderRoute(
332
- matched!,
333
- new Request("http://localhost/xss"),
334
- );
335
- const html = await readStream(res);
336
-
337
- expect(html).toContain("&lt;script&gt;");
338
- expect(html).not.toContain("<script>alert");
339
- });
340
-
341
- test("load return value is passed as the data prop (SvelteKit convention)", async () => {
342
- const tree = await scanRoutes(tmpDir);
343
- const matched = matchRoute(tree, new URL("http://localhost/blog"));
344
- const res = await renderRoute(
345
- matched!,
346
- new Request("http://localhost/blog"),
347
- );
348
- const html = await readStream(res);
349
- // blog/+page.server.ts returns { title: "My Blog" }
350
- // blog/+page.tsx receives { data: { title: "My Blog" } } — not spread
351
- expect(html).toContain("My Blog");
352
- // the state JSON embeds data under the data key
353
- const stateJson = JSON.parse(
354
- html.match(/<script[^>]*>([^<]+)<\/script>/)?.[1] ?? "{}",
355
- );
356
- expect(stateJson.data?.title).toBe("My Blog");
357
- expect(html).not.toContain("[object Object]");
358
- });
359
-
360
- test("redirect() from load returns 3xx with Location header", async () => {
361
- const tree = await scanRoutes(tmpDir);
362
- const matched = matchRoute(tree, new URL("http://localhost/redirect-page"));
363
- const res = await renderRoute(
364
- matched!,
365
- new Request("http://localhost/redirect-page"),
366
- );
367
- expect(res.status).toBe(302);
368
- expect(res.headers.get("Location")).toBe("/login");
369
- });
370
-
371
- test("error() from load renders +error.tsx when present", async () => {
372
- const tree = await scanRoutes(tmpDir);
373
- const matched = matchRoute(tree, new URL("http://localhost/error-page"));
374
- const res = await renderRoute(
375
- matched!,
376
- new Request("http://localhost/error-page"),
377
- tree.errors,
378
- );
379
- expect(res.status).toBe(404);
380
- const html = await res.text();
381
- expect(html).toContain('class="error"');
382
- expect(html).toContain("404");
383
- expect(html).toContain("Not found");
384
- });
385
-
386
- test("error() from load falls back to plain text when no error routes provided", async () => {
387
- const tree = await scanRoutes(tmpDir);
388
- const matched = matchRoute(tree, new URL("http://localhost/error-page"));
389
- const res = await renderRoute(
390
- matched!,
391
- new Request("http://localhost/error-page"),
392
- [], // no error routes
393
- );
394
- expect(res.status).toBe(404);
395
- const text = await res.text();
396
- expect(text).toBe("Not found");
397
- });
398
-
399
- test("locals passed to renderRoute are visible inside load()", async () => {
400
- const tree = await scanRoutes(tmpDir);
401
- const matched = matchRoute(tree, new URL("http://localhost/locals-test"));
402
- const res = await renderRoute(
403
- matched!,
404
- new Request("http://localhost/locals-test"),
405
- [],
406
- undefined,
407
- { tag: "from-hook" },
408
- );
409
- const html = await readStream(res);
410
- expect(html).toContain("from-hook");
411
- });
412
-
413
- test("+layout.server.ts load data is passed to the layout component", async () => {
414
- const tree = await scanRoutes(tmpDir);
415
- const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
416
- const res = await renderRoute(
417
- matched!,
418
- new Request("http://localhost/dashboard"),
419
- );
420
- const html = await readStream(res);
421
- expect(html).toContain('class="dash"');
422
- expect(html).toContain('data-user="admin"');
423
- expect(html).toContain("dash content");
424
- });
425
- });
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "fs/promises";
3
+ import { tmpdir as osTmpDir } from "os";
4
+ import { join } from "path";
5
+ import { renderRoute } from "../src/rendering";
6
+ import { matchRoute } from "../src/routing/router";
7
+ import { scanRoutes } from "../src/routing/scanner";
8
+
9
+ /** Read a streaming Response body into a single string */
10
+ async function readStream(res: Response): Promise<string> {
11
+ const reader = res.body!.getReader();
12
+ const parts: string[] = [];
13
+ while (true) {
14
+ const { done, value } = await reader.read();
15
+ if (done) break;
16
+ // Bun ReadableStream chunks can be string, Uint8Array, or other
17
+ parts.push(String(value));
18
+ }
19
+ return parts.join("");
20
+ }
21
+
22
+ let tmpDir: string;
23
+
24
+ beforeAll(async () => {
25
+ tmpDir = join(osTmpDir(), `grimoire-render-${Date.now()}`);
26
+ await mkdir(join(tmpDir, "blog"), { recursive: true });
27
+
28
+ // simple static page
29
+ await writeFile(
30
+ join(tmpDir, "index.tsx"),
31
+ `
32
+ export default function Home() {
33
+ return <h1>Hello Grimoire</h1>;
34
+ }
35
+ `,
36
+ );
37
+
38
+ // page with load function
39
+ await writeFile(
40
+ join(tmpDir, "blog", "+page.server.ts"),
41
+ `
42
+ export async function load({ params }) {
43
+ return { title: 'My Blog' };
44
+ }
45
+ `,
46
+ );
47
+
48
+ await writeFile(
49
+ join(tmpDir, "blog", "+page.tsx"),
50
+ `
51
+ export default function Blog({ data }) {
52
+ return <h1>{data.title}</h1>;
53
+ }
54
+ `,
55
+ );
56
+
57
+ // layout
58
+ await writeFile(
59
+ join(tmpDir, "+layout.tsx"),
60
+ `
61
+ export default function Layout({ children }) {
62
+ return <div class="app" innerHTML={children}></div>;
63
+ }
64
+ `,
65
+ );
66
+
67
+ // root error page — renders status and message
68
+ await writeFile(
69
+ join(tmpDir, "+error.tsx"),
70
+ `
71
+ export default function ErrorPage({ status, message }) {
72
+ return <div class="error"><span class="status">{status}</span><span class="msg">{message}</span></div>;
73
+ }
74
+ `,
75
+ );
76
+
77
+ // page whose load throws redirect — use inline object so no grimoire import needed from /tmp
78
+ await mkdir(join(tmpDir, "redirect-page"), { recursive: true });
79
+ await writeFile(
80
+ join(tmpDir, "redirect-page", "+page.server.ts"),
81
+ `export async function load() { throw { __redirect: true, status: 302, location: "/login" }; }`,
82
+ );
83
+ await writeFile(
84
+ join(tmpDir, "redirect-page", "+page.tsx"),
85
+ `export default function P() { return <div>never</div>; }`,
86
+ );
87
+
88
+ // page whose load throws error — same pattern
89
+ await mkdir(join(tmpDir, "error-page"), { recursive: true });
90
+ await writeFile(
91
+ join(tmpDir, "error-page", "+page.server.ts"),
92
+ `export async function load() { throw { __error: true, status: 404, message: "Not found" }; }`,
93
+ );
94
+ await writeFile(
95
+ join(tmpDir, "error-page", "+page.tsx"),
96
+ `export default function P() { return <div>never</div>; }`,
97
+ );
98
+
99
+ // page whose load returns locals.tag (tests locals passthrough)
100
+ await mkdir(join(tmpDir, "locals-test"), { recursive: true });
101
+ await writeFile(
102
+ join(tmpDir, "locals-test", "+page.server.ts"),
103
+ `
104
+ export async function load({ locals }) { return { tag: locals.tag ?? "none" }; }
105
+ `,
106
+ );
107
+ await writeFile(
108
+ join(tmpDir, "locals-test", "+page.tsx"),
109
+ `export default function P({ data }) { return <div class="tag">{data.tag}</div>; }`,
110
+ );
111
+
112
+ // layout.server test — nested layout with server data
113
+ await mkdir(join(tmpDir, "dashboard"), { recursive: true });
114
+ await writeFile(
115
+ join(tmpDir, "dashboard", "+layout.server.ts"),
116
+ `export async function load() { return { user: "admin" }; }`,
117
+ );
118
+ await writeFile(
119
+ join(tmpDir, "dashboard", "+layout.tsx"),
120
+ `export default function DashLayout({ data, children }) {
121
+ return <div class="dash" data-user={data.user} innerHTML={children}></div>;
122
+ }`,
123
+ );
124
+ await writeFile(
125
+ join(tmpDir, "dashboard", "index.tsx"),
126
+ `export default function Dash() { return <p>dash content</p>; }`,
127
+ );
128
+
129
+ await mkdir(join(tmpDir, "section", "nested"), { recursive: true });
130
+ await writeFile(
131
+ join(tmpDir, "section", "+layout.tsx"),
132
+ `export default function SectionLayout({ children }) {
133
+ return <div class="section" innerHTML={children}></div>;
134
+ }`,
135
+ );
136
+ await writeFile(
137
+ join(tmpDir, "section", "nested", "+page.tsx"),
138
+ `export default function NestedPage() { return <p class="nested-content">nested</p>; }`,
139
+ );
140
+
141
+ // triple nesting — root + section + deep
142
+ await mkdir(join(tmpDir, "section", "deep", "page"), { recursive: true });
143
+ await writeFile(
144
+ join(tmpDir, "section", "deep", "+layout.tsx"),
145
+ `export default function DeepLayout({ children }) {
146
+ return <div class="deep" innerHTML={children}></div>;
147
+ }`,
148
+ );
149
+ await writeFile(
150
+ join(tmpDir, "section", "deep", "page", "+page.tsx"),
151
+ `export default function DeepPage() { return <p class="deep-content">deep</p>; }`,
152
+ );
153
+ });
154
+
155
+ afterAll(async () => {
156
+ await rm(tmpDir, { recursive: true });
157
+ });
158
+
159
+ describe("renderRoute", () => {
160
+ test("nested layouts stack from root to innermost", async () => {
161
+ const tree = await scanRoutes(tmpDir);
162
+ const matched = matchRoute(tree, new URL("http://localhost/section/nested"));
163
+ const res = await renderRoute(
164
+ matched!,
165
+ new Request("http://localhost/section/nested"),
166
+ );
167
+ const html = await readStream(res);
168
+
169
+ // root layout wraps section layout wraps page
170
+ expect(html).toContain('class="app"'); // root layout
171
+ expect(html).toContain('class="section"'); // section layout
172
+ expect(html).toContain('class="nested-content"'); // page
173
+
174
+ // root must appear before section in the output
175
+ const rootIdx = html.indexOf('class="app"');
176
+ const sectionIdx = html.indexOf('class="section"');
177
+ const pageIdx = html.indexOf('class="nested-content"');
178
+ expect(rootIdx).toBeLessThan(sectionIdx);
179
+ expect(sectionIdx).toBeLessThan(pageIdx);
180
+ });
181
+
182
+ test("triple nested layouts render in correct order", async () => {
183
+ const tree = await scanRoutes(tmpDir);
184
+ const matched = matchRoute(
185
+ tree,
186
+ new URL("http://localhost/section/deep/page"),
187
+ );
188
+ const res = await renderRoute(
189
+ matched!,
190
+ new Request("http://localhost/section/deep/page"),
191
+ );
192
+ const html = await readStream(res);
193
+
194
+ expect(html).toContain('class="app"'); // root
195
+ expect(html).toContain('class="section"'); // section
196
+ expect(html).toContain('class="deep"'); // deep
197
+ expect(html).toContain('class="deep-content"'); // page
198
+
199
+ const rootIdx = html.indexOf('class="app"');
200
+ const sectionIdx = html.indexOf('class="section"');
201
+ const deepIdx = html.indexOf('class="deep"');
202
+ const pageIdx = html.indexOf('class="deep-content"');
203
+ expect(rootIdx).toBeLessThan(sectionIdx);
204
+ expect(sectionIdx).toBeLessThan(deepIdx);
205
+ expect(deepIdx).toBeLessThan(pageIdx);
206
+ });
207
+
208
+ test("page with no ancestor section layout only gets root layout", async () => {
209
+ const tree = await scanRoutes(tmpDir);
210
+ const matched = matchRoute(tree, new URL("http://localhost/blog"));
211
+ const res = await renderRoute(
212
+ matched!,
213
+ new Request("http://localhost/blog"),
214
+ );
215
+ const html = await readStream(res);
216
+
217
+ expect(html).toContain('class="app"'); // root layout
218
+ expect(html).not.toContain('class="section"'); // no section layout
219
+ expect(html).not.toContain('class="dash"'); // no dashboard layout
220
+ });
221
+
222
+ test("layout.server.ts data reaches the correct layout in a nested chain", async () => {
223
+ const tree = await scanRoutes(tmpDir);
224
+ const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
225
+ const res = await renderRoute(
226
+ matched!,
227
+ new Request("http://localhost/dashboard"),
228
+ );
229
+ const html = await readStream(res);
230
+
231
+ // root layout wraps dashboard layout
232
+ expect(html).toContain('class="app"');
233
+ expect(html).toContain('class="dash"');
234
+ expect(html).toContain('data-user="admin"');
235
+
236
+ const rootIdx = html.indexOf('class="app"');
237
+ const dashIdx = html.indexOf('class="dash"');
238
+ expect(rootIdx).toBeLessThan(dashIdx);
239
+ });
240
+
241
+ test("renders static page", async () => {
242
+ const tree = await scanRoutes(tmpDir);
243
+ const matched = matchRoute(tree, new URL("http://localhost/"));
244
+ const res = await renderRoute(matched!, new Request("http://localhost/"));
245
+ const html = await readStream(res);
246
+ expect(html).toContain("<h1>Hello Grimoire</h1>");
247
+ expect(html).toContain("<!DOCTYPE html>");
248
+ });
249
+
250
+ test("renders page with load data", async () => {
251
+ const tree = await scanRoutes(tmpDir);
252
+ const matched = matchRoute(tree, new URL("http://localhost/blog"));
253
+ console.log("matched.route.type:", matched?.route.type);
254
+ console.log("matched.pageServer:", matched?.pageServer?.filePath);
255
+ const res = await renderRoute(
256
+ matched!,
257
+ new Request("http://localhost/blog"),
258
+ );
259
+ const html = await readStream(res);
260
+ expect(html).toContain("<!--g-->My Blog<!--/g-->");
261
+ });
262
+
263
+ test("wraps page in layout", async () => {
264
+ const tree = await scanRoutes(tmpDir);
265
+ const matched = matchRoute(tree, new URL("http://localhost/"));
266
+ const res = await renderRoute(matched!, new Request("http://localhost/"));
267
+ const html = await readStream(res);
268
+ expect(html).toContain('class="app"');
269
+ expect(html).toContain("<h1>Hello Grimoire</h1>");
270
+ });
271
+
272
+ test("returns 404 shape for unmatched route", async () => {
273
+ const tree = await scanRoutes(tmpDir);
274
+ const matched = matchRoute(tree, new URL("http://localhost/nonexistent"));
275
+ expect(matched).toBeNull();
276
+ });
277
+
278
+ test("injects grimoire state script", async () => {
279
+ const tree = await scanRoutes(tmpDir);
280
+ const matched = matchRoute(tree, new URL("http://localhost/"));
281
+ const res = await renderRoute(matched!, new Request("http://localhost/"));
282
+ const html = await readStream(res);
283
+ expect(html).toContain("__grimoire_state__");
284
+ });
285
+
286
+ test("component output is not escaped", async () => {
287
+ await writeFile(
288
+ join(tmpDir, "inner.tsx"),
289
+ `
290
+ export default function Inner() {
291
+ return <strong>bold</strong>;
292
+ }
293
+ `,
294
+ );
295
+ await writeFile(
296
+ join(tmpDir, "outer.tsx"),
297
+ `
298
+ import Inner from './inner';
299
+ export default function Outer() {
300
+ return <div><Inner /></div>;
301
+ }
302
+ `,
303
+ );
304
+
305
+ const tree = await scanRoutes(tmpDir);
306
+ const matched = matchRoute(tree, new URL("http://localhost/outer"));
307
+ const res = await renderRoute(
308
+ matched!,
309
+ new Request("http://localhost/outer"),
310
+ );
311
+ const html = await readStream(res);
312
+
313
+ // should contain actual HTML tags, not escaped versions
314
+ expect(html).toContain("<strong>bold</strong>");
315
+ expect(html).not.toContain("&lt;strong&gt;");
316
+ });
317
+
318
+ test("user data is escaped", async () => {
319
+ await writeFile(
320
+ join(tmpDir, "xss.tsx"),
321
+ `
322
+ export default function XSS() {
323
+ const userInput = '<script>alert("xss")</script>';
324
+ return <div>{userInput}</div>;
325
+ }
326
+ `,
327
+ );
328
+
329
+ const tree = await scanRoutes(tmpDir);
330
+ const matched = matchRoute(tree, new URL("http://localhost/xss"));
331
+ const res = await renderRoute(
332
+ matched!,
333
+ new Request("http://localhost/xss"),
334
+ );
335
+ const html = await readStream(res);
336
+
337
+ expect(html).toContain("&lt;script&gt;");
338
+ expect(html).not.toContain("<script>alert");
339
+ });
340
+
341
+ test("load return value is passed as the data prop (SvelteKit convention)", async () => {
342
+ const tree = await scanRoutes(tmpDir);
343
+ const matched = matchRoute(tree, new URL("http://localhost/blog"));
344
+ const res = await renderRoute(
345
+ matched!,
346
+ new Request("http://localhost/blog"),
347
+ );
348
+ const html = await readStream(res);
349
+ // blog/+page.server.ts returns { title: "My Blog" }
350
+ // blog/+page.tsx receives { data: { title: "My Blog" } } — not spread
351
+ expect(html).toContain("My Blog");
352
+ // the state JSON embeds data under the data key
353
+ const stateJson = JSON.parse(
354
+ html.match(/<script[^>]*>([^<]+)<\/script>/)?.[1] ?? "{}",
355
+ );
356
+ expect(stateJson.data?.title).toBe("My Blog");
357
+ expect(html).not.toContain("[object Object]");
358
+ });
359
+
360
+ test("redirect() from load returns 3xx with Location header", async () => {
361
+ const tree = await scanRoutes(tmpDir);
362
+ const matched = matchRoute(tree, new URL("http://localhost/redirect-page"));
363
+ const res = await renderRoute(
364
+ matched!,
365
+ new Request("http://localhost/redirect-page"),
366
+ );
367
+ expect(res.status).toBe(302);
368
+ expect(res.headers.get("Location")).toBe("/login");
369
+ });
370
+
371
+ test("error() from load renders +error.tsx when present", async () => {
372
+ const tree = await scanRoutes(tmpDir);
373
+ const matched = matchRoute(tree, new URL("http://localhost/error-page"));
374
+ const res = await renderRoute(
375
+ matched!,
376
+ new Request("http://localhost/error-page"),
377
+ tree.errors,
378
+ );
379
+ expect(res.status).toBe(404);
380
+ const html = await res.text();
381
+ expect(html).toContain('class="error"');
382
+ expect(html).toContain("404");
383
+ expect(html).toContain("Not found");
384
+ });
385
+
386
+ test("error() from load falls back to plain text when no error routes provided", async () => {
387
+ const tree = await scanRoutes(tmpDir);
388
+ const matched = matchRoute(tree, new URL("http://localhost/error-page"));
389
+ const res = await renderRoute(
390
+ matched!,
391
+ new Request("http://localhost/error-page"),
392
+ [], // no error routes
393
+ );
394
+ expect(res.status).toBe(404);
395
+ const text = await res.text();
396
+ expect(text).toBe("Not found");
397
+ });
398
+
399
+ test("locals passed to renderRoute are visible inside load()", async () => {
400
+ const tree = await scanRoutes(tmpDir);
401
+ const matched = matchRoute(tree, new URL("http://localhost/locals-test"));
402
+ const res = await renderRoute(
403
+ matched!,
404
+ new Request("http://localhost/locals-test"),
405
+ [],
406
+ undefined,
407
+ { tag: "from-hook" },
408
+ );
409
+ const html = await readStream(res);
410
+ expect(html).toContain("from-hook");
411
+ });
412
+
413
+ test("+layout.server.ts load data is passed to the layout component", async () => {
414
+ const tree = await scanRoutes(tmpDir);
415
+ const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
416
+ const res = await renderRoute(
417
+ matched!,
418
+ new Request("http://localhost/dashboard"),
419
+ );
420
+ const html = await readStream(res);
421
+ expect(html).toContain('class="dash"');
422
+ expect(html).toContain('data-user="admin"');
423
+ expect(html).toContain("dash content");
424
+ });
425
+ });