@troyio/vite-file-router 0.2.6 → 0.2.7

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
@@ -197,8 +197,36 @@ remain in the tree. React Router's generated `routes` remain the runtime source
197
197
  of truth.
198
198
 
199
199
  The development server watches the configured directory. Adding, removing,
200
- renaming, or editing route files invalidates both virtual modules and reloads
201
- the app without requiring a server restart.
200
+ renaming, or editing route files invalidates all generated virtual modules and
201
+ reloads the app without requiring a server restart.
202
+
203
+ ### Typed route helpers
204
+
205
+ Import `routePath` from `virtual:route-helpers` to build paths for discovered
206
+ pages. The helper is compatible with React Router's `Link`, `NavLink`,
207
+ `navigate`, and `redirect` APIs. Dynamic parameter names and required values are
208
+ checked from the path literal; values are URL-encoded. Splat values use the
209
+ React Router `"*"` parameter and are encoded segment-by-segment so `/`
210
+ separators are preserved:
211
+
212
+ ```tsx
213
+ import { Link } from "react-router";
214
+ import { routePath } from "virtual:route-helpers";
215
+
216
+ export function UserLink({ userId }: { userId: string }) {
217
+ return (
218
+ <Link to={routePath("/users/:userId", { userId })}>
219
+ View user
220
+ </Link>
221
+ );
222
+ }
223
+
224
+ const docsPath = routePath("/docs/*", { "*": "guides/getting-started" });
225
+ ```
226
+
227
+ Static routes need no parameter object: `routePath("/")`. Unknown route
228
+ patterns and missing or extra parameters are rejected at runtime. The generated
229
+ `routePatterns` export contains the discovered page paths.
202
230
 
203
231
  ### TypeScript setup
204
232
 
package/dist/index.js CHANGED
@@ -14,6 +14,12 @@ function getRouteSegment(name) {
14
14
  if (name.startsWith("[...") && name.endsWith("]")) return "*";
15
15
  return name.replace(/^\[(.+)\]$/, ":$1");
16
16
  }
17
+ function collectPageRoutePatterns(node, segments = [], root = true) {
18
+ const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
19
+ const patterns = node.page ? [`/${routeSegments.join("/")}`] : [];
20
+ for (const child of node.children) patterns.push(...collectPageRoutePatterns(child, routeSegments, false));
21
+ return patterns;
22
+ }
17
23
  function scan(directory, conventions = DEFAULT_CONVENTIONS) {
18
24
  const node = {
19
25
  name: path.basename(directory),
@@ -210,6 +216,8 @@ var ROUTES_MODULE_ID = "virtual:routes";
210
216
  var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
211
217
  var MANIFEST_MODULE_ID = "virtual:route-manifest";
212
218
  var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
219
+ var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
220
+ var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
213
221
  function resolveOptions(input) {
214
222
  const options = typeof input === "string" ? { directory: input } : input;
215
223
  if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
@@ -281,6 +289,49 @@ function routes(input = "src/pages") {
281
289
  const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
282
290
  return format(`export const routeManifest = ${JSON.stringify(manifest, null, 2)};`, { parser: "typescript" });
283
291
  }
292
+ async function executeRouteHelpers() {
293
+ const tree = scan(root, options.conventions);
294
+ const patterns = [...new Set(collectPageRoutePatterns(tree))].sort();
295
+ const code = `
296
+ export const routePatterns = ${JSON.stringify(patterns)};
297
+
298
+ export function routePath(pattern, ...args) {
299
+ if (!routePatterns.includes(pattern)) {
300
+ throw new TypeError(\`Unknown route pattern "\${pattern}".\`);
301
+ }
302
+
303
+ const params = args[0] ?? {};
304
+ const parameterNames = [...pattern.matchAll(/:([^/]+)|\\*/g)].map(
305
+ ([, name]) => name ?? "*",
306
+ );
307
+ for (const key of Object.keys(params)) {
308
+ if (!parameterNames.includes(key)) {
309
+ throw new TypeError(\`Unknown route parameter "\${key}" for "\${pattern}".\`);
310
+ }
311
+ }
312
+ return pattern.replace(/:([^/]+)|\\*/g, (token, name) => {
313
+ const key = name ?? "*";
314
+ if (!Object.hasOwn(params, key)) {
315
+ throw new TypeError(\`Missing route parameter "\${key}" for "\${pattern}".\`);
316
+ }
317
+ const value = String(params[key]);
318
+ if (key !== "*" && value.length === 0) {
319
+ throw new TypeError(
320
+ 'Route parameter "' +
321
+ key +
322
+ '" for "' +
323
+ pattern +
324
+ '" cannot be empty.',
325
+ );
326
+ }
327
+ return key === "*"
328
+ ? value.split("/").map(encodeURIComponent).join("/")
329
+ : encodeURIComponent(value);
330
+ });
331
+ }
332
+ `;
333
+ return format(code, { parser: "typescript" });
334
+ }
284
335
  return {
285
336
  name: "routes",
286
337
  configResolved(config) {
@@ -291,10 +342,12 @@ function routes(input = "src/pages") {
291
342
  resolveId: (id) => {
292
343
  if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
293
344
  if (id === MANIFEST_MODULE_ID) return MANIFEST_RESOLVED_ID;
345
+ if (id === ROUTE_HELPERS_MODULE_ID) return ROUTE_HELPERS_RESOLVED_ID;
294
346
  },
295
347
  load: (id) => {
296
348
  if (id === ROUTES_RESOLVED_ID) return execute();
297
349
  if (id === MANIFEST_RESOLVED_ID) return executeManifest();
350
+ if (id === ROUTE_HELPERS_RESOLVED_ID) return executeRouteHelpers();
298
351
  },
299
352
  configureServer: (server) => {
300
353
  server.watcher.add(root);
@@ -309,7 +362,11 @@ function routes(input = "src/pages") {
309
362
  const location = path.resolve(file);
310
363
  const relative = path.relative(root, location);
311
364
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
312
- for (const id of [ROUTES_RESOLVED_ID, MANIFEST_RESOLVED_ID]) {
365
+ for (const id of [
366
+ ROUTES_RESOLVED_ID,
367
+ MANIFEST_RESOLVED_ID,
368
+ ROUTE_HELPERS_RESOLVED_ID
369
+ ]) {
313
370
  const module = server.moduleGraph.getModuleById(id);
314
371
  if (module) server.moduleGraph.invalidateModule(module);
315
372
  }
package/dist/scanner.d.ts CHANGED
@@ -21,5 +21,6 @@ export type RouteManifestNode = {
21
21
  };
22
22
  export declare const DEFAULT_CONVENTIONS: Record<RouteFileKind, string>;
23
23
  export declare function getRouteSegment(name: string): string;
24
+ export declare function collectPageRoutePatterns(node: Node, segments?: string[], root?: boolean): string[];
24
25
  export declare function scan(directory: string, conventions?: Record<RouteFileKind, string>): Node;
25
26
  export declare function createRouteManifest(node: Node, projectRoot: string, parentPath?: string, root?: boolean): RouteManifestNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,
package/types/index.d.ts CHANGED
@@ -7,3 +7,47 @@ export type {
7
7
  RouteManifestNode,
8
8
  RoutesOptions,
9
9
  } from "../dist/index.js";
10
+
11
+ export type RouteParameterValue = string | number;
12
+
13
+ type RouteParametersForSegment<Segment extends string> =
14
+ Segment extends `:${infer Name}`
15
+ ? { [Key in Name]: RouteParameterValue }
16
+ : Segment extends "*"
17
+ ? { "*": RouteParameterValue }
18
+ : {};
19
+
20
+ export type RouteParameters<Path extends string> =
21
+ Path extends `${infer Segment}/${infer Rest}`
22
+ ? RouteParametersForSegment<Segment> & RouteParameters<Rest>
23
+ : RouteParametersForSegment<Path>;
24
+
25
+ type RouteParameterShapes<Path extends string> =
26
+ Path extends unknown ? RouteParameters<Path> : never;
27
+
28
+ type UnionToIntersection<Union> = (
29
+ Union extends unknown ? (value: Union) => void : never
30
+ ) extends (value: infer Intersection) => void
31
+ ? Intersection
32
+ : never;
33
+
34
+ type RouteRequiresParameters<Path extends string> =
35
+ Path extends unknown
36
+ ? keyof RouteParameters<Path> extends never
37
+ ? false
38
+ : true
39
+ : never;
40
+
41
+ type HasParameterShapeMismatch<Path extends string> =
42
+ [RouteParameterShapes<Path>] extends [
43
+ UnionToIntersection<RouteParameterShapes<Path>>,
44
+ ]
45
+ ? false
46
+ : true;
47
+
48
+ export type RoutePathArguments<Path extends string> =
49
+ true extends RouteRequiresParameters<Path>
50
+ ? HasParameterShapeMismatch<Path> extends true
51
+ ? [params: never]
52
+ : [params: RouteParameters<Path>]
53
+ : [];
@@ -7,3 +7,13 @@ declare module "virtual:route-manifest" {
7
7
  import type { RouteManifestNode } from "@troyio/vite-file-router";
8
8
  export const routeManifest: RouteManifestNode;
9
9
  }
10
+
11
+ declare module "virtual:route-helpers" {
12
+ import type { RoutePathArguments } from "@troyio/vite-file-router";
13
+
14
+ export const routePatterns: readonly string[];
15
+ export function routePath<const Path extends string>(
16
+ pattern: Path,
17
+ ...args: RoutePathArguments<Path>
18
+ ): string;
19
+ }