@troyio/vite-file-router 0.2.2 → 0.2.4
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 +82 -24
- package/dist/generator.d.ts +9 -0
- package/dist/index.js +89 -17
- package/dist/scanner.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,52 +28,110 @@ project root to use another directory:
|
|
|
28
28
|
plugins: [routes("src/routes")];
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
The router scans the pages directory recursively. Each directory becomes a
|
|
32
|
+
nested route, and the following files are recognized:
|
|
33
|
+
|
|
34
|
+
| File | Purpose |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `page.jsx` / `page.tsx` | The page component for the directory's URL. |
|
|
37
|
+
| `layout.jsx` / `layout.tsx` | A layout around the directory's page and child routes. |
|
|
38
|
+
| `loading.jsx` / `loading.tsx` | A Suspense fallback for the directory's page and child routes. |
|
|
39
|
+
|
|
40
|
+
Each file must default-export a React component. If both extensions exist for
|
|
41
|
+
the same file type in one directory, `.tsx` takes precedence.
|
|
42
|
+
|
|
43
|
+
For example, this structure creates a home page, user pages, and two auth
|
|
44
|
+
pages:
|
|
37
45
|
|
|
38
46
|
```text
|
|
39
47
|
src/pages/
|
|
40
|
-
layout.jsx
|
|
41
|
-
loading.jsx
|
|
42
48
|
page.jsx
|
|
49
|
+
layout.tsx
|
|
50
|
+
loading.tsx
|
|
43
51
|
users/
|
|
44
|
-
loading.tsx
|
|
45
52
|
page.tsx
|
|
46
53
|
[id]/
|
|
47
54
|
page.tsx
|
|
55
|
+
(auth)/
|
|
56
|
+
layout.tsx
|
|
57
|
+
sign-in/
|
|
58
|
+
page.tsx
|
|
59
|
+
sign-up/
|
|
60
|
+
page.tsx
|
|
61
|
+
[...slug]/
|
|
62
|
+
page.tsx
|
|
48
63
|
```
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
`[
|
|
65
|
+
This maps to `/`, `/users`, `/users/:id`, `/sign-in`, `/sign-up`, and `/*`.
|
|
66
|
+
Square-bracket segments are dynamic parameters: `[id]` becomes `:id`.
|
|
67
|
+
Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
|
|
68
|
+
are route groups; they organize files and can have their own page, layout, and
|
|
69
|
+
loading files, but their names do not add a URL segment.
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
your application:
|
|
71
|
+
### Layouts
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
The plugin adapts React Router's matched child route to a `children` prop. A
|
|
74
|
+
layout can render that prop directly and does not need to import or render
|
|
75
|
+
`Outlet`:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import type { PropsWithChildren } from "react";
|
|
79
|
+
|
|
80
|
+
export default function Layout({ children }: PropsWithChildren) {
|
|
81
|
+
return <div className="app-shell">{children}</div>;
|
|
82
|
+
}
|
|
58
83
|
```
|
|
59
84
|
|
|
60
|
-
|
|
61
|
-
|
|
85
|
+
### Loading fallbacks
|
|
86
|
+
|
|
87
|
+
Add a `loading.jsx` or `loading.tsx` beside a page or layout to show a Suspense
|
|
88
|
+
fallback while that route subtree loads:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
export default function Loading() {
|
|
92
|
+
return <p>Loading...</p>;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A nested loading file creates a more specific boundary for that directory.
|
|
97
|
+
|
|
98
|
+
### Dynamic routes and route groups
|
|
99
|
+
|
|
100
|
+
Two pages must not resolve to the same URL. This includes pages with the same
|
|
101
|
+
path in separate route groups, such as `(auth)/sign-in/page.tsx` and
|
|
102
|
+
`(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
|
|
103
|
+
page listing the conflicting files; production builds fail with the same
|
|
104
|
+
diagnostic until the conflict is fixed.
|
|
105
|
+
|
|
106
|
+
## Use the generated routes
|
|
107
|
+
|
|
108
|
+
Import `routes` from the virtual module and pass it to React Router:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { createBrowserRouter, RouterProvider } from "react-router";
|
|
112
|
+
import { routes } from "virtual:routes";
|
|
113
|
+
|
|
114
|
+
const router = createBrowserRouter(routes);
|
|
115
|
+
|
|
116
|
+
export default function App() {
|
|
117
|
+
return <RouterProvider router={router} />;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
62
120
|
|
|
63
|
-
### TypeScript
|
|
121
|
+
### TypeScript setup
|
|
64
122
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
`src/vite-env.d.ts`:
|
|
123
|
+
Add Vite's client types and the package's virtual-module declaration to your
|
|
124
|
+
application TypeScript project. For example, create `src/vite-env.d.ts`:
|
|
68
125
|
|
|
69
126
|
```ts
|
|
70
127
|
/// <reference types="vite/client" />
|
|
71
128
|
/// <reference types="@troyio/vite-file-router" />
|
|
72
129
|
```
|
|
73
130
|
|
|
74
|
-
If
|
|
75
|
-
|
|
76
|
-
|
|
131
|
+
If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
|
|
132
|
+
and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
|
|
133
|
+
`virtual:routes`. Restart your editor's TypeScript server if the declaration
|
|
134
|
+
isn't picked up immediately.
|
|
77
135
|
|
|
78
136
|
## Build
|
|
79
137
|
|
package/dist/generator.d.ts
CHANGED
|
@@ -4,4 +4,13 @@ export type GeneratedRoutes = {
|
|
|
4
4
|
helpers: string[];
|
|
5
5
|
routes: string;
|
|
6
6
|
};
|
|
7
|
+
export type RouteConflict = {
|
|
8
|
+
routePath: string;
|
|
9
|
+
files: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare class RouteConflictError extends Error {
|
|
12
|
+
readonly conflicts: RouteConflict[];
|
|
13
|
+
constructor(conflicts: RouteConflict[]);
|
|
14
|
+
}
|
|
15
|
+
export declare function findPageRouteConflicts(node: Node, pages?: Map<string, string[]>, segments?: string[], root?: boolean): RouteConflict[];
|
|
7
16
|
export declare function generate(node: Node, root?: boolean, projectRoot?: string): GeneratedRoutes;
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,15 @@ import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork
|
|
|
3
3
|
import { format } from "prettier";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
// src/generator.ts
|
|
6
|
+
var RouteConflictError = class extends Error {
|
|
7
|
+
conflicts;
|
|
8
|
+
constructor(conflicts) {
|
|
9
|
+
const details = conflicts.map(({ routePath, files }) => ` ${routePath}\n${files.map((file) => ` - ${file}`).join("\n")}`).join("\n");
|
|
10
|
+
super(`Multiple page files resolve to the same route:\n${details}\n\nGive each page a unique URL path. Parenthesized route-group names are omitted from URLs, so pages in different groups can still conflict.`);
|
|
11
|
+
this.conflicts = conflicts;
|
|
12
|
+
this.name = "RouteConflictError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
6
15
|
function getImportPath(file, projectRoot) {
|
|
7
16
|
const relativePath = path.relative(projectRoot, file).split(path.sep).join("/");
|
|
8
17
|
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
@@ -12,18 +21,39 @@ function getRoutePath(directory) {
|
|
|
12
21
|
if (segment.startsWith("[...") && segment.endsWith("]")) return "*";
|
|
13
22
|
return segment.replace(/^\[(.+)\]$/, ":$1");
|
|
14
23
|
}
|
|
24
|
+
function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
|
|
25
|
+
const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
|
|
26
|
+
if (node.page) {
|
|
27
|
+
const routePath = `/${routeSegments.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
|
|
28
|
+
pages.set(routePath, [...pages.get(routePath) ?? [], node.page]);
|
|
29
|
+
}
|
|
30
|
+
for (const child of node.children) findPageRouteConflicts(child, pages, routeSegments, false);
|
|
31
|
+
return [...pages.entries()].filter(([, files]) => files.length > 1).map(([routePath, files]) => ({
|
|
32
|
+
routePath,
|
|
33
|
+
files
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
15
36
|
function generateImport(file, projectRoot) {
|
|
16
37
|
return `async () => ({
|
|
17
38
|
Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
|
|
18
39
|
})`;
|
|
19
40
|
}
|
|
41
|
+
function generateLayoutImport(file, projectRoot) {
|
|
42
|
+
return `async () => ({
|
|
43
|
+
Component: withOutlet((await import(${JSON.stringify(getImportPath(file, projectRoot))})).default)
|
|
44
|
+
})`;
|
|
45
|
+
}
|
|
20
46
|
function generateLazyComponent(file, projectRoot) {
|
|
21
47
|
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}))`;
|
|
22
48
|
}
|
|
49
|
+
function generateLazyLayout(file, projectRoot) {
|
|
50
|
+
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
|
|
51
|
+
}
|
|
23
52
|
function generateNode(node, root, context, inheritedBoundary) {
|
|
24
53
|
const route = {};
|
|
25
54
|
const hasBoundary = inheritedBoundary || Boolean(node.loading);
|
|
26
|
-
if (
|
|
55
|
+
if (node.layout) context.hasLayouts = true;
|
|
56
|
+
if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
|
|
27
57
|
let loadingComponent;
|
|
28
58
|
if (node.loading) {
|
|
29
59
|
loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
|
|
@@ -31,11 +61,11 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
31
61
|
context.hasBoundaries = true;
|
|
32
62
|
}
|
|
33
63
|
if (loadingComponent) {
|
|
34
|
-
const child = node.layout ?
|
|
64
|
+
const child = node.layout ? generateLazyLayout(node.layout, context.projectRoot) : "Outlet";
|
|
35
65
|
route.Component = `createBoundary(${loadingComponent}, ${child})`;
|
|
36
66
|
} else if (node.layout) {
|
|
37
|
-
if (hasBoundary) route.Component =
|
|
38
|
-
else route.lazy =
|
|
67
|
+
if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
|
|
68
|
+
else route.lazy = generateLayoutImport(node.layout, context.projectRoot);
|
|
39
69
|
}
|
|
40
70
|
const children = [];
|
|
41
71
|
if (node.page) {
|
|
@@ -53,18 +83,26 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
53
83
|
return genObjectFromRaw(route);
|
|
54
84
|
}
|
|
55
85
|
function generate(node, root = false, projectRoot = process.cwd()) {
|
|
86
|
+
const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
|
|
87
|
+
if (conflicts.length) throw new RouteConflictError(conflicts);
|
|
56
88
|
const context = {
|
|
57
89
|
projectRoot,
|
|
58
90
|
loadingImports: [],
|
|
59
91
|
nextLoadingId: 0,
|
|
60
|
-
hasBoundaries: false
|
|
92
|
+
hasBoundaries: false,
|
|
93
|
+
hasLayouts: false
|
|
61
94
|
};
|
|
62
95
|
const routes = generateNode(node, root, context, false);
|
|
63
96
|
const imports = [...context.loadingImports];
|
|
64
97
|
const helpers = [];
|
|
65
|
-
if (context.hasBoundaries) {
|
|
66
|
-
|
|
67
|
-
|
|
98
|
+
if (context.hasBoundaries || context.hasLayouts) {
|
|
99
|
+
const reactImports = ["createElement"];
|
|
100
|
+
if (context.hasBoundaries || context.hasLayouts) reactImports.push("lazy");
|
|
101
|
+
if (context.hasBoundaries) reactImports.push("Suspense");
|
|
102
|
+
imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
|
|
103
|
+
if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
|
|
104
|
+
}
|
|
105
|
+
if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
|
|
68
106
|
return function RouteBoundary() {
|
|
69
107
|
return createElement(
|
|
70
108
|
Suspense,
|
|
@@ -73,7 +111,11 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
73
111
|
);
|
|
74
112
|
};
|
|
75
113
|
}`);
|
|
76
|
-
|
|
114
|
+
if (context.hasLayouts) helpers.push(`function withOutlet(Layout) {
|
|
115
|
+
return function LayoutRoute() {
|
|
116
|
+
return createElement(Layout, null, createElement(Outlet));
|
|
117
|
+
};
|
|
118
|
+
}`);
|
|
77
119
|
return {
|
|
78
120
|
imports,
|
|
79
121
|
helpers,
|
|
@@ -85,6 +127,7 @@ function scan(directory) {
|
|
|
85
127
|
const node = {
|
|
86
128
|
name: path.basename(directory),
|
|
87
129
|
directory,
|
|
130
|
+
routeGroup: /^\(.+\)$/.test(path.basename(directory)),
|
|
88
131
|
children: []
|
|
89
132
|
};
|
|
90
133
|
if (!fs.existsSync(directory)) return node;
|
|
@@ -109,16 +152,44 @@ var RESOLVED_ID = `\0${MODULE_ID}`;
|
|
|
109
152
|
function routes(directory = "src/pages") {
|
|
110
153
|
let projectRoot = "";
|
|
111
154
|
let root = "";
|
|
155
|
+
let dev = false;
|
|
156
|
+
function sortImports(imports) {
|
|
157
|
+
return imports.sort((left, right) => {
|
|
158
|
+
const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
|
|
159
|
+
const rightSource = right.match(/from ["'](.+?)["']/)?.[1] ?? right;
|
|
160
|
+
const leftIsRelative = leftSource.startsWith(".");
|
|
161
|
+
if (leftIsRelative !== rightSource.startsWith(".")) return leftIsRelative ? 1 : -1;
|
|
162
|
+
return leftSource.localeCompare(rightSource);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
112
165
|
async function execute() {
|
|
113
|
-
const
|
|
166
|
+
const tree = scan(root);
|
|
167
|
+
const conflicts = findPageRouteConflicts(tree);
|
|
168
|
+
if (conflicts.length) {
|
|
169
|
+
const error = new RouteConflictError(conflicts);
|
|
170
|
+
if (!dev) throw error;
|
|
171
|
+
const code = `
|
|
172
|
+
import { createElement } from "react";
|
|
173
|
+
|
|
174
|
+
const message = ${JSON.stringify(error.message)};
|
|
175
|
+
|
|
176
|
+
function RouteConflictPage() {
|
|
177
|
+
return createElement(
|
|
178
|
+
"main",
|
|
179
|
+
{ style: { fontFamily: "system-ui, sans-serif", margin: "3rem auto", maxWidth: "60rem", padding: "0 1.5rem" } },
|
|
180
|
+
createElement("h1", null, "Conflicting routes"),
|
|
181
|
+
createElement("p", null, "The file-based router found page files that resolve to the same URL. Fix these conflicts, then the page will reload."),
|
|
182
|
+
createElement("pre", { style: { background: "#f3f4f6", overflowX: "auto", padding: "1rem", whiteSpace: "pre-wrap" } }, message),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const routes = [{ path: "*", Component: RouteConflictPage }];
|
|
187
|
+
`;
|
|
188
|
+
return format(code, { parser: "typescript" });
|
|
189
|
+
}
|
|
190
|
+
const generated = generate(tree, true, projectRoot);
|
|
114
191
|
const sections = [
|
|
115
|
-
generated.imports.
|
|
116
|
-
const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
|
|
117
|
-
const rightSource = right.match(/from ["'](.+?)["']/)?.[1] ?? right;
|
|
118
|
-
const leftIsRelative = leftSource.startsWith(".");
|
|
119
|
-
if (leftIsRelative !== rightSource.startsWith(".")) return leftIsRelative ? 1 : -1;
|
|
120
|
-
return leftSource.localeCompare(rightSource);
|
|
121
|
-
}).join("\n"),
|
|
192
|
+
sortImports(generated.imports).join("\n"),
|
|
122
193
|
generated.helpers.join("\n"),
|
|
123
194
|
`export const routes = ${genArrayFromRaw([generated.routes])};`
|
|
124
195
|
].filter(Boolean);
|
|
@@ -129,6 +200,7 @@ function routes(directory = "src/pages") {
|
|
|
129
200
|
configResolved(config) {
|
|
130
201
|
projectRoot = config.root;
|
|
131
202
|
root = path.resolve(projectRoot, directory);
|
|
203
|
+
dev = config.command === "serve";
|
|
132
204
|
},
|
|
133
205
|
resolveId: (id) => id === MODULE_ID ? RESOLVED_ID : void 0,
|
|
134
206
|
load: (id) => id === RESOLVED_ID ? execute() : void 0,
|
package/dist/scanner.d.ts
CHANGED