@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 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`. Restart your editor's TypeScript server if the declaration
376
- isn't picked up immediately.
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;
@@ -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 collectPageRoutePatterns(node, segments = [], root = true) {
38
+ function collectPageRoutes(node, segments = [], root = true) {
39
39
  const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
40
- const patterns = node.page ? [`/${routeSegments.join("/")}`] : [];
41
- for (const child of node.children) patterns.push(...collectPageRoutePatterns(child, routeSegments, false));
42
- return patterns;
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 genObjectFromRaw(route);
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 declare function collectPageRoutePatterns(node: Node, segments?: string[], root?: boolean): string[];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,
package/types/index.d.ts CHANGED
@@ -1,60 +1,143 @@
1
- /// <reference path="./virtual-routes.d.ts" />
2
-
3
- export { routes } from "../dist/index.js";
4
- export type {
5
- RouteConventions,
6
- RouteFileKind,
7
- RouteManifestNode,
8
- RoutesOptions,
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
- : [];
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
- declare module "virtual:route-manifest" {
7
- import type { RouteManifestNode } from "@troyio/vite-file-router";
8
- export const routeManifest: RouteManifestNode;
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
- }
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
+ }