@troyio/vite-file-router 0.2.6 → 0.2.8
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 +67 -5
- package/devtools/app.js +143 -0
- package/devtools/index.html +33 -0
- package/devtools/style.css +231 -0
- package/dist/index.js +141 -5
- package/dist/scanner.d.ts +2 -0
- package/package.json +4 -2
- package/types/index.d.ts +51 -0
- package/types/virtual-routes.d.ts +10 -0
package/README.md
CHANGED
|
@@ -77,6 +77,9 @@ src/pages/
|
|
|
77
77
|
page.tsx
|
|
78
78
|
[id]/
|
|
79
79
|
page.tsx
|
|
80
|
+
docs/
|
|
81
|
+
[[lang]]/
|
|
82
|
+
page.tsx
|
|
80
83
|
(auth)/
|
|
81
84
|
layout.tsx
|
|
82
85
|
sign-in/
|
|
@@ -87,8 +90,10 @@ src/pages/
|
|
|
87
90
|
page.tsx
|
|
88
91
|
```
|
|
89
92
|
|
|
90
|
-
This maps to `/`, `/users`, `/users/:id`, `/
|
|
91
|
-
Square-bracket segments are dynamic parameters: `[id]`
|
|
93
|
+
This maps to `/`, `/users`, `/users/:id`, `/docs/:lang?`, `/sign-in`,
|
|
94
|
+
`/sign-up`, and `/*`. Square-bracket segments are dynamic parameters: `[id]`
|
|
95
|
+
becomes `:id`; double-bracket segments are optional dynamic parameters, so
|
|
96
|
+
`[[lang]]` becomes `:lang?` and matches both `/docs` and `/docs/en`.
|
|
92
97
|
Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
|
|
93
98
|
are route groups; they organize files and can have their own page, layout, and
|
|
94
99
|
loading files, but their names do not add a URL segment.
|
|
@@ -184,7 +189,8 @@ export default function App() {
|
|
|
184
189
|
Import `routeManifest` from `virtual:route-manifest` for a stable, nested
|
|
185
190
|
description of the scanned routes. Source files are relative to the Vite
|
|
186
191
|
project root and use forward slashes. Each node includes its URL `fullPath`,
|
|
187
|
-
the parameters introduced by that segment, and its
|
|
192
|
+
the parameters introduced by that segment, any `optionalParams`, and its
|
|
193
|
+
recognized files:
|
|
188
194
|
|
|
189
195
|
```ts
|
|
190
196
|
import { routeManifest } from "virtual:route-manifest";
|
|
@@ -197,8 +203,64 @@ remain in the tree. React Router's generated `routes` remain the runtime source
|
|
|
197
203
|
of truth.
|
|
198
204
|
|
|
199
205
|
The development server watches the configured directory. Adding, removing,
|
|
200
|
-
renaming, or editing route files invalidates
|
|
201
|
-
the app without requiring a server restart.
|
|
206
|
+
renaming, or editing route files invalidates all generated virtual modules and
|
|
207
|
+
reloads the app without requiring a server restart.
|
|
208
|
+
|
|
209
|
+
### Route tree inspection
|
|
210
|
+
|
|
211
|
+
The route tree can be inspected in Vite DevTools. This requires Vite 8.3 or
|
|
212
|
+
later and the DevTools package:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
npm install -D @vitejs/devtools
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Enable the dock in your Vite configuration:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
export default defineConfig({
|
|
222
|
+
devtools: true,
|
|
223
|
+
plugins: [routes()],
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Open the **Routes** dock to browse the nested route tree, filter by path or
|
|
228
|
+
source file, and inspect parameters and special files. The panel refreshes its
|
|
229
|
+
manifest automatically while the dev server is running. Vite DevTools must be
|
|
230
|
+
enabled; route-tree inspection is development-only.
|
|
231
|
+
|
|
232
|
+
### Typed route helpers
|
|
233
|
+
|
|
234
|
+
Import `routePath` from `virtual:route-helpers` to build paths for discovered
|
|
235
|
+
pages. The helper is compatible with React Router's `Link`, `NavLink`,
|
|
236
|
+
`navigate`, and `redirect` APIs. Dynamic parameter names and required values are
|
|
237
|
+
checked from the path literal; values are URL-encoded. Splat values use the
|
|
238
|
+
React Router `"*"` parameter and are encoded segment-by-segment so `/`
|
|
239
|
+
separators are preserved:
|
|
240
|
+
|
|
241
|
+
```tsx
|
|
242
|
+
import { Link } from "react-router";
|
|
243
|
+
import { routePath } from "virtual:route-helpers";
|
|
244
|
+
|
|
245
|
+
export function UserLink({ userId }: { userId: string }) {
|
|
246
|
+
return (
|
|
247
|
+
<Link to={routePath("/users/:userId", { userId })}>
|
|
248
|
+
View user
|
|
249
|
+
</Link>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const docsPath = routePath("/docs/*", { "*": "guides/getting-started" });
|
|
254
|
+
const localizedDocsPath = routePath("/docs/:lang?", { lang: "en" });
|
|
255
|
+
const defaultDocsPath = routePath("/docs/:lang?");
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Static routes and routes with only optional parameters need no parameter object.
|
|
259
|
+
An optional parameter can also be omitted from the params object; its URL
|
|
260
|
+
segment is removed. Unknown route patterns and missing required or extra
|
|
261
|
+
parameters are rejected at runtime. Routes whose optional segment overlaps
|
|
262
|
+
another page's URL pattern are reported as conflicts. The generated
|
|
263
|
+
`routePatterns` export contains the discovered page paths.
|
|
202
264
|
|
|
203
265
|
### TypeScript setup
|
|
204
266
|
|
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";
|
|
@@ -12,8 +13,32 @@ var DEFAULT_CONVENTIONS = {
|
|
|
12
13
|
};
|
|
13
14
|
function getRouteSegment(name) {
|
|
14
15
|
if (name.startsWith("[...") && name.endsWith("]")) return "*";
|
|
16
|
+
const optionalParameter = /^\[\[(.+)\]\]$/.exec(name);
|
|
17
|
+
if (optionalParameter) return `:${optionalParameter[1]}?`;
|
|
15
18
|
return name.replace(/^\[(.+)\]$/, ":$1");
|
|
16
19
|
}
|
|
20
|
+
function getRouteParameter(name) {
|
|
21
|
+
if (name.startsWith("[...") && name.endsWith("]")) return {
|
|
22
|
+
name: "*",
|
|
23
|
+
optional: false
|
|
24
|
+
};
|
|
25
|
+
const optionalParameter = /^\[\[(.+)\]\]$/.exec(name);
|
|
26
|
+
if (optionalParameter) return {
|
|
27
|
+
name: optionalParameter[1],
|
|
28
|
+
optional: true
|
|
29
|
+
};
|
|
30
|
+
const parameter = /^\[(.+)\]$/.exec(name);
|
|
31
|
+
return parameter ? {
|
|
32
|
+
name: parameter[1],
|
|
33
|
+
optional: false
|
|
34
|
+
} : void 0;
|
|
35
|
+
}
|
|
36
|
+
function collectPageRoutePatterns(node, segments = [], root = true) {
|
|
37
|
+
const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
|
|
38
|
+
const patterns = node.page ? [`/${routeSegments.join("/")}`] : [];
|
|
39
|
+
for (const child of node.children) patterns.push(...collectPageRoutePatterns(child, routeSegments, false));
|
|
40
|
+
return patterns;
|
|
41
|
+
}
|
|
17
42
|
function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
18
43
|
const node = {
|
|
19
44
|
name: path.basename(directory),
|
|
@@ -41,7 +66,9 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
|
41
66
|
function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
|
|
42
67
|
const segment = root || node.routeGroup ? null : getRouteSegment(node.name);
|
|
43
68
|
const fullPath = segment === null ? parentPath || "/" : `${parentPath}/${segment}` || "/";
|
|
44
|
-
const
|
|
69
|
+
const routeParameter = !root && !node.routeGroup ? getRouteParameter(node.name) : void 0;
|
|
70
|
+
const params = routeParameter ? [routeParameter.name] : [];
|
|
71
|
+
const optionalParams = routeParameter?.optional ? [routeParameter.name] : [];
|
|
45
72
|
const files = {};
|
|
46
73
|
for (const kind of Object.keys(DEFAULT_CONVENTIONS)) {
|
|
47
74
|
const file = node[kind];
|
|
@@ -51,6 +78,7 @@ function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
|
|
|
51
78
|
segment,
|
|
52
79
|
fullPath,
|
|
53
80
|
params,
|
|
81
|
+
optionalParams,
|
|
54
82
|
routeGroup: node.routeGroup,
|
|
55
83
|
files,
|
|
56
84
|
children: node.children.map((child) => createRouteManifest(child, projectRoot, fullPath === "/" ? "" : fullPath, false))
|
|
@@ -61,7 +89,7 @@ var RouteConflictError = class extends Error {
|
|
|
61
89
|
conflicts;
|
|
62
90
|
constructor(conflicts) {
|
|
63
91
|
const details = conflicts.map(({ routePath, files }) => ` ${routePath}\n${files.map((file) => ` - ${file}`).join("\n")}`).join("\n");
|
|
64
|
-
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.`);
|
|
92
|
+
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.`);
|
|
65
93
|
this.conflicts = conflicts;
|
|
66
94
|
this.name = "RouteConflictError";
|
|
67
95
|
}
|
|
@@ -76,8 +104,12 @@ function getRoutePath(directory) {
|
|
|
76
104
|
function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
|
|
77
105
|
const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
|
|
78
106
|
if (node.page) {
|
|
79
|
-
const
|
|
80
|
-
|
|
107
|
+
const variants = routeSegments.reduce((paths, segment) => segment.startsWith(":") && segment.endsWith("?") ? paths.flatMap((segments) => [[...segments, segment.slice(0, -1)], segments]) : paths.map((segments) => [...segments, segment]), [[]]);
|
|
108
|
+
for (const variant of variants) {
|
|
109
|
+
const routePath = `/${variant.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
|
|
110
|
+
const files = pages.get(routePath) ?? [];
|
|
111
|
+
if (!files.includes(node.page)) pages.set(routePath, [...files, node.page]);
|
|
112
|
+
}
|
|
81
113
|
}
|
|
82
114
|
for (const child of node.children) findPageRouteConflicts(child, pages, routeSegments, false);
|
|
83
115
|
return [...pages.entries()].filter(([, files]) => files.length > 1).map(([routePath, files]) => ({
|
|
@@ -210,6 +242,10 @@ var ROUTES_MODULE_ID = "virtual:routes";
|
|
|
210
242
|
var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
|
|
211
243
|
var MANIFEST_MODULE_ID = "virtual:route-manifest";
|
|
212
244
|
var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
|
|
245
|
+
var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
|
|
246
|
+
var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
|
|
247
|
+
var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
|
|
248
|
+
var ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
|
|
213
249
|
function resolveOptions(input) {
|
|
214
250
|
const options = typeof input === "string" ? { directory: input } : input;
|
|
215
251
|
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
|
|
@@ -281,6 +317,72 @@ function routes(input = "src/pages") {
|
|
|
281
317
|
const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
|
|
282
318
|
return format(`export const routeManifest = ${JSON.stringify(manifest, null, 2)};`, { parser: "typescript" });
|
|
283
319
|
}
|
|
320
|
+
async function executeRouteHelpers() {
|
|
321
|
+
const tree = scan(root, options.conventions);
|
|
322
|
+
const patterns = [...new Set(collectPageRoutePatterns(tree))].sort();
|
|
323
|
+
const code = `
|
|
324
|
+
export const routePatterns = ${JSON.stringify(patterns)};
|
|
325
|
+
|
|
326
|
+
export function routePath(pattern, ...args) {
|
|
327
|
+
if (!routePatterns.includes(pattern)) {
|
|
328
|
+
throw new TypeError(\`Unknown route pattern "\${pattern}".\`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const params = args[0] ?? {};
|
|
332
|
+
const segments = pattern.split("/").slice(1);
|
|
333
|
+
const parameterNames = segments.flatMap((segment) => {
|
|
334
|
+
const parameter = /^:([^/?]+)(\\?)?$/.exec(segment);
|
|
335
|
+
return parameter ? [parameter[1]] : segment === "*" ? ["*"] : [];
|
|
336
|
+
});
|
|
337
|
+
for (const key of Object.keys(params)) {
|
|
338
|
+
if (!parameterNames.includes(key)) {
|
|
339
|
+
throw new TypeError(\`Unknown route parameter "\${key}" for "\${pattern}".\`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let omittedOptionalParameter = false;
|
|
343
|
+
const pathSegments = segments.map((segment) => {
|
|
344
|
+
const parameter = /^:([^/?]+)(\\?)?$/.exec(segment);
|
|
345
|
+
const key = parameter?.[1] ?? (segment === "*" ? "*" : undefined);
|
|
346
|
+
if (key === undefined) return segment;
|
|
347
|
+
const optional = parameter?.[2] === "?";
|
|
348
|
+
if (!Object.hasOwn(params, key)) {
|
|
349
|
+
if (optional) {
|
|
350
|
+
omittedOptionalParameter = true;
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
throw new TypeError(\`Missing route parameter "\${key}" for "\${pattern}".\`);
|
|
354
|
+
}
|
|
355
|
+
if (optional && params[key] === undefined) {
|
|
356
|
+
omittedOptionalParameter = true;
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
if (omittedOptionalParameter) {
|
|
360
|
+
throw new TypeError(
|
|
361
|
+
\`Cannot provide route parameter "\${key}" for "\${pattern}" when an earlier optional parameter is omitted.\`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const value = String(params[key]);
|
|
365
|
+
if (key !== "*" && value.length === 0) {
|
|
366
|
+
throw new TypeError(
|
|
367
|
+
'Route parameter "' +
|
|
368
|
+
key +
|
|
369
|
+
'" for "' +
|
|
370
|
+
pattern +
|
|
371
|
+
'" cannot be empty.',
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const encoded = key === "*"
|
|
375
|
+
? value.split("/").map(encodeURIComponent).join("/")
|
|
376
|
+
: encodeURIComponent(value);
|
|
377
|
+
return encoded;
|
|
378
|
+
});
|
|
379
|
+
return \`/\${pathSegments
|
|
380
|
+
.filter((segment) => segment !== undefined)
|
|
381
|
+
.join("/")}\`;
|
|
382
|
+
}
|
|
383
|
+
`;
|
|
384
|
+
return format(code, { parser: "typescript" });
|
|
385
|
+
}
|
|
284
386
|
return {
|
|
285
387
|
name: "routes",
|
|
286
388
|
configResolved(config) {
|
|
@@ -291,11 +393,41 @@ function routes(input = "src/pages") {
|
|
|
291
393
|
resolveId: (id) => {
|
|
292
394
|
if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
|
|
293
395
|
if (id === MANIFEST_MODULE_ID) return MANIFEST_RESOLVED_ID;
|
|
396
|
+
if (id === ROUTE_HELPERS_MODULE_ID) return ROUTE_HELPERS_RESOLVED_ID;
|
|
294
397
|
},
|
|
295
398
|
load: (id) => {
|
|
296
399
|
if (id === ROUTES_RESOLVED_ID) return execute();
|
|
297
400
|
if (id === MANIFEST_RESOLVED_ID) return executeManifest();
|
|
401
|
+
if (id === ROUTE_HELPERS_RESOLVED_ID) return executeRouteHelpers();
|
|
298
402
|
},
|
|
403
|
+
devtools: { setup(ctx) {
|
|
404
|
+
if (ctx.mode !== "dev" || !ctx.viteServer) return;
|
|
405
|
+
ctx.views.hostStatic(ROUTE_INSPECTOR_PATH, fileURLToPath(new URL("../devtools/", import.meta.url)));
|
|
406
|
+
ctx.docks.register({
|
|
407
|
+
id: "vite-file-router:routes",
|
|
408
|
+
title: "Routes",
|
|
409
|
+
icon: "ph:tree-structure-duotone",
|
|
410
|
+
type: "iframe",
|
|
411
|
+
url: ROUTE_INSPECTOR_PATH
|
|
412
|
+
});
|
|
413
|
+
ctx.viteServer.middlewares.use((req, res, next) => {
|
|
414
|
+
if (new URL(req.url ?? "/", "http://vite-file-router").pathname !== ROUTE_INSPECTOR_DATA_PATH) {
|
|
415
|
+
next();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (req.method !== "GET") {
|
|
419
|
+
res.statusCode = 405;
|
|
420
|
+
res.setHeader("Allow", "GET");
|
|
421
|
+
res.end("Method Not Allowed");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
|
|
425
|
+
res.statusCode = 200;
|
|
426
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
427
|
+
res.setHeader("Cache-Control", "no-store");
|
|
428
|
+
res.end(JSON.stringify(manifest));
|
|
429
|
+
});
|
|
430
|
+
} },
|
|
299
431
|
configureServer: (server) => {
|
|
300
432
|
server.watcher.add(root);
|
|
301
433
|
server.watcher.on("all", (event, file) => {
|
|
@@ -309,7 +441,11 @@ function routes(input = "src/pages") {
|
|
|
309
441
|
const location = path.resolve(file);
|
|
310
442
|
const relative = path.relative(root, location);
|
|
311
443
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
|
|
312
|
-
for (const id of [
|
|
444
|
+
for (const id of [
|
|
445
|
+
ROUTES_RESOLVED_ID,
|
|
446
|
+
MANIFEST_RESOLVED_ID,
|
|
447
|
+
ROUTE_HELPERS_RESOLVED_ID
|
|
448
|
+
]) {
|
|
313
449
|
const module = server.moduleGraph.getModuleById(id);
|
|
314
450
|
if (module) server.moduleGraph.invalidateModule(module);
|
|
315
451
|
}
|
package/dist/scanner.d.ts
CHANGED
|
@@ -15,11 +15,13 @@ export type RouteManifestNode = {
|
|
|
15
15
|
segment: string | null;
|
|
16
16
|
fullPath: string;
|
|
17
17
|
params: string[];
|
|
18
|
+
optionalParams: string[];
|
|
18
19
|
routeGroup: boolean;
|
|
19
20
|
files: Partial<Record<RouteFileKind, string>>;
|
|
20
21
|
children: RouteManifestNode[];
|
|
21
22
|
};
|
|
22
23
|
export declare const DEFAULT_CONVENTIONS: Record<RouteFileKind, string>;
|
|
23
24
|
export declare function getRouteSegment(name: string): string;
|
|
25
|
+
export declare function collectPageRoutePatterns(node: Node, segments?: string[], root?: boolean): string[];
|
|
24
26
|
export declare function scan(directory: string, conventions?: Record<RouteFileKind, string>): Node;
|
|
25
27
|
export declare function createRouteManifest(node: Node, projectRoot: string, parentPath?: string, root?: boolean): RouteManifestNode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@troyio/vite-file-router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@biomejs/biome": "^2.6.4",
|
|
41
|
+
"@vitejs/devtools-kit": "^0.8.2",
|
|
40
42
|
"@types/node": "^24.19.2",
|
|
41
43
|
"typescript": "~6.0.3",
|
|
42
44
|
"vite": "^8.3.5"
|
package/types/index.d.ts
CHANGED
|
@@ -7,3 +7,54 @@ export type {
|
|
|
7
7
|
RouteManifestNode,
|
|
8
8
|
RoutesOptions,
|
|
9
9
|
} from "../dist/index.js";
|
|
10
|
+
|
|
11
|
+
export type RouteParameterValue = string | number;
|
|
12
|
+
|
|
13
|
+
type RouteParametersForSegment<Segment extends string> =
|
|
14
|
+
Segment extends `:${infer Name}?`
|
|
15
|
+
? { [Key in Name]?: RouteParameterValue }
|
|
16
|
+
: Segment extends `:${infer Name}`
|
|
17
|
+
? { [Key in Name]: RouteParameterValue }
|
|
18
|
+
: Segment extends "*"
|
|
19
|
+
? { "*": RouteParameterValue }
|
|
20
|
+
: {};
|
|
21
|
+
|
|
22
|
+
export type RouteParameters<Path extends string> =
|
|
23
|
+
Path extends `${infer Segment}/${infer Rest}`
|
|
24
|
+
? RouteParametersForSegment<Segment> & RouteParameters<Rest>
|
|
25
|
+
: RouteParametersForSegment<Path>;
|
|
26
|
+
|
|
27
|
+
type RouteParameterShapes<Path extends string> =
|
|
28
|
+
Path extends unknown ? RouteParameters<Path> : never;
|
|
29
|
+
|
|
30
|
+
type UnionToIntersection<Union> = (
|
|
31
|
+
Union extends unknown ? (value: Union) => void : never
|
|
32
|
+
) extends (value: infer Intersection) => void
|
|
33
|
+
? Intersection
|
|
34
|
+
: never;
|
|
35
|
+
|
|
36
|
+
type RouteRequiresParameters<Path extends string> =
|
|
37
|
+
Path extends unknown
|
|
38
|
+
? keyof RouteParameters<Path> extends never
|
|
39
|
+
? false
|
|
40
|
+
: true
|
|
41
|
+
: never;
|
|
42
|
+
|
|
43
|
+
type RouteRequiresRequiredParameters<Path extends string> =
|
|
44
|
+
Path extends unknown ? ({} extends RouteParameters<Path> ? false : true) : never;
|
|
45
|
+
|
|
46
|
+
type HasParameterShapeMismatch<Path extends string> =
|
|
47
|
+
[RouteParameterShapes<Path>] extends [
|
|
48
|
+
UnionToIntersection<RouteParameterShapes<Path>>,
|
|
49
|
+
]
|
|
50
|
+
? false
|
|
51
|
+
: true;
|
|
52
|
+
|
|
53
|
+
export type RoutePathArguments<Path extends string> =
|
|
54
|
+
true extends RouteRequiresParameters<Path>
|
|
55
|
+
? HasParameterShapeMismatch<Path> extends true
|
|
56
|
+
? [params: never]
|
|
57
|
+
: true extends RouteRequiresRequiredParameters<Path>
|
|
58
|
+
? [params: RouteParameters<Path>]
|
|
59
|
+
: [params?: RouteParameters<Path>]
|
|
60
|
+
: [];
|
|
@@ -7,3 +7,13 @@ declare module "virtual:route-manifest" {
|
|
|
7
7
|
import type { RouteManifestNode } from "@troyio/vite-file-router";
|
|
8
8
|
export const routeManifest: RouteManifestNode;
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
declare module "virtual:route-helpers" {
|
|
12
|
+
import type { RoutePathArguments } from "@troyio/vite-file-router";
|
|
13
|
+
|
|
14
|
+
export const routePatterns: readonly string[];
|
|
15
|
+
export function routePath<const Path extends string>(
|
|
16
|
+
pattern: Path,
|
|
17
|
+
...args: RoutePathArguments<Path>
|
|
18
|
+
): string;
|
|
19
|
+
}
|