@sigil-dev/grimoire 0.7.5 → 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.
- package/.grimoire/_routes.dom.js +8 -0
- package/.grimoire/_routes.hydrate.js +8 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +59 -0
- package/.grimoire/types/api/hello/$types.d.ts +50 -0
- package/.grimoire/types/api/items/$types.d.ts +50 -0
- package/.grimoire/types/echo/$types.d.ts +50 -0
- package/.grimoire/types/env-private.d.ts +5 -0
- package/.grimoire/types/env-public.d.ts +5 -0
- package/.grimoire/types/mixed/$types.d.ts +50 -0
- package/.grimoire/types/params/[docId]/$types.d.ts +52 -0
- package/.grimoire/types/reject/$types.d.ts +50 -0
- package/index.ts +21 -20
- package/package.json +13 -7
- package/preload.js +3 -0
- package/public/__grimoire__/hydrate.js +585 -0
- package/public/__grimoire__/index.js +490 -0
- package/server.ts +13 -13
- package/src/client/head.ts +29 -0
- package/src/client/router.ts +254 -40
- package/src/dev/compile-module.ts +173 -0
- package/src/dev/effect-registry.ts +23 -0
- package/src/dev/graph.ts +114 -0
- package/src/dev/hmr-client.ts +158 -0
- package/src/dev/hmr-server.ts +187 -0
- package/src/dev/loader.ts +47 -0
- package/src/dev/paths.ts +14 -0
- package/src/dev/runtime-bundle.ts +49 -0
- package/src/dev/watcher.ts +44 -0
- package/src/env/index.ts +25 -0
- package/src/env/plugin.ts +13 -0
- package/src/env/private.ts +5 -0
- package/src/env/public.ts +7 -0
- package/src/env/typegen.ts +51 -0
- package/src/integrations/vite.ts +1 -0
- package/src/rendering/head.ts +22 -2
- package/src/rendering/hydrate.ts +111 -18
- package/src/rendering/index.ts +263 -153
- package/src/rendering/ssrPlugin.ts +59 -39
- package/src/routing/manifest-gen.ts +18 -2
- package/src/routing/router.ts +94 -83
- package/src/routing/scanner.ts +26 -14
- package/src/routing/transform-routes.ts +68 -68
- package/src/server/build.ts +225 -76
- package/src/server/coordinator.ts +9 -0
- package/src/server/hooks.ts +24 -3
- package/src/server/index.ts +388 -104
- package/src/typegen/index.ts +30 -14
- package/src/types.ts +12 -2
- package/test/middleware.test.ts +6 -4
- package/test/rendering.test.ts +510 -356
- package/test/routing.test.ts +36 -0
- package/test/scanning.test.ts +39 -8
- package/test/scope.test.ts +24 -8
- package/test/server.test.ts +27 -7
- package/test/streaming.test.ts +117 -98
- package/test/typegen.test.ts +52 -24
- package/tsconfig.json +1 -0
package/test/rendering.test.ts
CHANGED
|
@@ -8,418 +8,572 @@ import { scanRoutes } from "../src/routing/scanner";
|
|
|
8
8
|
|
|
9
9
|
/** Read a streaming Response body into a single string */
|
|
10
10
|
async function readStream(res: Response): Promise<string> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
20
|
}
|
|
21
21
|
|
|
22
22
|
let tmpDir: string;
|
|
23
23
|
|
|
24
24
|
beforeAll(async () => {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
tmpDir = join(osTmpDir(), `grimoire-render-${Date.now()}`);
|
|
26
|
+
await mkdir(join(tmpDir, "blog"), { recursive: true });
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// simple static page
|
|
29
|
+
await writeFile(
|
|
30
|
+
join(tmpDir, "index.tsx"),
|
|
31
|
+
`
|
|
32
32
|
export default function Home() {
|
|
33
33
|
return <h1>Hello Grimoire</h1>;
|
|
34
34
|
}
|
|
35
35
|
`,
|
|
36
|
-
|
|
36
|
+
);
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// page with load function
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(tmpDir, "blog", "+page.server.ts"),
|
|
41
|
+
`
|
|
42
42
|
export async function load({ params }) {
|
|
43
43
|
return { title: 'My Blog' };
|
|
44
44
|
}
|
|
45
45
|
`,
|
|
46
|
-
|
|
46
|
+
);
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
await writeFile(
|
|
49
|
+
join(tmpDir, "blog", "+page.tsx"),
|
|
50
|
+
`
|
|
51
51
|
export default function Blog({ data }) {
|
|
52
52
|
return <h1>{data.title}</h1>;
|
|
53
53
|
}
|
|
54
54
|
`,
|
|
55
|
-
|
|
55
|
+
);
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// layout
|
|
58
|
+
await writeFile(
|
|
59
|
+
join(tmpDir, "+layout.tsx"),
|
|
60
|
+
`
|
|
61
61
|
export default function Layout({ children }) {
|
|
62
62
|
return <div class="app" innerHTML={children}></div>;
|
|
63
63
|
}
|
|
64
64
|
`,
|
|
65
|
-
|
|
65
|
+
);
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// root error page — renders status and message
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(tmpDir, "+error.tsx"),
|
|
70
|
+
`
|
|
71
71
|
export default function ErrorPage({ status, message }) {
|
|
72
72
|
return <div class="error"><span class="status">{status}</span><span class="msg">{message}</span></div>;
|
|
73
73
|
}
|
|
74
74
|
`,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
104
|
export async function load({ locals }) { return { tag: locals.tag ?? "none" }; }
|
|
105
105
|
`,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
121
|
return <div class="dash" data-user={data.user} innerHTML={children}></div>;
|
|
122
122
|
}`,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
133
|
return <div class="section" innerHTML={children}></div>;
|
|
134
134
|
}`,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
146
|
return <div class="deep" innerHTML={children}></div>;
|
|
147
147
|
}`,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
// canMatch — layout blocks
|
|
154
|
+
await mkdir(join(tmpDir, "guarded"), { recursive: true });
|
|
155
|
+
await writeFile(
|
|
156
|
+
join(tmpDir, "guarded", "+layout.tsx"),
|
|
157
|
+
`export const canMatch = async () => false;
|
|
158
|
+
export default function L({ children }) { return <div innerHTML={children}></div>; }`,
|
|
159
|
+
);
|
|
160
|
+
await writeFile(
|
|
161
|
+
join(tmpDir, "guarded", "+page.tsx"),
|
|
162
|
+
`export default function P() { return <p>secret</p>; }`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// canMatch — layout throws redirect
|
|
166
|
+
await mkdir(join(tmpDir, "guarded-redirect"), { recursive: true });
|
|
167
|
+
await writeFile(
|
|
168
|
+
join(tmpDir, "guarded-redirect", "+layout.tsx"),
|
|
169
|
+
`export const canMatch = async () => { throw { __redirect: true, status: 302, location: "/login" }; };
|
|
170
|
+
export default function L({ children }) { return <div innerHTML={children}></div>; }`,
|
|
171
|
+
);
|
|
172
|
+
await writeFile(
|
|
173
|
+
join(tmpDir, "guarded-redirect", "+page.tsx"),
|
|
174
|
+
`export default function P() { return <p>secret</p>; }`,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// canMatch — page blocks
|
|
178
|
+
await mkdir(join(tmpDir, "blocked"), { recursive: true });
|
|
179
|
+
await writeFile(
|
|
180
|
+
join(tmpDir, "blocked", "+page.tsx"),
|
|
181
|
+
`export const canMatch = async () => false;
|
|
182
|
+
export default function P() { return <p>secret</p>; }`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// universal load — merge with server
|
|
186
|
+
await mkdir(join(tmpDir, "universal-merge"), { recursive: true });
|
|
187
|
+
await writeFile(
|
|
188
|
+
join(tmpDir, "universal-merge", "+page.server.ts"),
|
|
189
|
+
`export async function load() { return { b: "server" }; }`,
|
|
190
|
+
);
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(tmpDir, "universal-merge", "+page.tsx"),
|
|
193
|
+
`export async function load() { return { a: "universal" }; }
|
|
194
|
+
export default function P({ data }) { return <p>{data.a}-{data.b}</p>; }`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// universal load — server wins on conflict
|
|
198
|
+
await mkdir(join(tmpDir, "universal-conflict"), { recursive: true });
|
|
199
|
+
await writeFile(
|
|
200
|
+
join(tmpDir, "universal-conflict", "+page.server.ts"),
|
|
201
|
+
`export async function load() { return { val: "server" }; }`,
|
|
202
|
+
);
|
|
203
|
+
await writeFile(
|
|
204
|
+
join(tmpDir, "universal-conflict", "+page.tsx"),
|
|
205
|
+
`export async function load() { return { val: "universal" }; }
|
|
206
|
+
export default function P({ data }) { return <p class="val">{data.val}</p>; }`,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// universal load — layout load
|
|
210
|
+
await mkdir(join(tmpDir, "universal-layout"), { recursive: true });
|
|
211
|
+
await writeFile(
|
|
212
|
+
join(tmpDir, "universal-layout", "+layout.tsx"),
|
|
213
|
+
`export async function load() { return { user: "cane" }; }
|
|
214
|
+
export default function L({ data, children }) { return <div data-user={data.user} innerHTML={children}></div>; }`,
|
|
215
|
+
);
|
|
216
|
+
await writeFile(
|
|
217
|
+
join(tmpDir, "universal-layout", "+page.tsx"),
|
|
218
|
+
`export default function P() { return <p>page</p>; }`,
|
|
219
|
+
);
|
|
153
220
|
});
|
|
154
221
|
|
|
155
222
|
afterAll(async () => {
|
|
156
|
-
|
|
223
|
+
await rm(tmpDir, { recursive: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("canMatch", () => {
|
|
227
|
+
test("layout canMatch returning false returns 404", async () => {
|
|
228
|
+
const tree = await scanRoutes(tmpDir);
|
|
229
|
+
const matched = matchRoute(tree, new URL("http://localhost/guarded"));
|
|
230
|
+
const res = await renderRoute(
|
|
231
|
+
matched!,
|
|
232
|
+
new Request("http://localhost/guarded"),
|
|
233
|
+
);
|
|
234
|
+
expect(res.status).toBe(404);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("layout canMatch throwing redirect returns 3xx", async () => {
|
|
238
|
+
const tree = await scanRoutes(tmpDir);
|
|
239
|
+
const matched = matchRoute(
|
|
240
|
+
tree,
|
|
241
|
+
new URL("http://localhost/guarded-redirect"),
|
|
242
|
+
);
|
|
243
|
+
const res = await renderRoute(
|
|
244
|
+
matched!,
|
|
245
|
+
new Request("http://localhost/guarded-redirect"),
|
|
246
|
+
);
|
|
247
|
+
expect(res.status).toBe(302);
|
|
248
|
+
expect(res.headers.get("Location")).toBe("/login");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("page canMatch returning false returns 404", async () => {
|
|
252
|
+
const tree = await scanRoutes(tmpDir);
|
|
253
|
+
const matched = matchRoute(tree, new URL("http://localhost/blocked"));
|
|
254
|
+
const res = await renderRoute(
|
|
255
|
+
matched!,
|
|
256
|
+
new Request("http://localhost/blocked"),
|
|
257
|
+
);
|
|
258
|
+
expect(res.status).toBe(404);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("universal load", () => {
|
|
263
|
+
test("page load merges with server data", async () => {
|
|
264
|
+
const tree = await scanRoutes(tmpDir);
|
|
265
|
+
const matched = matchRoute(
|
|
266
|
+
tree,
|
|
267
|
+
new URL("http://localhost/universal-merge"),
|
|
268
|
+
);
|
|
269
|
+
const res = await renderRoute(
|
|
270
|
+
matched!,
|
|
271
|
+
new Request("http://localhost/universal-merge"),
|
|
272
|
+
);
|
|
273
|
+
const html = await readStream(res);
|
|
274
|
+
expect(html).toContain("universal");
|
|
275
|
+
expect(html).toContain("server");
|
|
276
|
+
expect(html).toContain('"a":"universal"');
|
|
277
|
+
expect(html).toContain('"b":"server"');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("server data wins over universal data on key conflict", async () => {
|
|
281
|
+
const tree = await scanRoutes(tmpDir);
|
|
282
|
+
const matched = matchRoute(
|
|
283
|
+
tree,
|
|
284
|
+
new URL("http://localhost/universal-conflict"),
|
|
285
|
+
);
|
|
286
|
+
const res = await renderRoute(
|
|
287
|
+
matched!,
|
|
288
|
+
new Request("http://localhost/universal-conflict"),
|
|
289
|
+
);
|
|
290
|
+
const html = await readStream(res);
|
|
291
|
+
expect(html).toContain('"val":"server"');
|
|
292
|
+
expect(html).not.toContain('"val":"universal"');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("layout load runs and data reaches layout component", async () => {
|
|
296
|
+
const tree = await scanRoutes(tmpDir);
|
|
297
|
+
const matched = matchRoute(
|
|
298
|
+
tree,
|
|
299
|
+
new URL("http://localhost/universal-layout"),
|
|
300
|
+
);
|
|
301
|
+
const res = await renderRoute(
|
|
302
|
+
matched!,
|
|
303
|
+
new Request("http://localhost/universal-layout"),
|
|
304
|
+
);
|
|
305
|
+
const html = await readStream(res);
|
|
306
|
+
expect(html).toContain('data-user="cane"');
|
|
307
|
+
});
|
|
157
308
|
});
|
|
158
309
|
|
|
159
310
|
describe("renderRoute", () => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
311
|
+
test("nested layouts stack from root to innermost", async () => {
|
|
312
|
+
const tree = await scanRoutes(tmpDir);
|
|
313
|
+
const matched = matchRoute(
|
|
314
|
+
tree,
|
|
315
|
+
new URL("http://localhost/section/nested"),
|
|
316
|
+
);
|
|
317
|
+
const res = await renderRoute(
|
|
318
|
+
matched!,
|
|
319
|
+
new Request("http://localhost/section/nested"),
|
|
320
|
+
);
|
|
321
|
+
const html = await readStream(res);
|
|
322
|
+
|
|
323
|
+
// root layout wraps section layout wraps page
|
|
324
|
+
expect(html).toContain('class="app"'); // root layout
|
|
325
|
+
expect(html).toContain('class="section"'); // section layout
|
|
326
|
+
expect(html).toContain('class="nested-content"'); // page
|
|
327
|
+
|
|
328
|
+
// root must appear before section in the output
|
|
329
|
+
const rootIdx = html.indexOf('class="app"');
|
|
330
|
+
const sectionIdx = html.indexOf('class="section"');
|
|
331
|
+
const pageIdx = html.indexOf('class="nested-content"');
|
|
332
|
+
expect(rootIdx).toBeLessThan(sectionIdx);
|
|
333
|
+
expect(sectionIdx).toBeLessThan(pageIdx);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("triple nested layouts render in correct order", async () => {
|
|
337
|
+
const tree = await scanRoutes(tmpDir);
|
|
338
|
+
const matched = matchRoute(
|
|
339
|
+
tree,
|
|
340
|
+
new URL("http://localhost/section/deep/page"),
|
|
341
|
+
);
|
|
342
|
+
const res = await renderRoute(
|
|
343
|
+
matched!,
|
|
344
|
+
new Request("http://localhost/section/deep/page"),
|
|
345
|
+
);
|
|
346
|
+
const html = await readStream(res);
|
|
347
|
+
|
|
348
|
+
expect(html).toContain('class="app"'); // root
|
|
349
|
+
expect(html).toContain('class="section"'); // section
|
|
350
|
+
expect(html).toContain('class="deep"'); // deep
|
|
351
|
+
expect(html).toContain('class="deep-content"'); // page
|
|
352
|
+
|
|
353
|
+
const rootIdx = html.indexOf('class="app"');
|
|
354
|
+
const sectionIdx = html.indexOf('class="section"');
|
|
355
|
+
const deepIdx = html.indexOf('class="deep"');
|
|
356
|
+
const pageIdx = html.indexOf('class="deep-content"');
|
|
357
|
+
expect(rootIdx).toBeLessThan(sectionIdx);
|
|
358
|
+
expect(sectionIdx).toBeLessThan(deepIdx);
|
|
359
|
+
expect(deepIdx).toBeLessThan(pageIdx);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("page with no ancestor section layout only gets root layout", async () => {
|
|
363
|
+
const tree = await scanRoutes(tmpDir);
|
|
364
|
+
const matched = matchRoute(tree, new URL("http://localhost/blog"));
|
|
365
|
+
const res = await renderRoute(
|
|
366
|
+
matched!,
|
|
367
|
+
new Request("http://localhost/blog"),
|
|
368
|
+
);
|
|
369
|
+
const html = await readStream(res);
|
|
370
|
+
|
|
371
|
+
expect(html).toContain('class="app"'); // root layout
|
|
372
|
+
expect(html).not.toContain('class="section"'); // no section layout
|
|
373
|
+
expect(html).not.toContain('class="dash"'); // no dashboard layout
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("layout.server.ts data reaches the correct layout in a nested chain", async () => {
|
|
377
|
+
const tree = await scanRoutes(tmpDir);
|
|
378
|
+
const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
|
|
379
|
+
const res = await renderRoute(
|
|
380
|
+
matched!,
|
|
381
|
+
new Request("http://localhost/dashboard"),
|
|
382
|
+
);
|
|
383
|
+
const html = await readStream(res);
|
|
384
|
+
|
|
385
|
+
// root layout wraps dashboard layout
|
|
386
|
+
expect(html).toContain('class="app"');
|
|
387
|
+
expect(html).toContain('class="dash"');
|
|
388
|
+
expect(html).toContain('data-user="admin"');
|
|
389
|
+
|
|
390
|
+
const rootIdx = html.indexOf('class="app"');
|
|
391
|
+
const dashIdx = html.indexOf('class="dash"');
|
|
392
|
+
expect(rootIdx).toBeLessThan(dashIdx);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("renders static page", async () => {
|
|
396
|
+
const tree = await scanRoutes(tmpDir);
|
|
397
|
+
const matched = matchRoute(tree, new URL("http://localhost/"));
|
|
398
|
+
const res = await renderRoute(matched!, new Request("http://localhost/"));
|
|
399
|
+
const html = await readStream(res);
|
|
400
|
+
expect(html).toContain("<h1>Hello Grimoire</h1>");
|
|
401
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("renders page with load data", async () => {
|
|
405
|
+
const tree = await scanRoutes(tmpDir);
|
|
406
|
+
const matched = matchRoute(tree, new URL("http://localhost/blog"));
|
|
407
|
+
console.log("matched.route.type:", matched?.route.type);
|
|
408
|
+
console.log("matched.pageServer:", matched?.pageServer?.filePath);
|
|
409
|
+
const res = await renderRoute(
|
|
410
|
+
matched!,
|
|
411
|
+
new Request("http://localhost/blog"),
|
|
412
|
+
);
|
|
413
|
+
const html = await readStream(res);
|
|
414
|
+
expect(html).toContain("<!--g-->My Blog<!--/g-->");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("wraps page in layout", async () => {
|
|
418
|
+
const tree = await scanRoutes(tmpDir);
|
|
419
|
+
const matched = matchRoute(tree, new URL("http://localhost/"));
|
|
420
|
+
const res = await renderRoute(matched!, new Request("http://localhost/"));
|
|
421
|
+
const html = await readStream(res);
|
|
422
|
+
expect(html).toContain('class="app"');
|
|
423
|
+
expect(html).toContain("<h1>Hello Grimoire</h1>");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("returns 404 shape for unmatched route", async () => {
|
|
427
|
+
const tree = await scanRoutes(tmpDir);
|
|
428
|
+
const matched = matchRoute(tree, new URL("http://localhost/nonexistent"));
|
|
429
|
+
expect(matched).toBeNull();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("injects grimoire state script", async () => {
|
|
433
|
+
const tree = await scanRoutes(tmpDir);
|
|
434
|
+
const matched = matchRoute(tree, new URL("http://localhost/"));
|
|
435
|
+
const res = await renderRoute(matched!, new Request("http://localhost/"));
|
|
436
|
+
const html = await readStream(res);
|
|
437
|
+
expect(html).toContain("__grimoire_state__");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("component output is not escaped", async () => {
|
|
441
|
+
await writeFile(
|
|
442
|
+
join(tmpDir, "inner.tsx"),
|
|
443
|
+
`
|
|
290
444
|
export default function Inner() {
|
|
291
445
|
return <strong>bold</strong>;
|
|
292
446
|
}
|
|
293
447
|
`,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
448
|
+
);
|
|
449
|
+
await writeFile(
|
|
450
|
+
join(tmpDir, "outer.tsx"),
|
|
451
|
+
`
|
|
298
452
|
import Inner from './inner';
|
|
299
453
|
export default function Outer() {
|
|
300
454
|
return <div><Inner /></div>;
|
|
301
455
|
}
|
|
302
456
|
`,
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const tree = await scanRoutes(tmpDir);
|
|
460
|
+
const matched = matchRoute(tree, new URL("http://localhost/outer"));
|
|
461
|
+
const res = await renderRoute(
|
|
462
|
+
matched!,
|
|
463
|
+
new Request("http://localhost/outer"),
|
|
464
|
+
);
|
|
465
|
+
const html = await readStream(res);
|
|
466
|
+
|
|
467
|
+
// should contain actual HTML tags, not escaped versions
|
|
468
|
+
expect(html).toContain("<strong>bold</strong>");
|
|
469
|
+
expect(html).not.toContain("<strong>");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("user data is escaped", async () => {
|
|
473
|
+
await writeFile(
|
|
474
|
+
join(tmpDir, "xss.tsx"),
|
|
475
|
+
`
|
|
322
476
|
export default function XSS() {
|
|
323
477
|
const userInput = '<script>alert("xss")</script>';
|
|
324
478
|
return <div>{userInput}</div>;
|
|
325
479
|
}
|
|
326
480
|
`,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const tree = await scanRoutes(tmpDir);
|
|
484
|
+
const matched = matchRoute(tree, new URL("http://localhost/xss"));
|
|
485
|
+
const res = await renderRoute(
|
|
486
|
+
matched!,
|
|
487
|
+
new Request("http://localhost/xss"),
|
|
488
|
+
);
|
|
489
|
+
const html = await readStream(res);
|
|
490
|
+
|
|
491
|
+
expect(html).toContain("<script>");
|
|
492
|
+
expect(html).not.toContain("<script>alert");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("load return value is passed as the data prop (SvelteKit convention)", async () => {
|
|
496
|
+
const tree = await scanRoutes(tmpDir);
|
|
497
|
+
const matched = matchRoute(tree, new URL("http://localhost/blog"));
|
|
498
|
+
const res = await renderRoute(
|
|
499
|
+
matched!,
|
|
500
|
+
new Request("http://localhost/blog"),
|
|
501
|
+
);
|
|
502
|
+
const html = await readStream(res);
|
|
503
|
+
// blog/+page.server.ts returns { title: "My Blog" }
|
|
504
|
+
// blog/+page.tsx receives { data: { title: "My Blog" } } — not spread
|
|
505
|
+
expect(html).toContain("My Blog");
|
|
506
|
+
// the state JSON embeds data under the data key
|
|
507
|
+
const stateJson = JSON.parse(
|
|
508
|
+
html.match(/<script[^>]*>([^<]+)<\/script>/)?.[1] ?? "{}",
|
|
509
|
+
);
|
|
510
|
+
expect(stateJson.data?.title).toBe("My Blog");
|
|
511
|
+
expect(html).not.toContain("[object Object]");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("redirect() from load returns 3xx with Location header", async () => {
|
|
515
|
+
const tree = await scanRoutes(tmpDir);
|
|
516
|
+
const matched = matchRoute(tree, new URL("http://localhost/redirect-page"));
|
|
517
|
+
const res = await renderRoute(
|
|
518
|
+
matched!,
|
|
519
|
+
new Request("http://localhost/redirect-page"),
|
|
520
|
+
);
|
|
521
|
+
expect(res.status).toBe(302);
|
|
522
|
+
expect(res.headers.get("Location")).toBe("/login");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("error() from load renders +error.tsx when present", async () => {
|
|
526
|
+
const tree = await scanRoutes(tmpDir);
|
|
527
|
+
const matched = matchRoute(tree, new URL("http://localhost/error-page"));
|
|
528
|
+
const res = await renderRoute(
|
|
529
|
+
matched!,
|
|
530
|
+
new Request("http://localhost/error-page"),
|
|
531
|
+
tree.errors,
|
|
532
|
+
);
|
|
533
|
+
expect(res.status).toBe(404);
|
|
534
|
+
const html = await res.text();
|
|
535
|
+
expect(html).toContain('class="error"');
|
|
536
|
+
expect(html).toContain("404");
|
|
537
|
+
expect(html).toContain("Not found");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("error() from load falls back to plain text when no error routes provided", async () => {
|
|
541
|
+
const tree = await scanRoutes(tmpDir);
|
|
542
|
+
const matched = matchRoute(tree, new URL("http://localhost/error-page"));
|
|
543
|
+
const res = await renderRoute(
|
|
544
|
+
matched!,
|
|
545
|
+
new Request("http://localhost/error-page"),
|
|
546
|
+
[], // no error routes
|
|
547
|
+
);
|
|
548
|
+
expect(res.status).toBe(404);
|
|
549
|
+
const text = await res.text();
|
|
550
|
+
expect(text).toBe("Not found");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("locals passed to renderRoute are visible inside load()", async () => {
|
|
554
|
+
const tree = await scanRoutes(tmpDir);
|
|
555
|
+
const matched = matchRoute(tree, new URL("http://localhost/locals-test"));
|
|
556
|
+
const res = await renderRoute(
|
|
557
|
+
matched!,
|
|
558
|
+
new Request("http://localhost/locals-test"),
|
|
559
|
+
[],
|
|
560
|
+
undefined,
|
|
561
|
+
{ tag: "from-hook" },
|
|
562
|
+
);
|
|
563
|
+
const html = await readStream(res);
|
|
564
|
+
expect(html).toContain("from-hook");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("+layout.server.ts load data is passed to the layout component", async () => {
|
|
568
|
+
const tree = await scanRoutes(tmpDir);
|
|
569
|
+
const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
|
|
570
|
+
const res = await renderRoute(
|
|
571
|
+
matched!,
|
|
572
|
+
new Request("http://localhost/dashboard"),
|
|
573
|
+
);
|
|
574
|
+
const html = await readStream(res);
|
|
575
|
+
expect(html).toContain('class="dash"');
|
|
576
|
+
expect(html).toContain('data-user="admin"');
|
|
577
|
+
expect(html).toContain("dash content");
|
|
578
|
+
});
|
|
425
579
|
});
|