@troyio/vite-file-router 0.2.8 → 0.3.1
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 +101 -9
- package/dist/index.js +32 -7
- package/dist/scanner.d.ts +3 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -29,9 +29,10 @@ directory:
|
|
|
29
29
|
plugins: [routes("src/routes")];
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
You can also configure the directory and special filenames together.
|
|
33
|
-
values are filename stems without
|
|
34
|
-
|
|
32
|
+
You can also configure the directory and special filenames together.
|
|
33
|
+
Convention values are filename stems without extensions. Page and component
|
|
34
|
+
conventions use `.jsx` / `.tsx` files (`.tsx` takes precedence); `loader` and
|
|
35
|
+
`action` conventions use `.js` / `.ts` files (`.ts` takes precedence):
|
|
35
36
|
|
|
36
37
|
```ts
|
|
37
38
|
plugins: [
|
|
@@ -43,13 +44,16 @@ plugins: [
|
|
|
43
44
|
loading: "pending",
|
|
44
45
|
notFound: "missing",
|
|
45
46
|
error: "fault",
|
|
47
|
+
loader: "load",
|
|
48
|
+
action: "mutate",
|
|
46
49
|
},
|
|
47
50
|
}),
|
|
48
51
|
];
|
|
49
52
|
```
|
|
50
53
|
|
|
51
54
|
Omitted convention values keep their defaults: `page`, `layout`, `loading`,
|
|
52
|
-
`not-found`, and `
|
|
55
|
+
`not-found`, `error`, `loader`, and `action`. Each convention must have a
|
|
56
|
+
unique filename stem.
|
|
53
57
|
|
|
54
58
|
The router scans the pages directory recursively. Each directory becomes a
|
|
55
59
|
nested route, and the following files are recognized:
|
|
@@ -61,9 +65,13 @@ nested route, and the following files are recognized:
|
|
|
61
65
|
| `loading.jsx` / `loading.tsx` | A Suspense fallback for the directory's page and child routes. |
|
|
62
66
|
| `not-found.jsx` / `not-found.tsx` | A not-found page for unmatched URLs within the directory's route subtree. |
|
|
63
67
|
| `error.jsx` / `error.tsx` | A React Router error boundary for the directory's route subtree. |
|
|
68
|
+
| `loader.js` / `loader.ts` | A React Router loader for the directory's route and its descendants. |
|
|
69
|
+
| `action.js` / `action.ts` | A React Router action selected when that directory route is the submission target. |
|
|
64
70
|
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
Component files must default-export a React component. Loader and action
|
|
72
|
+
modules must named-export their handler as `loader` or `action`; if both
|
|
73
|
+
supported extensions exist for the same convention in one directory, the
|
|
74
|
+
TypeScript file takes precedence.
|
|
67
75
|
|
|
68
76
|
For example, this structure creates a home page, user pages, and two auth
|
|
69
77
|
pages:
|
|
@@ -73,8 +81,10 @@ src/pages/
|
|
|
73
81
|
page.jsx
|
|
74
82
|
layout.tsx
|
|
75
83
|
loading.tsx
|
|
84
|
+
loader.ts
|
|
76
85
|
users/
|
|
77
86
|
page.tsx
|
|
87
|
+
action.ts
|
|
78
88
|
[id]/
|
|
79
89
|
page.tsx
|
|
80
90
|
docs/
|
|
@@ -95,8 +105,8 @@ This maps to `/`, `/users`, `/users/:id`, `/docs/:lang?`, `/sign-in`,
|
|
|
95
105
|
becomes `:id`; double-bracket segments are optional dynamic parameters, so
|
|
96
106
|
`[[lang]]` becomes `:lang?` and matches both `/docs` and `/docs/en`.
|
|
97
107
|
Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
|
|
98
|
-
are route groups; they organize files and can have their own page, layout,
|
|
99
|
-
loading files, but their names do not add a URL segment.
|
|
108
|
+
are route groups; they organize files and can have their own page, layout,
|
|
109
|
+
loading files, loaders, and actions, but their names do not add a URL segment.
|
|
100
110
|
|
|
101
111
|
### Layouts
|
|
102
112
|
|
|
@@ -157,6 +167,87 @@ export default function ErrorPage() {
|
|
|
157
167
|
}
|
|
158
168
|
```
|
|
159
169
|
|
|
170
|
+
### Loaders, actions, and route guards
|
|
171
|
+
|
|
172
|
+
Add a `loader.ts` / `loader.js` or `action.ts` / `action.js` beside the route
|
|
173
|
+
files and export a named handler. The generated React Router route calls these
|
|
174
|
+
handlers with React Router's loader/action arguments. Their returned values,
|
|
175
|
+
thrown errors, and redirect responses use React Router's normal data-router
|
|
176
|
+
behavior. A loader on a directory route runs when that route matches, including
|
|
177
|
+
when a descendant URL matches. An action runs only when its route is selected
|
|
178
|
+
as the submission target; a parent action does not automatically handle
|
|
179
|
+
submissions targeting child routes.
|
|
180
|
+
|
|
181
|
+
A loader module may also export `shouldRevalidate(args)` to control the route's
|
|
182
|
+
React Router `shouldRevalidate` option. It receives the same arguments as
|
|
183
|
+
`shouldRevalidate` and must synchronously return a boolean:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import type { LoaderFunctionArgs, ShouldRevalidateFunctionArgs } from "react-router";
|
|
187
|
+
|
|
188
|
+
export function shouldRevalidate({ nextUrl, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
|
|
189
|
+
if (nextUrl.searchParams.get("refresh") === "never") return false;
|
|
190
|
+
return defaultShouldRevalidate;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
194
|
+
return getDashboardData(request);
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Use a loader to redirect users who should not enter a protected route subtree.
|
|
199
|
+
For example, `src/pages/dashboard/loader.ts` can guard `/dashboard` and all of
|
|
200
|
+
its nested pages:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { redirect, type LoaderFunctionArgs } from "react-router";
|
|
204
|
+
import { getUserForRequest } from "../../lib/auth.server";
|
|
205
|
+
|
|
206
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
207
|
+
const user = await getUserForRequest(request);
|
|
208
|
+
if (!user) {
|
|
209
|
+
const returnTo = new URL(request.url).pathname;
|
|
210
|
+
throw redirect(`/sign-in?returnTo=${encodeURIComponent(returnTo)}`);
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Actions use the same pattern and receive React Router's `ActionFunctionArgs`:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { redirect, type ActionFunctionArgs } from "react-router";
|
|
220
|
+
|
|
221
|
+
export async function action({ request }: ActionFunctionArgs) {
|
|
222
|
+
const formData = await request.formData();
|
|
223
|
+
await updateProfile(formData);
|
|
224
|
+
return redirect("/profile");
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
When a directory has both a page and a loader, that page is generated as an
|
|
229
|
+
index child route, while the loader remains on the directory route so it also
|
|
230
|
+
runs for descendant pages. Read the directory loader's result in the page with
|
|
231
|
+
React Router's `useRouteLoaderData`. The generated route ID is
|
|
232
|
+
`file-route:` followed by the directory path relative to the Vite project
|
|
233
|
+
root, with `/` separators:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
import { useRouteLoaderData } from "react-router";
|
|
237
|
+
|
|
238
|
+
export default function DashboardPage() {
|
|
239
|
+
const data = useRouteLoaderData<{ message: string }>(
|
|
240
|
+
"file-route:src/pages/dashboard",
|
|
241
|
+
);
|
|
242
|
+
return <h1>{data?.message}</h1>;
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The authentication and mutation functions above are application code. A
|
|
247
|
+
client-side route guard controls navigation and presentation; it is not a
|
|
248
|
+
security boundary. Protect data and mutations in the server/API layer as well,
|
|
249
|
+
and do not rely on a parent loader running before descendant loaders.
|
|
250
|
+
|
|
160
251
|
### Dynamic routes and route groups
|
|
161
252
|
|
|
162
253
|
Two pages must not resolve to the same URL. This includes pages with the same
|
|
@@ -174,7 +265,8 @@ first.
|
|
|
174
265
|
Import `routes` from the virtual module and pass it to React Router:
|
|
175
266
|
|
|
176
267
|
```tsx
|
|
177
|
-
import { createBrowserRouter
|
|
268
|
+
import { createBrowserRouter } from "react-router";
|
|
269
|
+
import { RouterProvider } from "react-router";
|
|
178
270
|
import { routes } from "virtual:routes";
|
|
179
271
|
|
|
180
272
|
const router = createBrowserRouter(routes);
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,9 @@ var DEFAULT_CONVENTIONS = {
|
|
|
9
9
|
layout: "layout",
|
|
10
10
|
loading: "loading",
|
|
11
11
|
notFound: "not-found",
|
|
12
|
-
error: "error"
|
|
12
|
+
error: "error",
|
|
13
|
+
loader: "loader",
|
|
14
|
+
action: "action"
|
|
13
15
|
};
|
|
14
16
|
function getRouteSegment(name) {
|
|
15
17
|
if (name.startsWith("[...") && name.endsWith("]")) return "*";
|
|
@@ -54,12 +56,15 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
|
54
56
|
node.children.push(scan(file, conventions));
|
|
55
57
|
continue;
|
|
56
58
|
}
|
|
57
|
-
const match = /^(.*)\.(jsx|tsx)$/.exec(entry.name);
|
|
59
|
+
const match = /^(.*)\.(jsx|tsx|js|ts)$/.exec(entry.name);
|
|
58
60
|
if (!match) continue;
|
|
59
61
|
const [, basename, extension] = match;
|
|
60
62
|
const kind = Object.keys(conventions).find((candidate) => conventions[candidate] === basename);
|
|
61
63
|
if (!kind) continue;
|
|
62
|
-
|
|
64
|
+
const isDataModule = kind === "loader" || kind === "action";
|
|
65
|
+
if (!(isDataModule ? ["js", "ts"] : ["jsx", "tsx"]).includes(extension)) continue;
|
|
66
|
+
const preferredExtension = isDataModule ? "ts" : "tsx";
|
|
67
|
+
if (!node[kind] || extension === preferredExtension) node[kind] = file;
|
|
63
68
|
}
|
|
64
69
|
return node;
|
|
65
70
|
}
|
|
@@ -127,9 +132,19 @@ function generateImport(file, projectRoot) {
|
|
|
127
132
|
Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
|
|
128
133
|
})`;
|
|
129
134
|
}
|
|
130
|
-
function generateRouteLazy(node, projectRoot, includeLayout) {
|
|
135
|
+
function generateRouteLazy(node, projectRoot, includeLayout, includeLoader) {
|
|
131
136
|
const properties = [];
|
|
132
137
|
if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
|
|
138
|
+
if (includeLoader && node.loader) return `async () => {
|
|
139
|
+
const loaderModule = await import(${JSON.stringify(getImportPath(node.loader, projectRoot))});
|
|
140
|
+
return {
|
|
141
|
+
loader: loaderModule.loader,
|
|
142
|
+
...(typeof loaderModule.shouldRevalidate === "function"
|
|
143
|
+
? { shouldRevalidate: loaderModule.shouldRevalidate }
|
|
144
|
+
: {}),
|
|
145
|
+
${properties.join(",\n ")}
|
|
146
|
+
};
|
|
147
|
+
}`;
|
|
133
148
|
return `async () => ({ ${properties.join(", ")} })`;
|
|
134
149
|
}
|
|
135
150
|
function generateLazyComponent(file, projectRoot) {
|
|
@@ -138,15 +153,23 @@ function generateLazyComponent(file, projectRoot) {
|
|
|
138
153
|
function generateLazyLayout(file, projectRoot) {
|
|
139
154
|
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
|
|
140
155
|
}
|
|
156
|
+
function generateDataHandler(file, projectRoot) {
|
|
157
|
+
return `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
|
|
158
|
+
}
|
|
159
|
+
function getRouteId(directory, projectRoot) {
|
|
160
|
+
return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
|
|
161
|
+
}
|
|
141
162
|
function generateNode(node, root, context, inheritedBoundary) {
|
|
142
163
|
const route = {};
|
|
143
164
|
const hasBoundary = inheritedBoundary || Boolean(node.loading);
|
|
144
165
|
if (node.layout) context.hasLayouts = true;
|
|
145
166
|
if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
|
|
167
|
+
if (node.loader) route.id = JSON.stringify(getRouteId(node.directory, context.projectRoot));
|
|
146
168
|
if (node.error) {
|
|
147
169
|
context.hasErrorBoundaries = true;
|
|
148
170
|
route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
|
|
149
171
|
}
|
|
172
|
+
if (node.action) route.action = generateDataHandler(node.action, context.projectRoot);
|
|
150
173
|
let loadingComponent;
|
|
151
174
|
if (node.loading) {
|
|
152
175
|
loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
|
|
@@ -159,15 +182,17 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
159
182
|
} else if (node.layout) {
|
|
160
183
|
if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
|
|
161
184
|
}
|
|
162
|
-
if (node.layout && !hasBoundary) route.lazy = generateRouteLazy(node, context.projectRoot,
|
|
185
|
+
if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader));
|
|
163
186
|
const children = [];
|
|
164
187
|
if (node.page) {
|
|
165
188
|
const page = hasBoundary ? {
|
|
166
189
|
index: "true",
|
|
167
|
-
Component: generateLazyComponent(node.page, context.projectRoot)
|
|
190
|
+
Component: generateLazyComponent(node.page, context.projectRoot),
|
|
191
|
+
...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
|
|
168
192
|
} : {
|
|
169
193
|
index: "true",
|
|
170
|
-
lazy: generateImport(node.page, context.projectRoot)
|
|
194
|
+
lazy: generateImport(node.page, context.projectRoot),
|
|
195
|
+
...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
|
|
171
196
|
};
|
|
172
197
|
children.push(genObjectFromRaw(page));
|
|
173
198
|
}
|
package/dist/scanner.d.ts
CHANGED
|
@@ -7,9 +7,11 @@ export type Node = {
|
|
|
7
7
|
loading?: string;
|
|
8
8
|
notFound?: string;
|
|
9
9
|
error?: string;
|
|
10
|
+
loader?: string;
|
|
11
|
+
action?: string;
|
|
10
12
|
children: Node[];
|
|
11
13
|
};
|
|
12
|
-
export type RouteFileKind = "page" | "layout" | "loading" | "notFound" | "error";
|
|
14
|
+
export type RouteFileKind = "page" | "layout" | "loading" | "notFound" | "error" | "loader" | "action";
|
|
13
15
|
export type RouteConventions = Partial<Record<RouteFileKind, string>>;
|
|
14
16
|
export type RouteManifestNode = {
|
|
15
17
|
segment: string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@troyio/vite-file-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "File-system routing plugin for Vite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -38,8 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@biomejs/biome": "^2.6.4",
|
|
41
|
-
"@vitejs/devtools-kit": "^0.8.2",
|
|
42
41
|
"@types/node": "^24.19.2",
|
|
42
|
+
"@vitejs/devtools-kit": "^0.8.2",
|
|
43
|
+
"react": "^19.3.1",
|
|
44
|
+
"react-dom": "^19.3.1",
|
|
43
45
|
"typescript": "~6.0.3",
|
|
44
46
|
"vite": "^8.3.5"
|
|
45
47
|
},
|