@troyio/vite-file-router 0.2.7 → 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 +39 -5
- package/devtools/app.js +143 -0
- package/devtools/index.html +33 -0
- package/devtools/style.css +231 -0
- package/dist/index.js +89 -10
- package/dist/scanner.d.ts +1 -0
- package/package.json +4 -2
- package/types/index.d.ts +9 -2
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";
|
|
@@ -200,6 +206,29 @@ The development server watches the configured directory. Adding, removing,
|
|
|
200
206
|
renaming, or editing route files invalidates all generated virtual modules and
|
|
201
207
|
reloads the app without requiring a server restart.
|
|
202
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
|
+
|
|
203
232
|
### Typed route helpers
|
|
204
233
|
|
|
205
234
|
Import `routePath` from `virtual:route-helpers` to build paths for discovered
|
|
@@ -222,10 +251,15 @@ export function UserLink({ userId }: { userId: string }) {
|
|
|
222
251
|
}
|
|
223
252
|
|
|
224
253
|
const docsPath = routePath("/docs/*", { "*": "guides/getting-started" });
|
|
254
|
+
const localizedDocsPath = routePath("/docs/:lang?", { lang: "en" });
|
|
255
|
+
const defaultDocsPath = routePath("/docs/:lang?");
|
|
225
256
|
```
|
|
226
257
|
|
|
227
|
-
Static routes need no parameter object
|
|
228
|
-
|
|
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
|
|
229
263
|
`routePatterns` export contains the discovered page paths.
|
|
230
264
|
|
|
231
265
|
### 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";
|
|
@@ -12,8 +13,26 @@ 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
|
+
}
|
|
17
36
|
function collectPageRoutePatterns(node, segments = [], root = true) {
|
|
18
37
|
const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
|
|
19
38
|
const patterns = node.page ? [`/${routeSegments.join("/")}`] : [];
|
|
@@ -47,7 +66,9 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
|
47
66
|
function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
|
|
48
67
|
const segment = root || node.routeGroup ? null : getRouteSegment(node.name);
|
|
49
68
|
const fullPath = segment === null ? parentPath || "/" : `${parentPath}/${segment}` || "/";
|
|
50
|
-
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] : [];
|
|
51
72
|
const files = {};
|
|
52
73
|
for (const kind of Object.keys(DEFAULT_CONVENTIONS)) {
|
|
53
74
|
const file = node[kind];
|
|
@@ -57,6 +78,7 @@ function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
|
|
|
57
78
|
segment,
|
|
58
79
|
fullPath,
|
|
59
80
|
params,
|
|
81
|
+
optionalParams,
|
|
60
82
|
routeGroup: node.routeGroup,
|
|
61
83
|
files,
|
|
62
84
|
children: node.children.map((child) => createRouteManifest(child, projectRoot, fullPath === "/" ? "" : fullPath, false))
|
|
@@ -67,7 +89,7 @@ var RouteConflictError = class extends Error {
|
|
|
67
89
|
conflicts;
|
|
68
90
|
constructor(conflicts) {
|
|
69
91
|
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.`);
|
|
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.`);
|
|
71
93
|
this.conflicts = conflicts;
|
|
72
94
|
this.name = "RouteConflictError";
|
|
73
95
|
}
|
|
@@ -82,8 +104,12 @@ function getRoutePath(directory) {
|
|
|
82
104
|
function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
|
|
83
105
|
const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
|
|
84
106
|
if (node.page) {
|
|
85
|
-
const
|
|
86
|
-
|
|
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
|
+
}
|
|
87
113
|
}
|
|
88
114
|
for (const child of node.children) findPageRouteConflicts(child, pages, routeSegments, false);
|
|
89
115
|
return [...pages.entries()].filter(([, files]) => files.length > 1).map(([routePath, files]) => ({
|
|
@@ -218,6 +244,8 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
|
|
|
218
244
|
var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
|
|
219
245
|
var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
|
|
220
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";
|
|
221
249
|
function resolveOptions(input) {
|
|
222
250
|
const options = typeof input === "string" ? { directory: input } : input;
|
|
223
251
|
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
|
|
@@ -301,19 +329,38 @@ function routes(input = "src/pages") {
|
|
|
301
329
|
}
|
|
302
330
|
|
|
303
331
|
const params = args[0] ?? {};
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
});
|
|
307
337
|
for (const key of Object.keys(params)) {
|
|
308
338
|
if (!parameterNames.includes(key)) {
|
|
309
339
|
throw new TypeError(\`Unknown route parameter "\${key}" for "\${pattern}".\`);
|
|
310
340
|
}
|
|
311
341
|
}
|
|
312
|
-
|
|
313
|
-
|
|
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] === "?";
|
|
314
348
|
if (!Object.hasOwn(params, key)) {
|
|
349
|
+
if (optional) {
|
|
350
|
+
omittedOptionalParameter = true;
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
315
353
|
throw new TypeError(\`Missing route parameter "\${key}" for "\${pattern}".\`);
|
|
316
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
|
+
}
|
|
317
364
|
const value = String(params[key]);
|
|
318
365
|
if (key !== "*" && value.length === 0) {
|
|
319
366
|
throw new TypeError(
|
|
@@ -324,10 +371,14 @@ function routes(input = "src/pages") {
|
|
|
324
371
|
'" cannot be empty.',
|
|
325
372
|
);
|
|
326
373
|
}
|
|
327
|
-
|
|
374
|
+
const encoded = key === "*"
|
|
328
375
|
? value.split("/").map(encodeURIComponent).join("/")
|
|
329
376
|
: encodeURIComponent(value);
|
|
377
|
+
return encoded;
|
|
330
378
|
});
|
|
379
|
+
return \`/\${pathSegments
|
|
380
|
+
.filter((segment) => segment !== undefined)
|
|
381
|
+
.join("/")}\`;
|
|
331
382
|
}
|
|
332
383
|
`;
|
|
333
384
|
return format(code, { parser: "typescript" });
|
|
@@ -349,6 +400,34 @@ function routes(input = "src/pages") {
|
|
|
349
400
|
if (id === MANIFEST_RESOLVED_ID) return executeManifest();
|
|
350
401
|
if (id === ROUTE_HELPERS_RESOLVED_ID) return executeRouteHelpers();
|
|
351
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
|
+
} },
|
|
352
431
|
configureServer: (server) => {
|
|
353
432
|
server.watcher.add(root);
|
|
354
433
|
server.watcher.on("all", (event, file) => {
|
package/dist/scanner.d.ts
CHANGED
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
|
@@ -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
|
: [];
|