@troyio/vite-file-router 0.2.7 → 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,10 +81,15 @@ 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
90
+ docs/
91
+ [[lang]]/
92
+ page.tsx
80
93
  (auth)/
81
94
  layout.tsx
82
95
  sign-in/
@@ -87,11 +100,13 @@ src/pages/
87
100
  page.tsx
88
101
  ```
89
102
 
90
- This maps to `/`, `/users`, `/users/:id`, `/sign-in`, `/sign-up`, and `/*`.
91
- Square-bracket segments are dynamic parameters: `[id]` becomes `:id`.
103
+ This maps to `/`, `/users`, `/users/:id`, `/docs/:lang?`, `/sign-in`,
104
+ `/sign-up`, and `/*`. Square-bracket segments are dynamic parameters: `[id]`
105
+ becomes `:id`; double-bracket segments are optional dynamic parameters, so
106
+ `[[lang]]` becomes `:lang?` and matches both `/docs` and `/docs/en`.
92
107
  Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
93
- are route groups; they organize files and can have their own page, layout, and
94
- 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.
95
110
 
96
111
  ### Layouts
97
112
 
@@ -152,6 +167,70 @@ export default function ErrorPage() {
152
167
  }
153
168
  ```
154
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
+
155
234
  ### Dynamic routes and route groups
156
235
 
157
236
  Two pages must not resolve to the same URL. This includes pages with the same
@@ -169,7 +248,8 @@ first.
169
248
  Import `routes` from the virtual module and pass it to React Router:
170
249
 
171
250
  ```tsx
172
- import { createBrowserRouter, RouterProvider } from "react-router";
251
+ import { createBrowserRouter } from "react-router";
252
+ import { RouterProvider } from "react-router";
173
253
  import { routes } from "virtual:routes";
174
254
 
175
255
  const router = createBrowserRouter(routes);
@@ -184,7 +264,8 @@ export default function App() {
184
264
  Import `routeManifest` from `virtual:route-manifest` for a stable, nested
185
265
  description of the scanned routes. Source files are relative to the Vite
186
266
  project root and use forward slashes. Each node includes its URL `fullPath`,
187
- the parameters introduced by that segment, and its recognized files:
267
+ the parameters introduced by that segment, any `optionalParams`, and its
268
+ recognized files:
188
269
 
189
270
  ```ts
190
271
  import { routeManifest } from "virtual:route-manifest";
@@ -200,6 +281,29 @@ The development server watches the configured directory. Adding, removing,
200
281
  renaming, or editing route files invalidates all generated virtual modules and
201
282
  reloads the app without requiring a server restart.
202
283
 
284
+ ### Route tree inspection
285
+
286
+ The route tree can be inspected in Vite DevTools. This requires Vite 8.3 or
287
+ later and the DevTools package:
288
+
289
+ ```sh
290
+ npm install -D @vitejs/devtools
291
+ ```
292
+
293
+ Enable the dock in your Vite configuration:
294
+
295
+ ```ts
296
+ export default defineConfig({
297
+ devtools: true,
298
+ plugins: [routes()],
299
+ });
300
+ ```
301
+
302
+ Open the **Routes** dock to browse the nested route tree, filter by path or
303
+ source file, and inspect parameters and special files. The panel refreshes its
304
+ manifest automatically while the dev server is running. Vite DevTools must be
305
+ enabled; route-tree inspection is development-only.
306
+
203
307
  ### Typed route helpers
204
308
 
205
309
  Import `routePath` from `virtual:route-helpers` to build paths for discovered
@@ -222,10 +326,15 @@ export function UserLink({ userId }: { userId: string }) {
222
326
  }
223
327
 
224
328
  const docsPath = routePath("/docs/*", { "*": "guides/getting-started" });
329
+ const localizedDocsPath = routePath("/docs/:lang?", { lang: "en" });
330
+ const defaultDocsPath = routePath("/docs/:lang?");
225
331
  ```
226
332
 
227
- Static routes need no parameter object: `routePath("/")`. Unknown route
228
- patterns and missing or extra parameters are rejected at runtime. The generated
333
+ Static routes and routes with only optional parameters need no parameter object.
334
+ An optional parameter can also be omitted from the params object; its URL
335
+ segment is removed. Unknown route patterns and missing required or extra
336
+ parameters are rejected at runtime. Routes whose optional segment overlaps
337
+ another page's URL pattern are reported as conflicts. The generated
229
338
  `routePatterns` export contains the discovered page paths.
230
339
 
231
340
  ### TypeScript setup
@@ -0,0 +1,143 @@
1
+ const treeElement = document.querySelector("#tree");
2
+ const statusElement = document.querySelector("#status");
3
+ const errorElement = document.querySelector("#error");
4
+ const filterElement = document.querySelector("#filter");
5
+ const toggleElement = document.querySelector("#toggle");
6
+ const fileLabels = {
7
+ page: "Page",
8
+ layout: "Layout",
9
+ loading: "Loading",
10
+ notFound: "Not found",
11
+ error: "Error boundary",
12
+ };
13
+
14
+ let manifest;
15
+ let manifestSnapshot;
16
+ let expanded = false;
17
+ let expansionTouched = false;
18
+ let refreshing = false;
19
+
20
+ function createElement(tag, className, text) {
21
+ const element = document.createElement(tag);
22
+ if (className) element.className = className;
23
+ if (text !== undefined) element.textContent = text;
24
+ return element;
25
+ }
26
+
27
+ function renderNode(node, query, depth = 0) {
28
+ const children = node.children
29
+ .map((child) => renderNode(child, query, depth + 1))
30
+ .filter(Boolean);
31
+ const fileEntries = Object.entries(node.files ?? {});
32
+ const searchText = [
33
+ node.fullPath,
34
+ ...node.params,
35
+ ...fileEntries.flatMap(([kind, file]) => [kind, file]),
36
+ ]
37
+ .join(" ")
38
+ .toLowerCase();
39
+ const matches = !query || searchText.includes(query);
40
+ if (!matches && children.length === 0) return null;
41
+
42
+ const item = createElement("li", "route-node");
43
+ const details = createElement("details", "route-details");
44
+ details.open = query
45
+ ? true
46
+ : expansionTouched
47
+ ? expanded
48
+ : depth < 2;
49
+ const summary = createElement("summary", "route-summary");
50
+ const routePath = node.routeGroup
51
+ ? `${node.fullPath} (route group)`
52
+ : node.fullPath;
53
+ summary.append(createElement("code", "route-path", routePath));
54
+
55
+ if (node.routeGroup) {
56
+ summary.append(createElement("span", "badge group-badge", "group"));
57
+ }
58
+ for (const param of node.params) {
59
+ const optional = node.optionalParams?.includes(param);
60
+ summary.append(
61
+ createElement(
62
+ "span",
63
+ `badge ${optional ? "optional-badge" : "param-badge"}`,
64
+ optional ? `${param}?` : param,
65
+ ),
66
+ );
67
+ }
68
+ details.append(summary);
69
+
70
+ if (fileEntries.length) {
71
+ const files = createElement("ul", "file-list");
72
+ for (const [kind, file] of fileEntries) {
73
+ const row = createElement("li", "file-row");
74
+ row.append(
75
+ createElement("span", "file-kind", fileLabels[kind] ?? kind),
76
+ createElement("code", "file-path", file),
77
+ );
78
+ files.append(row);
79
+ }
80
+ details.append(files);
81
+ }
82
+
83
+ if (children.length) {
84
+ const childList = createElement("ul", "route-list");
85
+ childList.append(...children);
86
+ details.append(childList);
87
+ }
88
+ item.append(details);
89
+ return item;
90
+ }
91
+
92
+ function render() {
93
+ if (!manifest) return;
94
+ const query = filterElement.value.trim().toLowerCase();
95
+ const root = renderNode(manifest, query);
96
+ treeElement.replaceChildren();
97
+
98
+ if (!root) {
99
+ treeElement.append(createElement("p", "empty", "No routes match this filter."));
100
+ } else {
101
+ const list = createElement("ul", "route-list root-list");
102
+ list.append(root);
103
+ treeElement.append(list);
104
+ }
105
+ toggleElement.textContent = expanded ? "Collapse all" : "Expand all";
106
+ toggleElement.disabled = Boolean(query);
107
+ }
108
+
109
+ async function refresh() {
110
+ if (refreshing) return;
111
+ refreshing = true;
112
+ try {
113
+ const response = await fetch("/__vite-file-router-data", {
114
+ cache: "no-store",
115
+ });
116
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
117
+ const nextManifest = await response.json();
118
+ const nextSnapshot = JSON.stringify(nextManifest);
119
+ const manifestChanged = nextSnapshot !== manifestSnapshot;
120
+ manifest = nextManifest;
121
+ manifestSnapshot = nextSnapshot;
122
+ errorElement.hidden = true;
123
+ statusElement.textContent = "Route data refreshes automatically.";
124
+ if (manifestChanged) render();
125
+ } catch (error) {
126
+ errorElement.textContent = `Unable to load routes: ${error.message}`;
127
+ errorElement.hidden = false;
128
+ statusElement.textContent = "Route data is unavailable.";
129
+ } finally {
130
+ refreshing = false;
131
+ }
132
+ }
133
+
134
+ filterElement.addEventListener("input", render);
135
+ document.querySelector("#refresh").addEventListener("click", refresh);
136
+ toggleElement.addEventListener("click", () => {
137
+ expansionTouched = true;
138
+ expanded = !expanded;
139
+ render();
140
+ });
141
+
142
+ refresh();
143
+ window.setInterval(refresh, 2000);
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Route Tree</title>
7
+ <link rel="stylesheet" href="/__vite-file-router/style.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <header>
12
+ <div>
13
+ <p class="eyebrow">VITE FILE ROUTER</p>
14
+ <h1>Route tree</h1>
15
+ <p id="status" role="status">Loading routes...</p>
16
+ </div>
17
+ <button id="refresh" type="button" aria-label="Refresh routes">
18
+ Refresh
19
+ </button>
20
+ </header>
21
+ <section class="toolbar" aria-label="Route tree controls">
22
+ <label class="search">
23
+ <span class="visually-hidden">Filter routes</span>
24
+ <input id="filter" type="search" placeholder="Filter paths or files" />
25
+ </label>
26
+ <button id="toggle" type="button">Collapse all</button>
27
+ </section>
28
+ <p id="error" role="alert" hidden></p>
29
+ <div id="tree"></div>
30
+ </main>
31
+ <script type="module" src="/__vite-file-router/app.js"></script>
32
+ </body>
33
+ </html>
@@ -0,0 +1,231 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
4
+ font-size: 13px;
5
+ color: #24292f;
6
+ background: #fff;
7
+ font-synthesis: none;
8
+ text-rendering: optimizeLegibility;
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ min-width: 320px;
18
+ }
19
+
20
+ main {
21
+ max-width: 1100px;
22
+ margin: 0 auto;
23
+ padding: 24px;
24
+ }
25
+
26
+ header,
27
+ .toolbar {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: space-between;
31
+ gap: 16px;
32
+ }
33
+
34
+ header {
35
+ margin-bottom: 20px;
36
+ }
37
+
38
+ .eyebrow {
39
+ margin: 0 0 5px;
40
+ color: #6e7781;
41
+ font-size: 10px;
42
+ font-weight: 700;
43
+ letter-spacing: 0.1em;
44
+ }
45
+
46
+ h1 {
47
+ margin: 0;
48
+ font-size: 22px;
49
+ }
50
+
51
+ #status {
52
+ margin: 6px 0 0;
53
+ color: #6e7781;
54
+ }
55
+
56
+ button,
57
+ input {
58
+ min-height: 34px;
59
+ border: 1px solid #d0d7de;
60
+ border-radius: 6px;
61
+ background: #fff;
62
+ color: inherit;
63
+ font: inherit;
64
+ }
65
+
66
+ button {
67
+ padding: 0 12px;
68
+ cursor: pointer;
69
+ }
70
+
71
+ button:hover {
72
+ background: #f6f8fa;
73
+ }
74
+
75
+ .toolbar {
76
+ justify-content: flex-start;
77
+ margin-bottom: 16px;
78
+ }
79
+
80
+ .search {
81
+ flex: 1;
82
+ }
83
+
84
+ input {
85
+ width: 100%;
86
+ padding: 0 10px;
87
+ }
88
+
89
+ #error {
90
+ padding: 10px;
91
+ border: 1px solid #cf222e;
92
+ border-radius: 6px;
93
+ color: #cf222e;
94
+ }
95
+
96
+ .route-list,
97
+ .file-list {
98
+ margin: 0;
99
+ padding-left: 20px;
100
+ list-style: none;
101
+ }
102
+
103
+ .root-list {
104
+ padding: 0;
105
+ }
106
+
107
+ .route-node {
108
+ position: relative;
109
+ padding: 3px 0;
110
+ }
111
+
112
+ .route-details {
113
+ min-width: 0;
114
+ }
115
+
116
+ .route-summary {
117
+ display: flex;
118
+ align-items: center;
119
+ flex-wrap: wrap;
120
+ gap: 7px;
121
+ min-height: 28px;
122
+ cursor: pointer;
123
+ }
124
+
125
+ .route-path,
126
+ .file-path {
127
+ overflow-wrap: anywhere;
128
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
129
+ }
130
+
131
+ .route-path {
132
+ color: #0969da;
133
+ font-weight: 600;
134
+ }
135
+
136
+ .badge {
137
+ padding: 2px 6px;
138
+ border-radius: 10px;
139
+ background: #ddf4ff;
140
+ color: #0550ae;
141
+ font-size: 11px;
142
+ }
143
+
144
+ .group-badge {
145
+ background: #fbefff;
146
+ color: #8250df;
147
+ }
148
+
149
+ .optional-badge {
150
+ background: #fff8c5;
151
+ color: #7d4e00;
152
+ }
153
+
154
+ .file-list {
155
+ padding-top: 3px;
156
+ padding-bottom: 5px;
157
+ }
158
+
159
+ .file-row {
160
+ display: flex;
161
+ flex-wrap: wrap;
162
+ gap: 10px;
163
+ padding: 3px 0;
164
+ color: #57606a;
165
+ }
166
+
167
+ .file-kind {
168
+ min-width: 90px;
169
+ font-weight: 600;
170
+ }
171
+
172
+ .empty {
173
+ padding: 20px 0;
174
+ color: #6e7781;
175
+ }
176
+
177
+ .visually-hidden {
178
+ position: absolute;
179
+ width: 1px;
180
+ height: 1px;
181
+ overflow: hidden;
182
+ clip: rect(0, 0, 0, 0);
183
+ white-space: nowrap;
184
+ }
185
+
186
+ @media (prefers-color-scheme: dark) {
187
+ :root {
188
+ color: #e6edf3;
189
+ background: #0d1117;
190
+ }
191
+
192
+ button,
193
+ input {
194
+ border-color: #30363d;
195
+ background: #161b22;
196
+ }
197
+
198
+ button:hover {
199
+ background: #21262d;
200
+ }
201
+
202
+ #error {
203
+ color: #ff7b72;
204
+ }
205
+
206
+ .route-path {
207
+ color: #79c0ff;
208
+ }
209
+
210
+ .badge {
211
+ background: #0c2d6b;
212
+ color: #a5d6ff;
213
+ }
214
+
215
+ .group-badge {
216
+ background: #2d1f4d;
217
+ color: #d2a8ff;
218
+ }
219
+
220
+ .optional-badge {
221
+ background: #3d2e00;
222
+ color: #eac54f;
223
+ }
224
+
225
+ .file-row,
226
+ .empty,
227
+ #status,
228
+ .eyebrow {
229
+ color: #8b949e;
230
+ }
231
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
2
3
  import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork";
3
4
  import { format } from "prettier";
4
5
  import fs from "node:fs";
@@ -8,12 +9,32 @@ var DEFAULT_CONVENTIONS = {
8
9
  layout: "layout",
9
10
  loading: "loading",
10
11
  notFound: "not-found",
11
- error: "error"
12
+ error: "error",
13
+ loader: "loader",
14
+ action: "action"
12
15
  };
13
16
  function getRouteSegment(name) {
14
17
  if (name.startsWith("[...") && name.endsWith("]")) return "*";
18
+ const optionalParameter = /^\[\[(.+)\]\]$/.exec(name);
19
+ if (optionalParameter) return `:${optionalParameter[1]}?`;
15
20
  return name.replace(/^\[(.+)\]$/, ":$1");
16
21
  }
22
+ function getRouteParameter(name) {
23
+ if (name.startsWith("[...") && name.endsWith("]")) return {
24
+ name: "*",
25
+ optional: false
26
+ };
27
+ const optionalParameter = /^\[\[(.+)\]\]$/.exec(name);
28
+ if (optionalParameter) return {
29
+ name: optionalParameter[1],
30
+ optional: true
31
+ };
32
+ const parameter = /^\[(.+)\]$/.exec(name);
33
+ return parameter ? {
34
+ name: parameter[1],
35
+ optional: false
36
+ } : void 0;
37
+ }
17
38
  function collectPageRoutePatterns(node, segments = [], root = true) {
18
39
  const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
19
40
  const patterns = node.page ? [`/${routeSegments.join("/")}`] : [];
@@ -35,19 +56,24 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
35
56
  node.children.push(scan(file, conventions));
36
57
  continue;
37
58
  }
38
- const match = /^(.*)\.(jsx|tsx)$/.exec(entry.name);
59
+ const match = /^(.*)\.(jsx|tsx|js|ts)$/.exec(entry.name);
39
60
  if (!match) continue;
40
61
  const [, basename, extension] = match;
41
62
  const kind = Object.keys(conventions).find((candidate) => conventions[candidate] === basename);
42
63
  if (!kind) continue;
43
- 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;
44
68
  }
45
69
  return node;
46
70
  }
47
71
  function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
48
72
  const segment = root || node.routeGroup ? null : getRouteSegment(node.name);
49
73
  const fullPath = segment === null ? parentPath || "/" : `${parentPath}/${segment}` || "/";
50
- const params = !root && !node.routeGroup && node.name.startsWith("[") ? [node.name.startsWith("[...") ? "*" : node.name.slice(1, -1)] : [];
74
+ const routeParameter = !root && !node.routeGroup ? getRouteParameter(node.name) : void 0;
75
+ const params = routeParameter ? [routeParameter.name] : [];
76
+ const optionalParams = routeParameter?.optional ? [routeParameter.name] : [];
51
77
  const files = {};
52
78
  for (const kind of Object.keys(DEFAULT_CONVENTIONS)) {
53
79
  const file = node[kind];
@@ -57,6 +83,7 @@ function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
57
83
  segment,
58
84
  fullPath,
59
85
  params,
86
+ optionalParams,
60
87
  routeGroup: node.routeGroup,
61
88
  files,
62
89
  children: node.children.map((child) => createRouteManifest(child, projectRoot, fullPath === "/" ? "" : fullPath, false))
@@ -67,7 +94,7 @@ var RouteConflictError = class extends Error {
67
94
  conflicts;
68
95
  constructor(conflicts) {
69
96
  const details = conflicts.map(({ routePath, files }) => ` ${routePath}\n${files.map((file) => ` - ${file}`).join("\n")}`).join("\n");
70
- 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.`);
97
+ 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. Optional segments also match URLs without that segment. Give each page a unique URL pattern.`);
71
98
  this.conflicts = conflicts;
72
99
  this.name = "RouteConflictError";
73
100
  }
@@ -82,8 +109,12 @@ function getRoutePath(directory) {
82
109
  function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
83
110
  const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
84
111
  if (node.page) {
85
- const routePath = `/${routeSegments.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
86
- pages.set(routePath, [...pages.get(routePath) ?? [], node.page]);
112
+ const variants = routeSegments.reduce((paths, segment) => segment.startsWith(":") && segment.endsWith("?") ? paths.flatMap((segments) => [[...segments, segment.slice(0, -1)], segments]) : paths.map((segments) => [...segments, segment]), [[]]);
113
+ for (const variant of variants) {
114
+ const routePath = `/${variant.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
115
+ const files = pages.get(routePath) ?? [];
116
+ if (!files.includes(node.page)) pages.set(routePath, [...files, node.page]);
117
+ }
87
118
  }
88
119
  for (const child of node.children) findPageRouteConflicts(child, pages, routeSegments, false);
89
120
  return [...pages.entries()].filter(([, files]) => files.length > 1).map(([routePath, files]) => ({
@@ -112,15 +143,24 @@ function generateLazyComponent(file, projectRoot) {
112
143
  function generateLazyLayout(file, projectRoot) {
113
144
  return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
114
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
+ }
115
152
  function generateNode(node, root, context, inheritedBoundary) {
116
153
  const route = {};
117
154
  const hasBoundary = inheritedBoundary || Boolean(node.loading);
118
155
  if (node.layout) context.hasLayouts = true;
119
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));
120
158
  if (node.error) {
121
159
  context.hasErrorBoundaries = true;
122
160
  route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
123
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);
124
164
  let loadingComponent;
125
165
  if (node.loading) {
126
166
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -138,10 +178,12 @@ function generateNode(node, root, context, inheritedBoundary) {
138
178
  if (node.page) {
139
179
  const page = hasBoundary ? {
140
180
  index: "true",
141
- Component: generateLazyComponent(node.page, context.projectRoot)
181
+ Component: generateLazyComponent(node.page, context.projectRoot),
182
+ ...node.action ? { action: generateDataHandler(node.action, "action", context.projectRoot) } : {}
142
183
  } : {
143
184
  index: "true",
144
- lazy: generateImport(node.page, context.projectRoot)
185
+ lazy: generateImport(node.page, context.projectRoot),
186
+ ...node.action ? { action: generateDataHandler(node.action, "action", context.projectRoot) } : {}
145
187
  };
146
188
  children.push(genObjectFromRaw(page));
147
189
  }
@@ -218,6 +260,8 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
218
260
  var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
219
261
  var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
220
262
  var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
263
+ var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
264
+ var ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
221
265
  function resolveOptions(input) {
222
266
  const options = typeof input === "string" ? { directory: input } : input;
223
267
  if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
@@ -301,19 +345,38 @@ function routes(input = "src/pages") {
301
345
  }
302
346
 
303
347
  const params = args[0] ?? {};
304
- const parameterNames = [...pattern.matchAll(/:([^/]+)|\\*/g)].map(
305
- ([, name]) => name ?? "*",
306
- );
348
+ const segments = pattern.split("/").slice(1);
349
+ const parameterNames = segments.flatMap((segment) => {
350
+ const parameter = /^:([^/?]+)(\\?)?$/.exec(segment);
351
+ return parameter ? [parameter[1]] : segment === "*" ? ["*"] : [];
352
+ });
307
353
  for (const key of Object.keys(params)) {
308
354
  if (!parameterNames.includes(key)) {
309
355
  throw new TypeError(\`Unknown route parameter "\${key}" for "\${pattern}".\`);
310
356
  }
311
357
  }
312
- return pattern.replace(/:([^/]+)|\\*/g, (token, name) => {
313
- const key = name ?? "*";
358
+ let omittedOptionalParameter = false;
359
+ const pathSegments = segments.map((segment) => {
360
+ const parameter = /^:([^/?]+)(\\?)?$/.exec(segment);
361
+ const key = parameter?.[1] ?? (segment === "*" ? "*" : undefined);
362
+ if (key === undefined) return segment;
363
+ const optional = parameter?.[2] === "?";
314
364
  if (!Object.hasOwn(params, key)) {
365
+ if (optional) {
366
+ omittedOptionalParameter = true;
367
+ return undefined;
368
+ }
315
369
  throw new TypeError(\`Missing route parameter "\${key}" for "\${pattern}".\`);
316
370
  }
371
+ if (optional && params[key] === undefined) {
372
+ omittedOptionalParameter = true;
373
+ return undefined;
374
+ }
375
+ if (omittedOptionalParameter) {
376
+ throw new TypeError(
377
+ \`Cannot provide route parameter "\${key}" for "\${pattern}" when an earlier optional parameter is omitted.\`,
378
+ );
379
+ }
317
380
  const value = String(params[key]);
318
381
  if (key !== "*" && value.length === 0) {
319
382
  throw new TypeError(
@@ -324,10 +387,14 @@ function routes(input = "src/pages") {
324
387
  '" cannot be empty.',
325
388
  );
326
389
  }
327
- return key === "*"
390
+ const encoded = key === "*"
328
391
  ? value.split("/").map(encodeURIComponent).join("/")
329
392
  : encodeURIComponent(value);
393
+ return encoded;
330
394
  });
395
+ return \`/\${pathSegments
396
+ .filter((segment) => segment !== undefined)
397
+ .join("/")}\`;
331
398
  }
332
399
  `;
333
400
  return format(code, { parser: "typescript" });
@@ -349,6 +416,34 @@ function routes(input = "src/pages") {
349
416
  if (id === MANIFEST_RESOLVED_ID) return executeManifest();
350
417
  if (id === ROUTE_HELPERS_RESOLVED_ID) return executeRouteHelpers();
351
418
  },
419
+ devtools: { setup(ctx) {
420
+ if (ctx.mode !== "dev" || !ctx.viteServer) return;
421
+ ctx.views.hostStatic(ROUTE_INSPECTOR_PATH, fileURLToPath(new URL("../devtools/", import.meta.url)));
422
+ ctx.docks.register({
423
+ id: "vite-file-router:routes",
424
+ title: "Routes",
425
+ icon: "ph:tree-structure-duotone",
426
+ type: "iframe",
427
+ url: ROUTE_INSPECTOR_PATH
428
+ });
429
+ ctx.viteServer.middlewares.use((req, res, next) => {
430
+ if (new URL(req.url ?? "/", "http://vite-file-router").pathname !== ROUTE_INSPECTOR_DATA_PATH) {
431
+ next();
432
+ return;
433
+ }
434
+ if (req.method !== "GET") {
435
+ res.statusCode = 405;
436
+ res.setHeader("Allow", "GET");
437
+ res.end("Method Not Allowed");
438
+ return;
439
+ }
440
+ const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
441
+ res.statusCode = 200;
442
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
443
+ res.setHeader("Cache-Control", "no-store");
444
+ res.end(JSON.stringify(manifest));
445
+ });
446
+ } },
352
447
  configureServer: (server) => {
353
448
  server.watcher.add(root);
354
449
  server.watcher.on("all", (event, file) => {
package/dist/scanner.d.ts CHANGED
@@ -7,14 +7,17 @@ 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;
16
18
  fullPath: string;
17
19
  params: string[];
20
+ optionalParams: string[];
18
21
  routeGroup: boolean;
19
22
  files: Partial<Record<RouteFileKind, string>>;
20
23
  children: RouteManifestNode[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
- "types"
19
+ "types",
20
+ "devtools"
20
21
  ],
21
22
  "main": "./dist/index.js",
22
23
  "types": "./types/index.d.ts",
@@ -38,6 +39,9 @@
38
39
  "devDependencies": {
39
40
  "@biomejs/biome": "^2.6.4",
40
41
  "@types/node": "^24.19.2",
42
+ "@vitejs/devtools-kit": "^0.8.2",
43
+ "react": "^19.3.1",
44
+ "react-dom": "^19.3.1",
41
45
  "typescript": "~6.0.3",
42
46
  "vite": "^8.3.5"
43
47
  },
package/types/index.d.ts CHANGED
@@ -11,7 +11,9 @@ export type {
11
11
  export type RouteParameterValue = string | number;
12
12
 
13
13
  type RouteParametersForSegment<Segment extends string> =
14
- Segment extends `:${infer Name}`
14
+ Segment extends `:${infer Name}?`
15
+ ? { [Key in Name]?: RouteParameterValue }
16
+ : Segment extends `:${infer Name}`
15
17
  ? { [Key in Name]: RouteParameterValue }
16
18
  : Segment extends "*"
17
19
  ? { "*": RouteParameterValue }
@@ -38,6 +40,9 @@ type RouteRequiresParameters<Path extends string> =
38
40
  : true
39
41
  : never;
40
42
 
43
+ type RouteRequiresRequiredParameters<Path extends string> =
44
+ Path extends unknown ? ({} extends RouteParameters<Path> ? false : true) : never;
45
+
41
46
  type HasParameterShapeMismatch<Path extends string> =
42
47
  [RouteParameterShapes<Path>] extends [
43
48
  UnionToIntersection<RouteParameterShapes<Path>>,
@@ -49,5 +54,7 @@ export type RoutePathArguments<Path extends string> =
49
54
  true extends RouteRequiresParameters<Path>
50
55
  ? HasParameterShapeMismatch<Path> extends true
51
56
  ? [params: never]
52
- : [params: RouteParameters<Path>]
57
+ : true extends RouteRequiresRequiredParameters<Path>
58
+ ? [params: RouteParameters<Path>]
59
+ : [params?: RouteParameters<Path>]
53
60
  : [];