@troyio/vite-file-router 0.2.4 → 0.2.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.
- package/README.md +85 -3
- package/dist/generator.d.ts +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +142 -47
- package/dist/scanner.d.ts +16 -1
- package/package.json +1 -1
- package/types/index.d.ts +6 -0
- package/types/virtual-routes.d.ts +5 -0
package/README.md
CHANGED
|
@@ -21,13 +21,36 @@ export default defineConfig({
|
|
|
21
21
|
});
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
By default, the plugin scans `src/pages
|
|
25
|
-
project root to use another
|
|
24
|
+
By default, the plugin scans `src/pages` and recognizes the file conventions
|
|
25
|
+
listed below. Pass a path relative to the Vite project root to use another
|
|
26
|
+
directory:
|
|
26
27
|
|
|
27
28
|
```ts
|
|
28
29
|
plugins: [routes("src/routes")];
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
You can also configure the directory and special filenames together. Convention
|
|
33
|
+
values are filename stems without `.jsx` or `.tsx`; `.tsx` takes precedence when
|
|
34
|
+
both extensions exist:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
plugins: [
|
|
38
|
+
routes({
|
|
39
|
+
directory: "app/views",
|
|
40
|
+
conventions: {
|
|
41
|
+
page: "route",
|
|
42
|
+
layout: "shell",
|
|
43
|
+
loading: "pending",
|
|
44
|
+
notFound: "missing",
|
|
45
|
+
error: "fault",
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Omitted convention values keep their defaults: `page`, `layout`, `loading`,
|
|
52
|
+
`not-found`, and `error`. Each convention must have a unique filename stem.
|
|
53
|
+
|
|
31
54
|
The router scans the pages directory recursively. Each directory becomes a
|
|
32
55
|
nested route, and the following files are recognized:
|
|
33
56
|
|
|
@@ -36,6 +59,8 @@ nested route, and the following files are recognized:
|
|
|
36
59
|
| `page.jsx` / `page.tsx` | The page component for the directory's URL. |
|
|
37
60
|
| `layout.jsx` / `layout.tsx` | A layout around the directory's page and child routes. |
|
|
38
61
|
| `loading.jsx` / `loading.tsx` | A Suspense fallback for the directory's page and child routes. |
|
|
62
|
+
| `not-found.jsx` / `not-found.tsx` | A not-found page for unmatched URLs within the directory's route subtree. |
|
|
63
|
+
| `error.jsx` / `error.tsx` | A React Router error boundary for the directory's route subtree. |
|
|
39
64
|
|
|
40
65
|
Each file must default-export a React component. If both extensions exist for
|
|
41
66
|
the same file type in one directory, `.tsx` takes precedence.
|
|
@@ -95,13 +120,49 @@ export default function Loading() {
|
|
|
95
120
|
|
|
96
121
|
A nested loading file creates a more specific boundary for that directory.
|
|
97
122
|
|
|
123
|
+
### Not-found pages
|
|
124
|
+
|
|
125
|
+
Add a `not-found.jsx` or `not-found.tsx` file to handle unmatched URLs. The plugin generates
|
|
126
|
+
a catch-all child route, and the most specific matching route subtree's
|
|
127
|
+
not-found page is used:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
src/pages/
|
|
131
|
+
not-found.tsx
|
|
132
|
+
users/
|
|
133
|
+
not-found.tsx
|
|
134
|
+
page.tsx
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The root not-found page handles unknown URLs generally, while `users/not-found.tsx`
|
|
138
|
+
handles unmatched URLs under `/users`.
|
|
139
|
+
|
|
140
|
+
### Route error boundaries
|
|
141
|
+
|
|
142
|
+
Add an `error.jsx` or `error.tsx` file to handle errors from that route and its
|
|
143
|
+
descendants. The nearest route boundary is used, following React Router's error
|
|
144
|
+
handling behavior. Use React Router's `useRouteError` hook to read the error:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { useRouteError } from "react-router";
|
|
148
|
+
|
|
149
|
+
export default function ErrorPage() {
|
|
150
|
+
const error = useRouteError();
|
|
151
|
+
return <main>Something went wrong: {String(error)}</main>;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
98
155
|
### Dynamic routes and route groups
|
|
99
156
|
|
|
100
157
|
Two pages must not resolve to the same URL. This includes pages with the same
|
|
101
158
|
path in separate route groups, such as `(auth)/sign-in/page.tsx` and
|
|
102
159
|
`(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
|
|
103
160
|
page listing the conflicting files; production builds fail with the same
|
|
104
|
-
diagnostic until the conflict is fixed.
|
|
161
|
+
diagnostic until the conflict is fixed. Dynamic parameter names do not make
|
|
162
|
+
otherwise identical patterns distinct (`users/[id]` and `users/[slug]`
|
|
163
|
+
conflict). Static and dynamic siblings such as `users/new` and `users/[id]`
|
|
164
|
+
are distinct; React Router's normal route ranking selects the static route
|
|
165
|
+
first.
|
|
105
166
|
|
|
106
167
|
## Use the generated routes
|
|
107
168
|
|
|
@@ -118,6 +179,27 @@ export default function App() {
|
|
|
118
179
|
}
|
|
119
180
|
```
|
|
120
181
|
|
|
182
|
+
### Route manifest
|
|
183
|
+
|
|
184
|
+
Import `routeManifest` from `virtual:route-manifest` for a stable, nested
|
|
185
|
+
description of the scanned routes. Source files are relative to the Vite
|
|
186
|
+
project root and use forward slashes. Each node includes its URL `fullPath`,
|
|
187
|
+
the parameters introduced by that segment, and its recognized files:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { routeManifest } from "virtual:route-manifest";
|
|
191
|
+
|
|
192
|
+
console.log(routeManifest.children);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The root node has `fullPath: "/"`; route-group nodes have no URL segment but
|
|
196
|
+
remain in the tree. React Router's generated `routes` remain the runtime source
|
|
197
|
+
of truth.
|
|
198
|
+
|
|
199
|
+
The development server watches the configured directory. Adding, removing,
|
|
200
|
+
renaming, or editing route files invalidates both virtual modules and reloads
|
|
201
|
+
the app without requiring a server restart.
|
|
202
|
+
|
|
121
203
|
### TypeScript setup
|
|
122
204
|
|
|
123
205
|
Add Vite's client types and the package's virtual-module declaration to your
|
package/dist/generator.d.ts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { Plugin } from "vite";
|
|
2
|
-
|
|
2
|
+
import { type RouteConventions } from "./scanner";
|
|
3
|
+
export type RoutesOptions = {
|
|
4
|
+
directory?: string;
|
|
5
|
+
conventions?: RouteConventions;
|
|
6
|
+
};
|
|
7
|
+
export type { RouteConventions, RouteFileKind, RouteManifestNode, } from "./scanner";
|
|
8
|
+
export declare function routes(input?: string | RoutesOptions): Plugin;
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,66 @@ import path from "node:path";
|
|
|
2
2
|
import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork";
|
|
3
3
|
import { format } from "prettier";
|
|
4
4
|
import fs from "node:fs";
|
|
5
|
+
// src/scanner.ts
|
|
6
|
+
var DEFAULT_CONVENTIONS = {
|
|
7
|
+
page: "page",
|
|
8
|
+
layout: "layout",
|
|
9
|
+
loading: "loading",
|
|
10
|
+
notFound: "not-found",
|
|
11
|
+
error: "error"
|
|
12
|
+
};
|
|
13
|
+
function getRouteSegment(name) {
|
|
14
|
+
if (name.startsWith("[...") && name.endsWith("]")) return "*";
|
|
15
|
+
return name.replace(/^\[(.+)\]$/, ":$1");
|
|
16
|
+
}
|
|
17
|
+
function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
18
|
+
const node = {
|
|
19
|
+
name: path.basename(directory),
|
|
20
|
+
directory,
|
|
21
|
+
routeGroup: /^\(.+\)$/.test(path.basename(directory)),
|
|
22
|
+
children: []
|
|
23
|
+
};
|
|
24
|
+
if (!fs.existsSync(directory)) return node;
|
|
25
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const file = path.join(directory, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
node.children.push(scan(file, conventions));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const match = /^(.*)\.(jsx|tsx)$/.exec(entry.name);
|
|
33
|
+
if (!match) continue;
|
|
34
|
+
const [, basename, extension] = match;
|
|
35
|
+
const kind = Object.keys(conventions).find((candidate) => conventions[candidate] === basename);
|
|
36
|
+
if (!kind) continue;
|
|
37
|
+
if (!node[kind] || extension === "tsx") node[kind] = file;
|
|
38
|
+
}
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
|
|
42
|
+
const segment = root || node.routeGroup ? null : getRouteSegment(node.name);
|
|
43
|
+
const fullPath = segment === null ? parentPath || "/" : `${parentPath}/${segment}` || "/";
|
|
44
|
+
const params = !root && !node.routeGroup && node.name.startsWith("[") ? [node.name.startsWith("[...") ? "*" : node.name.slice(1, -1)] : [];
|
|
45
|
+
const files = {};
|
|
46
|
+
for (const kind of Object.keys(DEFAULT_CONVENTIONS)) {
|
|
47
|
+
const file = node[kind];
|
|
48
|
+
if (file) files[kind] = path.relative(projectRoot, file).split(path.sep).join("/");
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
segment,
|
|
52
|
+
fullPath,
|
|
53
|
+
params,
|
|
54
|
+
routeGroup: node.routeGroup,
|
|
55
|
+
files,
|
|
56
|
+
children: node.children.map((child) => createRouteManifest(child, projectRoot, fullPath === "/" ? "" : fullPath, false))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
5
59
|
// src/generator.ts
|
|
6
60
|
var RouteConflictError = class extends Error {
|
|
7
61
|
conflicts;
|
|
8
62
|
constructor(conflicts) {
|
|
9
63
|
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\
|
|
64
|
+
super(`Multiple page files resolve to the same route pattern:\n${details}\n\nReact Router treats dynamic parameters as equivalent regardless of their names, and parenthesized route-group names are omitted from URLs. Give each page a unique URL pattern.`);
|
|
11
65
|
this.conflicts = conflicts;
|
|
12
66
|
this.name = "RouteConflictError";
|
|
13
67
|
}
|
|
@@ -17,9 +71,7 @@ function getImportPath(file, projectRoot) {
|
|
|
17
71
|
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
18
72
|
}
|
|
19
73
|
function getRoutePath(directory) {
|
|
20
|
-
|
|
21
|
-
if (segment.startsWith("[...") && segment.endsWith("]")) return "*";
|
|
22
|
-
return segment.replace(/^\[(.+)\]$/, ":$1");
|
|
74
|
+
return getRouteSegment(path.basename(directory));
|
|
23
75
|
}
|
|
24
76
|
function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
|
|
25
77
|
const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
|
|
@@ -33,15 +85,20 @@ function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segment
|
|
|
33
85
|
files
|
|
34
86
|
}));
|
|
35
87
|
}
|
|
88
|
+
function validateRouteGroupNotFound(node, segments = [], root = true) {
|
|
89
|
+
const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
|
|
90
|
+
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.`);
|
|
91
|
+
for (const child of node.children) validateRouteGroupNotFound(child, routeSegments, false);
|
|
92
|
+
}
|
|
36
93
|
function generateImport(file, projectRoot) {
|
|
37
94
|
return `async () => ({
|
|
38
95
|
Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
|
|
39
96
|
})`;
|
|
40
97
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
function generateRouteLazy(node, projectRoot, includeLayout) {
|
|
99
|
+
const properties = [];
|
|
100
|
+
if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
|
|
101
|
+
return `async () => ({ ${properties.join(", ")} })`;
|
|
45
102
|
}
|
|
46
103
|
function generateLazyComponent(file, projectRoot) {
|
|
47
104
|
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}))`;
|
|
@@ -54,6 +111,10 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
54
111
|
const hasBoundary = inheritedBoundary || Boolean(node.loading);
|
|
55
112
|
if (node.layout) context.hasLayouts = true;
|
|
56
113
|
if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
|
|
114
|
+
if (node.error) {
|
|
115
|
+
context.hasErrorBoundaries = true;
|
|
116
|
+
route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
|
|
117
|
+
}
|
|
57
118
|
let loadingComponent;
|
|
58
119
|
if (node.loading) {
|
|
59
120
|
loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
|
|
@@ -65,8 +126,8 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
65
126
|
route.Component = `createBoundary(${loadingComponent}, ${child})`;
|
|
66
127
|
} else if (node.layout) {
|
|
67
128
|
if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
|
|
68
|
-
else route.lazy = generateLayoutImport(node.layout, context.projectRoot);
|
|
69
129
|
}
|
|
130
|
+
if (node.layout && !hasBoundary) route.lazy = generateRouteLazy(node, context.projectRoot, true);
|
|
70
131
|
const children = [];
|
|
71
132
|
if (node.page) {
|
|
72
133
|
const page = hasBoundary ? {
|
|
@@ -79,10 +140,21 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
79
140
|
children.push(genObjectFromRaw(page));
|
|
80
141
|
}
|
|
81
142
|
for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
|
|
143
|
+
if (node.notFound) {
|
|
144
|
+
const notFoundRoute = hasBoundary ? {
|
|
145
|
+
path: JSON.stringify("*"),
|
|
146
|
+
Component: generateLazyComponent(node.notFound, context.projectRoot)
|
|
147
|
+
} : {
|
|
148
|
+
path: JSON.stringify("*"),
|
|
149
|
+
lazy: generateImport(node.notFound, context.projectRoot)
|
|
150
|
+
};
|
|
151
|
+
children.push(genObjectFromRaw(notFoundRoute));
|
|
152
|
+
}
|
|
82
153
|
if (children.length) route.children = genArrayFromRaw(children);
|
|
83
154
|
return genObjectFromRaw(route);
|
|
84
155
|
}
|
|
85
156
|
function generate(node, root = false, projectRoot = process.cwd()) {
|
|
157
|
+
validateRouteGroupNotFound(node, [], root);
|
|
86
158
|
const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
|
|
87
159
|
if (conflicts.length) throw new RouteConflictError(conflicts);
|
|
88
160
|
const context = {
|
|
@@ -90,15 +162,17 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
90
162
|
loadingImports: [],
|
|
91
163
|
nextLoadingId: 0,
|
|
92
164
|
hasBoundaries: false,
|
|
93
|
-
hasLayouts: false
|
|
165
|
+
hasLayouts: false,
|
|
166
|
+
hasErrorBoundaries: false
|
|
94
167
|
};
|
|
95
168
|
const routes = generateNode(node, root, context, false);
|
|
96
169
|
const imports = [...context.loadingImports];
|
|
97
170
|
const helpers = [];
|
|
98
|
-
if (context.hasBoundaries || context.hasLayouts) {
|
|
99
|
-
const reactImports = [
|
|
100
|
-
if (context.hasBoundaries || context.hasLayouts) reactImports.push("
|
|
101
|
-
if (context.hasBoundaries) reactImports.push("
|
|
171
|
+
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
|
|
172
|
+
const reactImports = [];
|
|
173
|
+
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("createElement");
|
|
174
|
+
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
|
|
175
|
+
if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
|
|
102
176
|
imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
|
|
103
177
|
if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
|
|
104
178
|
}
|
|
@@ -115,6 +189,15 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
115
189
|
return function LayoutRoute() {
|
|
116
190
|
return createElement(Layout, null, createElement(Outlet));
|
|
117
191
|
};
|
|
192
|
+
}`);
|
|
193
|
+
if (context.hasErrorBoundaries) helpers.push(`function createErrorBoundary(Component) {
|
|
194
|
+
return function RouteErrorBoundary() {
|
|
195
|
+
return createElement(
|
|
196
|
+
Suspense,
|
|
197
|
+
{ fallback: createElement("p", null, "Loading error page...") },
|
|
198
|
+
createElement(Component),
|
|
199
|
+
);
|
|
200
|
+
};
|
|
118
201
|
}`);
|
|
119
202
|
return {
|
|
120
203
|
imports,
|
|
@@ -122,34 +205,33 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
122
205
|
routes
|
|
123
206
|
};
|
|
124
207
|
}
|
|
125
|
-
// src/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
208
|
+
// src/index.ts
|
|
209
|
+
var ROUTES_MODULE_ID = "virtual:routes";
|
|
210
|
+
var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
|
|
211
|
+
var MANIFEST_MODULE_ID = "virtual:route-manifest";
|
|
212
|
+
var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
|
|
213
|
+
function resolveOptions(input) {
|
|
214
|
+
const options = typeof input === "string" ? { directory: input } : input;
|
|
215
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
|
|
216
|
+
for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions") throw new TypeError(`Unknown routes() option "${key}".`);
|
|
217
|
+
const directory = options.directory ?? "src/pages";
|
|
218
|
+
if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
|
|
219
|
+
const conventions = { ...DEFAULT_CONVENTIONS };
|
|
220
|
+
if (options.conventions !== void 0 && (!options.conventions || typeof options.conventions !== "object" || Array.isArray(options.conventions))) throw new TypeError("routes() conventions must be an object.");
|
|
221
|
+
for (const [kind, basename] of Object.entries(options.conventions ?? {})) {
|
|
222
|
+
if (!Object.hasOwn(DEFAULT_CONVENTIONS, kind)) throw new TypeError(`Unknown route file convention "${kind}".`);
|
|
223
|
+
if (typeof basename !== "string" || basename.trim() === "" || basename === "." || basename === ".." || /[\\/]/.test(basename) || /\.[^.]+$/.test(basename)) throw new TypeError(`Route file convention "${kind}" must be a filename without an extension.`);
|
|
224
|
+
conventions[kind] = basename;
|
|
225
|
+
}
|
|
226
|
+
const names = Object.values(conventions);
|
|
227
|
+
if (new Set(names).size !== names.length) throw new TypeError("Route file convention names must be unique.");
|
|
228
|
+
return {
|
|
129
229
|
directory,
|
|
130
|
-
|
|
131
|
-
children: []
|
|
230
|
+
conventions
|
|
132
231
|
};
|
|
133
|
-
if (!fs.existsSync(directory)) return node;
|
|
134
|
-
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
135
|
-
for (const entry of entries) {
|
|
136
|
-
const file = path.join(directory, entry.name);
|
|
137
|
-
if (entry.isDirectory()) {
|
|
138
|
-
node.children.push(scan(file));
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
const match = /^(page|layout|loading)\.(jsx|tsx)$/.exec(entry.name);
|
|
142
|
-
if (!match) continue;
|
|
143
|
-
const [, name, extension] = match;
|
|
144
|
-
const key = name;
|
|
145
|
-
if (!node[key] || extension === "tsx") node[key] = file;
|
|
146
|
-
}
|
|
147
|
-
return node;
|
|
148
232
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
var RESOLVED_ID = `\0${MODULE_ID}`;
|
|
152
|
-
function routes(directory = "src/pages") {
|
|
233
|
+
function routes(input = "src/pages") {
|
|
234
|
+
const options = resolveOptions(input);
|
|
153
235
|
let projectRoot = "";
|
|
154
236
|
let root = "";
|
|
155
237
|
let dev = false;
|
|
@@ -163,7 +245,7 @@ function routes(directory = "src/pages") {
|
|
|
163
245
|
});
|
|
164
246
|
}
|
|
165
247
|
async function execute() {
|
|
166
|
-
const tree = scan(root);
|
|
248
|
+
const tree = scan(root, options.conventions);
|
|
167
249
|
const conflicts = findPageRouteConflicts(tree);
|
|
168
250
|
if (conflicts.length) {
|
|
169
251
|
const error = new RouteConflictError(conflicts);
|
|
@@ -178,7 +260,7 @@ function routes(directory = "src/pages") {
|
|
|
178
260
|
"main",
|
|
179
261
|
{ style: { fontFamily: "system-ui, sans-serif", margin: "3rem auto", maxWidth: "60rem", padding: "0 1.5rem" } },
|
|
180
262
|
createElement("h1", null, "Conflicting routes"),
|
|
181
|
-
createElement("p", null, "The file-based router found
|
|
263
|
+
createElement("p", null, "The file-based router found pages with the same effective URL pattern. React Router treats dynamic parameter names as equivalent, and route-group names do not add URL segments. Fix these conflicts, then the page will reload."),
|
|
182
264
|
createElement("pre", { style: { background: "#f3f4f6", overflowX: "auto", padding: "1rem", whiteSpace: "pre-wrap" } }, message),
|
|
183
265
|
);
|
|
184
266
|
}
|
|
@@ -195,15 +277,25 @@ function routes(directory = "src/pages") {
|
|
|
195
277
|
].filter(Boolean);
|
|
196
278
|
return format(sections.join("\n\n"), { parser: "typescript" });
|
|
197
279
|
}
|
|
280
|
+
async function executeManifest() {
|
|
281
|
+
const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
|
|
282
|
+
return format(`export const routeManifest = ${JSON.stringify(manifest, null, 2)};`, { parser: "typescript" });
|
|
283
|
+
}
|
|
198
284
|
return {
|
|
199
285
|
name: "routes",
|
|
200
286
|
configResolved(config) {
|
|
201
287
|
projectRoot = config.root;
|
|
202
|
-
root = path.resolve(projectRoot, directory);
|
|
288
|
+
root = path.resolve(projectRoot, options.directory);
|
|
203
289
|
dev = config.command === "serve";
|
|
204
290
|
},
|
|
205
|
-
resolveId: (id) =>
|
|
206
|
-
|
|
291
|
+
resolveId: (id) => {
|
|
292
|
+
if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
|
|
293
|
+
if (id === MANIFEST_MODULE_ID) return MANIFEST_RESOLVED_ID;
|
|
294
|
+
},
|
|
295
|
+
load: (id) => {
|
|
296
|
+
if (id === ROUTES_RESOLVED_ID) return execute();
|
|
297
|
+
if (id === MANIFEST_RESOLVED_ID) return executeManifest();
|
|
298
|
+
},
|
|
207
299
|
configureServer: (server) => {
|
|
208
300
|
server.watcher.add(root);
|
|
209
301
|
server.watcher.on("all", (event, file) => {
|
|
@@ -215,9 +307,12 @@ function routes(directory = "src/pages") {
|
|
|
215
307
|
"change"
|
|
216
308
|
].includes(event)) return;
|
|
217
309
|
const location = path.resolve(file);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
310
|
+
const relative = path.relative(root, location);
|
|
311
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
|
|
312
|
+
for (const id of [ROUTES_RESOLVED_ID, MANIFEST_RESOLVED_ID]) {
|
|
313
|
+
const module = server.moduleGraph.getModuleById(id);
|
|
314
|
+
if (module) server.moduleGraph.invalidateModule(module);
|
|
315
|
+
}
|
|
221
316
|
server.ws.send({ type: "full-reload" });
|
|
222
317
|
});
|
|
223
318
|
}
|
package/dist/scanner.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ export type Node = {
|
|
|
5
5
|
page?: string;
|
|
6
6
|
layout?: string;
|
|
7
7
|
loading?: string;
|
|
8
|
+
notFound?: string;
|
|
9
|
+
error?: string;
|
|
8
10
|
children: Node[];
|
|
9
11
|
};
|
|
10
|
-
export
|
|
12
|
+
export type RouteFileKind = "page" | "layout" | "loading" | "notFound" | "error";
|
|
13
|
+
export type RouteConventions = Partial<Record<RouteFileKind, string>>;
|
|
14
|
+
export type RouteManifestNode = {
|
|
15
|
+
segment: string | null;
|
|
16
|
+
fullPath: string;
|
|
17
|
+
params: string[];
|
|
18
|
+
routeGroup: boolean;
|
|
19
|
+
files: Partial<Record<RouteFileKind, string>>;
|
|
20
|
+
children: RouteManifestNode[];
|
|
21
|
+
};
|
|
22
|
+
export declare const DEFAULT_CONVENTIONS: Record<RouteFileKind, string>;
|
|
23
|
+
export declare function getRouteSegment(name: string): string;
|
|
24
|
+
export declare function scan(directory: string, conventions?: Record<RouteFileKind, string>): Node;
|
|
25
|
+
export declare function createRouteManifest(node: Node, projectRoot: string, parentPath?: string, root?: boolean): RouteManifestNode;
|
package/package.json
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -2,3 +2,8 @@ declare module "virtual:routes" {
|
|
|
2
2
|
import type { RouteObject } from "react-router";
|
|
3
3
|
export const routes: RouteObject[];
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
declare module "virtual:route-manifest" {
|
|
7
|
+
import type { RouteManifestNode } from "@troyio/vite-file-router";
|
|
8
|
+
export const routeManifest: RouteManifestNode;
|
|
9
|
+
}
|