@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/src/client/router.ts
CHANGED
|
@@ -1,67 +1,281 @@
|
|
|
1
|
+
import { popHydrationNodes, pushHydrationNodes } from "@sigil-dev/runtime";
|
|
1
2
|
import { withEffectScope } from "./scope";
|
|
2
3
|
|
|
3
|
-
let routeMap: Record<
|
|
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 }[] = [];
|
|
4
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;
|
|
5
16
|
|
|
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
|
-
|
|
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);
|
|
37
217
|
}
|
|
38
|
-
let currentPath = location.pathname;
|
|
39
218
|
|
|
40
219
|
export function initRouter(
|
|
41
|
-
routes: Record<
|
|
220
|
+
routes: Record<
|
|
221
|
+
string,
|
|
222
|
+
((props: any) => any) & { load?: (ctx: any) => Promise<any> }
|
|
223
|
+
>,
|
|
224
|
+
layouts: any[],
|
|
42
225
|
initialDispose?: () => void,
|
|
43
226
|
) {
|
|
44
227
|
routeMap = routes;
|
|
228
|
+
layoutList = layouts;
|
|
45
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();
|
|
46
250
|
document.addEventListener("click", handleClick);
|
|
47
251
|
window.addEventListener("popstate", () => {
|
|
48
252
|
if (location.pathname !== currentPath) {
|
|
253
|
+
saveScrollPosition();
|
|
49
254
|
currentPath = location.pathname;
|
|
50
255
|
navigate(location.pathname);
|
|
51
256
|
}
|
|
52
257
|
});
|
|
258
|
+
setupPreload();
|
|
53
259
|
}
|
|
54
260
|
|
|
55
261
|
function updateHead(headHtml: string) {
|
|
56
262
|
document
|
|
57
263
|
.querySelectorAll("[data-grimoire-head]")
|
|
264
|
+
//biome-ignore lint/suspicious/useIterableCallbackReturn: shut up
|
|
58
265
|
.forEach((el) => el.remove());
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
279
|
}
|
|
66
280
|
}
|
|
67
281
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { DevGraph } from "./graph";
|
|
5
|
+
import { normalizePath } from "./paths";
|
|
6
|
+
|
|
7
|
+
const RUNTIME_URL = "/__grimoire__/runtime.js";
|
|
8
|
+
const MODULES_BASE = "/__grimoire__/m/";
|
|
9
|
+
const DEPS_BASE = "/__grimoire__/dep/";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rewrite a single import specifier to a browser-servable URL.
|
|
13
|
+
* project-relative → /__grimoire__/m/...
|
|
14
|
+
* @sigil-dev/runtime → /__grimoire__/runtime.js
|
|
15
|
+
* bare npm → /__grimoire__/dep/...
|
|
16
|
+
* relative → /__grimoire__/m/... (resolved)
|
|
17
|
+
*/
|
|
18
|
+
function rewriteSpecifier(
|
|
19
|
+
specifier: string,
|
|
20
|
+
fromFile: string,
|
|
21
|
+
projectRoot: string,
|
|
22
|
+
): string {
|
|
23
|
+
if (specifier === "@sigil-dev/runtime") return RUNTIME_URL;
|
|
24
|
+
// if (specifier.startsWith("@sigil-dev/")) return RUNTIME_URL; // all sigil packages
|
|
25
|
+
|
|
26
|
+
// relative import — resolve to absolute, then make a module URL
|
|
27
|
+
if (specifier.startsWith(".")) {
|
|
28
|
+
const resolved = join(dirname(fromFile), specifier);
|
|
29
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
30
|
+
const candidate = resolved.endsWith(ext) ? resolved : resolved + ext;
|
|
31
|
+
if (existsSync(candidate)) {
|
|
32
|
+
const rel = relative(projectRoot, candidate).replace(/\\/g, "/");
|
|
33
|
+
return `${MODULES_BASE}${rel}.js`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// already has extension
|
|
37
|
+
const rel = relative(projectRoot, resolved).replace(/\\/g, "/");
|
|
38
|
+
return `${MODULES_BASE}${rel}.js`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// $lib alias
|
|
42
|
+
if (specifier.startsWith("$lib/")) {
|
|
43
|
+
const rel = specifier.replace("$lib/", "src/lib/");
|
|
44
|
+
// try common extensions
|
|
45
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
46
|
+
const candidate = join(projectRoot, rel + ext);
|
|
47
|
+
if (existsSync(candidate)) {
|
|
48
|
+
return `${MODULES_BASE}${rel}${ext}.js`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return `${MODULES_BASE}${rel}.ts.js`; // fallback
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// $env — keep as-is, these are virtual modules handled elsewhere
|
|
55
|
+
if (specifier.startsWith("$env/")) return specifier;
|
|
56
|
+
|
|
57
|
+
// bare npm specifier → dep bundle
|
|
58
|
+
const pkgName = specifier.startsWith("@")
|
|
59
|
+
? specifier.split("/").slice(0, 2).join("/")
|
|
60
|
+
: specifier.split("/")[0];
|
|
61
|
+
return `${DEPS_BASE}${pkgName!.replace("/", "__")}.js`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rewrite all import/export from specifiers in compiled JS output.
|
|
66
|
+
* Works on the static pattern `from "..."` — sufficient for Babel output.
|
|
67
|
+
*/
|
|
68
|
+
function rewriteImports(
|
|
69
|
+
code: string,
|
|
70
|
+
filePath: string,
|
|
71
|
+
projectRoot: string,
|
|
72
|
+
): string {
|
|
73
|
+
return code.replace(
|
|
74
|
+
/(from\s+|import\s+)(["'])([^"']+)\2/g,
|
|
75
|
+
(match, keyword, quote, specifier) => {
|
|
76
|
+
const rewritten = rewriteSpecifier(specifier, filePath, projectRoot);
|
|
77
|
+
return `${keyword}${quote}${rewritten}${quote}`;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CompileResult {
|
|
83
|
+
js: string;
|
|
84
|
+
css: string | null;
|
|
85
|
+
cssHash: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function compileForBrowser(
|
|
89
|
+
filePath: string,
|
|
90
|
+
projectRoot: string,
|
|
91
|
+
): Promise<CompileResult> {
|
|
92
|
+
const source = await Bun.file(filePath).text();
|
|
93
|
+
|
|
94
|
+
// extract scoped CSS
|
|
95
|
+
const STYLE_RE = /<style[^>]*>([\s\S]*?)<\/style>/i;
|
|
96
|
+
const { computeHash, scopeCSS } = await import(
|
|
97
|
+
pathToFileURL(
|
|
98
|
+
join(
|
|
99
|
+
projectRoot,
|
|
100
|
+
"node_modules/@sigil-dev/compiler/src/babel/util/css.ts",
|
|
101
|
+
),
|
|
102
|
+
).href
|
|
103
|
+
);
|
|
104
|
+
const cssHash = computeHash(filePath);
|
|
105
|
+
const styleMatch = source.match(STYLE_RE);
|
|
106
|
+
const css = styleMatch ? scopeCSS(styleMatch[1].trim(), cssHash) : null;
|
|
107
|
+
const sourceNoStyle = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
|
|
108
|
+
|
|
109
|
+
// Babel transform — dom mode
|
|
110
|
+
const { transformSync } = await import("@babel/core");
|
|
111
|
+
const sigilPlugin = (await import("@sigil-dev/compiler/babel")).default;
|
|
112
|
+
const { createHash } = await import("node:crypto");
|
|
113
|
+
const hash = createHash("md5").update(filePath).digest("hex").slice(0, 8);
|
|
114
|
+
|
|
115
|
+
const babelResult = transformSync(sourceNoStyle, {
|
|
116
|
+
configFile: false,
|
|
117
|
+
babelrc: false,
|
|
118
|
+
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
119
|
+
plugins: [[sigilPlugin, { mode: "dom", hash }]],
|
|
120
|
+
filename: filePath,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
console.log(
|
|
124
|
+
"[compile] babel output first 500 chars:",
|
|
125
|
+
babelResult?.code?.slice(0, 500),
|
|
126
|
+
);
|
|
127
|
+
// strip TypeScript types
|
|
128
|
+
const transpiler = new Bun.Transpiler({ loader: "ts", target: "browser" });
|
|
129
|
+
let js = transpiler.transformSync(babelResult?.code ?? "");
|
|
130
|
+
|
|
131
|
+
const runtimeSpecifiers = [
|
|
132
|
+
"createSignal",
|
|
133
|
+
"createRawSignal",
|
|
134
|
+
"createEffect",
|
|
135
|
+
"createMemo",
|
|
136
|
+
"reconcile",
|
|
137
|
+
"claim",
|
|
138
|
+
"claimText",
|
|
139
|
+
"claimComment",
|
|
140
|
+
"hydrateKeyedList",
|
|
141
|
+
"insert",
|
|
142
|
+
"getHydrationNodes",
|
|
143
|
+
"pushHydrationNodes",
|
|
144
|
+
"popHydrationNodes",
|
|
145
|
+
"snapshot",
|
|
146
|
+
"tracking",
|
|
147
|
+
"createEffectPre",
|
|
148
|
+
"createInspectEffect",
|
|
149
|
+
"withEffectScope",
|
|
150
|
+
].join(", ");
|
|
151
|
+
|
|
152
|
+
js = js.replace(
|
|
153
|
+
/import\s*\{[^}]+\}\s*from\s*["']@sigil-dev\/runtime["'];?/,
|
|
154
|
+
`import { ${runtimeSpecifiers} } from "@sigil-dev/runtime";`,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// inject scoped CSS at runtime
|
|
158
|
+
if (css) {
|
|
159
|
+
//biome-ignore lint: bro shut up already
|
|
160
|
+
js =
|
|
161
|
+
`if (typeof document !== 'undefined' && !document.getElementById('sigil-${hash}')) {
|
|
162
|
+
const __s = document.createElement('style');
|
|
163
|
+
__s.id = 'sigil-${hash}';
|
|
164
|
+
__s.textContent = ${JSON.stringify(css)};
|
|
165
|
+
document.head.appendChild(__s);
|
|
166
|
+
}\n` + js;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// rewrite imports to browser-servable URLs
|
|
170
|
+
js = rewriteImports(js, filePath, projectRoot);
|
|
171
|
+
|
|
172
|
+
return { js, css, cssHash };
|
|
173
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizePath } from "./paths";
|
|
2
|
+
|
|
3
|
+
const registry = new Map<string, (() => void)[]>();
|
|
4
|
+
|
|
5
|
+
export function registerModuleEffect(
|
|
6
|
+
filePath: string,
|
|
7
|
+
dispose: () => void,
|
|
8
|
+
): void {
|
|
9
|
+
const key = normalizePath(filePath);
|
|
10
|
+
let list = registry.get(key);
|
|
11
|
+
if (!list) registry.set(key, (list = []));
|
|
12
|
+
list.push(dispose);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function disposeModuleEffects(filePath: string): void {
|
|
16
|
+
const key = normalizePath(filePath);
|
|
17
|
+
for (const d of registry.get(key) ?? []) {
|
|
18
|
+
try {
|
|
19
|
+
d();
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
registry.delete(key);
|
|
23
|
+
}
|
package/src/dev/graph.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { RouteFile } from "../routing/scanner";
|
|
2
|
+
import { normalizePath } from "./paths";
|
|
3
|
+
|
|
4
|
+
export interface SpecRef {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
target: string; // normalizePath key or dep name
|
|
8
|
+
kind: "project" | "dep" | "runtime";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModNode {
|
|
12
|
+
key: string;
|
|
13
|
+
displayPath: string;
|
|
14
|
+
importers: Set<string>;
|
|
15
|
+
imports: Set<string>;
|
|
16
|
+
version: number;
|
|
17
|
+
compiled: {
|
|
18
|
+
js: string;
|
|
19
|
+
css: string | null;
|
|
20
|
+
cssHash: string;
|
|
21
|
+
specifiers: SpecRef[];
|
|
22
|
+
} | null;
|
|
23
|
+
isRoute: RouteFile | null;
|
|
24
|
+
lastSubstituted: Map<string, number>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class DevGraph {
|
|
28
|
+
private nodes = new Map<string, ModNode>();
|
|
29
|
+
|
|
30
|
+
get(key: string): ModNode | undefined {
|
|
31
|
+
return this.nodes.get(normalizePath(key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getOrCreate(filePath: string, displayPath?: string): ModNode {
|
|
35
|
+
const key = normalizePath(filePath);
|
|
36
|
+
if (!this.nodes.has(key)) {
|
|
37
|
+
this.nodes.set(key, {
|
|
38
|
+
key,
|
|
39
|
+
displayPath: displayPath ?? filePath,
|
|
40
|
+
importers: new Set(),
|
|
41
|
+
imports: new Set(),
|
|
42
|
+
version: 0,
|
|
43
|
+
compiled: null,
|
|
44
|
+
isRoute: null,
|
|
45
|
+
lastSubstituted: new Map(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return this.nodes.get(key)!;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
addEdge(importerPath: string, importedPath: string): void {
|
|
52
|
+
const a = normalizePath(importerPath);
|
|
53
|
+
const b = normalizePath(importedPath);
|
|
54
|
+
this.getOrCreate(a).imports.add(b);
|
|
55
|
+
this.getOrCreate(b).importers.add(a);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DFS for cycles in the subgraph reachable from key via forward edges */
|
|
59
|
+
hasCycle(key: string): boolean {
|
|
60
|
+
const k = normalizePath(key);
|
|
61
|
+
const visited = new Set<string>();
|
|
62
|
+
const stack = new Set<string>();
|
|
63
|
+
const dfs = (node: string): boolean => {
|
|
64
|
+
if (stack.has(node)) return true;
|
|
65
|
+
if (visited.has(node)) return false;
|
|
66
|
+
visited.add(node);
|
|
67
|
+
stack.add(node);
|
|
68
|
+
for (const imp of this.nodes.get(node)?.imports ?? []) {
|
|
69
|
+
if (dfs(imp)) return true;
|
|
70
|
+
}
|
|
71
|
+
stack.delete(node);
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
return dfs(k);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Walk importers up to routes. Returns affected RouteFiles. */
|
|
78
|
+
affectedRoutes(changedKey: string): RouteFile[] {
|
|
79
|
+
const key = normalizePath(changedKey);
|
|
80
|
+
const seen = new Set<string>([key]);
|
|
81
|
+
const queue = [key];
|
|
82
|
+
const hits: RouteFile[] = [];
|
|
83
|
+
while (queue.length) {
|
|
84
|
+
const f = queue.pop()!;
|
|
85
|
+
const node = this.nodes.get(f);
|
|
86
|
+
if (!node) continue;
|
|
87
|
+
if (node.isRoute) {
|
|
88
|
+
hits.push(node.isRoute);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const importer of node.importers) {
|
|
92
|
+
if (!seen.has(importer)) {
|
|
93
|
+
seen.add(importer);
|
|
94
|
+
queue.push(importer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return hits;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
markRoute(filePath: string, route: RouteFile): void {
|
|
102
|
+
this.getOrCreate(filePath).isRoute = route;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
bump(filePath: string): number {
|
|
106
|
+
const node = this.getOrCreate(filePath);
|
|
107
|
+
node.version++;
|
|
108
|
+
return node.version;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
all(): IterableIterator<ModNode> {
|
|
112
|
+
return this.nodes.values();
|
|
113
|
+
}
|
|
114
|
+
}
|