@troyio/vite-file-router 0.2.3 → 0.2.5
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 +116 -29
- package/dist/index.js +60 -10
- package/dist/scanner.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,57 +28,144 @@ 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
|
+
| `not-found.jsx` / `not-found.tsx` | A not-found page for unmatched URLs within the directory's route subtree. |
|
|
40
|
+
| `error.jsx` / `error.tsx` | A React Router error boundary for the directory's route subtree. |
|
|
41
|
+
|
|
42
|
+
Each file must default-export a React component. If both extensions exist for
|
|
43
|
+
the same file type in one directory, `.tsx` takes precedence.
|
|
44
|
+
|
|
45
|
+
For example, this structure creates a home page, user pages, and two auth
|
|
46
|
+
pages:
|
|
37
47
|
|
|
38
48
|
```text
|
|
39
49
|
src/pages/
|
|
40
|
-
layout.jsx
|
|
41
|
-
loading.jsx
|
|
42
50
|
page.jsx
|
|
51
|
+
layout.tsx
|
|
52
|
+
loading.tsx
|
|
43
53
|
users/
|
|
44
|
-
loading.tsx
|
|
45
54
|
page.tsx
|
|
46
55
|
[id]/
|
|
47
56
|
page.tsx
|
|
57
|
+
(auth)/
|
|
58
|
+
layout.tsx
|
|
59
|
+
sign-in/
|
|
60
|
+
page.tsx
|
|
61
|
+
sign-up/
|
|
62
|
+
page.tsx
|
|
63
|
+
[...slug]/
|
|
64
|
+
page.tsx
|
|
48
65
|
```
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
files
|
|
54
|
-
|
|
55
|
-
diagnostic page listing the conflicting files and explaining how to fix them.
|
|
56
|
-
Production builds fail with the same diagnostic until the conflicts are fixed.
|
|
67
|
+
This maps to `/`, `/users`, `/users/:id`, `/sign-in`, `/sign-up`, and `/*`.
|
|
68
|
+
Square-bracket segments are dynamic parameters: `[id]` becomes `:id`.
|
|
69
|
+
Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
|
|
70
|
+
are route groups; they organize files and can have their own page, layout, and
|
|
71
|
+
loading files, but their names do not add a URL segment.
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
your application:
|
|
73
|
+
### Layouts
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
The plugin adapts React Router's matched child route to a `children` prop. A
|
|
76
|
+
layout can render that prop directly and does not need to import or render
|
|
77
|
+
`Outlet`:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import type { PropsWithChildren } from "react";
|
|
81
|
+
|
|
82
|
+
export default function Layout({ children }: PropsWithChildren) {
|
|
83
|
+
return <div className="app-shell">{children}</div>;
|
|
84
|
+
}
|
|
63
85
|
```
|
|
64
86
|
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
### Loading fallbacks
|
|
88
|
+
|
|
89
|
+
Add a `loading.jsx` or `loading.tsx` beside a page or layout to show a Suspense
|
|
90
|
+
fallback while that route subtree loads:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
export default function Loading() {
|
|
94
|
+
return <p>Loading...</p>;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A nested loading file creates a more specific boundary for that directory.
|
|
99
|
+
|
|
100
|
+
### Not-found pages
|
|
101
|
+
|
|
102
|
+
Add a `not-found.jsx` or `not-found.tsx` file to handle unmatched URLs. The plugin generates
|
|
103
|
+
a catch-all child route, and the most specific matching route subtree's
|
|
104
|
+
not-found page is used:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
src/pages/
|
|
108
|
+
not-found.tsx
|
|
109
|
+
users/
|
|
110
|
+
not-found.tsx
|
|
111
|
+
page.tsx
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The root not-found page handles unknown URLs generally, while `users/not-found.tsx`
|
|
115
|
+
handles unmatched URLs under `/users`.
|
|
116
|
+
|
|
117
|
+
### Route error boundaries
|
|
118
|
+
|
|
119
|
+
Add an `error.jsx` or `error.tsx` file to handle errors from that route and its
|
|
120
|
+
descendants. The nearest route boundary is used, following React Router's error
|
|
121
|
+
handling behavior. Use React Router's `useRouteError` hook to read the error:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { useRouteError } from "react-router";
|
|
125
|
+
|
|
126
|
+
export default function ErrorPage() {
|
|
127
|
+
const error = useRouteError();
|
|
128
|
+
return <main>Something went wrong: {String(error)}</main>;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Dynamic routes and route groups
|
|
133
|
+
|
|
134
|
+
Two pages must not resolve to the same URL. This includes pages with the same
|
|
135
|
+
path in separate route groups, such as `(auth)/sign-in/page.tsx` and
|
|
136
|
+
`(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
|
|
137
|
+
page listing the conflicting files; production builds fail with the same
|
|
138
|
+
diagnostic until the conflict is fixed.
|
|
139
|
+
|
|
140
|
+
## Use the generated routes
|
|
141
|
+
|
|
142
|
+
Import `routes` from the virtual module and pass it to React Router:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
import { createBrowserRouter, RouterProvider } from "react-router";
|
|
146
|
+
import { routes } from "virtual:routes";
|
|
147
|
+
|
|
148
|
+
const router = createBrowserRouter(routes);
|
|
149
|
+
|
|
150
|
+
export default function App() {
|
|
151
|
+
return <RouterProvider router={router} />;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
67
154
|
|
|
68
|
-
### TypeScript
|
|
155
|
+
### TypeScript setup
|
|
69
156
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`src/vite-env.d.ts`:
|
|
157
|
+
Add Vite's client types and the package's virtual-module declaration to your
|
|
158
|
+
application TypeScript project. For example, create `src/vite-env.d.ts`:
|
|
73
159
|
|
|
74
160
|
```ts
|
|
75
161
|
/// <reference types="vite/client" />
|
|
76
162
|
/// <reference types="@troyio/vite-file-router" />
|
|
77
163
|
```
|
|
78
164
|
|
|
79
|
-
If
|
|
80
|
-
|
|
81
|
-
|
|
165
|
+
If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
|
|
166
|
+
and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
|
|
167
|
+
`virtual:routes`. Restart your editor's TypeScript server if the declaration
|
|
168
|
+
isn't picked up immediately.
|
|
82
169
|
|
|
83
170
|
## Build
|
|
84
171
|
|
package/dist/index.js
CHANGED
|
@@ -33,18 +33,36 @@ function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segment
|
|
|
33
33
|
files
|
|
34
34
|
}));
|
|
35
35
|
}
|
|
36
|
+
function validateRouteGroupNotFound(node, segments = [], root = true) {
|
|
37
|
+
const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
|
|
38
|
+
if (node.routeGroup && node.notFound && routeSegments.length === 0) throw new Error(`Route-group not-found page "${node.notFound}" has no URL subtree to match. Move it into a directory with a URL segment so it is scoped to a distinct route subtree.`);
|
|
39
|
+
for (const child of node.children) validateRouteGroupNotFound(child, routeSegments, false);
|
|
40
|
+
}
|
|
36
41
|
function generateImport(file, projectRoot) {
|
|
37
42
|
return `async () => ({
|
|
38
43
|
Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
|
|
39
44
|
})`;
|
|
40
45
|
}
|
|
46
|
+
function generateRouteLazy(node, projectRoot, includeLayout) {
|
|
47
|
+
const properties = [];
|
|
48
|
+
if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
|
|
49
|
+
return `async () => ({ ${properties.join(", ")} })`;
|
|
50
|
+
}
|
|
41
51
|
function generateLazyComponent(file, projectRoot) {
|
|
42
52
|
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}))`;
|
|
43
53
|
}
|
|
54
|
+
function generateLazyLayout(file, projectRoot) {
|
|
55
|
+
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
|
|
56
|
+
}
|
|
44
57
|
function generateNode(node, root, context, inheritedBoundary) {
|
|
45
58
|
const route = {};
|
|
46
59
|
const hasBoundary = inheritedBoundary || Boolean(node.loading);
|
|
60
|
+
if (node.layout) context.hasLayouts = true;
|
|
47
61
|
if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
|
|
62
|
+
if (node.error) {
|
|
63
|
+
context.hasErrorBoundaries = true;
|
|
64
|
+
route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
|
|
65
|
+
}
|
|
48
66
|
let loadingComponent;
|
|
49
67
|
if (node.loading) {
|
|
50
68
|
loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
|
|
@@ -52,12 +70,12 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
52
70
|
context.hasBoundaries = true;
|
|
53
71
|
}
|
|
54
72
|
if (loadingComponent) {
|
|
55
|
-
const child = node.layout ?
|
|
73
|
+
const child = node.layout ? generateLazyLayout(node.layout, context.projectRoot) : "Outlet";
|
|
56
74
|
route.Component = `createBoundary(${loadingComponent}, ${child})`;
|
|
57
75
|
} else if (node.layout) {
|
|
58
|
-
if (hasBoundary) route.Component =
|
|
59
|
-
else route.lazy = generateImport(node.layout, context.projectRoot);
|
|
76
|
+
if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
|
|
60
77
|
}
|
|
78
|
+
if (node.layout && !hasBoundary) route.lazy = generateRouteLazy(node, context.projectRoot, true);
|
|
61
79
|
const children = [];
|
|
62
80
|
if (node.page) {
|
|
63
81
|
const page = hasBoundary ? {
|
|
@@ -70,24 +88,43 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
70
88
|
children.push(genObjectFromRaw(page));
|
|
71
89
|
}
|
|
72
90
|
for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
|
|
91
|
+
if (node.notFound) {
|
|
92
|
+
const notFoundRoute = hasBoundary ? {
|
|
93
|
+
path: JSON.stringify("*"),
|
|
94
|
+
Component: generateLazyComponent(node.notFound, context.projectRoot)
|
|
95
|
+
} : {
|
|
96
|
+
path: JSON.stringify("*"),
|
|
97
|
+
lazy: generateImport(node.notFound, context.projectRoot)
|
|
98
|
+
};
|
|
99
|
+
children.push(genObjectFromRaw(notFoundRoute));
|
|
100
|
+
}
|
|
73
101
|
if (children.length) route.children = genArrayFromRaw(children);
|
|
74
102
|
return genObjectFromRaw(route);
|
|
75
103
|
}
|
|
76
104
|
function generate(node, root = false, projectRoot = process.cwd()) {
|
|
105
|
+
validateRouteGroupNotFound(node, [], root);
|
|
77
106
|
const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
|
|
78
107
|
if (conflicts.length) throw new RouteConflictError(conflicts);
|
|
79
108
|
const context = {
|
|
80
109
|
projectRoot,
|
|
81
110
|
loadingImports: [],
|
|
82
111
|
nextLoadingId: 0,
|
|
83
|
-
hasBoundaries: false
|
|
112
|
+
hasBoundaries: false,
|
|
113
|
+
hasLayouts: false,
|
|
114
|
+
hasErrorBoundaries: false
|
|
84
115
|
};
|
|
85
116
|
const routes = generateNode(node, root, context, false);
|
|
86
117
|
const imports = [...context.loadingImports];
|
|
87
118
|
const helpers = [];
|
|
88
|
-
if (context.hasBoundaries) {
|
|
89
|
-
|
|
90
|
-
|
|
119
|
+
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
|
|
120
|
+
const reactImports = [];
|
|
121
|
+
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("createElement");
|
|
122
|
+
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
|
|
123
|
+
if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
|
|
124
|
+
imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
|
|
125
|
+
if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
|
|
126
|
+
}
|
|
127
|
+
if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
|
|
91
128
|
return function RouteBoundary() {
|
|
92
129
|
return createElement(
|
|
93
130
|
Suspense,
|
|
@@ -96,7 +133,20 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
96
133
|
);
|
|
97
134
|
};
|
|
98
135
|
}`);
|
|
99
|
-
|
|
136
|
+
if (context.hasLayouts) helpers.push(`function withOutlet(Layout) {
|
|
137
|
+
return function LayoutRoute() {
|
|
138
|
+
return createElement(Layout, null, createElement(Outlet));
|
|
139
|
+
};
|
|
140
|
+
}`);
|
|
141
|
+
if (context.hasErrorBoundaries) helpers.push(`function createErrorBoundary(Component) {
|
|
142
|
+
return function RouteErrorBoundary() {
|
|
143
|
+
return createElement(
|
|
144
|
+
Suspense,
|
|
145
|
+
{ fallback: createElement("p", null, "Loading error page...") },
|
|
146
|
+
createElement(Component),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
}`);
|
|
100
150
|
return {
|
|
101
151
|
imports,
|
|
102
152
|
helpers,
|
|
@@ -119,10 +169,10 @@ function scan(directory) {
|
|
|
119
169
|
node.children.push(scan(file));
|
|
120
170
|
continue;
|
|
121
171
|
}
|
|
122
|
-
const match = /^(page|layout|loading)\.(jsx|tsx)$/.exec(entry.name);
|
|
172
|
+
const match = /^(page|layout|loading|not-found|error)\.(jsx|tsx)$/.exec(entry.name);
|
|
123
173
|
if (!match) continue;
|
|
124
174
|
const [, name, extension] = match;
|
|
125
|
-
const key = name;
|
|
175
|
+
const key = name === "not-found" ? "notFound" : name;
|
|
126
176
|
if (!node[key] || extension === "tsx") node[key] = file;
|
|
127
177
|
}
|
|
128
178
|
return node;
|
package/dist/scanner.d.ts
CHANGED