@troyio/vite-file-router 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -2
- package/dist/declarations.d.ts +9 -0
- package/dist/generator.d.ts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +164 -13
- package/dist/scanner.d.ts +6 -1
- package/package.json +1 -1
- package/types/index.d.ts +143 -60
- package/types/virtual-routes.d.ts +46 -19
package/README.md
CHANGED
|
@@ -108,6 +108,12 @@ Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
|
|
|
108
108
|
are route groups; they organize files and can have their own page, layout,
|
|
109
109
|
loading files, loaders, and actions, but their names do not add a URL segment.
|
|
110
110
|
|
|
111
|
+
Directories whose names start with an underscore, such as `_components` or
|
|
112
|
+
`_utils`, are private folders. The plugin skips them and everything nested
|
|
113
|
+
inside them, so you can colocate components, hooks, and helpers next to the
|
|
114
|
+
routes that use them without creating routes. A `_components/page.tsx` file is
|
|
115
|
+
never routed.
|
|
116
|
+
|
|
111
117
|
### Layouts
|
|
112
118
|
|
|
113
119
|
The plugin adapts React Router's matched child route to a `children` prop. A
|
|
@@ -243,6 +249,10 @@ export default function DashboardPage() {
|
|
|
243
249
|
}
|
|
244
250
|
```
|
|
245
251
|
|
|
252
|
+
To get the type from the loader module instead of writing it by hand, use
|
|
253
|
+
`useRouteData` from `virtual:route-helpers`. See
|
|
254
|
+
[Typed params and loader data](#typed-params-and-loader-data).
|
|
255
|
+
|
|
246
256
|
The authentication and mutation functions above are application code. A
|
|
247
257
|
client-side route guard controls navigation and presentation; it is not a
|
|
248
258
|
security boundary. Protect data and mutations in the server/API layer as well,
|
|
@@ -354,6 +364,97 @@ parameters are rejected at runtime. Routes whose optional segment overlaps
|
|
|
354
364
|
another page's URL pattern are reported as conflicts. The generated
|
|
355
365
|
`routePatterns` export contains the discovered page paths.
|
|
356
366
|
|
|
367
|
+
### Generated route types
|
|
368
|
+
|
|
369
|
+
When the dev server starts and at the start of each build, the plugin writes
|
|
370
|
+
`src/route-types.d.ts`. It rewrites the file whenever route files are added,
|
|
371
|
+
removed, or renamed, and leaves it alone when nothing changed. The file lists
|
|
372
|
+
every discovered page pattern and loader module. With it in your TypeScript
|
|
373
|
+
project, `routePath`, `routePatterns`, and the hooks below only accept routes
|
|
374
|
+
that exist, and your editor autocompletes them:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
routePath("/users/:userId", { userId }); // OK
|
|
378
|
+
routePath("/usres/:userId", { userId }); // Type error: unknown route pattern
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Change the output path, or turn the file off, with the `dts` option. The path
|
|
382
|
+
is relative to the Vite project root and must end in `.d.ts`:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
plugins: [routes({ dts: "types/route-types.d.ts" })];
|
|
386
|
+
plugins: [routes({ dts: false })];
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Without the file, route patterns are typed as `string` and loader data as
|
|
390
|
+
`unknown`, the same as before. You can commit the file so types work before
|
|
391
|
+
the first dev run, or add it to `.gitignore`.
|
|
392
|
+
|
|
393
|
+
### Typed params and loader data
|
|
394
|
+
|
|
395
|
+
`virtual:route-helpers` also exports two hooks that wrap React Router's hooks
|
|
396
|
+
with types taken from the route tree:
|
|
397
|
+
|
|
398
|
+
- `useRouteParams(pattern)` returns `useParams()`, typed for that pattern.
|
|
399
|
+
Required params are `string`, optional params are `string | undefined`, and
|
|
400
|
+
a splat is a `string` under the `"*"` key.
|
|
401
|
+
- `useRouteData(key)` returns `useRouteLoaderData()`, typed from the loader
|
|
402
|
+
module. The key is either a page pattern, which reads the loader in that
|
|
403
|
+
page's directory, or a loader route ID such as
|
|
404
|
+
`"file-route:src/pages/users/[userId]"`, which can read any matched loader,
|
|
405
|
+
including a parent's. It returns `undefined` when that route isn't matched,
|
|
406
|
+
just like React Router's hook.
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
// src/pages/users/[userId]/loader.ts
|
|
410
|
+
import type { LoaderFunctionArgs } from "react-router";
|
|
411
|
+
|
|
412
|
+
export async function loader({ params }: LoaderFunctionArgs) {
|
|
413
|
+
return { name: await getUserName(params.userId!) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/pages/users/[userId]/page.tsx
|
|
417
|
+
import { useRouteData, useRouteParams } from "virtual:route-helpers";
|
|
418
|
+
|
|
419
|
+
export default function UserPage() {
|
|
420
|
+
const { userId } = useRouteParams("/users/:userId"); // { userId: string }
|
|
421
|
+
const data = useRouteData("/users/:userId"); // { name: string } | undefined
|
|
422
|
+
return <h1>{data?.name ?? userId}</h1>;
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The `RouteParams<"/users/:userId">`, `RouteLoaderData<key>`, `RoutePattern`,
|
|
427
|
+
and `RouteLoaderKey` types are exported from `@troyio/vite-file-router` for
|
|
428
|
+
your own helpers. Loader data is typed the same way as React Router's
|
|
429
|
+
`useRouteLoaderData<typeof loader>()`. JavaScript loader modules are typed as
|
|
430
|
+
`any`. Both hooks throw if they're passed a pattern or key the plugin didn't
|
|
431
|
+
discover.
|
|
432
|
+
|
|
433
|
+
### Route preloading
|
|
434
|
+
|
|
435
|
+
`virtual:routes` exports `preloadRoute(to, basename?)`. It matches a URL
|
|
436
|
+
against the route tree and imports the page, layout, and loader modules of
|
|
437
|
+
every matched route, so the next navigation there doesn't wait for those
|
|
438
|
+
downloads. Error and action modules are left out, since they're only needed
|
|
439
|
+
after an error or a form submission. Importing modules doesn't run loaders.
|
|
440
|
+
Each module downloads once, however often you call it. The promise resolves to
|
|
441
|
+
`false` when no route matches the URL.
|
|
442
|
+
|
|
443
|
+
Call it when a user is likely to navigate, such as on link hover or focus:
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
import { Link, type LinkProps } from "react-router";
|
|
447
|
+
import { preloadRoute } from "virtual:routes";
|
|
448
|
+
|
|
449
|
+
export function PreloadLink(props: LinkProps & { to: string }) {
|
|
450
|
+
const preload = () => void preloadRoute(props.to);
|
|
451
|
+
return <Link {...props} onMouseEnter={preload} onFocus={preload} />;
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
If the router uses a `basename`, pass it as the second argument. `to` also
|
|
456
|
+
accepts a partial location such as `{ pathname: "/users/42" }`.
|
|
457
|
+
|
|
357
458
|
### TypeScript setup
|
|
358
459
|
|
|
359
460
|
Add Vite's client types and the package's virtual-module declaration to your
|
|
@@ -366,8 +467,9 @@ application TypeScript project. For example, create `src/vite-env.d.ts`:
|
|
|
366
467
|
|
|
367
468
|
If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
|
|
368
469
|
and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
|
|
369
|
-
`virtual:routes`.
|
|
370
|
-
|
|
470
|
+
`virtual:routes`. Make sure the generated `src/route-types.d.ts`, or the path
|
|
471
|
+
you set with `dts`, is included by your `tsconfig.json`. Restart your editor's
|
|
472
|
+
TypeScript server if the declarations aren't picked up immediately.
|
|
371
473
|
|
|
372
474
|
## Build
|
|
373
475
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Node } from "./scanner";
|
|
2
|
+
export declare const PACKAGE_NAME = "@troyio/vite-file-router";
|
|
3
|
+
/**
|
|
4
|
+
* Builds a declaration file that registers the discovered page patterns and
|
|
5
|
+
* loader modules with the package's `Register` interface. The package's
|
|
6
|
+
* public types read from that interface, so `routePath()` and the typed hooks
|
|
7
|
+
* only accept routes that exist.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createRouteDeclarations(tree: Node, projectRoot: string, declarationFile: string): string;
|
package/dist/generator.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ export type GeneratedRoutes = {
|
|
|
3
3
|
imports: string[];
|
|
4
4
|
helpers: string[];
|
|
5
5
|
routes: string;
|
|
6
|
+
/**
|
|
7
|
+
* A tree that mirrors `routes`: each node lists the modules a matched route
|
|
8
|
+
* needs to render, so `preloadRoute()` can import them ahead of navigation.
|
|
9
|
+
*/
|
|
10
|
+
preloads: string;
|
|
6
11
|
};
|
|
7
12
|
export type RouteConflict = {
|
|
8
13
|
routePath: string;
|
|
@@ -13,4 +18,5 @@ export declare class RouteConflictError extends Error {
|
|
|
13
18
|
constructor(conflicts: RouteConflict[]);
|
|
14
19
|
}
|
|
15
20
|
export declare function findPageRouteConflicts(node: Node, pages?: Map<string, string[]>, segments?: string[], root?: boolean): RouteConflict[];
|
|
21
|
+
export declare function getRouteId(directory: string, projectRoot: string): string;
|
|
16
22
|
export declare function generate(node: Node, root?: boolean, projectRoot?: string): GeneratedRoutes;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { type RouteConventions } from "./scanner";
|
|
|
3
3
|
export type RoutesOptions = {
|
|
4
4
|
directory?: string;
|
|
5
5
|
conventions?: RouteConventions;
|
|
6
|
+
/**
|
|
7
|
+
* Where to write the generated route type declarations, relative to the Vite
|
|
8
|
+
* project root. Set to `false` to skip writing them.
|
|
9
|
+
* @default "src/route-types.d.ts"
|
|
10
|
+
*/
|
|
11
|
+
dts?: string | false;
|
|
6
12
|
};
|
|
7
13
|
export type { RouteConventions, RouteFileKind, RouteManifestNode, } from "./scanner";
|
|
8
14
|
export declare function routes(input?: string | RoutesOptions): Plugin;
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork";
|
|
4
5
|
import { format } from "prettier";
|
|
5
|
-
import fs from "node:fs";
|
|
6
6
|
// src/scanner.ts
|
|
7
7
|
var DEFAULT_CONVENTIONS = {
|
|
8
8
|
page: "page",
|
|
@@ -35,11 +35,17 @@ function getRouteParameter(name) {
|
|
|
35
35
|
optional: false
|
|
36
36
|
} : void 0;
|
|
37
37
|
}
|
|
38
|
-
function
|
|
38
|
+
function collectPageRoutes(node, segments = [], root = true) {
|
|
39
39
|
const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const pages = node.page ? [{
|
|
41
|
+
pattern: `/${routeSegments.join("/")}`,
|
|
42
|
+
node
|
|
43
|
+
}] : [];
|
|
44
|
+
for (const child of node.children) pages.push(...collectPageRoutes(child, routeSegments, false));
|
|
45
|
+
return pages;
|
|
46
|
+
}
|
|
47
|
+
function collectPageRoutePatterns(node) {
|
|
48
|
+
return collectPageRoutes(node).map(({ pattern }) => pattern);
|
|
43
49
|
}
|
|
44
50
|
function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
45
51
|
const node = {
|
|
@@ -53,6 +59,7 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
|
53
59
|
for (const entry of entries) {
|
|
54
60
|
const file = path.join(directory, entry.name);
|
|
55
61
|
if (entry.isDirectory()) {
|
|
62
|
+
if (entry.name.startsWith("_")) continue;
|
|
56
63
|
node.children.push(scan(file, conventions));
|
|
57
64
|
continue;
|
|
58
65
|
}
|
|
@@ -156,6 +163,13 @@ function generateLazyLayout(file, projectRoot) {
|
|
|
156
163
|
function generateDataHandler(file, projectRoot) {
|
|
157
164
|
return `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
|
|
158
165
|
}
|
|
166
|
+
function generatePreload(files, projectRoot, children = []) {
|
|
167
|
+
const imports = files.filter((file) => Boolean(file)).map((file) => `import(${JSON.stringify(getImportPath(file, projectRoot))})`);
|
|
168
|
+
const preload = {};
|
|
169
|
+
if (imports.length) preload.load = `() => Promise.all([${imports.join(", ")}])`;
|
|
170
|
+
if (children.length) preload.children = genArrayFromRaw(children);
|
|
171
|
+
return genObjectFromRaw(preload);
|
|
172
|
+
}
|
|
159
173
|
function getRouteId(directory, projectRoot) {
|
|
160
174
|
return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
|
|
161
175
|
}
|
|
@@ -184,6 +198,7 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
184
198
|
}
|
|
185
199
|
if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader));
|
|
186
200
|
const children = [];
|
|
201
|
+
const preloadChildren = [];
|
|
187
202
|
if (node.page) {
|
|
188
203
|
const page = hasBoundary ? {
|
|
189
204
|
index: "true",
|
|
@@ -195,8 +210,13 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
195
210
|
...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
|
|
196
211
|
};
|
|
197
212
|
children.push(genObjectFromRaw(page));
|
|
213
|
+
preloadChildren.push(generatePreload([node.page], context.projectRoot));
|
|
214
|
+
}
|
|
215
|
+
for (const child of node.children) {
|
|
216
|
+
const generated = generateNode(child, false, context, hasBoundary);
|
|
217
|
+
children.push(generated.route);
|
|
218
|
+
preloadChildren.push(generated.preload);
|
|
198
219
|
}
|
|
199
|
-
for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
|
|
200
220
|
if (node.notFound) {
|
|
201
221
|
const notFoundRoute = hasBoundary ? {
|
|
202
222
|
path: JSON.stringify("*"),
|
|
@@ -206,9 +226,13 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
206
226
|
lazy: generateImport(node.notFound, context.projectRoot)
|
|
207
227
|
};
|
|
208
228
|
children.push(genObjectFromRaw(notFoundRoute));
|
|
229
|
+
preloadChildren.push(generatePreload([node.notFound], context.projectRoot));
|
|
209
230
|
}
|
|
210
231
|
if (children.length) route.children = genArrayFromRaw(children);
|
|
211
|
-
return
|
|
232
|
+
return {
|
|
233
|
+
route: genObjectFromRaw(route),
|
|
234
|
+
preload: generatePreload([node.layout, node.loader], context.projectRoot, preloadChildren)
|
|
235
|
+
};
|
|
212
236
|
}
|
|
213
237
|
function generate(node, root = false, projectRoot = process.cwd()) {
|
|
214
238
|
validateRouteGroupNotFound(node, [], root);
|
|
@@ -222,7 +246,7 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
222
246
|
hasLayouts: false,
|
|
223
247
|
hasErrorBoundaries: false
|
|
224
248
|
};
|
|
225
|
-
const routes = generateNode(node, root, context, false);
|
|
249
|
+
const { route: routes, preload: preloads } = generateNode(node, root, context, false);
|
|
226
250
|
const imports = [...context.loadingImports];
|
|
227
251
|
const helpers = [];
|
|
228
252
|
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
|
|
@@ -231,8 +255,10 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
231
255
|
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
|
|
232
256
|
if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
|
|
233
257
|
imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
|
|
234
|
-
if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
|
|
235
258
|
}
|
|
259
|
+
const routerImports = ["matchRoutes"];
|
|
260
|
+
if (context.hasBoundaries || context.hasLayouts) routerImports.push("Outlet");
|
|
261
|
+
imports.push(`import { ${routerImports.join(", ")} } from "react-router";`);
|
|
236
262
|
if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
|
|
237
263
|
return function RouteBoundary() {
|
|
238
264
|
return createElement(
|
|
@@ -259,9 +285,63 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
259
285
|
return {
|
|
260
286
|
imports,
|
|
261
287
|
helpers,
|
|
262
|
-
routes
|
|
288
|
+
routes,
|
|
289
|
+
preloads
|
|
263
290
|
};
|
|
264
291
|
}
|
|
292
|
+
// src/declarations.ts
|
|
293
|
+
var PACKAGE_NAME = "@troyio/vite-file-router";
|
|
294
|
+
function collectLoaders(node, loaders = []) {
|
|
295
|
+
if (node.loader) loaders.push(node);
|
|
296
|
+
for (const child of node.children) collectLoaders(child, loaders);
|
|
297
|
+
return loaders;
|
|
298
|
+
}
|
|
299
|
+
function getTypeImportPath(file, declarationFile) {
|
|
300
|
+
const relativePath = path.relative(path.dirname(declarationFile), file).split(path.sep).join("/").replace(/\.ts$/, "");
|
|
301
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Builds a declaration file that registers the discovered page patterns and
|
|
305
|
+
* loader modules with the package's `Register` interface. The package's
|
|
306
|
+
* public types read from that interface, so `routePath()` and the typed hooks
|
|
307
|
+
* only accept routes that exist.
|
|
308
|
+
*/
|
|
309
|
+
function createRouteDeclarations(tree, projectRoot, declarationFile) {
|
|
310
|
+
const routes = /* @__PURE__ */ new Map();
|
|
311
|
+
for (const { pattern, node } of collectPageRoutes(tree)) {
|
|
312
|
+
if (routes.has(pattern)) continue;
|
|
313
|
+
routes.set(pattern, node.loader ? getRouteId(node.directory, projectRoot) : null);
|
|
314
|
+
}
|
|
315
|
+
const routeLines = [...routes.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([pattern, loader]) => ` ${JSON.stringify(pattern)}: { loader: ${loader === null ? "null" : JSON.stringify(loader)} };`);
|
|
316
|
+
const loaderLines = collectLoaders(tree).map((node) => {
|
|
317
|
+
const id = getRouteId(node.directory, projectRoot);
|
|
318
|
+
const file = node.loader;
|
|
319
|
+
const moduleType = file.endsWith(".ts") ? `typeof import(${JSON.stringify(getTypeImportPath(file, declarationFile))})` : "{ loader: (...args: any[]) => any }";
|
|
320
|
+
return [id, ` ${JSON.stringify(id)}: ${moduleType};`];
|
|
321
|
+
}).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, line]) => line);
|
|
322
|
+
return [
|
|
323
|
+
`// Generated by ${PACKAGE_NAME}. Do not edit this file.`,
|
|
324
|
+
"// It is rewritten whenever route files are added, removed, or renamed.",
|
|
325
|
+
"",
|
|
326
|
+
"export {};",
|
|
327
|
+
"",
|
|
328
|
+
`declare module ${JSON.stringify(PACKAGE_NAME)} {`,
|
|
329
|
+
" interface Register {",
|
|
330
|
+
routeLines.length ? [
|
|
331
|
+
" routes: {",
|
|
332
|
+
...routeLines,
|
|
333
|
+
" };"
|
|
334
|
+
].join("\n") : " routes: {};",
|
|
335
|
+
loaderLines.length ? [
|
|
336
|
+
" loaders: {",
|
|
337
|
+
...loaderLines,
|
|
338
|
+
" };"
|
|
339
|
+
].join("\n") : " loaders: {};",
|
|
340
|
+
" }",
|
|
341
|
+
"}",
|
|
342
|
+
""
|
|
343
|
+
].join("\n");
|
|
344
|
+
}
|
|
265
345
|
// src/index.ts
|
|
266
346
|
var ROUTES_MODULE_ID = "virtual:routes";
|
|
267
347
|
var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
|
|
@@ -271,10 +351,11 @@ var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
|
|
|
271
351
|
var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
|
|
272
352
|
var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
|
|
273
353
|
var ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
|
|
354
|
+
var DEFAULT_DTS = "src/route-types.d.ts";
|
|
274
355
|
function resolveOptions(input) {
|
|
275
356
|
const options = typeof input === "string" ? { directory: input } : input;
|
|
276
357
|
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
|
|
277
|
-
for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions") throw new TypeError(`Unknown routes() option "${key}".`);
|
|
358
|
+
for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts") throw new TypeError(`Unknown routes() option "${key}".`);
|
|
278
359
|
const directory = options.directory ?? "src/pages";
|
|
279
360
|
if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
|
|
280
361
|
const conventions = { ...DEFAULT_CONVENTIONS };
|
|
@@ -286,9 +367,12 @@ function resolveOptions(input) {
|
|
|
286
367
|
}
|
|
287
368
|
const names = Object.values(conventions);
|
|
288
369
|
if (new Set(names).size !== names.length) throw new TypeError("Route file convention names must be unique.");
|
|
370
|
+
const dts = options.dts ?? DEFAULT_DTS;
|
|
371
|
+
if (dts !== false && (typeof dts !== "string" || dts.trim() === "" || !dts.endsWith(".d.ts"))) throw new TypeError("routes() dts must be false or a path ending in \".d.ts\".");
|
|
289
372
|
return {
|
|
290
373
|
directory,
|
|
291
|
-
conventions
|
|
374
|
+
conventions,
|
|
375
|
+
dts
|
|
292
376
|
};
|
|
293
377
|
}
|
|
294
378
|
function routes(input = "src/pages") {
|
|
@@ -296,6 +380,14 @@ function routes(input = "src/pages") {
|
|
|
296
380
|
let projectRoot = "";
|
|
297
381
|
let root = "";
|
|
298
382
|
let dev = false;
|
|
383
|
+
let declarationFile;
|
|
384
|
+
function writeDeclarations() {
|
|
385
|
+
if (!declarationFile) return;
|
|
386
|
+
const content = createRouteDeclarations(scan(root, options.conventions), projectRoot, declarationFile);
|
|
387
|
+
if (fs.existsSync(declarationFile) && fs.readFileSync(declarationFile, "utf8") === content) return;
|
|
388
|
+
fs.mkdirSync(path.dirname(declarationFile), { recursive: true });
|
|
389
|
+
fs.writeFileSync(declarationFile, content);
|
|
390
|
+
}
|
|
299
391
|
function sortImports(imports) {
|
|
300
392
|
return imports.sort((left, right) => {
|
|
301
393
|
const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
|
|
@@ -327,6 +419,10 @@ function routes(input = "src/pages") {
|
|
|
327
419
|
}
|
|
328
420
|
|
|
329
421
|
export const routes = [{ path: "*", Component: RouteConflictPage }];
|
|
422
|
+
|
|
423
|
+
export async function preloadRoute() {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
330
426
|
`;
|
|
331
427
|
return format(code, { parser: "typescript" });
|
|
332
428
|
}
|
|
@@ -334,7 +430,30 @@ function routes(input = "src/pages") {
|
|
|
334
430
|
const sections = [
|
|
335
431
|
sortImports(generated.imports).join("\n"),
|
|
336
432
|
generated.helpers.join("\n"),
|
|
337
|
-
`export const routes = ${genArrayFromRaw([generated.routes])}
|
|
433
|
+
`export const routes = ${genArrayFromRaw([generated.routes])};`,
|
|
434
|
+
`const preloadTree = ${genArrayFromRaw([generated.preloads])};
|
|
435
|
+
const routePreloads = new WeakMap();
|
|
436
|
+
|
|
437
|
+
function registerPreloads(routeList, preloadList) {
|
|
438
|
+
routeList.forEach((route, index) => {
|
|
439
|
+
const preload = preloadList[index];
|
|
440
|
+
if (preload?.load) routePreloads.set(route, preload.load);
|
|
441
|
+
if (route.children) {
|
|
442
|
+
registerPreloads(route.children, preload?.children ?? []);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
registerPreloads(routes, preloadTree);
|
|
448
|
+
|
|
449
|
+
export async function preloadRoute(to, basename) {
|
|
450
|
+
const matches = matchRoutes(routes, to, basename);
|
|
451
|
+
if (!matches) return false;
|
|
452
|
+
await Promise.all(
|
|
453
|
+
matches.map(({ route }) => routePreloads.get(route)?.()),
|
|
454
|
+
);
|
|
455
|
+
return true;
|
|
456
|
+
}`
|
|
338
457
|
].filter(Boolean);
|
|
339
458
|
return format(sections.join("\n\n"), { parser: "typescript" });
|
|
340
459
|
}
|
|
@@ -345,8 +464,35 @@ function routes(input = "src/pages") {
|
|
|
345
464
|
async function executeRouteHelpers() {
|
|
346
465
|
const tree = scan(root, options.conventions);
|
|
347
466
|
const patterns = [...new Set(collectPageRoutePatterns(tree))].sort();
|
|
467
|
+
const routeLoaderIds = {};
|
|
468
|
+
for (const { pattern, node } of collectPageRoutes(tree)) if (node.loader && !Object.hasOwn(routeLoaderIds, pattern)) routeLoaderIds[pattern] = getRouteId(node.directory, projectRoot);
|
|
469
|
+
const collectLoaderIds = (node) => {
|
|
470
|
+
if (node.loader) {
|
|
471
|
+
const id = getRouteId(node.directory, projectRoot);
|
|
472
|
+
routeLoaderIds[id] = id;
|
|
473
|
+
}
|
|
474
|
+
node.children.forEach(collectLoaderIds);
|
|
475
|
+
};
|
|
476
|
+
collectLoaderIds(tree);
|
|
348
477
|
const code = `
|
|
478
|
+
import { useParams, useRouteLoaderData } from "react-router";
|
|
479
|
+
|
|
349
480
|
export const routePatterns = ${JSON.stringify(patterns)};
|
|
481
|
+
const routeLoaderIds = ${JSON.stringify(routeLoaderIds)};
|
|
482
|
+
|
|
483
|
+
export function useRouteParams(pattern) {
|
|
484
|
+
if (!routePatterns.includes(pattern)) {
|
|
485
|
+
throw new TypeError(\`Unknown route pattern "\${pattern}".\`);
|
|
486
|
+
}
|
|
487
|
+
return useParams();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function useRouteData(key) {
|
|
491
|
+
if (!Object.hasOwn(routeLoaderIds, key)) {
|
|
492
|
+
throw new TypeError(\`Route "\${key}" has no loader.\`);
|
|
493
|
+
}
|
|
494
|
+
return useRouteLoaderData(routeLoaderIds[key]);
|
|
495
|
+
}
|
|
350
496
|
|
|
351
497
|
export function routePath(pattern, ...args) {
|
|
352
498
|
if (!routePatterns.includes(pattern)) {
|
|
@@ -414,6 +560,10 @@ function routes(input = "src/pages") {
|
|
|
414
560
|
projectRoot = config.root;
|
|
415
561
|
root = path.resolve(projectRoot, options.directory);
|
|
416
562
|
dev = config.command === "serve";
|
|
563
|
+
declarationFile = options.dts === false ? void 0 : path.resolve(projectRoot, options.dts);
|
|
564
|
+
},
|
|
565
|
+
buildStart() {
|
|
566
|
+
writeDeclarations();
|
|
417
567
|
},
|
|
418
568
|
resolveId: (id) => {
|
|
419
569
|
if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
|
|
@@ -474,6 +624,7 @@ function routes(input = "src/pages") {
|
|
|
474
624
|
const module = server.moduleGraph.getModuleById(id);
|
|
475
625
|
if (module) server.moduleGraph.invalidateModule(module);
|
|
476
626
|
}
|
|
627
|
+
writeDeclarations();
|
|
477
628
|
server.ws.send({ type: "full-reload" });
|
|
478
629
|
});
|
|
479
630
|
}
|
package/dist/scanner.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export type RouteManifestNode = {
|
|
|
24
24
|
};
|
|
25
25
|
export declare const DEFAULT_CONVENTIONS: Record<RouteFileKind, string>;
|
|
26
26
|
export declare function getRouteSegment(name: string): string;
|
|
27
|
-
export
|
|
27
|
+
export type PageRoute = {
|
|
28
|
+
pattern: string;
|
|
29
|
+
node: Node;
|
|
30
|
+
};
|
|
31
|
+
export declare function collectPageRoutes(node: Node, segments?: string[], root?: boolean): PageRoute[];
|
|
32
|
+
export declare function collectPageRoutePatterns(node: Node): string[];
|
|
28
33
|
export declare function scan(directory: string, conventions?: Record<RouteFileKind, string>): Node;
|
|
29
34
|
export declare function createRouteManifest(node: Node, projectRoot: string, parentPath?: string, root?: boolean): RouteManifestNode;
|
package/package.json
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -1,60 +1,143 @@
|
|
|
1
|
-
/// <reference path="./virtual-routes.d.ts" />
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
type
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
? { [Key in Name]
|
|
18
|
-
: Segment extends
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
?
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
/// <reference path="./virtual-routes.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { useRouteLoaderData } from "react-router";
|
|
4
|
+
|
|
5
|
+
export { routes } from "../dist/index.js";
|
|
6
|
+
export type {
|
|
7
|
+
RouteConventions,
|
|
8
|
+
RouteFileKind,
|
|
9
|
+
RouteManifestNode,
|
|
10
|
+
RoutesOptions,
|
|
11
|
+
} from "../dist/index.js";
|
|
12
|
+
|
|
13
|
+
export type RouteParameterValue = string | number;
|
|
14
|
+
|
|
15
|
+
type RouteParametersForSegment<Segment extends string> =
|
|
16
|
+
Segment extends `:${infer Name}?`
|
|
17
|
+
? { [Key in Name]?: RouteParameterValue }
|
|
18
|
+
: Segment extends `:${infer Name}`
|
|
19
|
+
? { [Key in Name]: RouteParameterValue }
|
|
20
|
+
: Segment extends "*"
|
|
21
|
+
? { "*": RouteParameterValue }
|
|
22
|
+
: {};
|
|
23
|
+
|
|
24
|
+
export type RouteParameters<Path extends string> =
|
|
25
|
+
Path extends `${infer Segment}/${infer Rest}`
|
|
26
|
+
? RouteParametersForSegment<Segment> & RouteParameters<Rest>
|
|
27
|
+
: RouteParametersForSegment<Path>;
|
|
28
|
+
|
|
29
|
+
type RouteParameterShapes<Path extends string> =
|
|
30
|
+
Path extends unknown ? RouteParameters<Path> : never;
|
|
31
|
+
|
|
32
|
+
type UnionToIntersection<Union> = (
|
|
33
|
+
Union extends unknown ? (value: Union) => void : never
|
|
34
|
+
) extends (value: infer Intersection) => void
|
|
35
|
+
? Intersection
|
|
36
|
+
: never;
|
|
37
|
+
|
|
38
|
+
type RouteRequiresParameters<Path extends string> =
|
|
39
|
+
Path extends unknown
|
|
40
|
+
? keyof RouteParameters<Path> extends never
|
|
41
|
+
? false
|
|
42
|
+
: true
|
|
43
|
+
: never;
|
|
44
|
+
|
|
45
|
+
type RouteRequiresRequiredParameters<Path extends string> =
|
|
46
|
+
Path extends unknown ? ({} extends RouteParameters<Path> ? false : true) : never;
|
|
47
|
+
|
|
48
|
+
type HasParameterShapeMismatch<Path extends string> =
|
|
49
|
+
[RouteParameterShapes<Path>] extends [
|
|
50
|
+
UnionToIntersection<RouteParameterShapes<Path>>,
|
|
51
|
+
]
|
|
52
|
+
? false
|
|
53
|
+
: true;
|
|
54
|
+
|
|
55
|
+
export type RoutePathArguments<Path extends string> =
|
|
56
|
+
true extends RouteRequiresParameters<Path>
|
|
57
|
+
? HasParameterShapeMismatch<Path> extends true
|
|
58
|
+
? [params: never]
|
|
59
|
+
: true extends RouteRequiresRequiredParameters<Path>
|
|
60
|
+
? [params: RouteParameters<Path>]
|
|
61
|
+
: [params?: RouteParameters<Path>]
|
|
62
|
+
: [];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Filled in by the generated route declarations (`src/route-types.d.ts` by
|
|
66
|
+
* default) through module augmentation. While it is empty, route patterns and
|
|
67
|
+
* loader keys fall back to `string` and loader data to `unknown`.
|
|
68
|
+
*/
|
|
69
|
+
// biome-ignore lint/suspicious/noEmptyInterface: augmented by generated code
|
|
70
|
+
export interface Register {}
|
|
71
|
+
|
|
72
|
+
type RegisteredRoutes = Register extends { routes: infer Routes }
|
|
73
|
+
? Routes
|
|
74
|
+
: never;
|
|
75
|
+
type RegisteredLoaders = Register extends { loaders: infer Loaders }
|
|
76
|
+
? Loaders
|
|
77
|
+
: never;
|
|
78
|
+
type IsRegistered = [RegisteredRoutes] extends [never] ? false : true;
|
|
79
|
+
|
|
80
|
+
/** A page route pattern discovered from the routes directory. */
|
|
81
|
+
export type RoutePattern = IsRegistered extends true
|
|
82
|
+
? Extract<keyof RegisteredRoutes, string>
|
|
83
|
+
: string;
|
|
84
|
+
|
|
85
|
+
type PatternsWithLoaders = {
|
|
86
|
+
[Pattern in keyof RegisteredRoutes]: RegisteredRoutes[Pattern] extends {
|
|
87
|
+
loader: null;
|
|
88
|
+
}
|
|
89
|
+
? never
|
|
90
|
+
: Pattern;
|
|
91
|
+
}[keyof RegisteredRoutes];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A page pattern whose directory has a loader, or a loader route ID such as
|
|
95
|
+
* `"file-route:src/pages/dashboard"`.
|
|
96
|
+
*/
|
|
97
|
+
export type RouteLoaderKey = IsRegistered extends true
|
|
98
|
+
? Extract<PatternsWithLoaders | keyof RegisteredLoaders, string>
|
|
99
|
+
: string;
|
|
100
|
+
|
|
101
|
+
type LoaderModuleFor<Key> = Key extends keyof RegisteredLoaders
|
|
102
|
+
? RegisteredLoaders[Key]
|
|
103
|
+
: Key extends keyof RegisteredRoutes
|
|
104
|
+
? RegisteredRoutes[Key] extends {
|
|
105
|
+
loader: infer Id extends keyof RegisteredLoaders;
|
|
106
|
+
}
|
|
107
|
+
? RegisteredLoaders[Id]
|
|
108
|
+
: never
|
|
109
|
+
: never;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The data a route's loader returns, typed the same way as React Router's
|
|
113
|
+
* `useRouteLoaderData<typeof loader>()`.
|
|
114
|
+
*/
|
|
115
|
+
export type RouteLoaderData<Key extends string> = IsRegistered extends true
|
|
116
|
+
? LoaderModuleFor<Key> extends { loader: infer Loader }
|
|
117
|
+
? NonNullable<ReturnType<typeof useRouteLoaderData<Loader>>>
|
|
118
|
+
: never
|
|
119
|
+
: unknown;
|
|
120
|
+
|
|
121
|
+
type Simplify<Value> = { [Key in keyof Value]: Value[Key] } & {};
|
|
122
|
+
|
|
123
|
+
type RouteParamsForSegment<Segment extends string> =
|
|
124
|
+
Segment extends `:${infer Name}?`
|
|
125
|
+
? { [Key in Name]?: string | undefined }
|
|
126
|
+
: Segment extends `:${infer Name}`
|
|
127
|
+
? { [Key in Name]: string }
|
|
128
|
+
: Segment extends "*"
|
|
129
|
+
? { "*": string }
|
|
130
|
+
: {};
|
|
131
|
+
|
|
132
|
+
type RouteParamsIntersection<Path extends string> =
|
|
133
|
+
Path extends `${infer Segment}/${infer Rest}`
|
|
134
|
+
? RouteParamsForSegment<Segment> & RouteParamsIntersection<Rest>
|
|
135
|
+
: RouteParamsForSegment<Path>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The params React Router's `useParams()` returns for a route pattern, such
|
|
139
|
+
* as `RouteParams<"/users/:id">` → `{ id: string }`.
|
|
140
|
+
*/
|
|
141
|
+
export type RouteParams<Path extends string> = Path extends unknown
|
|
142
|
+
? Simplify<RouteParamsIntersection<Path>>
|
|
143
|
+
: never;
|
|
@@ -1,19 +1,46 @@
|
|
|
1
|
-
declare module "virtual:routes" {
|
|
2
|
-
import type { RouteObject } from "react-router";
|
|
3
|
-
export const routes: RouteObject[];
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
declare module "virtual:routes" {
|
|
2
|
+
import type { Location, RouteObject } from "react-router";
|
|
3
|
+
export const routes: RouteObject[];
|
|
4
|
+
/**
|
|
5
|
+
* Imports the page, layout, and loader modules for the routes that match a
|
|
6
|
+
* URL, so a later navigation renders without waiting for them. Resolves to
|
|
7
|
+
* `false` when no route matches.
|
|
8
|
+
*/
|
|
9
|
+
export function preloadRoute(
|
|
10
|
+
to: string | Partial<Location>,
|
|
11
|
+
basename?: string,
|
|
12
|
+
): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module "virtual:route-manifest" {
|
|
16
|
+
import type { RouteManifestNode } from "@troyio/vite-file-router";
|
|
17
|
+
export const routeManifest: RouteManifestNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module "virtual:route-helpers" {
|
|
21
|
+
import type {
|
|
22
|
+
RouteLoaderData,
|
|
23
|
+
RouteLoaderKey,
|
|
24
|
+
RouteParams,
|
|
25
|
+
RoutePathArguments,
|
|
26
|
+
RoutePattern,
|
|
27
|
+
} from "@troyio/vite-file-router";
|
|
28
|
+
|
|
29
|
+
export const routePatterns: readonly RoutePattern[];
|
|
30
|
+
export function routePath<const Path extends RoutePattern>(
|
|
31
|
+
pattern: Path,
|
|
32
|
+
...args: RoutePathArguments<Path>
|
|
33
|
+
): string;
|
|
34
|
+
/** React Router's `useParams()`, typed for the given page pattern. */
|
|
35
|
+
export function useRouteParams<const Path extends RoutePattern>(
|
|
36
|
+
pattern: Path,
|
|
37
|
+
): RouteParams<Path>;
|
|
38
|
+
/**
|
|
39
|
+
* React Router's `useRouteLoaderData()`, typed from the loader module. Pass
|
|
40
|
+
* a page pattern to read the loader in that page's directory, or a loader
|
|
41
|
+
* route ID to read any matched loader.
|
|
42
|
+
*/
|
|
43
|
+
export function useRouteData<const Key extends RouteLoaderKey>(
|
|
44
|
+
key: Key,
|
|
45
|
+
): RouteLoaderData<Key> | undefined;
|
|
46
|
+
}
|