@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 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`, `/sign-in`, `/sign-up`, and `/*`.
91
- Square-bracket segments are dynamic parameters: `[id]` becomes `: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 recognized files:
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: `routePath("/")`. Unknown route
228
- patterns and missing or extra parameters are rejected at runtime. The generated
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
@@ -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 params = !root && !node.routeGroup && node.name.startsWith("[") ? [node.name.startsWith("[...") ? "*" : node.name.slice(1, -1)] : [];
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 routePath = `/${routeSegments.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
86
- pages.set(routePath, [...pages.get(routePath) ?? [], node.page]);
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 parameterNames = [...pattern.matchAll(/:([^/]+)|\\*/g)].map(
305
- ([, name]) => name ?? "*",
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
- return pattern.replace(/:([^/]+)|\\*/g, (token, name) => {
313
- const key = name ?? "*";
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
- return key === "*"
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
@@ -15,6 +15,7 @@ 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[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.2.7",
3
+ "version": "0.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
- : [params: RouteParameters<Path>]
57
+ : true extends RouteRequiresRequiredParameters<Path>
58
+ ? [params: RouteParameters<Path>]
59
+ : [params?: RouteParameters<Path>]
53
60
  : [];