@sigil-dev/grimoire 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/index.ts +35 -34
  2. package/package.json +8 -6
  3. package/preload.js +3 -2
  4. package/server.ts +13 -13
  5. package/src/client/head.ts +29 -29
  6. package/src/client/router.ts +290 -224
  7. package/src/dev/compile-module.ts +173 -0
  8. package/src/dev/effect-registry.ts +23 -0
  9. package/src/dev/graph.ts +114 -0
  10. package/src/dev/hmr-client.ts +158 -0
  11. package/src/dev/hmr-server.ts +187 -0
  12. package/src/dev/loader.ts +47 -0
  13. package/src/dev/paths.ts +14 -0
  14. package/src/dev/runtime-bundle.ts +49 -0
  15. package/src/dev/watcher.ts +44 -0
  16. package/src/integrations/vite.ts +73 -72
  17. package/src/rendering/hydrate.ts +120 -81
  18. package/src/rendering/index.ts +296 -199
  19. package/src/rendering/ssrPlugin.ts +67 -53
  20. package/src/routing/manifest-gen.ts +42 -39
  21. package/src/routing/router.ts +109 -106
  22. package/src/routing/scanner.ts +141 -135
  23. package/src/routing/transform-routes.ts +101 -101
  24. package/src/server/build.ts +239 -147
  25. package/src/server/coordinator.ts +306 -306
  26. package/src/server/index.ts +260 -50
  27. package/src/server/worker.ts +59 -59
  28. package/src/typegen/index.ts +356 -353
  29. package/src/types.ts +270 -269
  30. package/test/context.test.ts +52 -52
  31. package/test/hydration.test.ts +119 -119
  32. package/test/middleware.test.ts +223 -223
  33. package/test/rendering.test.ts +579 -425
  34. package/test/routing.test.ts +81 -83
  35. package/test/scanning.test.ts +200 -181
  36. package/test/scope.test.ts +24 -8
  37. package/test/server.test.ts +249 -229
  38. package/test/streaming.test.ts +125 -106
  39. package/test/transform-routes.test.ts +84 -84
  40. package/test/typegen.test.ts +35 -25
  41. package/tsconfig.json +1 -0
@@ -1,224 +1,290 @@
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
- }
1
+ import { popHydrationNodes, pushHydrationNodes } from "@sigil-dev/runtime";
2
+ import { withEffectScope } from "./scope";
3
+
4
+ let routeMap: Record<
5
+ string,
6
+ ((props: any) => any) & { load?: (ctx: any) => Promise<any> }
7
+ > = {};
8
+ let layoutList: { path: string; component: (props: any) => any }[] = [];
9
+ let disposeCurrentPage: (() => void) | null = null;
10
+ let currentPath = location.pathname;
11
+
12
+ // C1: Navigation hooks
13
+ let beforeNavigateCb: ((url: URL) => boolean | void) | null = null;
14
+ let onNavigateCb: ((url: URL) => void) | null = null;
15
+ let afterNavigateCb: ((url: URL) => void) | null = null;
16
+
17
+ // // HMR
18
+ // (globalThis as any).__grimoire_routes__ = routeMap;
19
+ // (globalThis as any).__grimoire_current_pattern__ = currentPath;
20
+ // (globalThis as any).__grimoire_navigate__ = navigate;
21
+
22
+ export async function hmrRerender(): Promise<void> {
23
+ if (!lastState) {
24
+ location.reload();
25
+ return;
26
+ }
27
+ disposeCurrentPage?.();
28
+ pushHydrationNodes([]);
29
+ let rootNode: any;
30
+ disposeCurrentPage = withEffectScope(() => {
31
+ rootNode = buildRouteTree(lastState!);
32
+ });
33
+ popHydrationNodes();
34
+ document.getElementById("grimoire-root")?.replaceChildren(rootNode);
35
+ }
36
+
37
+ // (globalThis as any).__grimoire_rerender__ = hmrRerender;
38
+
39
+ export function beforeNavigate(cb: (url: URL) => boolean | void): void {
40
+ beforeNavigateCb = cb;
41
+ }
42
+
43
+ export function onNavigate(cb: (url: URL) => void): void {
44
+ onNavigateCb = cb;
45
+ }
46
+
47
+ export function afterNavigate(cb: (url: URL) => void): void {
48
+ afterNavigateCb = cb;
49
+ }
50
+
51
+ // C2: Scroll restoration
52
+ const scrollPositions = new Map<string, number>();
53
+
54
+ function saveScrollPosition(): void {
55
+ scrollPositions.set(currentPath, window.scrollY);
56
+ }
57
+
58
+ function restoreScrollPosition(path: string): void {
59
+ const saved = scrollPositions.get(path);
60
+ if (saved !== undefined) {
61
+ requestAnimationFrame(() => window.scrollTo(0, saved));
62
+ } else {
63
+ requestAnimationFrame(() => window.scrollTo(0, 0));
64
+ }
65
+ }
66
+
67
+ // C3: Preloading
68
+ let preloadTimer: Timer | null = null;
69
+
70
+ function setupPreload(): void {
71
+ document.addEventListener("mouseover", (e) => {
72
+ const a = (e.target as Element).closest("a");
73
+ if (!a) return;
74
+ const href = a.getAttribute("href");
75
+ if (!href) return;
76
+ if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href)) return;
77
+
78
+ const preloadData = a.hasAttribute("data-sigil-preload-data");
79
+ const preloadCode = a.hasAttribute("data-sigil-preload-code");
80
+ if (!preloadData && !preloadCode) return;
81
+
82
+ if (preloadTimer) clearTimeout(preloadTimer);
83
+ preloadTimer = setTimeout(() => {
84
+ if (preloadCode) {
85
+ const link = document.createElement("link");
86
+ link.rel = "prefetch";
87
+ link.href = href;
88
+ document.head.appendChild(link);
89
+ }
90
+ if (preloadData) {
91
+ fetch(href, {
92
+ headers: { "x-grimoire-navigate": "1" },
93
+ priority: "low",
94
+ }).catch(() => {});
95
+ }
96
+ }, 80);
97
+ });
98
+
99
+ document.addEventListener("mouseout", (e) => {
100
+ const a = (e.target as Element).closest("a");
101
+ if (!a) return;
102
+ if (preloadTimer) clearTimeout(preloadTimer);
103
+ });
104
+ }
105
+
106
+ let lastState: {
107
+ data: any;
108
+ layoutData: any[];
109
+ params: any;
110
+ pattern: string;
111
+ } | null = null;
112
+
113
+ function buildRouteTree(state: typeof lastState): any {
114
+ const { data, layoutData, params, pattern } = state!;
115
+ const Page = routeMap[pattern];
116
+ if (!Page) throw new Error(`no component for pattern ${pattern}`);
117
+
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
+ return renderFn();
150
+ }
151
+
152
+ export async function navigate(path: string) {
153
+ const url = new URL(path, location.origin);
154
+
155
+ if (beforeNavigateCb) {
156
+ const shouldProceed = beforeNavigateCb(url);
157
+ if (shouldProceed === false) return;
158
+ }
159
+ onNavigateCb?.(url);
160
+ saveScrollPosition();
161
+
162
+ const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
163
+ const contentType = res.headers.get("content-type") ?? "";
164
+ if (!contentType.includes("application/json")) {
165
+ window.location.href = path;
166
+ return;
167
+ }
168
+ const json = await res.json();
169
+ const { data, layoutData, params, pattern, head } = json;
170
+ const Page = routeMap[pattern];
171
+ if (!Page) {
172
+ window.location.href = path;
173
+ return;
174
+ }
175
+
176
+ let mergedData = data;
177
+ if (Page.load) {
178
+ try {
179
+ const clientData = await Page.load({
180
+ params,
181
+ url,
182
+ request: new Request(url),
183
+ });
184
+ mergedData = { ...clientData, ...data };
185
+ } catch (e: any) {
186
+ if (e?.location) {
187
+ window.location.href = e.location;
188
+ return;
189
+ }
190
+ console.error("[grimoire] universal load error:", e);
191
+ }
192
+ }
193
+
194
+ // track state for HMR rerender
195
+ lastState = { data: mergedData, layoutData, params, pattern };
196
+ (globalThis as any).__grimoire_current_pattern__ = pattern;
197
+
198
+ disposeCurrentPage?.();
199
+ disposeCurrentPage = null;
200
+ pushHydrationNodes([]);
201
+ let rootNode: any;
202
+ disposeCurrentPage = withEffectScope(() => {
203
+ try {
204
+ rootNode = buildRouteTree(lastState!);
205
+ } catch (e) {
206
+ console.error(e);
207
+ }
208
+ });
209
+ popHydrationNodes();
210
+
211
+ updateHead(head);
212
+ document.title = head?.title ?? document.title;
213
+ document.getElementById("grimoire-root")?.replaceChildren(rootNode);
214
+ currentPath = path;
215
+ restoreScrollPosition(path);
216
+ afterNavigateCb?.(url);
217
+ }
218
+
219
+ export function initRouter(
220
+ routes: Record<
221
+ string,
222
+ ((props: any) => any) & { load?: (ctx: any) => Promise<any> }
223
+ >,
224
+ layouts: any[],
225
+ initialDispose?: () => void,
226
+ ) {
227
+ routeMap = routes;
228
+ layoutList = layouts;
229
+ disposeCurrentPage = initialDispose ?? null;
230
+ currentPath = location.pathname;
231
+
232
+ // set initial HMR state from SSR
233
+ const stateEl = document.getElementById("__grimoire_state__");
234
+ if (stateEl) {
235
+ const state = JSON.parse(stateEl.textContent!);
236
+ lastState = {
237
+ data: state.data,
238
+ layoutData: state.layoutData,
239
+ params: state.params,
240
+ pattern: state.pattern,
241
+ };
242
+ (globalThis as any).__grimoire_current_pattern__ = state.pattern;
243
+ (globalThis as any).__grimoire_routes__ = routeMap;
244
+ (globalThis as any).__grimoire_rerender__ = hmrRerender;
245
+ (globalThis as any).__grimoire_navigate__ = (path: string) =>
246
+ navigate(path);
247
+ }
248
+
249
+ saveScrollPosition();
250
+ document.addEventListener("click", handleClick);
251
+ window.addEventListener("popstate", () => {
252
+ if (location.pathname !== currentPath) {
253
+ saveScrollPosition();
254
+ currentPath = location.pathname;
255
+ navigate(location.pathname);
256
+ }
257
+ });
258
+ setupPreload();
259
+ }
260
+
261
+ function updateHead(headHtml: string) {
262
+ document
263
+ .querySelectorAll("[data-grimoire-head]")
264
+ //biome-ignore lint/suspicious/useIterableCallbackReturn: shut up
265
+ .forEach((el) => el.remove());
266
+ const parsed = headHtml
267
+ ? new DOMParser().parseFromString(`<head>${headHtml}</head>`, "text/html")
268
+ : null;
269
+ const incomingTitle = parsed?.querySelector("title");
270
+ //biome-ignore lint/suspicious/useIterableCallbackReturn: shut up
271
+ document.querySelectorAll("title").forEach((el) => el.remove());
272
+ document.title = incomingTitle?.textContent ?? document.title;
273
+ if (parsed) {
274
+ for (const el of Array.from(parsed.head.children)) {
275
+ if (el.tagName === "TITLE") continue;
276
+ (el as HTMLElement).dataset.grimoireHead = "1";
277
+ document.head.appendChild(el);
278
+ }
279
+ }
280
+ }
281
+
282
+ function handleClick(e: MouseEvent) {
283
+ const a = (e.target as Element).closest("a");
284
+ if (!a) return;
285
+ const href = a.getAttribute("href");
286
+ if (!href) return;
287
+ if (/^(https?:\/\/|\/\/|#|mailto:|tel:)/.test(href)) return;
288
+ e.preventDefault();
289
+ navigate(href);
290
+ }