@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 +123 -14
- package/devtools/app.js +143 -0
- package/devtools/index.html +33 -0
- package/devtools/style.css +231 -0
- package/dist/index.js +110 -15
- package/dist/scanner.d.ts +4 -1
- package/package.json +6 -2
- package/types/index.d.ts +9 -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,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`, `/
|
|
91
|
-
Square-bracket segments are dynamic parameters: `[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,
|
|
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
|
|
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
|
|
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
|
|
228
|
-
|
|
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
|
package/devtools/app.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
86
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
:
|
|
57
|
+
: true extends RouteRequiresRequiredParameters<Path>
|
|
58
|
+
? [params: RouteParameters<Path>]
|
|
59
|
+
: [params?: RouteParameters<Path>]
|
|
53
60
|
: [];
|