@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 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`. Restart your editor's TypeScript server if the declaration
370
- 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.
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;
@@ -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 = {
@@ -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 genObjectFromRaw(route);
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 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.1",
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
+ }