@troyio/vite-file-router 0.2.5 → 0.2.6

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
@@ -21,13 +21,36 @@ export default defineConfig({
21
21
  });
22
22
  ```
23
23
 
24
- By default, the plugin scans `src/pages`. Pass a path relative to the Vite
25
- project root to use another directory:
24
+ By default, the plugin scans `src/pages` and recognizes the file conventions
25
+ listed below. Pass a path relative to the Vite project root to use another
26
+ directory:
26
27
 
27
28
  ```ts
28
29
  plugins: [routes("src/routes")];
29
30
  ```
30
31
 
32
+ You can also configure the directory and special filenames together. Convention
33
+ values are filename stems without `.jsx` or `.tsx`; `.tsx` takes precedence when
34
+ both extensions exist:
35
+
36
+ ```ts
37
+ plugins: [
38
+ routes({
39
+ directory: "app/views",
40
+ conventions: {
41
+ page: "route",
42
+ layout: "shell",
43
+ loading: "pending",
44
+ notFound: "missing",
45
+ error: "fault",
46
+ },
47
+ }),
48
+ ];
49
+ ```
50
+
51
+ Omitted convention values keep their defaults: `page`, `layout`, `loading`,
52
+ `not-found`, and `error`. Each convention must have a unique filename stem.
53
+
31
54
  The router scans the pages directory recursively. Each directory becomes a
32
55
  nested route, and the following files are recognized:
33
56
 
@@ -135,7 +158,11 @@ Two pages must not resolve to the same URL. This includes pages with the same
135
158
  path in separate route groups, such as `(auth)/sign-in/page.tsx` and
136
159
  `(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
137
160
  page listing the conflicting files; production builds fail with the same
138
- diagnostic until the conflict is fixed.
161
+ diagnostic until the conflict is fixed. Dynamic parameter names do not make
162
+ otherwise identical patterns distinct (`users/[id]` and `users/[slug]`
163
+ conflict). Static and dynamic siblings such as `users/new` and `users/[id]`
164
+ are distinct; React Router's normal route ranking selects the static route
165
+ first.
139
166
 
140
167
  ## Use the generated routes
141
168
 
@@ -152,6 +179,27 @@ export default function App() {
152
179
  }
153
180
  ```
154
181
 
182
+ ### Route manifest
183
+
184
+ Import `routeManifest` from `virtual:route-manifest` for a stable, nested
185
+ description of the scanned routes. Source files are relative to the Vite
186
+ project root and use forward slashes. Each node includes its URL `fullPath`,
187
+ the parameters introduced by that segment, and its recognized files:
188
+
189
+ ```ts
190
+ import { routeManifest } from "virtual:route-manifest";
191
+
192
+ console.log(routeManifest.children);
193
+ ```
194
+
195
+ The root node has `fullPath: "/"`; route-group nodes have no URL segment but
196
+ remain in the tree. React Router's generated `routes` remain the runtime source
197
+ of truth.
198
+
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.
202
+
155
203
  ### TypeScript setup
156
204
 
157
205
  Add Vite's client types and the package's virtual-module declaration to your
@@ -1,4 +1,4 @@
1
- import type { Node } from "./scanner";
1
+ import { type Node } from "./scanner";
2
2
  export type GeneratedRoutes = {
3
3
  imports: string[];
4
4
  helpers: string[];
package/dist/index.d.ts CHANGED
@@ -1,2 +1,8 @@
1
1
  import type { Plugin } from "vite";
2
- export declare function routes(directory?: string): Plugin;
2
+ import { type RouteConventions } from "./scanner";
3
+ export type RoutesOptions = {
4
+ directory?: string;
5
+ conventions?: RouteConventions;
6
+ };
7
+ export type { RouteConventions, RouteFileKind, RouteManifestNode, } from "./scanner";
8
+ export declare function routes(input?: string | RoutesOptions): Plugin;
package/dist/index.js CHANGED
@@ -2,12 +2,66 @@ import path from "node:path";
2
2
  import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork";
3
3
  import { format } from "prettier";
4
4
  import fs from "node:fs";
5
+ // src/scanner.ts
6
+ var DEFAULT_CONVENTIONS = {
7
+ page: "page",
8
+ layout: "layout",
9
+ loading: "loading",
10
+ notFound: "not-found",
11
+ error: "error"
12
+ };
13
+ function getRouteSegment(name) {
14
+ if (name.startsWith("[...") && name.endsWith("]")) return "*";
15
+ return name.replace(/^\[(.+)\]$/, ":$1");
16
+ }
17
+ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
18
+ const node = {
19
+ name: path.basename(directory),
20
+ directory,
21
+ routeGroup: /^\(.+\)$/.test(path.basename(directory)),
22
+ children: []
23
+ };
24
+ if (!fs.existsSync(directory)) return node;
25
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
26
+ for (const entry of entries) {
27
+ const file = path.join(directory, entry.name);
28
+ if (entry.isDirectory()) {
29
+ node.children.push(scan(file, conventions));
30
+ continue;
31
+ }
32
+ const match = /^(.*)\.(jsx|tsx)$/.exec(entry.name);
33
+ if (!match) continue;
34
+ const [, basename, extension] = match;
35
+ const kind = Object.keys(conventions).find((candidate) => conventions[candidate] === basename);
36
+ if (!kind) continue;
37
+ if (!node[kind] || extension === "tsx") node[kind] = file;
38
+ }
39
+ return node;
40
+ }
41
+ function createRouteManifest(node, projectRoot, parentPath = "", root = true) {
42
+ const segment = root || node.routeGroup ? null : getRouteSegment(node.name);
43
+ const fullPath = segment === null ? parentPath || "/" : `${parentPath}/${segment}` || "/";
44
+ const params = !root && !node.routeGroup && node.name.startsWith("[") ? [node.name.startsWith("[...") ? "*" : node.name.slice(1, -1)] : [];
45
+ const files = {};
46
+ for (const kind of Object.keys(DEFAULT_CONVENTIONS)) {
47
+ const file = node[kind];
48
+ if (file) files[kind] = path.relative(projectRoot, file).split(path.sep).join("/");
49
+ }
50
+ return {
51
+ segment,
52
+ fullPath,
53
+ params,
54
+ routeGroup: node.routeGroup,
55
+ files,
56
+ children: node.children.map((child) => createRouteManifest(child, projectRoot, fullPath === "/" ? "" : fullPath, false))
57
+ };
58
+ }
5
59
  // src/generator.ts
6
60
  var RouteConflictError = class extends Error {
7
61
  conflicts;
8
62
  constructor(conflicts) {
9
63
  const details = conflicts.map(({ routePath, files }) => ` ${routePath}\n${files.map((file) => ` - ${file}`).join("\n")}`).join("\n");
10
- super(`Multiple page files resolve to the same route:\n${details}\n\nGive each page a unique URL path. Parenthesized route-group names are omitted from URLs, so pages in different groups can still conflict.`);
64
+ super(`Multiple page files resolve to the same route pattern:\n${details}\n\nReact Router treats dynamic parameters as equivalent regardless of their names, and parenthesized route-group names are omitted from URLs. Give each page a unique URL pattern.`);
11
65
  this.conflicts = conflicts;
12
66
  this.name = "RouteConflictError";
13
67
  }
@@ -17,9 +71,7 @@ function getImportPath(file, projectRoot) {
17
71
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
18
72
  }
19
73
  function getRoutePath(directory) {
20
- const segment = path.basename(directory);
21
- if (segment.startsWith("[...") && segment.endsWith("]")) return "*";
22
- return segment.replace(/^\[(.+)\]$/, ":$1");
74
+ return getRouteSegment(path.basename(directory));
23
75
  }
24
76
  function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
25
77
  const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
@@ -153,34 +205,33 @@ function generate(node, root = false, projectRoot = process.cwd()) {
153
205
  routes
154
206
  };
155
207
  }
156
- // src/scanner.ts
157
- function scan(directory) {
158
- const node = {
159
- name: path.basename(directory),
208
+ // src/index.ts
209
+ var ROUTES_MODULE_ID = "virtual:routes";
210
+ var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
211
+ var MANIFEST_MODULE_ID = "virtual:route-manifest";
212
+ var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
213
+ function resolveOptions(input) {
214
+ const options = typeof input === "string" ? { directory: input } : input;
215
+ if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
216
+ for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions") throw new TypeError(`Unknown routes() option "${key}".`);
217
+ const directory = options.directory ?? "src/pages";
218
+ if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
219
+ const conventions = { ...DEFAULT_CONVENTIONS };
220
+ if (options.conventions !== void 0 && (!options.conventions || typeof options.conventions !== "object" || Array.isArray(options.conventions))) throw new TypeError("routes() conventions must be an object.");
221
+ for (const [kind, basename] of Object.entries(options.conventions ?? {})) {
222
+ if (!Object.hasOwn(DEFAULT_CONVENTIONS, kind)) throw new TypeError(`Unknown route file convention "${kind}".`);
223
+ if (typeof basename !== "string" || basename.trim() === "" || basename === "." || basename === ".." || /[\\/]/.test(basename) || /\.[^.]+$/.test(basename)) throw new TypeError(`Route file convention "${kind}" must be a filename without an extension.`);
224
+ conventions[kind] = basename;
225
+ }
226
+ const names = Object.values(conventions);
227
+ if (new Set(names).size !== names.length) throw new TypeError("Route file convention names must be unique.");
228
+ return {
160
229
  directory,
161
- routeGroup: /^\(.+\)$/.test(path.basename(directory)),
162
- children: []
230
+ conventions
163
231
  };
164
- if (!fs.existsSync(directory)) return node;
165
- const entries = fs.readdirSync(directory, { withFileTypes: true });
166
- for (const entry of entries) {
167
- const file = path.join(directory, entry.name);
168
- if (entry.isDirectory()) {
169
- node.children.push(scan(file));
170
- continue;
171
- }
172
- const match = /^(page|layout|loading|not-found|error)\.(jsx|tsx)$/.exec(entry.name);
173
- if (!match) continue;
174
- const [, name, extension] = match;
175
- const key = name === "not-found" ? "notFound" : name;
176
- if (!node[key] || extension === "tsx") node[key] = file;
177
- }
178
- return node;
179
232
  }
180
- // src/index.ts
181
- var MODULE_ID = "virtual:routes";
182
- var RESOLVED_ID = `\0${MODULE_ID}`;
183
- function routes(directory = "src/pages") {
233
+ function routes(input = "src/pages") {
234
+ const options = resolveOptions(input);
184
235
  let projectRoot = "";
185
236
  let root = "";
186
237
  let dev = false;
@@ -194,7 +245,7 @@ function routes(directory = "src/pages") {
194
245
  });
195
246
  }
196
247
  async function execute() {
197
- const tree = scan(root);
248
+ const tree = scan(root, options.conventions);
198
249
  const conflicts = findPageRouteConflicts(tree);
199
250
  if (conflicts.length) {
200
251
  const error = new RouteConflictError(conflicts);
@@ -209,7 +260,7 @@ function routes(directory = "src/pages") {
209
260
  "main",
210
261
  { style: { fontFamily: "system-ui, sans-serif", margin: "3rem auto", maxWidth: "60rem", padding: "0 1.5rem" } },
211
262
  createElement("h1", null, "Conflicting routes"),
212
- createElement("p", null, "The file-based router found page files that resolve to the same URL. Fix these conflicts, then the page will reload."),
263
+ createElement("p", null, "The file-based router found pages with the same effective URL pattern. React Router treats dynamic parameter names as equivalent, and route-group names do not add URL segments. Fix these conflicts, then the page will reload."),
213
264
  createElement("pre", { style: { background: "#f3f4f6", overflowX: "auto", padding: "1rem", whiteSpace: "pre-wrap" } }, message),
214
265
  );
215
266
  }
@@ -226,15 +277,25 @@ function routes(directory = "src/pages") {
226
277
  ].filter(Boolean);
227
278
  return format(sections.join("\n\n"), { parser: "typescript" });
228
279
  }
280
+ async function executeManifest() {
281
+ const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
282
+ return format(`export const routeManifest = ${JSON.stringify(manifest, null, 2)};`, { parser: "typescript" });
283
+ }
229
284
  return {
230
285
  name: "routes",
231
286
  configResolved(config) {
232
287
  projectRoot = config.root;
233
- root = path.resolve(projectRoot, directory);
288
+ root = path.resolve(projectRoot, options.directory);
234
289
  dev = config.command === "serve";
235
290
  },
236
- resolveId: (id) => id === MODULE_ID ? RESOLVED_ID : void 0,
237
- load: (id) => id === RESOLVED_ID ? execute() : void 0,
291
+ resolveId: (id) => {
292
+ if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
293
+ if (id === MANIFEST_MODULE_ID) return MANIFEST_RESOLVED_ID;
294
+ },
295
+ load: (id) => {
296
+ if (id === ROUTES_RESOLVED_ID) return execute();
297
+ if (id === MANIFEST_RESOLVED_ID) return executeManifest();
298
+ },
238
299
  configureServer: (server) => {
239
300
  server.watcher.add(root);
240
301
  server.watcher.on("all", (event, file) => {
@@ -246,9 +307,12 @@ function routes(directory = "src/pages") {
246
307
  "change"
247
308
  ].includes(event)) return;
248
309
  const location = path.resolve(file);
249
- if (location !== root && !location.startsWith(root + path.sep)) return;
250
- const module = server.moduleGraph.getModuleById(RESOLVED_ID);
251
- if (module) server.moduleGraph.invalidateModule(module);
310
+ const relative = path.relative(root, location);
311
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
312
+ for (const id of [ROUTES_RESOLVED_ID, MANIFEST_RESOLVED_ID]) {
313
+ const module = server.moduleGraph.getModuleById(id);
314
+ if (module) server.moduleGraph.invalidateModule(module);
315
+ }
252
316
  server.ws.send({ type: "full-reload" });
253
317
  });
254
318
  }
package/dist/scanner.d.ts CHANGED
@@ -9,4 +9,17 @@ export type Node = {
9
9
  error?: string;
10
10
  children: Node[];
11
11
  };
12
- export declare function scan(directory: string): Node;
12
+ export type RouteFileKind = "page" | "layout" | "loading" | "notFound" | "error";
13
+ export type RouteConventions = Partial<Record<RouteFileKind, string>>;
14
+ export type RouteManifestNode = {
15
+ segment: string | null;
16
+ fullPath: string;
17
+ params: string[];
18
+ routeGroup: boolean;
19
+ files: Partial<Record<RouteFileKind, string>>;
20
+ children: RouteManifestNode[];
21
+ };
22
+ export declare const DEFAULT_CONVENTIONS: Record<RouteFileKind, string>;
23
+ export declare function getRouteSegment(name: string): string;
24
+ export declare function scan(directory: string, conventions?: Record<RouteFileKind, string>): Node;
25
+ 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.5",
3
+ "version": "0.2.6",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,
package/types/index.d.ts CHANGED
@@ -1,3 +1,9 @@
1
1
  /// <reference path="./virtual-routes.d.ts" />
2
2
 
3
3
  export { routes } from "../dist/index.js";
4
+ export type {
5
+ RouteConventions,
6
+ RouteFileKind,
7
+ RouteManifestNode,
8
+ RoutesOptions,
9
+ } from "../dist/index.js";
@@ -2,3 +2,8 @@ declare module "virtual:routes" {
2
2
  import type { RouteObject } from "react-router";
3
3
  export const routes: RouteObject[];
4
4
  }
5
+
6
+ declare module "virtual:route-manifest" {
7
+ import type { RouteManifestNode } from "@troyio/vite-file-router";
8
+ export const routeManifest: RouteManifestNode;
9
+ }