@sigil-dev/grimoire 0.7.5 → 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 -27
  28. package/src/rendering/index.ts +199 -186
  29. package/src/rendering/ssrPlugin.ts +53 -47
  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 -101
  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 +144 -70
  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,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 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
- }
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,98 +1,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) 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 layout (longest matching prefix wins)
32
- const layouts = tree.layouts
33
- .filter(
34
- (l) =>
35
- l.type === "layout" &&
36
- pathname.startsWith(l.path === "/" ? "/" : l.path),
37
- )
38
- .sort((a, b) => a.path.length - b.path.length); // root first, innermost last
39
-
40
- const layoutServers = tree.layouts
41
- .filter(
42
- (l) =>
43
- l.type === "layoutServer" &&
44
- pathname.startsWith(l.path === "/" ? "/" : l.path),
45
- )
46
- .sort((a, b) => a.path.length - b.path.length); // root first, innermost last
47
-
48
- const pageServer = tree.routes.find(
49
- (r) => r.type === "pageServer" && r.path === route.path,
50
- );
51
-
52
- return { route, params, layouts, layoutServers, pageServer };
53
- }
54
-
55
- return null;
56
- }
57
-
58
- export function findClosestError(
59
- errors: RouteFile[],
60
- pathname: string,
61
- ): RouteFile | null {
62
- const segments = pathname.split("/").filter(Boolean);
63
-
64
- // walk from most specific to root
65
- while (segments.length >= 0) {
66
- const prefix = `/${segments.join("/")}`;
67
- const error = errors.find(
68
- (e) => e.path === prefix || e.path === prefix + "/",
69
- );
70
- if (error) return error;
71
- if (segments.length === 0) break;
72
- segments.pop();
73
- }
74
- return null;
75
- }
76
-
77
- function matchPattern(
78
- pattern: string,
79
- pathname: string,
80
- ): Record<string, string> | null {
81
- const patternParts = pattern.split("/").filter(Boolean);
82
- const pathParts = pathname.split("/").filter(Boolean);
83
-
84
- if (patternParts.length !== pathParts.length) return null;
85
-
86
- const params: Record<string, string> = {};
87
- for (let i = 0; i < patternParts.length; i++) {
88
- const patternPart = patternParts[i]!;
89
- const pathPart = pathParts[i]!;
90
-
91
- if (patternPart.startsWith(":")) {
92
- params[patternPart.slice(1)] = pathPart;
93
- } else if (patternPart !== pathPart) {
94
- return null;
95
- }
96
- }
97
- return params;
98
- }
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,129 +1,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
- }
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
- assertNoDuplicatePaths(
97
- routes.filter((r) => r.type === "page" || r.type === "simple"),
98
- "page",
99
- );
100
- assertNoDuplicatePaths(
101
- routes.filter((r) => r.type === "pageServer"),
102
- "page server",
103
- );
104
- assertNoDuplicatePaths(
105
- layouts.filter((l) => l.type === "layout"),
106
- "layout",
107
- );
108
- assertNoDuplicatePaths(
109
- layouts.filter((l) => l.type === "layoutServer"),
110
- "layout server",
111
- );
112
- assertNoDuplicatePaths(servers, "API route");
113
- assertNoDuplicatePaths(errors, "error page");
114
-
115
- return { routes, layouts, servers, errors };
116
- }
117
-
118
- function assertNoDuplicatePaths(files: RouteFile[], label: string): void {
119
- const seen = new Map<string, string>();
120
- for (const file of files) {
121
- const existing = seen.get(file.path);
122
- if (existing) {
123
- throw new Error(
124
- `Duplicate ${label} at "${file.path}": "${existing}" and "${file.clientPath}"`,
125
- );
126
- }
127
- seen.set(file.path, file.clientPath);
128
- }
129
- }
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/+pagedirectory
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
+ }