@troyio/vite-file-router 0.2.8 → 0.3.0
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 +84 -9
- package/dist/index.js +21 -5
- 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,70 @@ 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
|
+
Use a loader to redirect users who should not enter a protected route subtree.
|
|
182
|
+
For example, `src/pages/dashboard/loader.ts` can guard `/dashboard` and all of
|
|
183
|
+
its nested pages:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { redirect, type LoaderFunctionArgs } from "react-router";
|
|
187
|
+
import { getUserForRequest } from "../../lib/auth.server";
|
|
188
|
+
|
|
189
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
190
|
+
const user = await getUserForRequest(request);
|
|
191
|
+
if (!user) {
|
|
192
|
+
const returnTo = new URL(request.url).pathname;
|
|
193
|
+
throw redirect(`/sign-in?returnTo=${encodeURIComponent(returnTo)}`);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Actions use the same pattern and receive React Router's `ActionFunctionArgs`:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { redirect, type ActionFunctionArgs } from "react-router";
|
|
203
|
+
|
|
204
|
+
export async function action({ request }: ActionFunctionArgs) {
|
|
205
|
+
const formData = await request.formData();
|
|
206
|
+
await updateProfile(formData);
|
|
207
|
+
return redirect("/profile");
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
When a directory has both a page and a loader, that page is generated as an
|
|
212
|
+
index child route, while the loader remains on the directory route so it also
|
|
213
|
+
runs for descendant pages. Read the directory loader's result in the page with
|
|
214
|
+
React Router's `useRouteLoaderData`. The generated route ID is
|
|
215
|
+
`file-route:` followed by the directory path relative to the Vite project
|
|
216
|
+
root, with `/` separators:
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
import { useRouteLoaderData } from "react-router";
|
|
220
|
+
|
|
221
|
+
export default function DashboardPage() {
|
|
222
|
+
const data = useRouteLoaderData<{ message: string }>(
|
|
223
|
+
"file-route:src/pages/dashboard",
|
|
224
|
+
);
|
|
225
|
+
return <h1>{data?.message}</h1>;
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The authentication and mutation functions above are application code. A
|
|
230
|
+
client-side route guard controls navigation and presentation; it is not a
|
|
231
|
+
security boundary. Protect data and mutations in the server/API layer as well,
|
|
232
|
+
and do not rely on a parent loader running before descendant loaders.
|
|
233
|
+
|
|
160
234
|
### Dynamic routes and route groups
|
|
161
235
|
|
|
162
236
|
Two pages must not resolve to the same URL. This includes pages with the same
|
|
@@ -174,7 +248,8 @@ first.
|
|
|
174
248
|
Import `routes` from the virtual module and pass it to React Router:
|
|
175
249
|
|
|
176
250
|
```tsx
|
|
177
|
-
import { createBrowserRouter
|
|
251
|
+
import { createBrowserRouter } from "react-router";
|
|
252
|
+
import { RouterProvider } from "react-router";
|
|
178
253
|
import { routes } from "virtual:routes";
|
|
179
254
|
|
|
180
255
|
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
|
}
|
|
@@ -138,15 +143,24 @@ function generateLazyComponent(file, projectRoot) {
|
|
|
138
143
|
function generateLazyLayout(file, projectRoot) {
|
|
139
144
|
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
|
|
140
145
|
}
|
|
146
|
+
function generateDataHandler(file, kind, projectRoot) {
|
|
147
|
+
return `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).${kind}(args)`;
|
|
148
|
+
}
|
|
149
|
+
function getRouteId(directory, projectRoot) {
|
|
150
|
+
return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
|
|
151
|
+
}
|
|
141
152
|
function generateNode(node, root, context, inheritedBoundary) {
|
|
142
153
|
const route = {};
|
|
143
154
|
const hasBoundary = inheritedBoundary || Boolean(node.loading);
|
|
144
155
|
if (node.layout) context.hasLayouts = true;
|
|
145
156
|
if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
|
|
157
|
+
if (node.loader) route.id = JSON.stringify(getRouteId(node.directory, context.projectRoot));
|
|
146
158
|
if (node.error) {
|
|
147
159
|
context.hasErrorBoundaries = true;
|
|
148
160
|
route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
|
|
149
161
|
}
|
|
162
|
+
if (node.loader) route.loader = generateDataHandler(node.loader, "loader", context.projectRoot);
|
|
163
|
+
if (node.action) route.action = generateDataHandler(node.action, "action", context.projectRoot);
|
|
150
164
|
let loadingComponent;
|
|
151
165
|
if (node.loading) {
|
|
152
166
|
loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
|
|
@@ -164,10 +178,12 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
164
178
|
if (node.page) {
|
|
165
179
|
const page = hasBoundary ? {
|
|
166
180
|
index: "true",
|
|
167
|
-
Component: generateLazyComponent(node.page, context.projectRoot)
|
|
181
|
+
Component: generateLazyComponent(node.page, context.projectRoot),
|
|
182
|
+
...node.action ? { action: generateDataHandler(node.action, "action", context.projectRoot) } : {}
|
|
168
183
|
} : {
|
|
169
184
|
index: "true",
|
|
170
|
-
lazy: generateImport(node.page, context.projectRoot)
|
|
185
|
+
lazy: generateImport(node.page, context.projectRoot),
|
|
186
|
+
...node.action ? { action: generateDataHandler(node.action, "action", context.projectRoot) } : {}
|
|
171
187
|
};
|
|
172
188
|
children.push(genObjectFromRaw(page));
|
|
173
189
|
}
|
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.0",
|
|
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
|
},
|