@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 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";
@@ -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 both virtual modules and reloads
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
 
@@ -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 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] : [];
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 routePath = `/${routeSegments.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
80
- 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
+ }
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 [ROUTES_RESOLVED_ID, MANIFEST_RESOLVED_ID]) {
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.6",
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
+ }