@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 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. Convention
33
- values are filename stems without `.jsx` or `.tsx`; `.tsx` takes precedence when
34
- both extensions exist:
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 `error`. Each convention must have a unique filename stem.
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
- Each file must default-export a React component. If both extensions exist for
66
- the same file type in one directory, `.tsx` takes precedence.
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, and
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, RouterProvider } from "react-router";
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
- if (!node[kind] || extension === "tsx") node[kind] = file;
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, true);
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.2.8",
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
  },