@sigil-dev/grimoire 0.6.9 → 0.6.10
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/package.json +1 -1
- package/src/rendering/index.ts +151 -152
- package/src/routing/router.ts +72 -68
- package/test/exports.test.ts +0 -1
- package/test/rendering.test.ts +360 -254
- package/test/streaming.test.ts +101 -140
package/package.json
CHANGED
package/src/rendering/index.ts
CHANGED
|
@@ -11,177 +11,176 @@ import { collectHead, initHead } from "./head";
|
|
|
11
11
|
export type ModuleLoader = (path: string) => Promise<any>;
|
|
12
12
|
|
|
13
13
|
async function renderErrorPage(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
errorRoutes: RouteFile[],
|
|
15
|
+
pathname: string,
|
|
16
|
+
status: number,
|
|
17
|
+
message: string,
|
|
18
18
|
): Promise<Response | null> {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
const errorPage = findClosestError(errorRoutes, pathname);
|
|
20
|
+
if (!errorPage) return null;
|
|
21
|
+
const mod = await import(errorPage.filePath);
|
|
22
|
+
const html = mod.default({ status, message });
|
|
23
|
+
return new Response(html, {
|
|
24
|
+
status,
|
|
25
|
+
headers: { "Content-Type": "text/html" },
|
|
26
|
+
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function renderRoute(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
matched: MatchedRoute,
|
|
31
|
+
req: Request,
|
|
32
|
+
errorRoutes: RouteFile[] = [],
|
|
33
|
+
loadModule: ModuleLoader = (path) => import(path),
|
|
34
|
+
locals: Record<string, any> = {},
|
|
35
|
+
plugins: GrimoirePlugin[] = [],
|
|
36
36
|
): Promise<Response> {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
`<!DOCTYPE html>
|
|
37
|
+
return runWithContext(async () => {
|
|
38
|
+
const context: LoadContext = {
|
|
39
|
+
request: req,
|
|
40
|
+
params: matched.params,
|
|
41
|
+
url: new URL(req.url),
|
|
42
|
+
locals,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
initHead();
|
|
46
|
+
|
|
47
|
+
const route: Route = {
|
|
48
|
+
path: matched.route.path,
|
|
49
|
+
params: matched.params,
|
|
50
|
+
filePath: matched.route.filePath,
|
|
51
|
+
loadPath: matched.pageServer?.filePath,
|
|
52
|
+
layoutPath: matched.layouts.at(-1)?.filePath,
|
|
53
|
+
};
|
|
54
|
+
await runHook(plugins, "onRouteLoad", route, context);
|
|
55
|
+
|
|
56
|
+
const layoutPairs: { layout: RouteFile; data: unknown }[] = [];
|
|
57
|
+
for (const layout of matched.layouts) {
|
|
58
|
+
const layoutServer = matched.layoutServers.find(
|
|
59
|
+
ls => ls.path === layout.path
|
|
60
|
+
);
|
|
61
|
+
let data: unknown = undefined;
|
|
62
|
+
if (layoutServer) {
|
|
63
|
+
try {
|
|
64
|
+
const mod = await import(layoutServer.filePath);
|
|
65
|
+
data = await mod.load?.(context);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (isRedirectResult(e)) return new Response(null, {
|
|
68
|
+
status: e.status, headers: { Location: e.location }
|
|
69
|
+
});
|
|
70
|
+
if (isErrorResult(e)) return (
|
|
71
|
+
await renderErrorPage(errorRoutes, context.url.pathname, e.status, e.message)
|
|
72
|
+
?? new Response(e.message, { status: e.status })
|
|
73
|
+
);
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
layoutPairs.push({ layout, data });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let pageData: unknown;
|
|
81
|
+
if (matched.pageServer) {
|
|
82
|
+
try {
|
|
83
|
+
const mod = await import(matched.pageServer.filePath);
|
|
84
|
+
pageData = await mod.load?.(context);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (isRedirectResult(e)) {
|
|
87
|
+
return new Response(null, {
|
|
88
|
+
status: e.status,
|
|
89
|
+
headers: { Location: e.location },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (isErrorResult(e)) {
|
|
93
|
+
return (
|
|
94
|
+
(await renderErrorPage(
|
|
95
|
+
errorRoutes,
|
|
96
|
+
context.url.pathname,
|
|
97
|
+
e.status,
|
|
98
|
+
e.message,
|
|
99
|
+
)) ?? new Response(e.message, { status: e.status })
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const pageMod = await import(matched.route.filePath);
|
|
107
|
+
const pageHtml = pageMod.default({
|
|
108
|
+
data: pageData,
|
|
109
|
+
params: matched.params,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// collect head AFTER page render so <Head> calls are captured
|
|
113
|
+
const headHtml = collectHead();
|
|
114
|
+
|
|
115
|
+
// navigation request: return JSON, client handles rendering
|
|
116
|
+
if (req.headers.get("x-grimoire-navigate") === "1") {
|
|
117
|
+
return Response.json({
|
|
118
|
+
data: pageData ?? {},
|
|
119
|
+
params: matched.params,
|
|
120
|
+
pattern: matched.route.path,
|
|
121
|
+
head: headHtml,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const wrappedPage = `<div id="grimoire-page">${String(pageHtml)}</div>`;
|
|
126
|
+
|
|
127
|
+
let bodyHtml: string = wrappedPage;
|
|
128
|
+
for (const { layout, data } of [...layoutPairs].reverse()) {
|
|
129
|
+
const layoutMod = await import(layout.filePath);
|
|
130
|
+
bodyHtml = String(
|
|
131
|
+
layoutMod.default({
|
|
132
|
+
data,
|
|
133
|
+
children: new SafeHtml(bodyHtml),
|
|
134
|
+
params: matched.params,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stateJson = JSON.stringify({
|
|
140
|
+
params: matched.params,
|
|
141
|
+
data: pageData,
|
|
142
|
+
pattern: matched.route.path,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- Streaming SSR ---
|
|
146
|
+
// Send DOCTYPE + head skeleton immediately (browser starts resource fetch).
|
|
147
|
+
// Then stream body + full head content + state as they become available.
|
|
148
|
+
const stream = new ReadableStream({
|
|
149
|
+
start(controller) {
|
|
150
|
+
// 1. Document skeleton — browser starts parsing, fetches CSS/JS
|
|
151
|
+
controller.enqueue(
|
|
152
|
+
`<!DOCTYPE html>
|
|
154
153
|
<html>
|
|
155
154
|
<head>
|
|
156
155
|
<meta charset="UTF-8" />
|
|
157
156
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
158
157
|
<script type="module" src="/__grimoire__/hydrate.js"></script>`,
|
|
159
|
-
|
|
158
|
+
);
|
|
160
159
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
// 2. Head content (captured from <Head> component calls during render)
|
|
161
|
+
if (headHtml) {
|
|
162
|
+
controller.enqueue(`\n${headHtml}`);
|
|
163
|
+
}
|
|
165
164
|
|
|
166
|
-
|
|
165
|
+
controller.enqueue(`\n</head>
|
|
167
166
|
<body>
|
|
168
167
|
<div id="app">`);
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
// 3. Page body
|
|
170
|
+
controller.enqueue(bodyHtml);
|
|
172
171
|
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
// 4. State script + closing tags
|
|
173
|
+
controller.enqueue(`</div>
|
|
175
174
|
<script id="__grimoire_state__" type="application/json">${stateJson}</script>
|
|
176
175
|
</body>
|
|
177
176
|
</html>`);
|
|
178
177
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
controller.close();
|
|
179
|
+
},
|
|
180
|
+
});
|
|
182
181
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
return new Response(stream, {
|
|
183
|
+
headers: { "Content-Type": "text/html" },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
187
186
|
}
|
package/src/routing/router.ts
CHANGED
|
@@ -1,94 +1,98 @@
|
|
|
1
|
-
// packages/grimoire/src/router.ts
|
|
2
1
|
import type { RouteFile, RouteTree } from "./scanner";
|
|
3
2
|
|
|
4
3
|
export interface MatchedRoute {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
route: RouteFile;
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
layouts: RouteFile[]; // was: layout?: RouteFile
|
|
7
|
+
layoutServers: RouteFile[]; // was: layoutServer?: RouteFile
|
|
8
|
+
pageServer?: RouteFile;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
export function matchRoute(tree: RouteTree, url: URL): MatchedRoute | null {
|
|
13
|
-
|
|
12
|
+
const pathname = url.pathname;
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
// check server routes first
|
|
15
|
+
for (const server of tree.servers) {
|
|
16
|
+
const params = matchPattern(server.path, pathname);
|
|
17
|
+
if (params !== null) return {
|
|
18
|
+
route: server,
|
|
19
|
+
params,
|
|
20
|
+
layouts: [],
|
|
21
|
+
layoutServers: []
|
|
22
|
+
};
|
|
23
|
+
}
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// then pages
|
|
26
|
+
for (const route of tree.routes) {
|
|
27
|
+
if (route.type === "pageServer") continue;
|
|
28
|
+
const params = matchPattern(route.path, pathname);
|
|
29
|
+
if (params === null) continue;
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// find applicable layout (longest matching prefix wins)
|
|
32
|
+
const layouts = tree.layouts
|
|
33
|
+
.filter(
|
|
34
|
+
(l) =>
|
|
35
|
+
l.type === "layout" &&
|
|
36
|
+
pathname.startsWith(l.path === "/" ? "/" : l.path),
|
|
37
|
+
)
|
|
38
|
+
.sort((a, b) => a.path.length - b.path.length); // root first, innermost last
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const layoutServers = tree.layouts
|
|
41
|
+
.filter(
|
|
42
|
+
(l) =>
|
|
43
|
+
l.type === "layoutServer" &&
|
|
44
|
+
pathname.startsWith(l.path === "/" ? "/" : l.path),
|
|
45
|
+
)
|
|
46
|
+
.sort((a, b) => a.path.length - b.path.length); // root first, innermost last
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
const pageServer = tree.routes.find(
|
|
49
|
+
(r) => r.type === "pageServer" && r.path === route.path,
|
|
50
|
+
);
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
return { route, params, layouts, layoutServers, pageServer };
|
|
53
|
+
}
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
return null;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export function findClosestError(
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
errors: RouteFile[],
|
|
60
|
+
pathname: string,
|
|
57
61
|
): RouteFile | null {
|
|
58
|
-
|
|
62
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
// walk from most specific to root
|
|
65
|
+
while (segments.length >= 0) {
|
|
66
|
+
const prefix = `/${segments.join("/")}`;
|
|
67
|
+
const error = errors.find(
|
|
68
|
+
(e) => e.path === prefix || e.path === prefix + "/",
|
|
69
|
+
);
|
|
70
|
+
if (error) return error;
|
|
71
|
+
if (segments.length === 0) break;
|
|
72
|
+
segments.pop();
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
function matchPattern(
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
pattern: string,
|
|
79
|
+
pathname: string,
|
|
76
80
|
): Record<string, string> | null {
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
82
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
79
83
|
|
|
80
|
-
|
|
84
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const params: Record<string, string> = {};
|
|
87
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
88
|
+
const patternPart = patternParts[i]!;
|
|
89
|
+
const pathPart = pathParts[i]!;
|
|
86
90
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
if (patternPart.startsWith(":")) {
|
|
92
|
+
params[patternPart.slice(1)] = pathPart;
|
|
93
|
+
} else if (patternPart !== pathPart) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return params;
|
|
94
98
|
}
|
package/test/exports.test.ts
CHANGED
|
@@ -13,7 +13,6 @@ describe("README public API exports", () => {
|
|
|
13
13
|
expect(typeof mod.sequence).toBe("function");
|
|
14
14
|
expect(typeof mod.defineConfig).toBe("function");
|
|
15
15
|
expect(typeof mod.Head).toBe("function");
|
|
16
|
-
expect(typeof mod.createServer).toBe("function");
|
|
17
16
|
// isXxx helpers used internally
|
|
18
17
|
expect(typeof mod.isErrorResult).toBe("function");
|
|
19
18
|
expect(typeof mod.isFailResult).toBe("function");
|