@troyio/vite-file-router 0.3.2 → 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 +98 -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 +163 -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
|
@@ -249,6 +249,10 @@ export default function DashboardPage() {
|
|
|
249
249
|
}
|
|
250
250
|
```
|
|
251
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
|
+
|
|
252
256
|
The authentication and mutation functions above are application code. A
|
|
253
257
|
client-side route guard controls navigation and presentation; it is not a
|
|
254
258
|
security boundary. Protect data and mutations in the server/API layer as well,
|
|
@@ -360,6 +364,97 @@ parameters are rejected at runtime. Routes whose optional segment overlaps
|
|
|
360
364
|
another page's URL pattern are reported as conflicts. The generated
|
|
361
365
|
`routePatterns` export contains the discovered page paths.
|
|
362
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
|
+
|
|
363
458
|
### TypeScript setup
|
|
364
459
|
|
|
365
460
|
Add Vite's client types and the package's virtual-module declaration to your
|
|
@@ -372,8 +467,9 @@ application TypeScript project. For example, create `src/vite-env.d.ts`:
|
|
|
372
467
|
|
|
373
468
|
If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
|
|
374
469
|
and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
|
|
375
|
-
`virtual:routes`.
|
|
376
|
-
|
|
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.
|
|
377
473
|
|
|
378
474
|
## Build
|
|
379
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 = {
|
|
@@ -157,6 +163,13 @@ function generateLazyLayout(file, projectRoot) {
|
|
|
157
163
|
function generateDataHandler(file, projectRoot) {
|
|
158
164
|
return `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
|
|
159
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
|
+
}
|
|
160
173
|
function getRouteId(directory, projectRoot) {
|
|
161
174
|
return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
|
|
162
175
|
}
|
|
@@ -185,6 +198,7 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
185
198
|
}
|
|
186
199
|
if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader));
|
|
187
200
|
const children = [];
|
|
201
|
+
const preloadChildren = [];
|
|
188
202
|
if (node.page) {
|
|
189
203
|
const page = hasBoundary ? {
|
|
190
204
|
index: "true",
|
|
@@ -196,8 +210,13 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
196
210
|
...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
|
|
197
211
|
};
|
|
198
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);
|
|
199
219
|
}
|
|
200
|
-
for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
|
|
201
220
|
if (node.notFound) {
|
|
202
221
|
const notFoundRoute = hasBoundary ? {
|
|
203
222
|
path: JSON.stringify("*"),
|
|
@@ -207,9 +226,13 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
207
226
|
lazy: generateImport(node.notFound, context.projectRoot)
|
|
208
227
|
};
|
|
209
228
|
children.push(genObjectFromRaw(notFoundRoute));
|
|
229
|
+
preloadChildren.push(generatePreload([node.notFound], context.projectRoot));
|
|
210
230
|
}
|
|
211
231
|
if (children.length) route.children = genArrayFromRaw(children);
|
|
212
|
-
return
|
|
232
|
+
return {
|
|
233
|
+
route: genObjectFromRaw(route),
|
|
234
|
+
preload: generatePreload([node.layout, node.loader], context.projectRoot, preloadChildren)
|
|
235
|
+
};
|
|
213
236
|
}
|
|
214
237
|
function generate(node, root = false, projectRoot = process.cwd()) {
|
|
215
238
|
validateRouteGroupNotFound(node, [], root);
|
|
@@ -223,7 +246,7 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
223
246
|
hasLayouts: false,
|
|
224
247
|
hasErrorBoundaries: false
|
|
225
248
|
};
|
|
226
|
-
const routes = generateNode(node, root, context, false);
|
|
249
|
+
const { route: routes, preload: preloads } = generateNode(node, root, context, false);
|
|
227
250
|
const imports = [...context.loadingImports];
|
|
228
251
|
const helpers = [];
|
|
229
252
|
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
|
|
@@ -232,8 +255,10 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
232
255
|
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
|
|
233
256
|
if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
|
|
234
257
|
imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
|
|
235
|
-
if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
|
|
236
258
|
}
|
|
259
|
+
const routerImports = ["matchRoutes"];
|
|
260
|
+
if (context.hasBoundaries || context.hasLayouts) routerImports.push("Outlet");
|
|
261
|
+
imports.push(`import { ${routerImports.join(", ")} } from "react-router";`);
|
|
237
262
|
if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
|
|
238
263
|
return function RouteBoundary() {
|
|
239
264
|
return createElement(
|
|
@@ -260,9 +285,63 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
260
285
|
return {
|
|
261
286
|
imports,
|
|
262
287
|
helpers,
|
|
263
|
-
routes
|
|
288
|
+
routes,
|
|
289
|
+
preloads
|
|
264
290
|
};
|
|
265
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
|
+
}
|
|
266
345
|
// src/index.ts
|
|
267
346
|
var ROUTES_MODULE_ID = "virtual:routes";
|
|
268
347
|
var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
|
|
@@ -272,10 +351,11 @@ var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
|
|
|
272
351
|
var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
|
|
273
352
|
var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
|
|
274
353
|
var ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
|
|
354
|
+
var DEFAULT_DTS = "src/route-types.d.ts";
|
|
275
355
|
function resolveOptions(input) {
|
|
276
356
|
const options = typeof input === "string" ? { directory: input } : input;
|
|
277
357
|
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
|
|
278
|
-
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}".`);
|
|
279
359
|
const directory = options.directory ?? "src/pages";
|
|
280
360
|
if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
|
|
281
361
|
const conventions = { ...DEFAULT_CONVENTIONS };
|
|
@@ -287,9 +367,12 @@ function resolveOptions(input) {
|
|
|
287
367
|
}
|
|
288
368
|
const names = Object.values(conventions);
|
|
289
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\".");
|
|
290
372
|
return {
|
|
291
373
|
directory,
|
|
292
|
-
conventions
|
|
374
|
+
conventions,
|
|
375
|
+
dts
|
|
293
376
|
};
|
|
294
377
|
}
|
|
295
378
|
function routes(input = "src/pages") {
|
|
@@ -297,6 +380,14 @@ function routes(input = "src/pages") {
|
|
|
297
380
|
let projectRoot = "";
|
|
298
381
|
let root = "";
|
|
299
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
|
+
}
|
|
300
391
|
function sortImports(imports) {
|
|
301
392
|
return imports.sort((left, right) => {
|
|
302
393
|
const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
|
|
@@ -328,6 +419,10 @@ function routes(input = "src/pages") {
|
|
|
328
419
|
}
|
|
329
420
|
|
|
330
421
|
export const routes = [{ path: "*", Component: RouteConflictPage }];
|
|
422
|
+
|
|
423
|
+
export async function preloadRoute() {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
331
426
|
`;
|
|
332
427
|
return format(code, { parser: "typescript" });
|
|
333
428
|
}
|
|
@@ -335,7 +430,30 @@ function routes(input = "src/pages") {
|
|
|
335
430
|
const sections = [
|
|
336
431
|
sortImports(generated.imports).join("\n"),
|
|
337
432
|
generated.helpers.join("\n"),
|
|
338
|
-
`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
|
+
}`
|
|
339
457
|
].filter(Boolean);
|
|
340
458
|
return format(sections.join("\n\n"), { parser: "typescript" });
|
|
341
459
|
}
|
|
@@ -346,8 +464,35 @@ function routes(input = "src/pages") {
|
|
|
346
464
|
async function executeRouteHelpers() {
|
|
347
465
|
const tree = scan(root, options.conventions);
|
|
348
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);
|
|
349
477
|
const code = `
|
|
478
|
+
import { useParams, useRouteLoaderData } from "react-router";
|
|
479
|
+
|
|
350
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
|
+
}
|
|
351
496
|
|
|
352
497
|
export function routePath(pattern, ...args) {
|
|
353
498
|
if (!routePatterns.includes(pattern)) {
|
|
@@ -415,6 +560,10 @@ function routes(input = "src/pages") {
|
|
|
415
560
|
projectRoot = config.root;
|
|
416
561
|
root = path.resolve(projectRoot, options.directory);
|
|
417
562
|
dev = config.command === "serve";
|
|
563
|
+
declarationFile = options.dts === false ? void 0 : path.resolve(projectRoot, options.dts);
|
|
564
|
+
},
|
|
565
|
+
buildStart() {
|
|
566
|
+
writeDeclarations();
|
|
418
567
|
},
|
|
419
568
|
resolveId: (id) => {
|
|
420
569
|
if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
|
|
@@ -475,6 +624,7 @@ function routes(input = "src/pages") {
|
|
|
475
624
|
const module = server.moduleGraph.getModuleById(id);
|
|
476
625
|
if (module) server.moduleGraph.invalidateModule(module);
|
|
477
626
|
}
|
|
627
|
+
writeDeclarations();
|
|
478
628
|
server.ws.send({ type: "full-reload" });
|
|
479
629
|
});
|
|
480
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
|
+
}
|