@sigil-dev/grimoire 0.8.0 → 0.8.2

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.
@@ -1,11 +1,14 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import type { ServerWebSocket } from "bun";
4
+ import { log } from "../logger/instance";
4
5
  import { compileForBrowser } from "./compile-module";
5
6
  import type { DevGraph } from "./graph";
6
7
  import { normalizePath } from "./paths";
7
8
  import { safeRead } from "./watcher";
8
9
 
10
+ const logger = log.scope("hmr");
11
+
9
12
  export class HmrServer {
10
13
  private clients = new Set<ServerWebSocket<any>>();
11
14
  private v = 0;
@@ -31,7 +34,7 @@ export class HmrServer {
31
34
  }
32
35
 
33
36
  reload() {
34
- console.log("[sigil hmr] sending reload to", this.clients.size, "clients");
37
+ logger.info("sending reload to", this.clients.size, "clients");
35
38
  this.batch([{ kind: "reload" }]);
36
39
  }
37
40
 
@@ -69,7 +72,7 @@ export async function compileAndSwap(
69
72
  graph: DevGraph,
70
73
  hmr: HmrServer,
71
74
  projectRoot: string,
72
- routeTree: any,
75
+ _routeTree: any,
73
76
  ): Promise<void> {
74
77
  const key = normalizePath(filePath);
75
78
  const node = graph.get(key);
@@ -105,7 +108,7 @@ export async function compileAndSwap(
105
108
 
106
109
  if (!routeFile) {
107
110
  // no route found — fall back to reload
108
- console.log("[sigil hmr] no route affected, reloading");
111
+ logger.info("no route affected, reloading");
109
112
  hmr.reload();
110
113
  return;
111
114
  }
@@ -114,7 +117,7 @@ export async function compileAndSwap(
114
117
  const clientPath = relative(projectRoot, filePath).replace(/\\/g, "/");
115
118
  const modUrl = `${clientPath}.js`;
116
119
 
117
- console.log("[sigil hmr] compiled:", filePath, `v=${v}`);
120
+ logger.info(`compiled: ${filePath}, v=${v}`);
118
121
 
119
122
  const payloads: object[] = [];
120
123
 
@@ -144,10 +147,7 @@ export async function compileAndSwap(
144
147
  patterns: otherRoutes.map((r) => r.path),
145
148
  });
146
149
  }
147
- console.log(
148
- "[sigil hmr] sending batch:",
149
- JSON.stringify(payloads, null, 2),
150
- );
150
+ logger.info("sending batch:", JSON.stringify(payloads, null, 2));
151
151
  hmr.batch(payloads);
152
152
  } catch (e: any) {
153
153
  const loc = extractLoc(e, filePath);
@@ -161,7 +161,7 @@ export async function handleChange(
161
161
  filePath: string,
162
162
  graph: DevGraph,
163
163
  hmr: HmrServer,
164
- srcDir: string,
164
+ _srcDir: string,
165
165
  projectRoot: string,
166
166
  routeTree: any,
167
167
  ): Promise<void> {
@@ -177,11 +177,11 @@ export async function handleChange(
177
177
  }
178
178
  const node = graph.get(key);
179
179
  if (!node) {
180
- console.log("[sigil hmr] unknown file, reloading:", filePath);
180
+ logger.info("unknown file, reloading:", filePath);
181
181
  hmr.reload();
182
182
  return;
183
183
  }
184
184
 
185
- console.log("[sigil hmr] changed:", filePath);
185
+ logger.info("changed:", filePath);
186
186
  await compileAndSwap(filePath, graph, hmr, projectRoot, routeTree);
187
187
  }
@@ -1,5 +1,8 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { log } from "../logger/instance";
4
+
5
+ const logger = log.scope("runtime-bundle");
3
6
 
4
7
  export async function ensureRuntimeBundle(projectRoot: string): Promise<void> {
5
8
  const outDir = join(projectRoot, "public/__grimoire__");
@@ -8,7 +11,7 @@ export async function ensureRuntimeBundle(projectRoot: string): Promise<void> {
8
11
  // runtime
9
12
  const runtimeOut = join(outDir, "runtime.js");
10
13
  if (!(await Bun.file(runtimeOut).exists())) {
11
- console.log("[sigil hmr] bundling runtime...");
14
+ logger.debug("bundling runtime...");
12
15
  const result = await Bun.build({
13
16
  entrypoints: [
14
17
  join(projectRoot, "node_modules/@sigil-dev/runtime/index.ts"),
@@ -20,8 +23,8 @@ export async function ensureRuntimeBundle(projectRoot: string): Promise<void> {
20
23
  minify: false,
21
24
  });
22
25
  if (!result.success)
23
- console.error("[sigil hmr] runtime bundle failed:", result.logs);
24
- else console.log("[sigil hmr] runtime bundled");
26
+ logger.error("[sigil hmr] runtime bundle failed:", result.logs);
27
+ else logger.debug("[sigil hmr] runtime bundled");
25
28
  }
26
29
 
27
30
  // grimoire client
@@ -45,5 +48,5 @@ export async function ensureRuntimeBundle(projectRoot: string): Promise<void> {
45
48
  export const afterNavigate = (cb) => window.__grimoire_afterNavigate__?.(cb);
46
49
  `.trim(),
47
50
  );
48
- console.log("[sigil hmr] grimoire client shim written");
51
+ logger.info("grimoire client shim written");
49
52
  }
@@ -22,7 +22,7 @@ export function grimoire(options: { routes?: string } = {}): Plugin {
22
22
  : join(process.cwd(), options.routes ?? "src/routes");
23
23
 
24
24
  // client entry
25
- vite.middlewares.use("/__grimoire__/client.js", async (req, res) => {
25
+ vite.middlewares.use("/__grimoire__/client.js", async (_req, res) => {
26
26
  const result = await vite.transformRequest(CLIENT_ENTRY);
27
27
  if (!result) {
28
28
  res.statusCode = 404;
@@ -0,0 +1,6 @@
1
+ // src/logger/index.ts
2
+
3
+ export * from "./logger.ts";
4
+ export * from "./request-middleware.ts";
5
+ export * from "./transports.ts";
6
+ export * from "./types.ts";
@@ -0,0 +1,6 @@
1
+ import { Logger } from "./logger.ts";
2
+ import { ConsoleTransport } from "./transports.ts";
3
+
4
+ export const log = new Logger("grimoire", {
5
+ transports: [new ConsoleTransport()],
6
+ });
@@ -0,0 +1,113 @@
1
+ // packages/grimoire/src/logger/logger.ts
2
+
3
+ import type {
4
+ LogEntry,
5
+ LoggerOptions,
6
+ LogLevel,
7
+ LogTransport,
8
+ ScopedLogger,
9
+ } from "./types.ts";
10
+ import { LOG_LEVELS } from "./types.ts";
11
+
12
+ export class Logger implements ScopedLogger {
13
+ private transports: LogTransport[];
14
+ private minLevel: number;
15
+ private filter?: (entry: LogEntry) => boolean;
16
+ private _scope: string;
17
+ private _worker?: string;
18
+ private _requestId?: string;
19
+
20
+ constructor(scope: string, options: LoggerOptions = {}, worker?: string) {
21
+ this._scope = scope;
22
+ this._worker = worker;
23
+ this.transports = options.transports ?? [];
24
+ this.minLevel =
25
+ LOG_LEVELS[
26
+ options.level ??
27
+ (process.env.LOG_LEVEL as LogLevel | undefined) ??
28
+ "info"
29
+ ] ?? LOG_LEVELS.info;
30
+ this.filter = options.filter;
31
+ }
32
+
33
+ // called by loom coordinator after forking workers
34
+ setWorker(name: string) {
35
+ this._worker = name;
36
+ }
37
+
38
+ private write(level: LogLevel, message: string, ...args: unknown[]) {
39
+ if (LOG_LEVELS[level] < this.minLevel) return;
40
+
41
+ const data =
42
+ args.length === 0 ? undefined : args.length === 1 ? args[0] : args;
43
+
44
+ const entry: LogEntry = {
45
+ level,
46
+ scope: this._scope,
47
+ message,
48
+ data,
49
+ timestamp: Date.now(),
50
+ worker: this._worker,
51
+ requestId: this._requestId,
52
+ };
53
+
54
+ if (this.filter && !this.filter(entry)) return;
55
+ for (const t of this.transports) {
56
+ try {
57
+ t.write(entry);
58
+ } catch {}
59
+ }
60
+ }
61
+
62
+ debug(message: string, ...args: unknown[]) {
63
+ this.write("debug", message, ...args);
64
+ }
65
+ info(message: string, ...args: unknown[]) {
66
+ this.write("info", message, ...args);
67
+ }
68
+ warn(message: string, ...args: unknown[]) {
69
+ this.write("warn", message, ...args);
70
+ }
71
+ error(message: string, ...args: unknown[]) {
72
+ this.write("error", message, ...args);
73
+ }
74
+ fatal(message: string, ...args: unknown[]) {
75
+ this.write("fatal", message, ...args);
76
+ }
77
+
78
+ scope(name: string): ScopedLogger {
79
+ const child = new Logger(
80
+ `${this._scope}:${name}`,
81
+ {
82
+ transports: this.transports,
83
+ level: Object.keys(LOG_LEVELS).find(
84
+ (k) => LOG_LEVELS[k as LogLevel] === this.minLevel,
85
+ ) as LogLevel,
86
+ filter: this.filter,
87
+ },
88
+ this._worker,
89
+ );
90
+ child._requestId = this._requestId;
91
+ return child;
92
+ }
93
+
94
+ withRequest(requestId: string): ScopedLogger {
95
+ const child = new Logger(
96
+ this._scope,
97
+ {
98
+ transports: this.transports,
99
+ level: Object.keys(LOG_LEVELS).find(
100
+ (k) => LOG_LEVELS[k as LogLevel] === this.minLevel,
101
+ ) as LogLevel,
102
+ filter: this.filter,
103
+ },
104
+ this._worker,
105
+ );
106
+ child._requestId = requestId;
107
+ return child;
108
+ }
109
+
110
+ addTransport(t: LogTransport) {
111
+ this.transports.push(t);
112
+ }
113
+ }
@@ -0,0 +1,24 @@
1
+ // packages/grimoire/src/logger/request-middleware.ts
2
+ // Injected automatically by Grimoire before hooks.server.ts runs.
3
+
4
+ import crypto from "node:crypto";
5
+ import type { Logger } from "./logger.ts";
6
+ import type { ScopedLogger } from "./types.ts";
7
+
8
+ declare module "@sigil-dev/grimoire" {
9
+ interface RequestLocals {
10
+ log: ScopedLogger;
11
+ }
12
+ }
13
+
14
+ export function createRequestMiddleware(root: Logger) {
15
+ return function requestMiddleware(
16
+ _req: Request,
17
+ locals: Record<string, unknown>,
18
+ ) {
19
+ // 8-char hex ID — short enough to read in a terminal
20
+ const requestId = crypto.randomUUID().slice(0, 8);
21
+ locals.log = root.withRequest(requestId);
22
+ (locals.log as any)._requestId = requestId;
23
+ };
24
+ }
@@ -0,0 +1,117 @@
1
+ // packages/grimoire/src/logger/transports.ts
2
+
3
+ import { appendFileSync } from "node:fs";
4
+ import type { LogEntry, LogTransport } from "./types.ts";
5
+
6
+ // ── colours (only applied when stdout is a TTY) ──────────────────────────────
7
+
8
+ const isTTY = process.stdout.isTTY;
9
+ const c = isTTY
10
+ ? {
11
+ reset: "\x1b[0m",
12
+ dim: "\x1b[2m",
13
+ bold: "\x1b[1m",
14
+ debug: "\x1b[90m", // grey
15
+ info: "\x1b[36m", // cyan
16
+ warn: "\x1b[33m", // yellow
17
+ error: "\x1b[31m", // red
18
+ fatal: "\x1b[35m", // magenta
19
+ scope: "\x1b[34m", // blue
20
+ req: "\x1b[32m", // green
21
+ }
22
+ : (Object.fromEntries(
23
+ [
24
+ "reset",
25
+ "dim",
26
+ "bold",
27
+ "debug",
28
+ "info",
29
+ "warn",
30
+ "error",
31
+ "fatal",
32
+ "scope",
33
+ "req",
34
+ ].map((k) => [k, ""]),
35
+ ) as Record<string, string>);
36
+
37
+ const LEVEL_LABEL: Record<string, string> = {
38
+ debug: "DBG",
39
+ info: "INF",
40
+ warn: "WRN",
41
+ error: "ERR",
42
+ fatal: "FTL",
43
+ };
44
+
45
+ function ts(timestamp: number): string {
46
+ return new Date(timestamp).toISOString().slice(11, 23); // HH:mm:ss.ms
47
+ }
48
+
49
+ function formatData(data: unknown): string {
50
+ if (data === undefined) return "";
51
+ if (data instanceof Error) return `\n ${data.stack ?? data.message}`;
52
+ if (typeof data !== "object" || data === null) return ` ${String(data)}`;
53
+
54
+ // flat key=value for simple objects, JSON block only for nested/complex
55
+ const entries = Object.entries(data as Record<string, unknown>);
56
+ const isFlat = entries.every(([, v]) => typeof v !== "object" || v === null);
57
+ if (isFlat) {
58
+ return ` ${entries.map(([k, v]) => `${colors.dim(`${k}=`)}${v}`).join(" ")}`;
59
+ }
60
+ // nested — indent but don't pretty-print the whole thing
61
+ return `\n ${JSON.stringify(data).replace(/,/g, ", ")}`;
62
+ }
63
+
64
+ // ── ConsoleTransport ─────────────────────────────────────────────────────────
65
+
66
+ export class ConsoleTransport implements LogTransport {
67
+ write(entry: LogEntry) {
68
+ const levelColor = c[entry.level] ?? c.info;
69
+ const label = LEVEL_LABEL[entry.level] ?? entry.level.toUpperCase();
70
+
71
+ const worker = entry.worker ? `${c.dim}[${entry.worker}]${c.reset} ` : "";
72
+ const requestId = entry.requestId
73
+ ? `${c.req}#${entry.requestId.slice(0, 8)}${c.reset} `
74
+ : "";
75
+ const scope = `${c.scope}${entry.scope}${c.reset}`;
76
+ const time = `${c.dim}${ts(entry.timestamp)}${c.reset}`;
77
+ const lvl = `${levelColor}${c.bold}${label}${c.reset}`;
78
+ const msg = entry.message;
79
+ const data = formatData(entry.data);
80
+
81
+ const line = `${time} ${lvl} ${worker}${requestId}${scope} ${msg}${data}`;
82
+
83
+ if (entry.level === "error" || entry.level === "fatal") {
84
+ process.stderr.write(`${line}\n`);
85
+ } else {
86
+ process.stdout.write(`${line}\n`);
87
+ }
88
+ }
89
+ }
90
+
91
+ // ── FileTransport ────────────────────────────────────────────────────────────
92
+ // Writes newline-delimited JSON for ingestion by log collectors.
93
+
94
+ export class FileTransport implements LogTransport {
95
+ constructor(private path: string) {}
96
+
97
+ write(entry: LogEntry) {
98
+ try {
99
+ appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
100
+ } catch {
101
+ // if the file write fails we can't really do anything useful
102
+ }
103
+ }
104
+ }
105
+
106
+ // ── NullTransport ────────────────────────────────────────────────────────────
107
+ // For tests — captures entries instead of printing them.
108
+
109
+ export class NullTransport implements LogTransport {
110
+ readonly entries: LogEntry[] = [];
111
+ write(entry: LogEntry) {
112
+ this.entries.push(entry);
113
+ }
114
+ clear() {
115
+ this.entries.length = 0;
116
+ }
117
+ }
@@ -0,0 +1,39 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
2
+
3
+ export const LOG_LEVELS: Record<LogLevel, number> = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3,
8
+ fatal: 4,
9
+ };
10
+
11
+ export interface LogEntry {
12
+ level: LogLevel;
13
+ scope: string;
14
+ message: string;
15
+ data?: unknown;
16
+ timestamp: number;
17
+ worker?: string; // injected by loom, e.g. "violet"
18
+ requestId?: string; // injected per-request by middleware
19
+ }
20
+
21
+ export interface LogTransport {
22
+ write(entry: LogEntry): void | Promise<void>;
23
+ }
24
+
25
+ export interface ScopedLogger {
26
+ debug(message: string, ...args: unknown[]): void;
27
+ info(message: string, ...args: unknown[]): void;
28
+ warn(message: string, ...args: unknown[]): void;
29
+ error(message: string, ...args: unknown[]): void;
30
+ fatal(message: string, ...args: unknown[]): void;
31
+ scope(name: string): ScopedLogger;
32
+ withRequest(requestId: string): ScopedLogger;
33
+ }
34
+
35
+ export interface LoggerOptions {
36
+ transports?: LogTransport[];
37
+ level?: LogLevel;
38
+ filter?: (entry: LogEntry) => boolean;
39
+ }
@@ -1,119 +1,119 @@
1
- import {
2
- claim,
3
- getHydrationNodes,
4
- insert,
5
- popHydrationNodes,
6
- pushHydrationNodes,
7
- } from "@sigil-dev/runtime";
8
- //@ts-expect-error compiler generated
9
- import { layouts, routes } from "#grimoire-routes";
10
- import {
11
- afterNavigate,
12
- beforeNavigate,
13
- navigate,
14
- onNavigate,
15
- } from "../client/router";
16
- import { initRouter } from "../client/router.ts";
17
- import { withEffectScope } from "../client/scope.ts";
18
- import { Head } from "./head";
19
-
20
- // expose for HMR module shim
21
- (globalThis as any).__grimoire_Head__ = Head;
22
- (globalThis as any).__grimoire_navigate__ = navigate;
23
- (globalThis as any).__grimoire_beforeNavigate__ = beforeNavigate;
24
- (globalThis as any).__grimoire_onNavigate__ = onNavigate;
25
- (globalThis as any).__grimoire_afterNavigate__ = afterNavigate;
26
-
27
- const stateEl = document.getElementById("__grimoire_state__");
28
- let initialDispose: (() => void) | undefined;
29
-
30
- if (stateEl) {
31
- const state = JSON.parse(stateEl.textContent!);
32
- const Page = routes[state.pattern];
33
- if (Page) {
34
- const slot = document.getElementById("grimoire-root");
35
- if (slot) {
36
- const matchedLayouts = layouts
37
- .filter(
38
- (l: any) =>
39
- l.path === "/" ||
40
- state.pattern === l.path ||
41
- state.pattern.startsWith(l.path + "/"),
42
- )
43
- .sort((a: any, b: any) => a.path.length - b.path.length);
44
-
45
- // When layouts are involved, nested <!--g--> delimiters in the flat SSR pool
46
- // cause anchor-mount to claim the wrong anchor and remove layout DOM nodes.
47
- // Clear SSR content and re-render in DOM mode (empty pool) instead.
48
- const hasLayouts = matchedLayouts.length > 0;
49
- if (hasLayouts) {
50
- slot.replaceChildren();
51
- }
52
-
53
- const ssrClones = hasLayouts
54
- ? []
55
- : Array.from(slot.childNodes).map((n) => n.cloneNode(true));
56
- pushHydrationNodes(
57
- hasLayouts ? [] : (Array.from(slot.childNodes) as ChildNode[]),
58
- );
59
- try {
60
- initialDispose = withEffectScope(() => {
61
- try {
62
- let renderFn = () => {
63
- const pageDiv = claim(getHydrationNodes(), "div");
64
- pageDiv.id = "grimoire-page";
65
-
66
- if (pageDiv.childNodes.length > 0) {
67
- pushHydrationNodes(
68
- Array.from(pageDiv.childNodes) as ChildNode[],
69
- );
70
- const pageNode = Page({
71
- data: state.data,
72
- params: state.params,
73
- });
74
- popHydrationNodes();
75
- insert(pageDiv, pageNode);
76
- } else {
77
- const pageNode = Page({
78
- data: state.data,
79
- params: state.params,
80
- });
81
- insert(pageDiv, pageNode);
82
- }
83
-
84
- return pageDiv;
85
- };
86
-
87
- for (let i = matchedLayouts.length - 1; i >= 0; i--) {
88
- const LayoutComponent = matchedLayouts[i].component;
89
- const innerRender = renderFn;
90
- const layoutData = state.layoutData?.[i];
91
- renderFn = () => {
92
- // Pre-render inner content so children is a Node (insert() doesn't call functions)
93
- const childNode = innerRender();
94
- return LayoutComponent({
95
- data: layoutData,
96
- params: state.params,
97
- children: childNode,
98
- });
99
- };
100
- }
101
-
102
- const rootNode = renderFn();
103
- insert(slot, rootNode);
104
- } finally {
105
- popHydrationNodes();
106
- }
107
- });
108
- } catch (e) {
109
- console.warn("[grimoire] hydration error:", e);
110
- }
111
-
112
- if (!slot.hasChildNodes() && ssrClones.length > 0) {
113
- slot.replaceChildren(...ssrClones);
114
- }
115
- }
116
- }
117
- }
118
-
119
- initRouter(routes, layouts, initialDispose);
1
+ import {
2
+ claim,
3
+ getHydrationNodes,
4
+ insert,
5
+ popHydrationNodes,
6
+ pushHydrationNodes,
7
+ } from "@sigil-dev/runtime";
8
+ //@ts-expect-error compiler generated
9
+ import { layouts, routes } from "#grimoire-routes";
10
+ import {
11
+ afterNavigate,
12
+ beforeNavigate,
13
+ navigate,
14
+ onNavigate,
15
+ } from "../client/router";
16
+ import { initRouter } from "../client/router.ts";
17
+ import { withEffectScope } from "../client/scope.ts";
18
+ import { Head } from "./head";
19
+
20
+ // expose for HMR module shim
21
+ (globalThis as any).__grimoire_Head__ = Head;
22
+ (globalThis as any).__grimoire_navigate__ = navigate;
23
+ (globalThis as any).__grimoire_beforeNavigate__ = beforeNavigate;
24
+ (globalThis as any).__grimoire_onNavigate__ = onNavigate;
25
+ (globalThis as any).__grimoire_afterNavigate__ = afterNavigate;
26
+
27
+ const stateEl = document.getElementById("__grimoire_state__");
28
+ let initialDispose: (() => void) | undefined;
29
+
30
+ if (stateEl) {
31
+ const state = JSON.parse(stateEl.textContent!);
32
+ const Page = routes[state.pattern];
33
+ if (Page) {
34
+ const slot = document.getElementById("grimoire-root");
35
+ if (slot) {
36
+ const matchedLayouts = layouts
37
+ .filter(
38
+ (l: any) =>
39
+ l.path === "/" ||
40
+ state.pattern === l.path ||
41
+ state.pattern.startsWith(`${l.path}/`),
42
+ )
43
+ .sort((a: any, b: any) => a.path.length - b.path.length);
44
+
45
+ // When layouts are involved, nested <!--g--> delimiters in the flat SSR pool
46
+ // cause anchor-mount to claim the wrong anchor and remove layout DOM nodes.
47
+ // Clear SSR content and re-render in DOM mode (empty pool) instead.
48
+ const hasLayouts = matchedLayouts.length > 0;
49
+ if (hasLayouts) {
50
+ slot.replaceChildren();
51
+ }
52
+
53
+ const ssrClones = hasLayouts
54
+ ? []
55
+ : Array.from(slot.childNodes).map((n) => n.cloneNode(true));
56
+ pushHydrationNodes(
57
+ hasLayouts ? [] : (Array.from(slot.childNodes) as ChildNode[]),
58
+ );
59
+ try {
60
+ initialDispose = withEffectScope(() => {
61
+ try {
62
+ let renderFn = () => {
63
+ const pageDiv = claim(getHydrationNodes(), "div");
64
+ pageDiv.id = "grimoire-page";
65
+
66
+ if (pageDiv.childNodes.length > 0) {
67
+ pushHydrationNodes(
68
+ Array.from(pageDiv.childNodes) as ChildNode[],
69
+ );
70
+ const pageNode = Page({
71
+ data: state.data,
72
+ params: state.params,
73
+ });
74
+ popHydrationNodes();
75
+ insert(pageDiv, pageNode);
76
+ } else {
77
+ const pageNode = Page({
78
+ data: state.data,
79
+ params: state.params,
80
+ });
81
+ insert(pageDiv, pageNode);
82
+ }
83
+
84
+ return pageDiv;
85
+ };
86
+
87
+ for (let i = matchedLayouts.length - 1; i >= 0; i--) {
88
+ const LayoutComponent = matchedLayouts[i].component;
89
+ const innerRender = renderFn;
90
+ const layoutData = state.layoutData?.[i];
91
+ renderFn = () => {
92
+ // Pre-render inner content so children is a Node (insert() doesn't call functions)
93
+ const childNode = innerRender();
94
+ return LayoutComponent({
95
+ data: layoutData,
96
+ params: state.params,
97
+ children: childNode,
98
+ });
99
+ };
100
+ }
101
+
102
+ const rootNode = renderFn();
103
+ insert(slot, rootNode);
104
+ } finally {
105
+ popHydrationNodes();
106
+ }
107
+ });
108
+ } catch (e) {
109
+ console.warn("[grimoire] hydration error:", e);
110
+ }
111
+
112
+ if (!slot.hasChildNodes() && ssrClones.length > 0) {
113
+ slot.replaceChildren(...ssrClones);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ initRouter(routes, layouts, initialDispose);
@@ -1,5 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { SafeHtml } from "@sigil-dev/runtime";
2
- import { randomBytes } from "crypto";
3
3
  import { findClosestError, type MatchedRoute } from "../routing/router";
4
4
  import type { RouteFile } from "../routing/scanner";
5
5
  import { isErrorResult } from "../sentinels/error.ts";