@sigil-dev/grimoire 0.3.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.
Files changed (52) hide show
  1. package/.grimoire/_routes.dom.js +4 -0
  2. package/.grimoire/_routes.hydrate.js +4 -0
  3. package/.grimoire/_routes.ts +4 -0
  4. package/.grimoire/tsconfig.generated.json +11 -0
  5. package/.grimoire/types/ambient.d.ts +6 -0
  6. package/.grimoire/types/api/hello/$types.d.ts +29 -0
  7. package/README.md +1 -0
  8. package/index.ts +22 -0
  9. package/package.json +36 -0
  10. package/public/__grimoire__/client.js +86 -0
  11. package/public/__grimoire__/hydrate.js +101 -0
  12. package/src/client-router.ts +77 -0
  13. package/src/client.ts +4 -0
  14. package/src/context.ts +10 -0
  15. package/src/cookie-utils.ts +66 -0
  16. package/src/enhance.ts +97 -0
  17. package/src/error.ts +52 -0
  18. package/src/fail.ts +41 -0
  19. package/src/head.ts +27 -0
  20. package/src/headers.ts +114 -0
  21. package/src/hooks.ts +93 -0
  22. package/src/hydrate.ts +22 -0
  23. package/src/manifest-gen.ts +26 -0
  24. package/src/plugins.ts +25 -0
  25. package/src/redirect.ts +35 -0
  26. package/src/renderer.ts +142 -0
  27. package/src/router.ts +94 -0
  28. package/src/scanner.ts +97 -0
  29. package/src/scope.ts +22 -0
  30. package/src/server.ts +318 -0
  31. package/src/ssrPlugin.ts +26 -0
  32. package/src/sync.ts +18 -0
  33. package/src/transform-routes.ts +90 -0
  34. package/src/typegen.ts +263 -0
  35. package/src/types.ts +85 -0
  36. package/src/vite-plugin.ts +72 -0
  37. package/test/context.test.ts +52 -0
  38. package/test/fail.test.ts +46 -0
  39. package/test/headers.test.ts +96 -0
  40. package/test/hydration.test.ts +119 -0
  41. package/test/middleware.test.ts +217 -0
  42. package/test/preload.ts +5 -0
  43. package/test/redirect-error.test.ts +112 -0
  44. package/test/rendering.test.ts +172 -0
  45. package/test/routing.test.ts +45 -0
  46. package/test/scanning.test.ts +55 -0
  47. package/test/scope.test.ts +164 -0
  48. package/test/server.test.ts +30 -0
  49. package/test/streaming.test.ts +132 -0
  50. package/test/transform-routes.test.ts +84 -0
  51. package/test/typegen.test.ts +652 -0
  52. package/tsconfig.json +7 -0
package/src/head.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { createContext, getContext, setContext } from "@sigil-dev/runtime";
2
+
3
+ const HeadKey = createContext<string[]>();
4
+
5
+ export function initHead(): void {
6
+ setContext(HeadKey, []);
7
+ }
8
+
9
+ export function collectHead(): string {
10
+ return (getContext(HeadKey) ?? []).join("\n ");
11
+ }
12
+
13
+ export function Head({
14
+ children,
15
+ }: {
16
+ children: string | Node;
17
+ }): string | Comment {
18
+ if (typeof document === "undefined") {
19
+ const buf = getContext(HeadKey);
20
+ if (buf) buf.push(children as string);
21
+ return "";
22
+ }
23
+ if (children instanceof Node) {
24
+ document.head.appendChild(children);
25
+ }
26
+ return document.createComment("head");
27
+ }
package/src/headers.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { GrimoirePlugin } from "./types";
2
+
3
+ export interface SecurityHeadersConfig {
4
+ /** Content-Security-Policy. Set false to omit. */
5
+ contentSecurityPolicy?: string | false;
6
+ /** X-Content-Type-Options. Default: "nosniff" */
7
+ contentTypeOptions?: string | false;
8
+ /** X-Frame-Options. Default: "DENY" */
9
+ frameOptions?: string | false;
10
+ /** X-XSS-Protection. Default: "0" (modern browsers; "1; mode=block" for legacy) */
11
+ xssProtection?: string | false;
12
+ /** Referrer-Policy. Default: "strict-origin-when-cross-origin" */
13
+ referrerPolicy?: string | false;
14
+ /** Strict-Transport-Security. Set false to omit. */
15
+ strictTransportSecurity?: string | false;
16
+ /** Permissions-Policy. Default: "camera=(), microphone=(), geolocation=()" */
17
+ permissionsPolicy?: string | false;
18
+ /** Per-route overrides: path pattern → partial config */
19
+ routes?: Record<string, Partial<SecurityHeadersConfig>>;
20
+ }
21
+
22
+ const DEFAULTS: Required<Omit<SecurityHeadersConfig, "routes">> = {
23
+ contentSecurityPolicy:
24
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;",
25
+ contentTypeOptions: "nosniff",
26
+ frameOptions: "DENY",
27
+ xssProtection: "0",
28
+ referrerPolicy: "strict-origin-when-cross-origin",
29
+ strictTransportSecurity: "max-age=31536000; includeSubDomains",
30
+ permissionsPolicy: "camera=(), microphone=(), geolocation=()",
31
+ };
32
+
33
+ function resolveHeaders(
34
+ config: SecurityHeadersConfig,
35
+ pathname: string,
36
+ ): Record<string, string> {
37
+ // Start with defaults, overlay user config
38
+ const merged = { ...DEFAULTS, ...config };
39
+ delete (merged as any).routes;
40
+
41
+ // Apply route overrides
42
+ if (config.routes) {
43
+ for (const pattern in config.routes) {
44
+ if (pathname.startsWith(pattern)) {
45
+ Object.assign(merged, config.routes[pattern]);
46
+ }
47
+ }
48
+ }
49
+
50
+ const headers: Record<string, string> = {};
51
+
52
+ if (merged.contentSecurityPolicy !== false)
53
+ headers["Content-Security-Policy"] = merged.contentSecurityPolicy;
54
+ if (merged.contentTypeOptions !== false)
55
+ headers["X-Content-Type-Options"] = merged.contentTypeOptions;
56
+ if (merged.frameOptions !== false)
57
+ headers["X-Frame-Options"] = merged.frameOptions;
58
+ if (merged.xssProtection !== false)
59
+ headers["X-XSS-Protection"] = merged.xssProtection;
60
+ if (merged.referrerPolicy !== false)
61
+ headers["Referrer-Policy"] = merged.referrerPolicy;
62
+ if (merged.strictTransportSecurity !== false)
63
+ headers["Strict-Transport-Security"] = merged.strictTransportSecurity;
64
+ if (merged.permissionsPolicy !== false)
65
+ headers["Permissions-Policy"] = merged.permissionsPolicy;
66
+
67
+ return headers;
68
+ }
69
+
70
+ /**
71
+ * Security headers plugin.
72
+ * Adds standard security headers to all responses.
73
+ *
74
+ * Usage:
75
+ *
76
+ * import { securityHeaders } from "@sigil-dev/grimoire/headers";
77
+ *
78
+ * createServer({
79
+ * plugins: [
80
+ * securityHeaders({
81
+ * // override defaults
82
+ * frameOptions: "SAMEORIGIN",
83
+ * // per-route
84
+ * routes: {
85
+ * "/admin": { contentSecurityPolicy: "default-src 'self'" },
86
+ * },
87
+ * }),
88
+ * ],
89
+ * });
90
+ */
91
+ export function securityHeaders(
92
+ config: SecurityHeadersConfig = {},
93
+ ): GrimoirePlugin {
94
+ return {
95
+ name: "grimoire-security-headers",
96
+ async onRequest(req, next) {
97
+ const res = await next();
98
+ const url = new URL(req.url);
99
+ const headers = resolveHeaders(config, url.pathname);
100
+
101
+ // Clone response with security headers
102
+ const newHeaders = new Headers(res.headers);
103
+ for (const [key, value] of Object.entries(headers)) {
104
+ newHeaders.set(key, value);
105
+ }
106
+
107
+ return new Response(res.body, {
108
+ status: res.status,
109
+ statusText: res.statusText,
110
+ headers: newHeaders,
111
+ });
112
+ },
113
+ };
114
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * SvelteKit-style server hooks.
3
+ *
4
+ * Create a hooks.server.ts in your project root:
5
+ *
6
+ * import type { Handle } from "@sigil-dev/grimoire/hooks";
7
+ *
8
+ * export const handle: Handle = async ({ event, resolve }) => {
9
+ * event.locals.user = await getUser(event.cookies.get("session"));
10
+ * const response = await resolve(event);
11
+ * response.headers.set("x-custom", "value");
12
+ * return response;
13
+ * };
14
+ *
15
+ * // optional: runs once at server start
16
+ * export const init = () => {
17
+ * console.log("Server started");
18
+ * };
19
+ *
20
+ * Chain multiple handlers with sequence():
21
+ *
22
+ * import { sequence } from "@sigil-dev/grimoire/hooks";
23
+ * import { logger } from "./hooks/logger";
24
+ * import { auth } from "./hooks/auth";
25
+ *
26
+ * export const handle = sequence(logger, auth);
27
+ */
28
+
29
+ export type MaybePromise<T> = T | Promise<T>;
30
+
31
+ export interface RequestEvent {
32
+ request: Request;
33
+ url: URL;
34
+ params: Record<string, string>;
35
+ locals: Record<string, any>;
36
+ cookies: Cookies;
37
+ setHeaders: (headers: Record<string, string>) => void;
38
+ }
39
+
40
+ export interface Cookies {
41
+ get(name: string): string | undefined;
42
+ set(name: string, value: string, options?: CookieOptions): void;
43
+ delete(name: string): void;
44
+ }
45
+
46
+ export interface CookieOptions {
47
+ path?: string;
48
+ domain?: string;
49
+ maxAge?: number;
50
+ expires?: Date;
51
+ httpOnly?: boolean;
52
+ secure?: boolean;
53
+ sameSite?: "strict" | "lax" | "none";
54
+ }
55
+
56
+ export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
57
+
58
+ export type Handle = (input: {
59
+ event: RequestEvent;
60
+ resolve: ResolveFunction;
61
+ }) => MaybePromise<Response>;
62
+
63
+ export type InitFunction = () => void | Promise<void>;
64
+
65
+ /**
66
+ * Chain multiple Handle functions into one.
67
+ * First handler in the array runs first.
68
+ */
69
+ export function sequence(...handlers: Handle[]): Handle {
70
+ return async ({ event, resolve }) => {
71
+ let i = 0;
72
+
73
+ const next: ResolveFunction = async (evt) => {
74
+ if (i < handlers.length) {
75
+ const handler = handlers[i++];
76
+ return handler({ event: evt, resolve: next });
77
+ }
78
+ return resolve(evt);
79
+ };
80
+
81
+ return next(event);
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Create a hooks object with handle and optional init.
87
+ */
88
+ export function createHooks(
89
+ handle: Handle,
90
+ init?: InitFunction,
91
+ ): { handle: Handle; init?: InitFunction } {
92
+ return { handle, init };
93
+ }
package/src/hydrate.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { routes } from "#grimoire-routes";
2
+ import { withEffectScope } from "./scope.ts";
3
+ import { initRouter } from "./client-router.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
+ (globalThis as any).__nodes = Array.from(slot.childNodes);
15
+ initialDispose = withEffectScope(() => {
16
+ Page({ ...state.data, params: state.params });
17
+ });
18
+ }
19
+ }
20
+ }
21
+
22
+ initRouter(routes, initialDispose);
@@ -0,0 +1,26 @@
1
+ import type { RouteFile } from "./scanner";
2
+
3
+ /**
4
+ * Generates a manifest module that re-exports page route components keyed
5
+ * by route path. Emits plain JS so the same output is valid as both `.ts`
6
+ * and `.js`.
7
+ *
8
+ * If `fileMap` is provided, original `filePath`s are remapped to compiled
9
+ * `.js` paths (used after pre-transforming routes to plain JS).
10
+ */
11
+ export function generateManifest(
12
+ routes: RouteFile[],
13
+ fileMap?: Map<string, string>,
14
+ ): string {
15
+ const pages = routes.filter((r) => r.type === "page" || r.type === "simple");
16
+ const imports = pages
17
+ .map((r, i) => {
18
+ const path = fileMap?.get(r.filePath) ?? r.filePath;
19
+ return `import __Page${i} from ${JSON.stringify(path)};`;
20
+ })
21
+ .join("\n");
22
+ const map = pages
23
+ .map((r, i) => ` ${JSON.stringify(r.path)}: __Page${i},`)
24
+ .join("\n");
25
+ return `${imports}\nexport const routes = {\n${map}\n};\n`;
26
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { GrimoirePlugin } from "./types";
2
+
3
+ export async function runHook(
4
+ plugins: GrimoirePlugin[],
5
+ hook: keyof GrimoirePlugin,
6
+ ...args: any
7
+ ): Promise<void> {
8
+ for (const plugin of plugins) {
9
+ await (plugin[hook] as any)?.(...args);
10
+ }
11
+ }
12
+
13
+ export async function runRequestHooks(
14
+ plugins: GrimoirePlugin[],
15
+ req: Request,
16
+ final: () => Promise<Response>,
17
+ ): Promise<Response> {
18
+ const chain = plugins
19
+ .filter((p) => p.onRequest)
20
+ .reduceRight(
21
+ (next, plugin) => async () => plugin.onRequest!(req, next),
22
+ final,
23
+ );
24
+ return chain();
25
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Throw a redirect from a load function or form action.
3
+ * The server catches it and returns a 3xx Response.
4
+ *
5
+ * Usage in +page.server.ts:
6
+ *
7
+ * import { redirect } from "@sigil-dev/grimoire";
8
+ *
9
+ * export async function load({ locals }) {
10
+ * if (!locals.user) throw redirect(302, "/login");
11
+ * return { user: locals.user };
12
+ * }
13
+ *
14
+ * export async function POST({ request }) {
15
+ * // ... process form
16
+ * throw redirect(303, "/dashboard");
17
+ * }
18
+ */
19
+ export interface RedirectResult {
20
+ __redirect: true;
21
+ status: number;
22
+ location: string;
23
+ }
24
+
25
+ export function redirect(status: number, location: string): never {
26
+ throw { __redirect: true, status, location } as RedirectResult;
27
+ }
28
+
29
+ export function isRedirectResult(value: unknown): value is RedirectResult {
30
+ return (
31
+ typeof value === "object" &&
32
+ value !== null &&
33
+ (value as any).__redirect === true
34
+ );
35
+ }
@@ -0,0 +1,142 @@
1
+ import { SafeHtml } from "@sigil-dev/runtime";
2
+ import { isErrorResult } from "./error";
3
+ import { runWithContext } from "./context";
4
+ import { collectHead, initHead } from "./head";
5
+ import { isRedirectResult } from "./redirect";
6
+ import type { MatchedRoute } from "./router";
7
+ import type { LoadContext } from "./types";
8
+
9
+ export type ModuleLoader = (path: string) => Promise<any>;
10
+
11
+ export async function renderRoute(
12
+ matched: MatchedRoute,
13
+ req: Request,
14
+ loadModule: ModuleLoader = (path) => import(path),
15
+ locals: Record<string, any> = {},
16
+ ): Promise<Response> {
17
+ return runWithContext(async () => {
18
+ const context: LoadContext = {
19
+ request: req,
20
+ params: matched.params,
21
+ url: new URL(req.url),
22
+ locals,
23
+ };
24
+
25
+ initHead();
26
+
27
+ let layoutData: unknown;
28
+ if (matched.layoutServer) {
29
+ try {
30
+ const mod = await import(matched.layoutServer.filePath);
31
+ layoutData = await mod.load?.(context);
32
+ } catch (e) {
33
+ if (isRedirectResult(e)) {
34
+ return new Response(null, {
35
+ status: e.status,
36
+ headers: { Location: e.location },
37
+ });
38
+ }
39
+ if (isErrorResult(e)) {
40
+ return new Response(e.message, { status: e.status });
41
+ }
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ let pageData: unknown;
47
+ if (matched.pageServer) {
48
+ try {
49
+ const mod = await import(matched.pageServer.filePath);
50
+ pageData = await mod.load?.(context);
51
+ } catch (e) {
52
+ if (isRedirectResult(e)) {
53
+ return new Response(null, {
54
+ status: e.status,
55
+ headers: { Location: e.location },
56
+ });
57
+ }
58
+ if (isErrorResult(e)) {
59
+ return new Response(e.message, { status: e.status });
60
+ }
61
+ throw e;
62
+ }
63
+ }
64
+
65
+ const pageMod = await import(matched.route.filePath);
66
+ const pageHtml = pageMod.default({
67
+ ...(pageData as Record<string, unknown>),
68
+ params: matched.params,
69
+ });
70
+
71
+ // collect head AFTER page render so <Head> calls are captured
72
+ const headHtml = collectHead();
73
+
74
+ // navigation request: return JSON, client handles rendering
75
+ if (req.headers.get("x-grimoire-navigate") === "1") {
76
+ return Response.json({
77
+ data: pageData ?? {},
78
+ params: matched.params,
79
+ pattern: matched.route.path,
80
+ head: headHtml,
81
+ });
82
+ }
83
+
84
+ const wrappedPage = `<div id="grimoire-page">${String(pageHtml)}</div>`;
85
+
86
+ let bodyHtml: string = wrappedPage;
87
+ if (matched.layout) {
88
+ const layoutMod = await import(matched.layout.filePath);
89
+ bodyHtml = String(layoutMod.default({
90
+ ...(layoutData as Record<string, unknown>),
91
+ children: new SafeHtml(wrappedPage),
92
+ }));
93
+ }
94
+
95
+ const stateJson = JSON.stringify({
96
+ params: matched.params,
97
+ data: pageData,
98
+ pattern: matched.route.path,
99
+ });
100
+
101
+ // --- Streaming SSR ---
102
+ // Send DOCTYPE + head skeleton immediately (browser starts resource fetch).
103
+ // Then stream body + full head content + state as they become available.
104
+ const stream = new ReadableStream({
105
+ start(controller) {
106
+ // 1. Document skeleton — browser starts parsing, fetches CSS/JS
107
+ controller.enqueue(
108
+ `<!DOCTYPE html>
109
+ <html>
110
+ <head>
111
+ <meta charset="UTF-8" />
112
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
113
+ <script type="module" src="/__grimoire__/hydrate.js"></script>`,
114
+ );
115
+
116
+ // 2. Head content (captured from <Head> component calls during render)
117
+ if (headHtml) {
118
+ controller.enqueue(`\n${headHtml}`);
119
+ }
120
+
121
+ controller.enqueue(`\n</head>
122
+ <body>
123
+ <div id="app">`);
124
+
125
+ // 3. Page body
126
+ controller.enqueue(bodyHtml);
127
+
128
+ // 4. State script + closing tags
129
+ controller.enqueue(`</div>
130
+ <script id="__grimoire_state__" type="application/json">${stateJson}</script>
131
+ </body>
132
+ </html>`);
133
+
134
+ controller.close();
135
+ },
136
+ });
137
+
138
+ return new Response(stream, {
139
+ headers: { "Content-Type": "text/html" },
140
+ });
141
+ });
142
+ }
package/src/router.ts ADDED
@@ -0,0 +1,94 @@
1
+ // packages/grimoire/src/router.ts
2
+ import type { RouteFile, RouteTree } from "./scanner";
3
+
4
+ export interface MatchedRoute {
5
+ route: RouteFile;
6
+ params: Record<string, string>;
7
+ layout?: RouteFile;
8
+ layoutServer?: RouteFile;
9
+ pageServer?: RouteFile;
10
+ }
11
+
12
+ export function matchRoute(tree: RouteTree, url: URL): MatchedRoute | null {
13
+ const pathname = url.pathname;
14
+
15
+ // check server routes first
16
+ for (const server of tree.servers) {
17
+ const params = matchPattern(server.path, pathname);
18
+ if (params !== null) return { route: server, params };
19
+ }
20
+
21
+ // then pages
22
+ for (const route of tree.routes) {
23
+ if (route.type === "pageServer") continue;
24
+ const params = matchPattern(route.path, pathname);
25
+ if (params === null) continue;
26
+
27
+ // find applicable layout (longest matching prefix wins)
28
+ const layout = tree.layouts
29
+ .filter(
30
+ (l) =>
31
+ l.type === "layout" &&
32
+ pathname.startsWith(l.path === "/" ? "/" : l.path),
33
+ )
34
+ .sort((a, b) => b.path.length - a.path.length)[0];
35
+
36
+ const layoutServer = tree.layouts
37
+ .filter(
38
+ (l) =>
39
+ l.type === "layoutServer" &&
40
+ pathname.startsWith(l.path === "/" ? "/" : l.path),
41
+ )
42
+ .sort((a, b) => b.path.length - a.path.length)[0];
43
+
44
+ const pageServer = tree.routes.find(
45
+ (r) => r.type === "pageServer" && r.path === route.path,
46
+ );
47
+
48
+ return { route, params, layout, layoutServer, pageServer };
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ export function findClosestError(
55
+ tree: RouteTree,
56
+ pathname: string,
57
+ ): RouteFile | null {
58
+ const segments = pathname.split("/").filter(Boolean);
59
+
60
+ // walk from most specific to root
61
+ while (segments.length >= 0) {
62
+ const prefix = `/${segments.join("/")}`;
63
+ const error = tree.errors.find(
64
+ (e) => e.path === prefix || e.path === prefix + "/",
65
+ );
66
+ if (error) return error;
67
+ if (segments.length === 0) break;
68
+ segments.pop();
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function matchPattern(
74
+ pattern: string,
75
+ pathname: string,
76
+ ): Record<string, string> | null {
77
+ const patternParts = pattern.split("/").filter(Boolean);
78
+ const pathParts = pathname.split("/").filter(Boolean);
79
+
80
+ if (patternParts.length !== pathParts.length) return null;
81
+
82
+ const params: Record<string, string> = {};
83
+ for (let i = 0; i < patternParts.length; i++) {
84
+ const patternPart = patternParts[i]!;
85
+ const pathPart = pathParts[i]!;
86
+
87
+ if (patternPart.startsWith(":")) {
88
+ params[patternPart.slice(1)] = pathPart;
89
+ } else if (patternPart !== pathPart) {
90
+ return null;
91
+ }
92
+ }
93
+ return params;
94
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { basename, join, relative } from "node:path";
2
+
3
+ export interface RouteFile {
4
+ path: string; // URL (ie /blog/:slug)
5
+ filePath: string; // absolute disk path;
6
+ clientPath: string; // Relative path (/src/routes/spells/+page.tsx)
7
+ type:
8
+ | "page"
9
+ | "pageServer"
10
+ | "layout"
11
+ | "layoutServer"
12
+ | "server"
13
+ | "error"
14
+ | "simple";
15
+ paramNames: string[]; // ["slug"] in /blog/:slug
16
+ }
17
+
18
+ export interface RouteTree {
19
+ routes: RouteFile[];
20
+ layouts: RouteFile[]; // layout + layout.server
21
+ servers: RouteFile[]; // +server.ts API routes
22
+ errors: RouteFile[]; // +error.tsx
23
+ }
24
+
25
+ export function filePathToRoutePath(
26
+ filePath: string,
27
+ routesDir: string,
28
+ ): { pattern: string; params: string[] } {
29
+ const rel = relative(routesDir, filePath)
30
+ .replace(/\\/g, "/") // windows
31
+ .replace(/\.(tsx?|jsx?)$/, "");
32
+
33
+ const params: string[] = [];
34
+ const pattern =
35
+ rel
36
+ .replace(/\/?(\+\w+(\.\w+)?|index)$/, "") // index/+page → directory
37
+ .replace(/\[([^\]]+)\]/g, (_, p) => {
38
+ // [param] → :param
39
+ params.push(p);
40
+ return `:${p}`;
41
+ })
42
+ .replace(/^\/?/, "/") || // ensure leading slash
43
+ "/";
44
+
45
+ return { pattern, params };
46
+ }
47
+
48
+ export async function scanRoutes(
49
+ routesDir: string,
50
+ viteRoot: string = routesDir,
51
+ ): Promise<RouteTree> {
52
+ const glob = new Bun.Glob("**/*.{tsx,ts,jsx,js}");
53
+ const routes: RouteFile[] = [];
54
+ const layouts: RouteFile[] = [];
55
+ const servers: RouteFile[] = [];
56
+ const errors: RouteFile[] = [];
57
+
58
+ for await (const file of glob.scan(routesDir)) {
59
+ const filePath = join(routesDir, file);
60
+ const name = basename(file).replace(/\.(tsx?|jsx?)$/, "");
61
+ const { pattern, params } = filePathToRoutePath(filePath, routesDir);
62
+
63
+ let type: RouteFile["type"];
64
+ if (name === "+page") type = "page";
65
+ else if (name === "+page.server") type = "pageServer";
66
+ else if (name === "+layout") type = "layout";
67
+ else if (name === "+layout.server") type = "layoutServer";
68
+ else if (name === "+server") type = "server";
69
+ else if (name === "+error") type = "error";
70
+ else type = "simple";
71
+
72
+ const clientPath = "/" + relative(viteRoot, filePath).replace(/\\/g, "/");
73
+
74
+ const routeFile: RouteFile = {
75
+ path: pattern,
76
+ filePath,
77
+ clientPath, // add this
78
+ type,
79
+ paramNames: params,
80
+ };
81
+
82
+ if (type === "simple" || type === "page" || type === "pageServer")
83
+ routes.push(routeFile);
84
+ if (type === "layout" || type === "layoutServer") layouts.push(routeFile);
85
+ if (type === "server") servers.push(routeFile);
86
+ if (type === "error") errors.push(routeFile);
87
+ }
88
+
89
+ // sort so static routes match before dynamic ones
90
+ routes.sort((a, b) => {
91
+ const aScore = a.paramNames.length;
92
+ const bScore = b.paramNames.length;
93
+ return aScore - bScore;
94
+ });
95
+
96
+ return { routes, layouts, servers, errors };
97
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Open an effect scope: run fn() and return a dispose function that tears down
3
+ * every createEffect registered during that call.
4
+ *
5
+ * Communicates with @sigil-dev/runtime's createEffect via globalThis.__sigilEffectScope
6
+ * so this file does not need to import from @sigil-dev/runtime (which would pull the
7
+ * runtime source into Bun.build and break the test sandbox).
8
+ */
9
+ export function withEffectScope(fn: () => void): () => void {
10
+ const prev = (globalThis as any).__sigilEffectScope;
11
+ const disposers: (() => void)[] = [];
12
+ (globalThis as any).__sigilEffectScope = disposers;
13
+ try {
14
+ fn();
15
+ } finally {
16
+ (globalThis as any).__sigilEffectScope = prev;
17
+ }
18
+ return () => {
19
+ for (const d of disposers) d();
20
+ disposers.length = 0;
21
+ };
22
+ }