@sigil-dev/grimoire 0.7.6 → 0.8.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 (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 +120 -53
  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 +102 -64
  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
@@ -1,39 +1,42 @@
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 layouts = routes.filter((r) => r.type === "layout");
17
-
18
- const pageImports = pages
19
- .map((r, i) => {
20
- const path = fileMap?.get(r.filePath) ?? r.filePath;
21
- return `import __Page${i} from ${JSON.stringify(path)};`;
22
- })
23
- .join("\n");
24
- const layoutImports = layouts
25
- .map((r, i) => {
26
- const path = fileMap?.get(r.filePath) ?? r.filePath;
27
- return `import __Layout${i} from ${JSON.stringify(path)};`;
28
- })
29
- .join("\n");
30
-
31
- const map = pages
32
- .map((r, i) => ` ${JSON.stringify(r.path)}: __Page${i},`)
33
- .join("\n");
34
- const layoutMap = layouts
35
- .map((r, i) => ` { path: ${JSON.stringify(r.path)}, component: __Layout${i} },`)
36
- .join("\n");
37
-
38
- return `${pageImports}\n${layoutImports}\nexport const routes = {\n${map}\n};\nexport const layouts = [\n${layoutMap}\n];\n`;
39
- }
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 layouts = routes.filter((r) => r.type === "layout");
17
+
18
+ const pageImports = pages
19
+ .map((r, i) => {
20
+ const path = fileMap?.get(r.filePath) ?? r.filePath;
21
+ return `import __Page${i} from ${JSON.stringify(path)};`;
22
+ })
23
+ .join("\n");
24
+ const layoutImports = layouts
25
+ .map((r, i) => {
26
+ const path = fileMap?.get(r.filePath) ?? r.filePath;
27
+ return `import __Layout${i} from ${JSON.stringify(path)};`;
28
+ })
29
+ .join("\n");
30
+
31
+ const map = pages
32
+ .map((r, i) => ` ${JSON.stringify(r.path)}: __Page${i},`)
33
+ .join("\n");
34
+ const layoutMap = layouts
35
+ .map(
36
+ (r, i) =>
37
+ ` { path: ${JSON.stringify(r.path)}, component: __Layout${i}, load: __Layout${i}.load },`,
38
+ )
39
+ .join("\n");
40
+
41
+ return `${pageImports}\n${layoutImports}\nexport const routes = {\n${map}\n};\nexport const layouts = [\n${layoutMap}\n];\n`;
42
+ }
@@ -1,106 +1,109 @@
1
- import type { RouteFile, RouteTree } from "./scanner";
2
-
3
- export interface MatchedRoute {
4
- route: RouteFile;
5
- params: Record<string, string>;
6
- layouts: RouteFile[]; // was: layout?: RouteFile
7
- layoutServers: RouteFile[]; // was: layoutServer?: RouteFile
8
- pageServer?: RouteFile;
9
- }
10
-
11
- export function matchRoute(tree: RouteTree, url: URL): MatchedRoute | null {
12
- const pathname = url.pathname;
13
-
14
- // check server routes first
15
- for (const server of tree.servers) {
16
- const params = matchPattern(server.path, pathname);
17
- if (params !== null) return {
18
- route: server,
19
- params,
20
- layouts: [],
21
- layoutServers: []
22
- };
23
- }
24
-
25
- // then pages
26
- for (const route of tree.routes) {
27
- if (route.type === "pageServer") continue;
28
- const params = matchPattern(route.path, pathname);
29
- if (params === null) continue;
30
-
31
- // find applicable layouts by matching route pattern prefix, not URL pathname.
32
- // pathname.startsWith(l.path) would fail for parameterized layouts like /w/:workspace.
33
- const layoutMatches = (l: RouteFile) =>
34
- l.path === "/" || route.path === l.path || route.path.startsWith(l.path + "/");
35
-
36
- const layouts = tree.layouts
37
- .filter((l) => l.type === "layout" && layoutMatches(l))
38
- .sort((a, b) => a.path.length - b.path.length); // root first, innermost last
39
-
40
- const layoutServers = tree.layouts
41
- .filter((l) => l.type === "layoutServer" && layoutMatches(l))
42
- .sort((a, b) => a.path.length - b.path.length); // root first, innermost last
43
-
44
- const pageServer = tree.routes.find(
45
- (r) => r.type === "pageServer" && r.path === route.path,
46
- );
47
-
48
- return { route, params, layouts, layoutServers, pageServer };
49
- }
50
-
51
- return null;
52
- }
53
-
54
- export function findClosestError(
55
- errors: RouteFile[],
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 = 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
- const params: Record<string, string> = {};
81
- let pi = 0;
82
- let pj = 0;
83
- while (pi < patternParts.length) {
84
- const patternPart = patternParts[pi]!;
85
-
86
- if (patternPart.startsWith("*")) {
87
- // Rest param — captures zero or more remaining path segments joined by "/".
88
- // Rest must be the last segment in the pattern.
89
- params[patternPart.slice(1)] = pathParts.slice(pj).join("/");
90
- return params;
91
- }
92
-
93
- if (pj >= pathParts.length) return null;
94
- const pathPart = pathParts[pj]!;
95
-
96
- if (patternPart.startsWith(":")) {
97
- params[patternPart.slice(1)] = pathPart;
98
- } else if (patternPart !== pathPart) {
99
- return null;
100
- }
101
- pi++;
102
- pj++;
103
- }
104
- if (pj < pathParts.length) return null;
105
- return params;
106
- }
1
+ import type { RouteFile, RouteTree } from "./scanner";
2
+
3
+ export interface MatchedRoute {
4
+ route: RouteFile;
5
+ params: Record<string, string>;
6
+ layouts: RouteFile[]; // was: layout?: RouteFile
7
+ layoutServers: RouteFile[]; // was: layoutServer?: RouteFile
8
+ pageServer?: RouteFile;
9
+ }
10
+
11
+ export function matchRoute(tree: RouteTree, url: URL): MatchedRoute | null {
12
+ const pathname = url.pathname;
13
+
14
+ // check server routes first
15
+ for (const server of tree.servers) {
16
+ const params = matchPattern(server.path, pathname);
17
+ if (params !== null)
18
+ return {
19
+ route: server,
20
+ params,
21
+ layouts: [],
22
+ layoutServers: [],
23
+ };
24
+ }
25
+
26
+ // then pages
27
+ for (const route of tree.routes) {
28
+ if (route.type === "pageServer") continue;
29
+ const params = matchPattern(route.path, pathname);
30
+ if (params === null) continue;
31
+
32
+ // find applicable layouts by matching route pattern prefix, not URL pathname.
33
+ // pathname.startsWith(l.path) would fail for parameterized layouts like /w/:workspace.
34
+ const layoutMatches = (l: RouteFile) =>
35
+ l.path === "/" ||
36
+ route.path === l.path ||
37
+ route.path.startsWith(l.path + "/");
38
+
39
+ const layouts = tree.layouts
40
+ .filter((l) => l.type === "layout" && layoutMatches(l))
41
+ .sort((a, b) => a.path.length - b.path.length); // root first, innermost last
42
+
43
+ const layoutServers = tree.layouts
44
+ .filter((l) => l.type === "layoutServer" && layoutMatches(l))
45
+ .sort((a, b) => a.path.length - b.path.length); // root first, innermost last
46
+
47
+ const pageServer = tree.routes.find(
48
+ (r) => r.type === "pageServer" && r.path === route.path,
49
+ );
50
+
51
+ return { route, params, layouts, layoutServers, pageServer };
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ export function findClosestError(
58
+ errors: RouteFile[],
59
+ pathname: string,
60
+ ): RouteFile | null {
61
+ const segments = pathname.split("/").filter(Boolean);
62
+
63
+ // walk from most specific to root
64
+ while (segments.length >= 0) {
65
+ const prefix = `/${segments.join("/")}`;
66
+ const error = errors.find(
67
+ (e) => e.path === prefix || e.path === prefix + "/",
68
+ );
69
+ if (error) return error;
70
+ if (segments.length === 0) break;
71
+ segments.pop();
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function matchPattern(
77
+ pattern: string,
78
+ pathname: string,
79
+ ): Record<string, string> | null {
80
+ const patternParts = pattern.split("/").filter(Boolean);
81
+ const pathParts = pathname.split("/").filter(Boolean);
82
+
83
+ const params: Record<string, string> = {};
84
+ let pi = 0;
85
+ let pj = 0;
86
+ while (pi < patternParts.length) {
87
+ const patternPart = patternParts[pi]!;
88
+
89
+ if (patternPart.startsWith("*")) {
90
+ // Rest param — captures zero or more remaining path segments joined by "/".
91
+ // Rest must be the last segment in the pattern.
92
+ params[patternPart.slice(1)] = pathParts.slice(pj).join("/");
93
+ return params;
94
+ }
95
+
96
+ if (pj >= pathParts.length) return null;
97
+ const pathPart = pathParts[pj]!;
98
+
99
+ if (patternPart.startsWith(":")) {
100
+ params[patternPart.slice(1)] = pathPart;
101
+ } else if (patternPart !== pathPart) {
102
+ return null;
103
+ }
104
+ pi++;
105
+ pj++;
106
+ }
107
+ if (pj < pathParts.length) return null;
108
+ return params;
109
+ }
@@ -1,135 +1,141 @@
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
- restParamNames: string[]; // ["rest"] in /blog/*rest
17
- }
18
-
19
- export interface RouteTree {
20
- routes: RouteFile[];
21
- layouts: RouteFile[]; // layout + layout.server
22
- servers: RouteFile[]; // +server.ts API routes
23
- errors: RouteFile[]; // +error.tsx
24
- }
25
-
26
- export function filePathToRoutePath(
27
- filePath: string,
28
- routesDir: string,
29
- ): { pattern: string; params: string[]; restParams: string[] } {
30
- const rel = relative(routesDir, filePath)
31
- .replace(/\\/g, "/") // windows
32
- .replace(/\.(tsx?|jsx?)$/, "");
33
-
34
- const params: string[] = [];
35
- const restParams: string[] = [];
36
- const pattern =
37
- rel
38
- .replace(/\/?(\+\w+(\.\w+)?|index)$/, "") // index/+page → directory
39
- .replace(/\[(\.\.\.)?([^\]]+)\]/g, (_, dots, name) => {
40
- // [...rest] → *rest (captures zero or more remaining path segments)
41
- if (dots) {
42
- restParams.push(name);
43
- return `*${name}`;
44
- }
45
- // [param] → :param
46
- params.push(name);
47
- return `:${name}`;
48
- })
49
- .replace(/^\/?/, "/") || // ensure leading slash
50
- "/";
51
-
52
- return { pattern, params, restParams };
53
- }
54
-
55
- export async function scanRoutes(
56
- routesDir: string,
57
- viteRoot: string = routesDir,
58
- ): Promise<RouteTree> {
59
- const glob = new Bun.Glob("**/*.{tsx,ts,jsx,js}");
60
- const routes: RouteFile[] = [];
61
- const layouts: RouteFile[] = [];
62
- const servers: RouteFile[] = [];
63
- const errors: RouteFile[] = [];
64
-
65
- for await (const file of glob.scan(routesDir)) {
66
- const filePath = join(routesDir, file);
67
- const name = basename(file).replace(/\.(tsx?|jsx?)$/, "");
68
- const { pattern, params, restParams } = filePathToRoutePath(filePath, routesDir);
69
-
70
- let type: RouteFile["type"];
71
- if (name === "+page") type = "page";
72
- else if (name === "+page.server") type = "pageServer";
73
- else if (name === "+layout") type = "layout";
74
- else if (name === "+layout.server") type = "layoutServer";
75
- else if (name === "+server") type = "server";
76
- else if (name === "+error") type = "error";
77
- else type = "simple";
78
-
79
- const clientPath = "/" + relative(viteRoot, filePath).replace(/\\/g, "/");
80
-
81
- const routeFile: RouteFile = {
82
- path: pattern,
83
- filePath,
84
- clientPath, // add this
85
- type,
86
- paramNames: params,
87
- restParamNames: restParams,
88
- };
89
-
90
- if (type === "simple" || type === "page" || type === "pageServer")
91
- routes.push(routeFile);
92
- if (type === "layout" || type === "layoutServer") layouts.push(routeFile);
93
- if (type === "server") servers.push(routeFile);
94
- if (type === "error") errors.push(routeFile);
95
- }
96
-
97
- // sort so static routes match before dynamic ones, rest params last
98
- const scoreRoute = (r: RouteFile) => r.paramNames.length + r.restParamNames.length * 1000;
99
- routes.sort((a, b) => scoreRoute(a) - scoreRoute(b));
100
- servers.sort((a, b) => scoreRoute(a) - scoreRoute(b));
101
-
102
- assertNoDuplicatePaths(
103
- routes.filter((r) => r.type === "page" || r.type === "simple"),
104
- "page",
105
- );
106
- assertNoDuplicatePaths(
107
- routes.filter((r) => r.type === "pageServer"),
108
- "page server",
109
- );
110
- assertNoDuplicatePaths(
111
- layouts.filter((l) => l.type === "layout"),
112
- "layout",
113
- );
114
- assertNoDuplicatePaths(
115
- layouts.filter((l) => l.type === "layoutServer"),
116
- "layout server",
117
- );
118
- assertNoDuplicatePaths(servers, "API route");
119
- assertNoDuplicatePaths(errors, "error page");
120
-
121
- return { routes, layouts, servers, errors };
122
- }
123
-
124
- function assertNoDuplicatePaths(files: RouteFile[], label: string): void {
125
- const seen = new Map<string, string>();
126
- for (const file of files) {
127
- const existing = seen.get(file.path);
128
- if (existing) {
129
- throw new Error(
130
- `Duplicate ${label} at "${file.path}": "${existing}" and "${file.clientPath}"`,
131
- );
132
- }
133
- seen.set(file.path, file.clientPath);
134
- }
135
- }
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
+ restParamNames: string[]; // ["rest"] in /blog/*rest
17
+ }
18
+
19
+ export interface RouteTree {
20
+ routes: RouteFile[];
21
+ layouts: RouteFile[]; // layout + layout.server
22
+ servers: RouteFile[]; // +server.ts API routes
23
+ errors: RouteFile[]; // +error.tsx
24
+ }
25
+
26
+ export function filePathToRoutePath(
27
+ filePath: string,
28
+ routesDir: string,
29
+ ): { pattern: string; params: string[]; restParams: string[] } {
30
+ const rel = relative(routesDir, filePath)
31
+ .replace(/\\/g, "/")
32
+ .replace(/\.(tsx?|jsx?)$/, "")
33
+ .replace(/(?:^|\/)(\([^)]+\))(?=\/|$)/g, "") // strip (groupName) anywhere
34
+ .replace(/\/+/g, "/"); // collapse double slashes
35
+
36
+ const params: string[] = [];
37
+ const restParams: string[] = [];
38
+ const pattern =
39
+ rel
40
+ .replace(/\/?(\+\w+(\.\w+)?|index)$/, "") // index/+page directory
41
+ .replace(/\[(\.\.\.)?([^\]]+)\]/g, (_, dots, name) => {
42
+ // [...rest] → *rest (captures zero or more remaining path segments)
43
+ if (dots) {
44
+ restParams.push(name);
45
+ return `*${name}`;
46
+ }
47
+ // [param] → :param
48
+ params.push(name);
49
+ return `:${name}`;
50
+ })
51
+ .replace(/^\/?/, "/") || // ensure leading slash
52
+ "/";
53
+
54
+ return { pattern, params, restParams };
55
+ }
56
+
57
+ export async function scanRoutes(
58
+ routesDir: string,
59
+ viteRoot: string = routesDir,
60
+ ): Promise<RouteTree> {
61
+ const glob = new Bun.Glob("**/*.{tsx,ts,jsx,js}");
62
+ const routes: RouteFile[] = [];
63
+ const layouts: RouteFile[] = [];
64
+ const servers: RouteFile[] = [];
65
+ const errors: RouteFile[] = [];
66
+
67
+ for await (const file of glob.scan(routesDir)) {
68
+ const filePath = join(routesDir, file);
69
+ const name = basename(file).replace(/\.(tsx?|jsx?)$/, "");
70
+ const { pattern, params, restParams } = filePathToRoutePath(
71
+ filePath,
72
+ routesDir,
73
+ );
74
+
75
+ let type: RouteFile["type"];
76
+ if (name === "+page") type = "page";
77
+ else if (name === "+page.server") type = "pageServer";
78
+ else if (name === "+layout") type = "layout";
79
+ else if (name === "+layout.server") type = "layoutServer";
80
+ else if (name === "+server") type = "server";
81
+ else if (name === "+error") type = "error";
82
+ else type = "simple";
83
+
84
+ const clientPath = "/" + relative(viteRoot, filePath).replace(/\\/g, "/");
85
+
86
+ const routeFile: RouteFile = {
87
+ path: pattern,
88
+ filePath,
89
+ clientPath, // add this
90
+ type,
91
+ paramNames: params,
92
+ restParamNames: restParams,
93
+ };
94
+
95
+ if (type === "simple" || type === "page" || type === "pageServer")
96
+ routes.push(routeFile);
97
+ if (type === "layout" || type === "layoutServer") layouts.push(routeFile);
98
+ if (type === "server") servers.push(routeFile);
99
+ if (type === "error") errors.push(routeFile);
100
+ }
101
+
102
+ // sort so static routes match before dynamic ones, rest params last
103
+ const scoreRoute = (r: RouteFile) =>
104
+ r.paramNames.length + r.restParamNames.length * 1000;
105
+ routes.sort((a, b) => scoreRoute(a) - scoreRoute(b));
106
+ servers.sort((a, b) => scoreRoute(a) - scoreRoute(b));
107
+
108
+ assertNoDuplicatePaths(
109
+ routes.filter((r) => r.type === "page" || r.type === "simple"),
110
+ "page",
111
+ );
112
+ assertNoDuplicatePaths(
113
+ routes.filter((r) => r.type === "pageServer"),
114
+ "page server",
115
+ );
116
+ assertNoDuplicatePaths(
117
+ layouts.filter((l) => l.type === "layout"),
118
+ "layout",
119
+ );
120
+ assertNoDuplicatePaths(
121
+ layouts.filter((l) => l.type === "layoutServer"),
122
+ "layout server",
123
+ );
124
+ assertNoDuplicatePaths(servers, "API route");
125
+ assertNoDuplicatePaths(errors, "error page");
126
+
127
+ return { routes, layouts, servers, errors };
128
+ }
129
+
130
+ function assertNoDuplicatePaths(files: RouteFile[], label: string): void {
131
+ const seen = new Map<string, string>();
132
+ for (const file of files) {
133
+ const existing = seen.get(file.path);
134
+ if (existing) {
135
+ throw new Error(
136
+ `Duplicate ${label} at "${file.path}": "${existing}" and "${file.clientPath}"`,
137
+ );
138
+ }
139
+ seen.set(file.path, file.clientPath);
140
+ }
141
+ }