@sigil-dev/grimoire 0.7.4 → 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.
- 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 +34 -34
- package/package.json +8 -4
- package/preload.js +2 -0
- package/public/__grimoire__/hydrate.js +585 -0
- package/public/__grimoire__/index.js +490 -0
- package/src/client/head.ts +29 -0
- package/src/client/router.ts +224 -76
- 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 +72 -72
- package/src/rendering/head.ts +22 -2
- package/src/rendering/hydrate.ts +81 -26
- package/src/rendering/index.ts +199 -186
- package/src/rendering/ssrPlugin.ts +53 -42
- package/src/routing/manifest-gen.ts +39 -26
- package/src/routing/router.ts +106 -98
- package/src/routing/scanner.ts +135 -129
- package/src/routing/transform-routes.ts +101 -96
- package/src/server/build.ts +147 -90
- package/src/server/coordinator.ts +306 -297
- package/src/server/hooks.ts +24 -3
- package/src/server/index.ts +148 -71
- package/src/server/worker.ts +59 -59
- package/src/typegen/index.ts +353 -340
- package/src/types.ts +269 -260
- package/test/context.test.ts +52 -52
- package/test/hydration.test.ts +119 -119
- package/test/middleware.test.ts +223 -221
- package/test/rendering.test.ts +425 -425
- package/test/routing.test.ts +83 -45
- package/test/scanning.test.ts +181 -169
- package/test/server.test.ts +229 -229
- package/test/streaming.test.ts +106 -106
- package/test/transform-routes.test.ts +84 -84
- package/test/typegen.test.ts +19 -1
package/src/client/router.ts
CHANGED
|
@@ -1,76 +1,224 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
let
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
document.addEventListener("
|
|
47
|
-
|
|
48
|
-
if (
|
|
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
|
-
|
|
1
|
+
import { popHydrationNodes, pushHydrationNodes } from "@sigil-dev/runtime";
|
|
2
|
+
import { withEffectScope } from "./scope";
|
|
3
|
+
|
|
4
|
+
let routeMap: Record<string, (props: any) => any> = {};
|
|
5
|
+
let layoutList: { path: string; component: (props: any) => any }[] = [];
|
|
6
|
+
let disposeCurrentPage: (() => void) | null = null;
|
|
7
|
+
let currentPath = location.pathname;
|
|
8
|
+
|
|
9
|
+
// C1: Navigation hooks
|
|
10
|
+
let beforeNavigateCb: ((url: URL) => boolean | void) | null = null;
|
|
11
|
+
let onNavigateCb: ((url: URL) => void) | null = null;
|
|
12
|
+
let afterNavigateCb: ((url: URL) => void) | null = null;
|
|
13
|
+
|
|
14
|
+
export function beforeNavigate(cb: (url: URL) => boolean | void): void {
|
|
15
|
+
beforeNavigateCb = cb;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function onNavigate(cb: (url: URL) => void): void {
|
|
19
|
+
onNavigateCb = cb;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function afterNavigate(cb: (url: URL) => void): void {
|
|
23
|
+
afterNavigateCb = cb;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// C2: Scroll restoration
|
|
27
|
+
const scrollPositions = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
function saveScrollPosition(): void {
|
|
30
|
+
scrollPositions.set(currentPath, window.scrollY);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function restoreScrollPosition(path: string): void {
|
|
34
|
+
const saved = scrollPositions.get(path);
|
|
35
|
+
if (saved !== undefined) {
|
|
36
|
+
requestAnimationFrame(() => window.scrollTo(0, saved));
|
|
37
|
+
} else {
|
|
38
|
+
requestAnimationFrame(() => window.scrollTo(0, 0));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// C3: Preloading
|
|
43
|
+
let preloadTimer: Timer | null = null;
|
|
44
|
+
|
|
45
|
+
function setupPreload(): void {
|
|
46
|
+
document.addEventListener("mouseover", (e) => {
|
|
47
|
+
const a = (e.target as Element).closest("a");
|
|
48
|
+
if (!a) return;
|
|
49
|
+
const href = a.getAttribute("href");
|
|
50
|
+
if (!href) return;
|
|
51
|
+
if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href)) return;
|
|
52
|
+
|
|
53
|
+
const preloadData = a.hasAttribute("data-sigil-preload-data");
|
|
54
|
+
const preloadCode = a.hasAttribute("data-sigil-preload-code");
|
|
55
|
+
if (!preloadData && !preloadCode) return;
|
|
56
|
+
|
|
57
|
+
if (preloadTimer) clearTimeout(preloadTimer);
|
|
58
|
+
preloadTimer = setTimeout(() => {
|
|
59
|
+
if (preloadCode) {
|
|
60
|
+
const link = document.createElement("link");
|
|
61
|
+
link.rel = "prefetch";
|
|
62
|
+
link.href = href;
|
|
63
|
+
document.head.appendChild(link);
|
|
64
|
+
}
|
|
65
|
+
if (preloadData) {
|
|
66
|
+
fetch(href, {
|
|
67
|
+
headers: { "x-grimoire-navigate": "1" },
|
|
68
|
+
priority: "low",
|
|
69
|
+
}).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
}, 80);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
document.addEventListener("mouseout", (e) => {
|
|
75
|
+
const a = (e.target as Element).closest("a");
|
|
76
|
+
if (!a) return;
|
|
77
|
+
if (preloadTimer) clearTimeout(preloadTimer);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function navigate(path: string) {
|
|
82
|
+
const url = new URL(path, location.origin);
|
|
83
|
+
|
|
84
|
+
// C1: beforeNavigate — can cancel
|
|
85
|
+
if (beforeNavigateCb) {
|
|
86
|
+
const shouldProceed = beforeNavigateCb(url);
|
|
87
|
+
if (shouldProceed === false) return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// C1: onNavigate
|
|
91
|
+
onNavigateCb?.(url);
|
|
92
|
+
|
|
93
|
+
// C2: Save scroll position before navigating away
|
|
94
|
+
saveScrollPosition();
|
|
95
|
+
|
|
96
|
+
const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
|
|
97
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
98
|
+
if (!contentType.includes("application/json")) {
|
|
99
|
+
window.location.href = path;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const json = await res.json();
|
|
103
|
+
const { data, layoutData, params, pattern, head } = json;
|
|
104
|
+
const Page = routeMap[pattern];
|
|
105
|
+
if (!Page) {
|
|
106
|
+
window.location.href = path;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
disposeCurrentPage?.();
|
|
111
|
+
disposeCurrentPage = null;
|
|
112
|
+
|
|
113
|
+
// SPA navigation: empty pool so components create fresh DOM
|
|
114
|
+
pushHydrationNodes([]);
|
|
115
|
+
let rootNode: any;
|
|
116
|
+
disposeCurrentPage = withEffectScope(() => {
|
|
117
|
+
try {
|
|
118
|
+
const matchedLayouts = layoutList
|
|
119
|
+
.filter(
|
|
120
|
+
(l) =>
|
|
121
|
+
l.path === "/" ||
|
|
122
|
+
pattern === l.path ||
|
|
123
|
+
pattern.startsWith(l.path + "/"),
|
|
124
|
+
)
|
|
125
|
+
.sort((a, b) => a.path.length - b.path.length);
|
|
126
|
+
|
|
127
|
+
let renderFn = () => {
|
|
128
|
+
const pageNode = Page({ data, params });
|
|
129
|
+
const pageDiv = document.createElement("div");
|
|
130
|
+
pageDiv.id = "grimoire-page";
|
|
131
|
+
pageDiv.appendChild(pageNode);
|
|
132
|
+
return pageDiv;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
for (let i = matchedLayouts.length - 1; i >= 0; i--) {
|
|
136
|
+
const LayoutComponent = matchedLayouts[i].component;
|
|
137
|
+
const innerRender = renderFn;
|
|
138
|
+
const currentLayoutData = layoutData?.[i];
|
|
139
|
+
renderFn = () => {
|
|
140
|
+
const childNode = innerRender();
|
|
141
|
+
return LayoutComponent({
|
|
142
|
+
data: currentLayoutData,
|
|
143
|
+
params,
|
|
144
|
+
children: childNode,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
rootNode = renderFn();
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(e);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
popHydrationNodes();
|
|
155
|
+
|
|
156
|
+
// Update head from navigation response
|
|
157
|
+
updateHead(head);
|
|
158
|
+
|
|
159
|
+
document.title = head?.title ?? document.title;
|
|
160
|
+
document.getElementById("grimoire-root")?.replaceChildren(rootNode);
|
|
161
|
+
|
|
162
|
+
currentPath = path;
|
|
163
|
+
|
|
164
|
+
// C2: Restore scroll position
|
|
165
|
+
restoreScrollPosition(path);
|
|
166
|
+
|
|
167
|
+
// C1: afterNavigate
|
|
168
|
+
afterNavigateCb?.(url);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function initRouter(
|
|
172
|
+
routes: Record<string, any>,
|
|
173
|
+
layouts: any[],
|
|
174
|
+
initialDispose?: () => void,
|
|
175
|
+
) {
|
|
176
|
+
routeMap = routes;
|
|
177
|
+
layoutList = layouts;
|
|
178
|
+
disposeCurrentPage = initialDispose ?? null;
|
|
179
|
+
currentPath = location.pathname;
|
|
180
|
+
|
|
181
|
+
// C2: Save initial scroll position
|
|
182
|
+
saveScrollPosition();
|
|
183
|
+
|
|
184
|
+
document.addEventListener("click", handleClick);
|
|
185
|
+
window.addEventListener("popstate", () => {
|
|
186
|
+
if (location.pathname !== currentPath) {
|
|
187
|
+
saveScrollPosition();
|
|
188
|
+
currentPath = location.pathname;
|
|
189
|
+
navigate(location.pathname);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// C3: Setup preloading
|
|
194
|
+
setupPreload();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function updateHead(headHtml: string) {
|
|
198
|
+
document
|
|
199
|
+
.querySelectorAll("[data-grimoire-head]")
|
|
200
|
+
.forEach((el) => el.remove());
|
|
201
|
+
const parsed = headHtml
|
|
202
|
+
? new DOMParser().parseFromString(`<head>${headHtml}</head>`, "text/html")
|
|
203
|
+
: null;
|
|
204
|
+
const incomingTitle = parsed?.querySelector("title");
|
|
205
|
+
document.querySelectorAll("title").forEach((el) => el.remove());
|
|
206
|
+
document.title = incomingTitle?.textContent ?? document.title;
|
|
207
|
+
if (parsed) {
|
|
208
|
+
for (const el of Array.from(parsed.head.children)) {
|
|
209
|
+
if (el.tagName === "TITLE") continue;
|
|
210
|
+
(el as HTMLElement).dataset.grimoireHead = "1";
|
|
211
|
+
document.head.appendChild(el);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function handleClick(e: MouseEvent) {
|
|
217
|
+
const a = (e.target as Element).closest("a");
|
|
218
|
+
if (!a) return;
|
|
219
|
+
const href = a.getAttribute("href");
|
|
220
|
+
if (!href) return;
|
|
221
|
+
if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href)) return;
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
navigate(href);
|
|
224
|
+
}
|
package/src/env/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const publicPrefix = "PUBLIC_";
|
|
2
|
+
|
|
3
|
+
export function filterPublic(
|
|
4
|
+
env: Record<string, string | undefined>,
|
|
5
|
+
): Record<string, string> {
|
|
6
|
+
const result: Record<string, string> = {};
|
|
7
|
+
for (const [key, value] of Object.entries(env)) {
|
|
8
|
+
if (key.startsWith(publicPrefix) && value !== undefined) {
|
|
9
|
+
result[key] = value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function filterPrivate(
|
|
16
|
+
env: Record<string, string | undefined>,
|
|
17
|
+
): Record<string, string> {
|
|
18
|
+
const result: Record<string, string> = {};
|
|
19
|
+
for (const [key, value] of Object.entries(env)) {
|
|
20
|
+
if (!key.startsWith(publicPrefix) && value !== undefined) {
|
|
21
|
+
result[key] = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export const envPlugin = () => ({
|
|
4
|
+
name: "grimoire-env",
|
|
5
|
+
setup(build: any) {
|
|
6
|
+
build.onResolve({ filter: /^\$env\/static\/public$/ }, () => ({
|
|
7
|
+
path: join(import.meta.dir, "public.ts"),
|
|
8
|
+
}));
|
|
9
|
+
build.onResolve({ filter: /^\$env\/static\/private$/ }, () => ({
|
|
10
|
+
path: join(import.meta.dir, "private.ts"),
|
|
11
|
+
}));
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { filterPublic } from "./index";
|
|
2
|
+
|
|
3
|
+
// Inlined at build time — values captured from process.env during the build.
|
|
4
|
+
// For runtime reads (when not compiled), fall back to live process.env.
|
|
5
|
+
export const env = filterPublic(
|
|
6
|
+
typeof process !== "undefined" ? process.env : {},
|
|
7
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function generateEnvTypes(
|
|
5
|
+
projectRoot: string,
|
|
6
|
+
outDir: string,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
const envPath = join(projectRoot, ".env");
|
|
9
|
+
let envContent = "";
|
|
10
|
+
try {
|
|
11
|
+
envContent = await readFile(envPath, "utf-8");
|
|
12
|
+
} catch {
|
|
13
|
+
// no .env file — generate empty types
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const publicVars: string[] = [];
|
|
17
|
+
const privateVars: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const line of envContent.split("\n")) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
22
|
+
const eqIndex = trimmed.indexOf("=");
|
|
23
|
+
if (eqIndex === -1) continue;
|
|
24
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
25
|
+
if (!key) continue;
|
|
26
|
+
if (key.startsWith("PUBLIC_")) {
|
|
27
|
+
publicVars.push(key);
|
|
28
|
+
} else {
|
|
29
|
+
privateVars.push(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const publicTypeContent = `// Auto-generated by Grimoire. Do not edit.
|
|
34
|
+
declare module "$env/static/public" {
|
|
35
|
+
${publicVars.map((v) => `\texport const ${v}: string;`).join("\n")}
|
|
36
|
+
${publicVars.length === 0 ? "\texport {};" : ""}
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const privateTypeContent = `// Auto-generated by Grimoire. Do not edit.
|
|
41
|
+
declare module "$env/static/private" {
|
|
42
|
+
${privateVars.map((v) => `\texport const ${v}: string;`).join("\n")}
|
|
43
|
+
${privateVars.length === 0 ? "\texport {};" : ""}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
await Promise.all([
|
|
48
|
+
writeFile(join(outDir, "env-public.d.ts"), publicTypeContent, "utf-8"),
|
|
49
|
+
writeFile(join(outDir, "env-private.d.ts"), privateTypeContent, "utf-8"),
|
|
50
|
+
]);
|
|
51
|
+
}
|
package/src/integrations/vite.ts
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
-
import type { Plugin } from "vite";
|
|
3
|
-
import { renderRoute } from "../rendering";
|
|
4
|
-
import { matchRoute } from "../routing/router.ts";
|
|
5
|
-
import { scanRoutes } from "../routing/scanner.ts";
|
|
6
|
-
|
|
7
|
-
const CLIENT_ENTRY = resolve(import.meta.dir, "./index.ts");
|
|
8
|
-
|
|
9
|
-
export function grimoire(options: { routes?: string } = {}): Plugin {
|
|
10
|
-
let isBuild = false;
|
|
11
|
-
|
|
12
|
-
return {
|
|
13
|
-
name: "grimoire",
|
|
14
|
-
|
|
15
|
-
configResolved(config) {
|
|
16
|
-
isBuild = config.command === "build";
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
configureServer(vite) {
|
|
20
|
-
const routesDir = isAbsolute(options.routes ?? "src/routes")
|
|
21
|
-
? options.routes!
|
|
22
|
-
: join(process.cwd(), options.routes ?? "src/routes");
|
|
23
|
-
|
|
24
|
-
// client entry
|
|
25
|
-
vite.middlewares.use("/__grimoire__/client.js", async (req, res) => {
|
|
26
|
-
const result = await vite.transformRequest(CLIENT_ENTRY);
|
|
27
|
-
if (!result) {
|
|
28
|
-
res.statusCode = 404;
|
|
29
|
-
res.end();
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
res.setHeader("Content-Type", "application/javascript");
|
|
33
|
-
res.end(result.code);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// page routes
|
|
37
|
-
vite.middlewares.use(async (req, res, next) => {
|
|
38
|
-
const url = new URL(req.url!, "http://localhost");
|
|
39
|
-
if (url.pathname.startsWith("/__") || url.pathname.includes(".")) {
|
|
40
|
-
return next();
|
|
41
|
-
}
|
|
42
|
-
try {
|
|
43
|
-
const tree = await scanRoutes(routesDir, process.cwd());
|
|
44
|
-
const matched = matchRoute(tree, url);
|
|
45
|
-
if (!matched) return next();
|
|
46
|
-
|
|
47
|
-
const response = await renderRoute(
|
|
48
|
-
matched,
|
|
49
|
-
new Request(`http://localhost${req.url}`),
|
|
50
|
-
(path) => vite.ssrLoadModule(path), // Vite transforms SSR files correctly
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const html = await response.text();
|
|
54
|
-
res.setHeader("Content-Type", "text/html");
|
|
55
|
-
res.end(html);
|
|
56
|
-
} catch (e) {
|
|
57
|
-
vite.ssrFixStacktrace(e as Error);
|
|
58
|
-
next(e);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
buildStart() {
|
|
64
|
-
if (!isBuild) return;
|
|
65
|
-
this.emitFile({
|
|
66
|
-
type: "chunk",
|
|
67
|
-
id: CLIENT_ENTRY,
|
|
68
|
-
fileName: "client.js",
|
|
69
|
-
});
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
}
|
|
1
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import type { Plugin } from "vite";
|
|
3
|
+
import { renderRoute } from "../rendering";
|
|
4
|
+
import { matchRoute } from "../routing/router.ts";
|
|
5
|
+
import { scanRoutes } from "../routing/scanner.ts";
|
|
6
|
+
|
|
7
|
+
const CLIENT_ENTRY = resolve(import.meta.dir, "./index.ts");
|
|
8
|
+
|
|
9
|
+
export function grimoire(options: { routes?: string } = {}): Plugin {
|
|
10
|
+
let isBuild = false;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: "grimoire",
|
|
14
|
+
|
|
15
|
+
configResolved(config) {
|
|
16
|
+
isBuild = config.command === "build";
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
configureServer(vite) {
|
|
20
|
+
const routesDir = isAbsolute(options.routes ?? "src/routes")
|
|
21
|
+
? options.routes!
|
|
22
|
+
: join(process.cwd(), options.routes ?? "src/routes");
|
|
23
|
+
|
|
24
|
+
// client entry
|
|
25
|
+
vite.middlewares.use("/__grimoire__/client.js", async (req, res) => {
|
|
26
|
+
const result = await vite.transformRequest(CLIENT_ENTRY);
|
|
27
|
+
if (!result) {
|
|
28
|
+
res.statusCode = 404;
|
|
29
|
+
res.end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
33
|
+
res.end(result.code);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// page routes
|
|
37
|
+
vite.middlewares.use(async (req, res, next) => {
|
|
38
|
+
const url = new URL(req.url!, "http://localhost");
|
|
39
|
+
if (url.pathname.startsWith("/__") || url.pathname.includes(".")) {
|
|
40
|
+
return next();
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const tree = await scanRoutes(routesDir, process.cwd());
|
|
44
|
+
const matched = matchRoute(tree, url);
|
|
45
|
+
if (!matched) return next();
|
|
46
|
+
|
|
47
|
+
const response = await renderRoute(
|
|
48
|
+
matched,
|
|
49
|
+
new Request(`http://localhost${req.url}`),
|
|
50
|
+
(path) => vite.ssrLoadModule(path), // Vite transforms SSR files correctly
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const html = await response.text();
|
|
54
|
+
res.setHeader("Content-Type", "text/html");
|
|
55
|
+
res.end(html);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
vite.ssrFixStacktrace(e as Error);
|
|
58
|
+
next(e);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
buildStart() {
|
|
64
|
+
if (!isBuild) return;
|
|
65
|
+
this.emitFile({
|
|
66
|
+
type: "chunk",
|
|
67
|
+
id: CLIENT_ENTRY,
|
|
68
|
+
fileName: "client.js",
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/rendering/head.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { createContext, getContext, setContext } from "@sigil-dev/runtime";
|
|
2
2
|
|
|
3
3
|
const HeadKey = createContext<string[]>();
|
|
4
|
+
const NonceKey = createContext<string>();
|
|
4
5
|
|
|
5
|
-
export function initHead(): void {
|
|
6
|
+
export function initHead(nonce?: string): void {
|
|
6
7
|
setContext(HeadKey, []);
|
|
8
|
+
if (nonce) setContext(NonceKey, nonce);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function setHeadNonce(nonce: string): void {
|
|
12
|
+
setContext(NonceKey, nonce);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getHeadNonce(): string {
|
|
16
|
+
return getContext(NonceKey) ?? "";
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
export function collectHead(): string {
|
|
@@ -17,7 +27,17 @@ export function Head({
|
|
|
17
27
|
}): string | Comment {
|
|
18
28
|
if (typeof document === "undefined") {
|
|
19
29
|
const buf = getContext(HeadKey);
|
|
20
|
-
if (buf)
|
|
30
|
+
if (buf) {
|
|
31
|
+
const nonce = getContext(NonceKey);
|
|
32
|
+
if (nonce) {
|
|
33
|
+
const withNonce = String(children)
|
|
34
|
+
.replace(/<script /g, `<script nonce="${nonce}" `)
|
|
35
|
+
.replace(/<style /g, `<style nonce="${nonce}" `);
|
|
36
|
+
buf.push(withNonce);
|
|
37
|
+
} else {
|
|
38
|
+
buf.push(children as string);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
21
41
|
return "";
|
|
22
42
|
}
|
|
23
43
|
if (children instanceof Node) {
|