@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 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,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, RouterProvider } from "react-router";
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
- 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
  }
@@ -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.2.8",
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
  },