@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
@@ -0,0 +1,14 @@
1
+ import { resolve } from "node:path";
2
+
3
+ export function normalizePath(p: string) {
4
+ let n = resolve(p).replace(/\\/g, "/");
5
+ if (process.platform === "win32") n = n.toLowerCase();
6
+ return n;
7
+ }
8
+
9
+ export function keyFromUrl(pathname: string, prefix: string) {
10
+ // __grimoire__/m/src/lib/Example.tsx.js -> src/lib/Example.tsx
11
+ return decodeURIComponent(pathname.slice(prefix.length))
12
+ .replace(/\.js(\?.*)?$/, "")
13
+ .replace(/\\/g, "");
14
+ }
@@ -0,0 +1,49 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export async function ensureRuntimeBundle(projectRoot: string): Promise<void> {
5
+ const outDir = join(projectRoot, "public/__grimoire__");
6
+ mkdirSync(outDir, { recursive: true });
7
+
8
+ // runtime
9
+ const runtimeOut = join(outDir, "runtime.js");
10
+ if (!(await Bun.file(runtimeOut).exists())) {
11
+ console.log("[sigil hmr] bundling runtime...");
12
+ const result = await Bun.build({
13
+ entrypoints: [
14
+ join(projectRoot, "node_modules/@sigil-dev/runtime/index.ts"),
15
+ ],
16
+ outdir: outDir,
17
+ naming: "runtime.js",
18
+ target: "browser",
19
+ format: "esm",
20
+ minify: false,
21
+ });
22
+ if (!result.success)
23
+ console.error("[sigil hmr] runtime bundle failed:", result.logs);
24
+ else console.log("[sigil hmr] runtime bundled");
25
+ }
26
+
27
+ // grimoire client
28
+ const grimClientOut = join(outDir, "grimoire-client.js");
29
+ const ref = Bun.file(grimClientOut);
30
+ if (await ref.exists()) await ref.delete();
31
+ await Bun.write(
32
+ grimClientOut,
33
+ `
34
+ // Grimoire client shim for HMR dev mode
35
+ export function Head({ children }) {
36
+ if (children instanceof Node) {
37
+ document.head.appendChild(children);
38
+ }
39
+ return document.createComment("head");
40
+ }
41
+
42
+ export const navigate = (...args) => window.__grimoire_navigate__?.(...args);
43
+ export const beforeNavigate = (cb) => window.__grimoire_beforeNavigate__?.(cb);
44
+ export const onNavigate = (cb) => window.__grimoire_onNavigate__?.(cb);
45
+ export const afterNavigate = (cb) => window.__grimoire_afterNavigate__?.(cb);
46
+ `.trim(),
47
+ );
48
+ console.log("[sigil hmr] grimoire client shim written");
49
+ }
@@ -0,0 +1,44 @@
1
+ import { watch } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { normalizePath } from "./paths";
4
+
5
+ export type ChangeHandler = (filePath: string) => Promise<void>;
6
+
7
+ export function startWatcher(
8
+ srcDir: string,
9
+ onChange: ChangeHandler,
10
+ ): () => void {
11
+ const pending = new Set<string>();
12
+ let debounce: Timer | null = null;
13
+
14
+ const flush = async () => {
15
+ const files = [...pending];
16
+ pending.clear();
17
+ for (const f of files) {
18
+ await onChange(f);
19
+ }
20
+ };
21
+
22
+ const watcher = watch(srcDir, { recursive: true }, (_evt, filename) => {
23
+ console.log("[sigil hmr] fs.watch fired:", _evt, filename);
24
+ if (!filename) return;
25
+ const raw = join(srcDir, filename.toString());
26
+ pending.add(normalizePath(raw));
27
+ if (debounce) clearTimeout(debounce);
28
+ debounce = setTimeout(flush, 60);
29
+ });
30
+
31
+ return () => watcher.close();
32
+ }
33
+
34
+ /** Read file with atomic-write retry — VSCode on Windows writes temp-then-rename */
35
+ export async function safeRead(filePath: string): Promise<string | null> {
36
+ for (let attempt = 0; attempt < 3; attempt++) {
37
+ const text = await Bun.file(filePath)
38
+ .text()
39
+ .catch(() => null);
40
+ if (text && text.length > 0) return text;
41
+ await Bun.sleep(30);
42
+ }
43
+ return null;
44
+ }
@@ -1,72 +1,73 @@
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
+ [],
51
+ (path) => vite.ssrLoadModule(path), // Vite transforms SSR files correctly
52
+ );
53
+
54
+ const html = await response.text();
55
+ res.setHeader("Content-Type", "text/html");
56
+ res.end(html);
57
+ } catch (e) {
58
+ vite.ssrFixStacktrace(e as Error);
59
+ next(e);
60
+ }
61
+ });
62
+ },
63
+
64
+ buildStart() {
65
+ if (!isBuild) return;
66
+ this.emitFile({
67
+ type: "chunk",
68
+ id: CLIENT_ENTRY,
69
+ fileName: "client.js",
70
+ });
71
+ },
72
+ };
73
+ }
@@ -1,81 +1,120 @@
1
- import { routes, layouts } from "#grimoire-routes";
2
- import { popHydrationNodes, pushHydrationNodes, claim, insert, getHydrationNodes } from "@sigil-dev/runtime";
3
- import { initRouter } from "../client/router.ts";
4
- import { withEffectScope } from "../client/scope.ts";
5
-
6
- const stateEl = document.getElementById("__grimoire_state__");
7
- let initialDispose: (() => void) | undefined;
8
-
9
- if (stateEl) {
10
- const state = JSON.parse(stateEl.textContent!);
11
- const Page = routes[state.pattern];
12
- if (Page) {
13
- const slot = document.getElementById("grimoire-root");
14
- if (slot) {
15
- const matchedLayouts = layouts
16
- .filter((l: any) => l.path === "/" || state.pattern === l.path || state.pattern.startsWith(l.path + "/"))
17
- .sort((a: any, b: any) => a.path.length - b.path.length);
18
-
19
- // When layouts are involved, nested <!--g--> delimiters in the flat SSR pool
20
- // cause anchor-mount to claim the wrong anchor and remove layout DOM nodes.
21
- // Clear SSR content and re-render in DOM mode (empty pool) instead.
22
- const hasLayouts = matchedLayouts.length > 0;
23
- if (hasLayouts) {
24
- slot.replaceChildren();
25
- }
26
-
27
- const ssrClones = hasLayouts ? [] : Array.from(slot.childNodes).map(n => n.cloneNode(true));
28
- pushHydrationNodes(hasLayouts ? [] : Array.from(slot.childNodes) as ChildNode[]);
29
- try {
30
- initialDispose = withEffectScope(() => {
31
- try {
32
- let renderFn = () => {
33
- const pageDiv = claim(getHydrationNodes(), "div");
34
- pageDiv.id = "grimoire-page";
35
-
36
- if (pageDiv.childNodes.length > 0) {
37
- pushHydrationNodes(Array.from(pageDiv.childNodes) as ChildNode[]);
38
- const pageNode = Page({ data: state.data, params: state.params });
39
- popHydrationNodes();
40
- insert(pageDiv, pageNode);
41
- } else {
42
- const pageNode = Page({ data: state.data, params: state.params });
43
- insert(pageDiv, pageNode);
44
- }
45
-
46
- return pageDiv;
47
- };
48
-
49
- for (let i = matchedLayouts.length - 1; i >= 0; i--) {
50
- const LayoutComponent = matchedLayouts[i].component;
51
- const innerRender = renderFn;
52
- const layoutData = state.layoutData?.[i];
53
- renderFn = () => {
54
- // Pre-render inner content so children is a Node (insert() doesn't call functions)
55
- const childNode = innerRender();
56
- return LayoutComponent({
57
- data: layoutData,
58
- params: state.params,
59
- children: childNode,
60
- });
61
- };
62
- }
63
-
64
- const rootNode = renderFn();
65
- insert(slot, rootNode);
66
- } finally {
67
- popHydrationNodes();
68
- }
69
- });
70
- } catch (e) {
71
- console.warn("[grimoire] hydration error:", e);
72
- }
73
-
74
- if (!slot.hasChildNodes() && ssrClones.length > 0) {
75
- slot.replaceChildren(...ssrClones);
76
- }
77
- }
78
- }
79
- }
80
-
81
- initRouter(routes, layouts, initialDispose);
1
+ //@ts-expect-error compiler generated
2
+
3
+ import {
4
+ claim,
5
+ getHydrationNodes,
6
+ insert,
7
+ popHydrationNodes,
8
+ pushHydrationNodes,
9
+ } from "@sigil-dev/runtime";
10
+ import { layouts, routes } from "#grimoire-routes";
11
+ import {
12
+ afterNavigate,
13
+ beforeNavigate,
14
+ navigate,
15
+ onNavigate,
16
+ } from "../client/router";
17
+ import { initRouter } from "../client/router.ts";
18
+ import { withEffectScope } from "../client/scope.ts";
19
+ import { Head } from "./head";
20
+
21
+ // expose for HMR module shim
22
+ (globalThis as any).__grimoire_Head__ = Head;
23
+ (globalThis as any).__grimoire_navigate__ = navigate;
24
+ (globalThis as any).__grimoire_beforeNavigate__ = beforeNavigate;
25
+ (globalThis as any).__grimoire_onNavigate__ = onNavigate;
26
+ (globalThis as any).__grimoire_afterNavigate__ = afterNavigate;
27
+
28
+ const stateEl = document.getElementById("__grimoire_state__");
29
+ let initialDispose: (() => void) | undefined;
30
+
31
+ if (stateEl) {
32
+ const state = JSON.parse(stateEl.textContent!);
33
+ const Page = routes[state.pattern];
34
+ if (Page) {
35
+ const slot = document.getElementById("grimoire-root");
36
+ if (slot) {
37
+ const matchedLayouts = layouts
38
+ .filter(
39
+ (l: any) =>
40
+ l.path === "/" ||
41
+ state.pattern === l.path ||
42
+ state.pattern.startsWith(l.path + "/"),
43
+ )
44
+ .sort((a: any, b: any) => a.path.length - b.path.length);
45
+
46
+ // When layouts are involved, nested <!--g--> delimiters in the flat SSR pool
47
+ // cause anchor-mount to claim the wrong anchor and remove layout DOM nodes.
48
+ // Clear SSR content and re-render in DOM mode (empty pool) instead.
49
+ const hasLayouts = matchedLayouts.length > 0;
50
+ if (hasLayouts) {
51
+ slot.replaceChildren();
52
+ }
53
+
54
+ const ssrClones = hasLayouts
55
+ ? []
56
+ : Array.from(slot.childNodes).map((n) => n.cloneNode(true));
57
+ pushHydrationNodes(
58
+ hasLayouts ? [] : (Array.from(slot.childNodes) as ChildNode[]),
59
+ );
60
+ try {
61
+ initialDispose = withEffectScope(() => {
62
+ try {
63
+ let renderFn = () => {
64
+ const pageDiv = claim(getHydrationNodes(), "div");
65
+ pageDiv.id = "grimoire-page";
66
+
67
+ if (pageDiv.childNodes.length > 0) {
68
+ pushHydrationNodes(
69
+ Array.from(pageDiv.childNodes) as ChildNode[],
70
+ );
71
+ const pageNode = Page({
72
+ data: state.data,
73
+ params: state.params,
74
+ });
75
+ popHydrationNodes();
76
+ insert(pageDiv, pageNode);
77
+ } else {
78
+ const pageNode = Page({
79
+ data: state.data,
80
+ params: state.params,
81
+ });
82
+ insert(pageDiv, pageNode);
83
+ }
84
+
85
+ return pageDiv;
86
+ };
87
+
88
+ for (let i = matchedLayouts.length - 1; i >= 0; i--) {
89
+ const LayoutComponent = matchedLayouts[i].component;
90
+ const innerRender = renderFn;
91
+ const layoutData = state.layoutData?.[i];
92
+ renderFn = () => {
93
+ // Pre-render inner content so children is a Node (insert() doesn't call functions)
94
+ const childNode = innerRender();
95
+ return LayoutComponent({
96
+ data: layoutData,
97
+ params: state.params,
98
+ children: childNode,
99
+ });
100
+ };
101
+ }
102
+
103
+ const rootNode = renderFn();
104
+ insert(slot, rootNode);
105
+ } finally {
106
+ popHydrationNodes();
107
+ }
108
+ });
109
+ } catch (e) {
110
+ console.warn("[grimoire] hydration error:", e);
111
+ }
112
+
113
+ if (!slot.hasChildNodes() && ssrClones.length > 0) {
114
+ slot.replaceChildren(...ssrClones);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ initRouter(routes, layouts, initialDispose);