@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.
Files changed (50) hide show
  1. package/.grimoire/_routes.dom.js +8 -0
  2. package/.grimoire/_routes.hydrate.js +8 -0
  3. package/.grimoire/tsconfig.generated.json +11 -0
  4. package/.grimoire/types/ambient.d.ts +59 -0
  5. package/.grimoire/types/api/hello/$types.d.ts +50 -0
  6. package/.grimoire/types/api/items/$types.d.ts +50 -0
  7. package/.grimoire/types/echo/$types.d.ts +50 -0
  8. package/.grimoire/types/env-private.d.ts +5 -0
  9. package/.grimoire/types/env-public.d.ts +5 -0
  10. package/.grimoire/types/mixed/$types.d.ts +50 -0
  11. package/.grimoire/types/params/[docId]/$types.d.ts +52 -0
  12. package/.grimoire/types/reject/$types.d.ts +50 -0
  13. package/index.ts +34 -34
  14. package/package.json +8 -4
  15. package/preload.js +2 -0
  16. package/public/__grimoire__/hydrate.js +585 -0
  17. package/public/__grimoire__/index.js +490 -0
  18. package/src/client/head.ts +29 -0
  19. package/src/client/router.ts +224 -76
  20. package/src/env/index.ts +25 -0
  21. package/src/env/plugin.ts +13 -0
  22. package/src/env/private.ts +5 -0
  23. package/src/env/public.ts +7 -0
  24. package/src/env/typegen.ts +51 -0
  25. package/src/integrations/vite.ts +72 -72
  26. package/src/rendering/head.ts +22 -2
  27. package/src/rendering/hydrate.ts +81 -26
  28. package/src/rendering/index.ts +199 -186
  29. package/src/rendering/ssrPlugin.ts +53 -42
  30. package/src/routing/manifest-gen.ts +39 -26
  31. package/src/routing/router.ts +106 -98
  32. package/src/routing/scanner.ts +135 -129
  33. package/src/routing/transform-routes.ts +101 -96
  34. package/src/server/build.ts +147 -90
  35. package/src/server/coordinator.ts +306 -297
  36. package/src/server/hooks.ts +24 -3
  37. package/src/server/index.ts +148 -71
  38. package/src/server/worker.ts +59 -59
  39. package/src/typegen/index.ts +353 -340
  40. package/src/types.ts +269 -260
  41. package/test/context.test.ts +52 -52
  42. package/test/hydration.test.ts +119 -119
  43. package/test/middleware.test.ts +223 -221
  44. package/test/rendering.test.ts +425 -425
  45. package/test/routing.test.ts +83 -45
  46. package/test/scanning.test.ts +181 -169
  47. package/test/server.test.ts +229 -229
  48. package/test/streaming.test.ts +106 -106
  49. package/test/transform-routes.test.ts +84 -84
  50. package/test/typegen.test.ts +19 -1
@@ -1,26 +1,81 @@
1
- import { routes } from "#grimoire-routes";
2
- import { initRouter } from "../client/router.ts";
3
- import { withEffectScope } from "../client/scope.ts";
4
-
5
- const stateEl = document.getElementById("__grimoire_state__");
6
- let initialDispose: (() => void) | undefined;
7
-
8
- if (stateEl) {
9
- const state = JSON.parse(stateEl.textContent!);
10
- const Page = routes[state.pattern];
11
- if (Page) {
12
- const slot = document.getElementById("grimoire-page");
13
- if (slot) {
14
- pushHydrationNodes(Array.from(slot.childNodes) as ChildNode[]);
15
- initialDispose = withEffectScope(() => {
16
- try {
17
- Page({ data: state.data, params: state.params });
18
- } finally {
19
- popHydrationNodes();
20
- }
21
- });
22
- }
23
- }
24
- }
25
-
26
- initRouter(routes, initialDispose);
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,186 +1,199 @@
1
- import { SafeHtml } from "@sigil-dev/runtime";
2
- import { findClosestError, type MatchedRoute } from "../routing/router";
3
- import type { RouteFile } from "../routing/scanner";
4
- import { isErrorResult } from "../sentinels/error.ts";
5
- import { isRedirectResult } from "../sentinels/redirect.ts";
6
- import { runWithContext } from "../server/context";
7
- import { runHook } from "../server/plugins";
8
- import type { GrimoirePlugin, LoadContext, Route } from "../types";
9
- import { collectHead, initHead } from "./head";
10
-
11
- export type ModuleLoader = (path: string) => Promise<any>;
12
-
13
- async function renderErrorPage(
14
- errorRoutes: RouteFile[],
15
- pathname: string,
16
- status: number,
17
- message: string,
18
- ): Promise<Response | null> {
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
- }
28
-
29
- export async function renderRoute(
30
- matched: MatchedRoute,
31
- req: Request,
32
- errorRoutes: RouteFile[] = [],
33
- loadModule: ModuleLoader = (path) => import(path),
34
- locals: Record<string, any> = {},
35
- plugins: GrimoirePlugin[] = [],
36
- ): Promise<Response> {
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>
153
- <html>
154
- <head>
155
- <meta charset="UTF-8" />
156
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
157
- <script type="module" src="/__grimoire__/hydrate.js"></script>`,
158
- );
159
-
160
- // 2. Head content (captured from <Head> component calls during render)
161
- if (headHtml) {
162
- controller.enqueue(`\n${headHtml}`);
163
- }
164
-
165
- controller.enqueue(`\n</head>
166
- <body>
167
- <div id="app">`);
168
-
169
- // 3. Page body
170
- controller.enqueue(bodyHtml);
171
-
172
- // 4. State script + closing tags
173
- controller.enqueue(`</div>
174
- <script id="__grimoire_state__" type="application/json">${stateJson}</script>
175
- </body>
176
- </html>`);
177
-
178
- controller.close();
179
- },
180
- });
181
-
182
- return new Response(stream, {
183
- headers: { "Content-Type": "text/html" },
184
- });
185
- });
186
- }
1
+ import { SafeHtml } from "@sigil-dev/runtime";
2
+ import { findClosestError, type MatchedRoute } from "../routing/router";
3
+ import type { RouteFile } from "../routing/scanner";
4
+ import { isErrorResult } from "../sentinels/error.ts";
5
+ import { isRedirectResult } from "../sentinels/redirect.ts";
6
+ import { runWithContext } from "../server/context";
7
+ import { runHook } from "../server/plugins";
8
+ import type { GrimoirePlugin, LoadContext, Route } from "../types";
9
+ import { collectHead, initHead } from "./head";
10
+
11
+ export type ModuleLoader = (path: string) => Promise<any>;
12
+
13
+ async function renderErrorPage(
14
+ errorRoutes: RouteFile[],
15
+ pathname: string,
16
+ status: number,
17
+ message: string,
18
+ ): Promise<Response | null> {
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
+ }
28
+
29
+ export async function renderRoute(
30
+ matched: MatchedRoute,
31
+ req: Request,
32
+ errorRoutes: RouteFile[] = [],
33
+ loadModule: ModuleLoader = (path) => import(path),
34
+ locals: Record<string, any> = {},
35
+ plugins: GrimoirePlugin[] = [],
36
+ cspNonce?: string,
37
+ ): Promise<Response> {
38
+ return runWithContext(async () => {
39
+ const context: LoadContext = {
40
+ request: req,
41
+ params: matched.params,
42
+ url: new URL(req.url),
43
+ locals,
44
+ };
45
+
46
+ initHead(cspNonce);
47
+
48
+ const route: Route = {
49
+ path: matched.route.path,
50
+ params: matched.params,
51
+ filePath: matched.route.filePath,
52
+ loadPath: matched.pageServer?.filePath,
53
+ layoutPath: matched.layouts.at(-1)?.filePath,
54
+ };
55
+ await runHook(plugins, "onRouteLoad", route, context);
56
+
57
+ const layoutPairs: { layout: RouteFile; data: unknown }[] = [];
58
+ for (const layout of matched.layouts) {
59
+ const layoutServer = matched.layoutServers.find(
60
+ (ls) => ls.path === layout.path,
61
+ );
62
+ let data: unknown;
63
+ if (layoutServer) {
64
+ try {
65
+ const mod = await import(layoutServer.filePath);
66
+ data = await mod.load?.(context);
67
+ } catch (e) {
68
+ if (isRedirectResult(e))
69
+ return new Response(null, {
70
+ status: e.status,
71
+ headers: { Location: e.location },
72
+ });
73
+ if (isErrorResult(e))
74
+ return (
75
+ (await renderErrorPage(
76
+ errorRoutes,
77
+ context.url.pathname,
78
+ e.status,
79
+ e.message,
80
+ )) ?? new Response(e.message, { status: e.status })
81
+ );
82
+ throw e;
83
+ }
84
+ }
85
+ layoutPairs.push({ layout, data });
86
+ }
87
+
88
+ let pageData: unknown;
89
+ if (matched.pageServer) {
90
+ try {
91
+ const mod = await import(matched.pageServer.filePath);
92
+ pageData = await mod.load?.(context);
93
+ } catch (e) {
94
+ if (isRedirectResult(e)) {
95
+ return new Response(null, {
96
+ status: e.status,
97
+ headers: { Location: e.location },
98
+ });
99
+ }
100
+ if (isErrorResult(e)) {
101
+ return (
102
+ (await renderErrorPage(
103
+ errorRoutes,
104
+ context.url.pathname,
105
+ e.status,
106
+ e.message,
107
+ )) ?? new Response(e.message, { status: e.status })
108
+ );
109
+ }
110
+ throw e;
111
+ }
112
+ }
113
+
114
+ const pageMod = await import(matched.route.filePath);
115
+ const pageHtml = pageMod.default({
116
+ data: pageData,
117
+ params: matched.params,
118
+ });
119
+
120
+ // collect head AFTER page render so <Head> calls are captured
121
+ const headHtml = collectHead();
122
+
123
+ // navigation request: return JSON, client handles rendering
124
+ if (req.headers.get("x-grimoire-navigate") === "1") {
125
+ return Response.json({
126
+ data: pageData ?? {},
127
+ layoutData: layoutPairs.map((l) => l.data),
128
+ params: matched.params,
129
+ pattern: matched.route.path,
130
+ head: headHtml,
131
+ });
132
+ }
133
+
134
+ const wrappedPage = `<div id="grimoire-page">${String(pageHtml)}</div>`;
135
+
136
+ let bodyHtml: string = wrappedPage;
137
+ for (const { layout, data } of [...layoutPairs].reverse()) {
138
+ const layoutMod = await import(layout.filePath);
139
+ bodyHtml = String(
140
+ layoutMod.default({
141
+ data,
142
+ children: new SafeHtml(bodyHtml),
143
+ params: matched.params,
144
+ }),
145
+ );
146
+ }
147
+
148
+ bodyHtml = `<div id="grimoire-root">${bodyHtml}</div>`;
149
+
150
+ const stateJson = JSON.stringify({
151
+ params: matched.params,
152
+ data: pageData,
153
+ layoutData: layoutPairs.map((l) => l.data),
154
+ pattern: matched.route.path,
155
+ });
156
+
157
+ // --- Streaming SSR ---
158
+ // Send DOCTYPE + head skeleton immediately (browser starts resource fetch).
159
+ // Then stream body + full head content + state as they become available.
160
+ const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : "";
161
+ const stream = new ReadableStream({
162
+ start(controller) {
163
+ // 1. Document skeleton — browser starts parsing, fetches CSS/JS
164
+ controller.enqueue(
165
+ `<!DOCTYPE html>
166
+ <html>
167
+ <head>
168
+ <meta charset="UTF-8" />
169
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
170
+ <script type="module" src="/__grimoire__/hydrate.js"${nonceAttr}></script>`,
171
+ );
172
+
173
+ // 2. Head content (captured from <Head> component calls during render)
174
+ if (headHtml) {
175
+ controller.enqueue(`\n${headHtml}`);
176
+ }
177
+
178
+ controller.enqueue(`\n</head>
179
+ <body>
180
+ <div id="app">`);
181
+
182
+ // 3. Page body
183
+ controller.enqueue(bodyHtml);
184
+
185
+ // 4. State script + closing tags
186
+ controller.enqueue(`</div>
187
+ <script id="__grimoire_state__" type="application/json"${nonceAttr}>${stateJson}</script>
188
+ </body>
189
+ </html>`);
190
+
191
+ controller.close();
192
+ },
193
+ });
194
+
195
+ return new Response(stream, {
196
+ headers: { "Content-Type": "text/html" },
197
+ });
198
+ });
199
+ }
@@ -1,42 +1,53 @@
1
- import { transformSync } from "@babel/core";
2
- import sigilPlugin from "@sigil-dev/compiler/babel";
3
- import { createHash } from "node:crypto";
4
- import type { GrimoirePlugin } from "../types";
5
-
6
- let registered = false;
7
-
8
- export function registerSSRPlugin(plugins: GrimoirePlugin[] = []) {
9
- if (registered) return;
10
- registered = true;
11
-
12
- Bun.plugin({
13
- name: "sigil-ssr",
14
- setup(build) {
15
- // loader: "ts" not "tsx" — Babel already consumed the JSX,
16
- // Bun only needs to strip remaining TypeScript types
17
- const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" });
18
-
19
- build.onLoad({ filter: /\.[jt]sx$/ }, async ({ path }) => {
20
- const source = await Bun.file(path).text();
21
- const hash = createHash("md5").update(path).digest("hex").slice(0, 8);
22
-
23
- const result = transformSync(source, {
24
- parserOpts: {
25
- plugins: ["typescript", "jsx"],
26
- },
27
- plugins: [[sigilPlugin, { mode: "ssr", hash }]],
28
- filename: path,
29
- });
30
-
31
- let contents = transpiler.transformSync(result?.code ?? "");
32
-
33
- for (const plugin of plugins) {
34
- if (plugin.transform)
35
- contents = (await plugin.transform(contents, path)) ?? contents;
36
- }
37
-
38
- return { contents, loader: "js" as const };
39
- });
40
- },
41
- });
42
- }
1
+ import { transformSync } from "@babel/core";
2
+ import sigilPlugin from "@sigil-dev/compiler/babel";
3
+ import { createHash } from "node:crypto";
4
+ import type { GrimoirePlugin } from "../types";
5
+
6
+ let registered = false;
7
+
8
+ export function registerSSRPlugin(plugins: GrimoirePlugin[] = []) {
9
+ if (registered) return;
10
+ registered = true;
11
+
12
+ Bun.plugin({
13
+ name: "sigil-ssr",
14
+ setup(build) {
15
+ // loader: "ts" not "tsx" — Babel already consumed the JSX,
16
+ // Bun only needs to strip remaining TypeScript types
17
+ const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" });
18
+
19
+ build.onLoad({ filter: /\.tsx?$/ }, async ({ path }) => {
20
+ const source = await Bun.file(path).text();
21
+
22
+ // node_modules and .grimoire files are plain TypeScript (no sigil JSX).
23
+ // Still must return { contents, loader } — never return undefined from onLoad.
24
+ if (path.includes("node_modules") || path.includes(".grimoire")) {
25
+ return { contents: transpiler.transformSync(source), loader: "js" as const };
26
+ }
27
+
28
+ if (process.env.SIGIL_VERBOSE) {
29
+ console.log("[sigil-ssr] XFORM:", path);
30
+ }
31
+ const hash = createHash("md5").update(path).digest("hex").slice(0, 8);
32
+ const result = transformSync(source, {
33
+ configFile: false,
34
+ babelrc: false,
35
+ parserOpts: {
36
+ plugins: ["typescript", "jsx"],
37
+ },
38
+ plugins: [[sigilPlugin, { mode: "ssr", hash }]],
39
+ filename: path,
40
+ });
41
+
42
+ let contents = transpiler.transformSync(result?.code ?? "");
43
+
44
+ for (const plugin of plugins) {
45
+ if (plugin.transform)
46
+ contents = (await plugin.transform(contents, path)) ?? contents;
47
+ }
48
+
49
+ return { contents, loader: "js" as const };
50
+ });
51
+ },
52
+ });
53
+ }