@sigil-dev/grimoire 0.3.0 → 0.5.0

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/README.md CHANGED
@@ -1 +1,174 @@
1
- Grimoire Implementation
1
+ # @sigil-dev/grimoire
2
+
3
+ Full-stack server framework for [Sigil](https://git.cane1712.dev/sigil/sigil).
4
+ Heavily inspired by SvelteKit. Most of [its documentation](https://svelte.dev/docs/kit/introduction) applies here
5
+
6
+ ## Quick start
7
+
8
+ ```sh
9
+ bun i -g @sigil-dev/cli
10
+ sigil create
11
+ ```
12
+
13
+ ## Route files
14
+
15
+ Place files in /src/routes.
16
+
17
+ | File | Purpose |
18
+ |---|---|
19
+ | `+page.tsx | index.tsx | routeName.tsx` | Page |
20
+ | `+page.server.ts` | Server data passed to page / form actions |
21
+ | `+layout.tsx` | Page layout |
22
+ | `+layout.server.ts` | Layout-level `load` |
23
+ | `+server.ts` | API route (raw `Request → Response`) |
24
+ | `+error.tsx` | Error boundary page |
25
+ | `hooks.server.ts` | Global request middleware (project root) |
26
+
27
+ `/routes/about/index.tsx`, `/routes/about/+page.tsx` and `/routes/about.tsx` all work and all do the same thing.
28
+ For path parameters use square brackets. `src/routes/posts/[slug]/+page.tsx` → `/posts/:slug`.
29
+
30
+ ## Loading data
31
+
32
+ ```ts
33
+ // src/routes/posts/[slug]/+page.server.ts
34
+ import { error } from "@sigil-dev/grimoire";
35
+ import type { TypedLoadContext } from "@sigil-dev/grimoire";
36
+
37
+ export async function load({ params }: TypedLoadContext<{ slug: string }>) {
38
+ const post = await db.posts.findBySlug(params.slug);
39
+ if (!post) throw error(404, "Post not found");
40
+ return { post }; // → page receives { data: { post }, params }
41
+ }
42
+ ```
43
+
44
+ The return value is passed as the `data` prop to the matching `+page.tsx` component.
45
+
46
+ ## Form actions
47
+
48
+ Export HTTP method handlers alongside `load`:
49
+
50
+ ```ts
51
+ // src/routes/login/+page.server.ts
52
+ import { fail, redirect } from "@sigil-dev/grimoire";
53
+
54
+ export async function POST({ request }) {
55
+ const data = await request.formData();
56
+ const user = await auth.login(data.get("email"), data.get("password"));
57
+ if (!user) return fail(400, { error: "Invalid credentials" });
58
+ throw redirect(303, "/dashboard");
59
+ }
60
+ ```
61
+
62
+ Use `enhance` with the `use` prop on the client for fetch-based submission without a full page reload:
63
+ > This is jank and will likely be reworked.
64
+
65
+ ```tsx
66
+ // src/routes/login/+page.tsx
67
+ import { enhance } from "@sigil-dev/grimoire/client";
68
+
69
+ export default function LoginPage() {
70
+ const errors = $state({});
71
+
72
+ return (
73
+ <form use={[enhance, { onFail: (data) => errors.set(data) }]}>
74
+ <input name="email" />
75
+ <input name="password" type="password" />
76
+ <button>Log in</button>
77
+ </form>
78
+ );
79
+ }
80
+ ```
81
+
82
+ ## Error handling
83
+
84
+ `error(status, message)` can be thrown from any `load` function or action. Grimoire renders the closest `+error.tsx` if one exists, otherwise falls back to a plain-text response.
85
+
86
+ ```tsx
87
+ // src/routes/+error.tsx
88
+ export default function ErrorPage({ status, message }: { status: number; message: string }) {
89
+ return (
90
+ <html>
91
+ <body>
92
+ <h1>{status}</h1>
93
+ <p>{message}</p>
94
+ </body>
95
+ </html>
96
+ );
97
+ }
98
+ ```
99
+
100
+ ## API routes
101
+
102
+ ```ts
103
+ // src/routes/api/items/+server.ts
104
+ export async function GET({ url }) {
105
+ const items = await db.items.list();
106
+ return Response.json(items);
107
+ }
108
+
109
+ export async function POST({ request }) {
110
+ const body = await request.json();
111
+ const item = await db.items.create(body);
112
+ return Response.json(item, { status: 201 });
113
+ }
114
+ ```
115
+
116
+ ## Layouts
117
+
118
+ ```tsx
119
+ // src/routes/+layout.tsx
120
+ import { Head } from "@sigil-dev/grimoire";
121
+
122
+ export default function Layout({ children }) {
123
+ return (
124
+ <html>
125
+ <Head><title>My App</title></Head>
126
+ <body>{children}</body>
127
+ </html>
128
+ );
129
+ }
130
+ ```
131
+
132
+ ## Hooks (middleware)
133
+
134
+ ```ts
135
+ // hooks.server.ts (project root)
136
+ import type { Handle } from "@sigil-dev/grimoire/hooks";
137
+
138
+ export const handle: Handle = async ({ event, resolve }) => {
139
+ event.locals.user = await getUser(event.request);
140
+ return resolve(event);
141
+ };
142
+ ```
143
+
144
+ ## Configuration
145
+
146
+ ```ts
147
+ import { defineConfig } from "@sigil-dev/grimoire";
148
+
149
+ export default defineConfig({
150
+ port: 3000,
151
+ host: "localhost",
152
+ routes: "src/routes", // default
153
+ plugins: [],
154
+ });
155
+ ```
156
+
157
+ ## Helpers
158
+
159
+ | Import | Description |
160
+ |---|---|
161
+ | `error(status, message)` | Throw an HTTP error from `load` or an action |
162
+ | `fail(status, data)` | Return validation errors from an action |
163
+ | `redirect(status, location)` | Throw a redirect from `load` or an action |
164
+ | `sequence(...handles)` | Compose multiple `Handle` middleware functions |
165
+
166
+ ## Security headers
167
+
168
+ ```ts
169
+ import { securityHeaders } from "@sigil-dev/grimoire/headers";
170
+
171
+ // use as a Handle in hooks.server.ts, or compose with sequence()
172
+ ```
173
+
174
+ `securityHeaders()` returns a `Handle` that sets CSP, HSTS, X-Frame-Options, and related headers on every response.
package/index.ts CHANGED
@@ -1,22 +1,29 @@
1
- export type { RouteFile, RouteTree } from "./src/scanner";
2
- export { scanRoutes } from "./src/scanner";
3
- export { createServer } from "./src/server";
4
- export { fail, isFailResult } from "./src/fail";
5
- export type { FailResult } from "./src/fail";
6
- export { redirect, isRedirectResult } from "./src/redirect";
7
- export type { RedirectResult } from "./src/redirect";
8
- export { error, isErrorResult } from "./src/error";
9
- export type { ErrorResult } from "./src/error";
10
- export type { Handle, RequestEvent, Cookies, CookieOptions, ResolveFunction } from "./src/hooks";
11
- export { sequence, createHooks } from "./src/hooks";
12
- export type { TypegenConfig } from "./src/typegen";
13
- export { generateTypes } from "./src/typegen";
14
- export type {
15
- GrimoireConfig,
16
- GrimoirePlugin,
17
- LoadContext,
18
- RenderContext,
19
- RouteInfo,
20
- TypedLoadContext,
21
- } from "./src/types";
22
- export { defineConfig } from "./src/types";
1
+ export type { RouteFile, RouteTree } from "./src/scanner";
2
+ export { scanRoutes } from "./src/scanner";
3
+ export { buildProject } from "./src/build";
4
+ export { createServer } from "./src/server";
5
+ export { fail, isFailResult } from "./src/fail";
6
+ export type { FailResult } from "./src/fail";
7
+ export { redirect, isRedirectResult } from "./src/redirect";
8
+ export type { RedirectResult } from "./src/redirect";
9
+ export { error, isErrorResult } from "./src/error";
10
+ export type { ErrorResult } from "./src/error";
11
+ export type { Handle, RequestEvent, Cookies, CookieOptions, ResolveFunction } from "./src/hooks";
12
+ export { parseCookies } from "./src/cookie-utils";
13
+ export { sequence, createHooks } from "./src/hooks";
14
+ export type { TypegenConfig } from "./src/typegen";
15
+ export { generateTypes } from "./src/typegen";
16
+ export type {
17
+ GrimoireConfig,
18
+ GrimoirePlugin,
19
+ LoadContext,
20
+ RenderContext,
21
+ RouteInfo,
22
+ TypedLoadContext,
23
+ PageServerLoad,
24
+ LayoutServerLoad,
25
+ RequestHandler,
26
+ WsRouteHandler,
27
+ } from "./src/types";
28
+ export { defineConfig } from "./src/types";
29
+ export { Head } from "./src/head";
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
5
  "private": false,
6
- "version": "0.3.0",
6
+ "version": "0.5.0",
7
7
  "devDependencies": {
8
8
  "@types/bun": "latest"
9
9
  },
@@ -1,62 +1,31 @@
1
- // .grimoire/_routes.dom.js
1
+ // .grimoire/_routes.ts
2
2
  var routes = {};
3
3
 
4
- // src/scope.ts
5
- function withEffectScope(fn) {
6
- const prev = globalThis.__sigilEffectScope;
7
- const disposers = [];
8
- globalThis.__sigilEffectScope = disposers;
9
- try {
10
- fn();
11
- } finally {
12
- globalThis.__sigilEffectScope = prev;
13
- }
14
- return () => {
15
- for (const d of disposers)
16
- d();
17
- disposers.length = 0;
18
- };
19
- }
20
-
21
4
  // src/client-router.ts
22
5
  var routeMap = {};
23
- var disposeCurrentPage = null;
24
6
  async function navigate(path) {
25
7
  const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
26
8
  const json = await res.json();
9
+ console.log("navigate response:", json);
27
10
  const { data, params, pattern, head } = json;
28
11
  const Page = routeMap[pattern];
29
12
  if (!Page) {
30
13
  window.location.href = path;
31
14
  return;
32
15
  }
33
- disposeCurrentPage?.();
34
- disposeCurrentPage = null;
35
- globalThis.__nodes = [];
36
- let node;
37
- disposeCurrentPage = withEffectScope(() => {
38
- node = Page({ ...data, params });
39
- });
16
+ const node = Page({ ...data, params });
40
17
  const slot = document.getElementById("grimoire-page");
41
18
  if (!slot)
42
19
  return;
43
20
  slot.replaceChildren(node);
44
21
  updateHead(head);
45
22
  history.pushState({}, "", path);
46
- currentPath = path;
47
23
  window.scrollTo(0, 0);
48
24
  }
49
- var currentPath = location.pathname;
50
- function initRouter(routes2, initialDispose) {
25
+ function initRouter(routes2) {
51
26
  routeMap = routes2;
52
- disposeCurrentPage = initialDispose ?? null;
53
27
  document.addEventListener("click", handleClick);
54
- window.addEventListener("popstate", () => {
55
- if (location.pathname !== currentPath) {
56
- currentPath = location.pathname;
57
- navigate(location.pathname);
58
- }
59
- });
28
+ window.addEventListener("popstate", () => navigate(location.pathname));
60
29
  }
61
30
  function updateHead(headHtml) {
62
31
  document.querySelectorAll("[data-grimoire-head]").forEach((el) => el.remove());
@@ -1,62 +1,31 @@
1
- // .grimoire/_routes.hydrate.js
1
+ // .grimoire/_routes.ts
2
2
  var routes = {};
3
3
 
4
- // src/scope.ts
5
- function withEffectScope(fn) {
6
- const prev = globalThis.__sigilEffectScope;
7
- const disposers = [];
8
- globalThis.__sigilEffectScope = disposers;
9
- try {
10
- fn();
11
- } finally {
12
- globalThis.__sigilEffectScope = prev;
13
- }
14
- return () => {
15
- for (const d of disposers)
16
- d();
17
- disposers.length = 0;
18
- };
19
- }
20
-
21
4
  // src/client-router.ts
22
5
  var routeMap = {};
23
- var disposeCurrentPage = null;
24
6
  async function navigate(path) {
25
7
  const res = await fetch(path, { headers: { "x-grimoire-navigate": "1" } });
26
8
  const json = await res.json();
9
+ console.log("navigate response:", json);
27
10
  const { data, params, pattern, head } = json;
28
11
  const Page = routeMap[pattern];
29
12
  if (!Page) {
30
13
  window.location.href = path;
31
14
  return;
32
15
  }
33
- disposeCurrentPage?.();
34
- disposeCurrentPage = null;
35
- globalThis.__nodes = [];
36
- let node;
37
- disposeCurrentPage = withEffectScope(() => {
38
- node = Page({ ...data, params });
39
- });
16
+ const node = Page({ ...data, params });
40
17
  const slot = document.getElementById("grimoire-page");
41
18
  if (!slot)
42
19
  return;
43
20
  slot.replaceChildren(node);
44
21
  updateHead(head);
45
22
  history.pushState({}, "", path);
46
- currentPath = path;
47
23
  window.scrollTo(0, 0);
48
24
  }
49
- var currentPath = location.pathname;
50
- function initRouter(routes2, initialDispose) {
25
+ function initRouter(routes2) {
51
26
  routeMap = routes2;
52
- disposeCurrentPage = initialDispose ?? null;
53
27
  document.addEventListener("click", handleClick);
54
- window.addEventListener("popstate", () => {
55
- if (location.pathname !== currentPath) {
56
- currentPath = location.pathname;
57
- navigate(location.pathname);
58
- }
59
- });
28
+ window.addEventListener("popstate", () => navigate(location.pathname));
60
29
  }
61
30
  function updateHead(headHtml) {
62
31
  document.querySelectorAll("[data-grimoire-head]").forEach((el) => el.remove());
@@ -84,18 +53,11 @@ function handleClick(e) {
84
53
 
85
54
  // src/hydrate.ts
86
55
  var stateEl = document.getElementById("__grimoire_state__");
87
- var initialDispose;
88
56
  if (stateEl) {
89
57
  const state = JSON.parse(stateEl.textContent);
90
58
  const Page = routes[state.pattern];
91
59
  if (Page) {
92
- const slot = document.getElementById("grimoire-page");
93
- if (slot) {
94
- globalThis.__nodes = Array.from(slot.childNodes);
95
- initialDispose = withEffectScope(() => {
96
- Page({ ...state.data, params: state.params });
97
- });
98
- }
60
+ Page({ ...state.data, params: state.params });
99
61
  }
100
62
  }
101
- initRouter(routes, initialDispose);
63
+ initRouter(routes);
package/src/build.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { mkdir } from "fs/promises";
2
+ import { isAbsolute, join } from "path";
3
+ import { sigil } from "@sigil-dev/compiler/bun";
4
+ import { generateManifest } from "./manifest-gen";
5
+ import { runHook } from "./plugins";
6
+ import { scanRoutes } from "./scanner";
7
+ import type { RouteTree } from "./scanner";
8
+ import { transformRoutes } from "./transform-routes";
9
+ import { generateTypes } from "./typegen";
10
+ import type { BuildResult, GrimoireConfig, GrimoirePlugin } from "./types";
11
+
12
+ export async function buildProject(
13
+ config: GrimoireConfig,
14
+ plugins: GrimoirePlugin[] = [],
15
+ ): Promise<{ result: BuildResult; tree: RouteTree }> {
16
+ await runHook(plugins, "onBuildStart");
17
+
18
+ const { routes = "src/routes" } = config;
19
+
20
+ const routesDir = isAbsolute(routes)
21
+ ? routes.replace(/\0/g, "")
22
+ : join(process.cwd(), routes).replace(/\0/g, "");
23
+ const tree = await scanRoutes(routesDir, process.cwd());
24
+
25
+ await mkdir(join(process.cwd(), ".grimoire"), { recursive: true });
26
+
27
+ await generateTypes(tree, {
28
+ projectRoot: process.cwd(),
29
+ routesDir,
30
+ outDir: join(process.cwd(), ".grimoire/types"),
31
+ });
32
+
33
+ const compiledDir = join(process.cwd(), ".grimoire/compiled");
34
+ await mkdir(compiledDir, { recursive: true });
35
+
36
+ const pageRoutes = tree.routes.filter(
37
+ (r) => r.type === "page" || r.type === "simple",
38
+ );
39
+ const [hydrateFiles, domFiles] = await Promise.all([
40
+ transformRoutes(pageRoutes, compiledDir, "hydrate", plugins),
41
+ transformRoutes(pageRoutes, compiledDir, "dom", plugins),
42
+ ]);
43
+
44
+ const hydrateManifest = join(process.cwd(), ".grimoire/_routes.hydrate.js");
45
+ const domManifest = join(process.cwd(), ".grimoire/_routes.dom.js");
46
+ await Promise.all([
47
+ Bun.write(hydrateManifest, generateManifest(pageRoutes, hydrateFiles)),
48
+ Bun.write(domManifest, generateManifest(pageRoutes, domFiles)),
49
+ ]);
50
+
51
+ const makeRoutesPlugin = (manifestPath: string) => ({
52
+ name: "grimoire-routes",
53
+ setup(build: any) {
54
+ build.onResolve({ filter: /^#grimoire-routes$/ }, () => ({
55
+ path: manifestPath,
56
+ }));
57
+ },
58
+ });
59
+
60
+ const [hydrateResult, domResult] = await Promise.all([
61
+ Bun.build({
62
+ entrypoints: [join(import.meta.dir, "./hydrate.ts")],
63
+ outdir: join(process.cwd(), "public/__grimoire__"),
64
+ plugins: [sigil({ mode: "hydrate" }), makeRoutesPlugin(hydrateManifest)],
65
+ }),
66
+ Bun.build({
67
+ entrypoints: [join(import.meta.dir, "./client.ts")],
68
+ outdir: join(process.cwd(), "public/__grimoire__"),
69
+ plugins: [sigil({ mode: "dom" }), makeRoutesPlugin(domManifest)],
70
+ }),
71
+ ]);
72
+
73
+ if (!hydrateResult.success) {
74
+ for (const log of hydrateResult.logs) console.error(log);
75
+ }
76
+ if (!domResult.success) {
77
+ for (const log of domResult.logs) console.error(log);
78
+ }
79
+
80
+ const result: BuildResult = {
81
+ success: hydrateResult.success && domResult.success,
82
+ outputs: [...hydrateResult.outputs, ...domResult.outputs].map((o) => o.path),
83
+ errors: [...hydrateResult.logs, ...domResult.logs].map(String),
84
+ };
85
+
86
+ await runHook(plugins, "onBuildEnd", result);
87
+ return { result, tree };
88
+ }
@@ -24,7 +24,7 @@ async function navigate(path: string) {
24
24
 
25
25
  let node: any;
26
26
  disposeCurrentPage = withEffectScope(() => {
27
- node = Page({ ...data, params });
27
+ node = Page({ data, params });
28
28
  });
29
29
 
30
30
  const slot = document.getElementById("grimoire-page");
@@ -1,66 +1,66 @@
1
- /**
2
- * Parse cookies from a Cookie header string.
3
- */
4
- export function parseCookies(cookieHeader: string): Map<string, string> {
5
- const map = new Map<string, string>();
6
- if (!cookieHeader) return map;
7
- for (const pair of cookieHeader.split(";")) {
8
- const [name, ...rest] = pair.split("=");
9
- if (name) map.set(name.trim(), rest.join("=").trim());
10
- }
11
- return map;
12
- }
13
-
14
- /**
15
- * Build a Set-Cookie header from name, value, and options.
16
- */
17
- export function serializeCookie(
18
- name: string,
19
- value: string,
20
- options?: {
21
- path?: string;
22
- domain?: string;
23
- maxAge?: number;
24
- expires?: Date;
25
- httpOnly?: boolean;
26
- secure?: boolean;
27
- sameSite?: "strict" | "lax" | "none";
28
- },
29
- ): string {
30
- let cookie = `${name}=${encodeURIComponent(value)}`;
31
- if (options?.path) cookie += `; Path=${options.path}`;
32
- if (options?.domain) cookie += `; Domain=${options.domain}`;
33
- if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
34
- if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
35
- if (options?.httpOnly) cookie += "; HttpOnly";
36
- if (options?.secure) cookie += "; Secure";
37
- if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
38
- return cookie;
39
- }
40
-
41
- /**
42
- * Create a Cookies helper from a Cookie header string.
43
- */
44
- export function createCookies(
45
- cookieHeader: string,
46
- ): import("./hooks").Cookies & { toHeaders(): string[] } {
47
- const store = parseCookies(cookieHeader);
48
- const pending: string[] = [];
49
-
50
- return {
51
- get(name: string) {
52
- return store.get(name);
53
- },
54
- set(name: string, value: string, options?: any) {
55
- store.set(name, value);
56
- pending.push(serializeCookie(name, value, options));
57
- },
58
- delete(name: string) {
59
- store.delete(name);
60
- pending.push(serializeCookie(name, "", { maxAge: 0 }));
61
- },
62
- toHeaders() {
63
- return pending;
64
- },
65
- };
66
- }
1
+ /**
2
+ * Parse cookies from a Cookie header string.
3
+ */
4
+ export function parseCookies(cookieHeader: string): Map<string, string> {
5
+ const map = new Map<string, string>();
6
+ if (!cookieHeader) return map;
7
+ for (const pair of cookieHeader.split(";")) {
8
+ const [name, ...rest] = pair.split("=");
9
+ if (name) map.set(name.trim(), rest.join("=").trim());
10
+ }
11
+ return map;
12
+ }
13
+
14
+ /**
15
+ * Build a Set-Cookie header from name, value, and options.
16
+ */
17
+ export function serializeCookie(
18
+ name: string,
19
+ value: string,
20
+ options?: {
21
+ path?: string;
22
+ domain?: string;
23
+ maxAge?: number;
24
+ expires?: Date;
25
+ httpOnly?: boolean;
26
+ secure?: boolean;
27
+ sameSite?: "strict" | "lax" | "none";
28
+ },
29
+ ): string {
30
+ let cookie = `${name}=${encodeURIComponent(value)}`;
31
+ if (options?.path) cookie += `; Path=${options.path}`;
32
+ if (options?.domain) cookie += `; Domain=${options.domain}`;
33
+ if (options?.maxAge != null) cookie += `; Max-Age=${options.maxAge}`;
34
+ if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
35
+ if (options?.httpOnly) cookie += "; HttpOnly";
36
+ if (options?.secure) cookie += "; Secure";
37
+ if (options?.sameSite) cookie += `; SameSite=${options.sameSite}`;
38
+ return cookie;
39
+ }
40
+
41
+ /**
42
+ * Create a Cookies helper from a Cookie header string.
43
+ */
44
+ export function createCookies(
45
+ cookieHeader: string,
46
+ ): import("./hooks").Cookies & { toHeaders(): string[] } {
47
+ const store = parseCookies(cookieHeader);
48
+ const pending: string[] = [];
49
+
50
+ return {
51
+ get(name: string) {
52
+ return store.get(name);
53
+ },
54
+ set(name: string, value: string, options?: any) {
55
+ store.set(name, value);
56
+ pending.push(serializeCookie(name, value, options));
57
+ },
58
+ delete(name: string) {
59
+ store.delete(name);
60
+ pending.push(serializeCookie(name, "", { maxAge: 0 }));
61
+ },
62
+ toHeaders() {
63
+ return pending;
64
+ },
65
+ };
66
+ }
package/src/enhance.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * const [errors, setErrors] = createSignal({});
11
11
  *
12
- * <form use:enhance={{ action: "/api/login", onSuccess: () => { ... }, onFail: (data) => setErrors(data) }}>
12
+ * <form use={[enhance, { action: "/api/login", onSuccess: () => { ... }, onFail: (data) => setErrors(data) }]}>
13
13
  * ...
14
14
  * </form>
15
15
  */
package/src/hydrate.ts CHANGED
@@ -13,7 +13,7 @@ if (stateEl) {
13
13
  if (slot) {
14
14
  (globalThis as any).__nodes = Array.from(slot.childNodes);
15
15
  initialDispose = withEffectScope(() => {
16
- Page({ ...state.data, params: state.params });
16
+ Page({ data: state.data, params: state.params });
17
17
  });
18
18
  }
19
19
  }
package/src/plugins.ts CHANGED
@@ -1,12 +1,26 @@
1
- import type { GrimoirePlugin } from "./types";
1
+ import type { BuildResult, GrimoirePlugin, LoadContext, Route, Server } from "./types";
2
2
 
3
- export async function runHook(
3
+ // Fire-and-forget hooks routed through runHook.
4
+ // onRequest and onRouteRender are intentionally excluded: they have distinct
5
+ // calling conventions (middleware chain / inline transform loop) handled in server.ts.
6
+ // Keep HookArgs in sync with GrimoirePlugin in types.ts.
7
+ type HookArgs = {
8
+ onStart: [server: Server];
9
+ onStop: [reason: "shutdown" | "restart"];
10
+ onBuildStart: [];
11
+ onBuildEnd: [result: BuildResult];
12
+ onRouteLoad: [route: Route, context: LoadContext];
13
+ };
14
+ type FireAndForgetHook = keyof HookArgs;
15
+
16
+ export async function runHook<K extends FireAndForgetHook>(
4
17
  plugins: GrimoirePlugin[],
5
- hook: keyof GrimoirePlugin,
6
- ...args: any
18
+ hook: K,
19
+ ...args: HookArgs[K]
7
20
  ): Promise<void> {
8
21
  for (const plugin of plugins) {
9
- await (plugin[hook] as any)?.(...args);
22
+ const fn = plugin[hook] as ((...a: any[]) => any) | undefined;
23
+ await fn?.(...args);
10
24
  }
11
25
  }
12
26