@troyio/vite-file-router 0.2.1 → 0.2.3

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
@@ -48,7 +48,12 @@ src/pages/
48
48
  ```
49
49
 
50
50
  Dynamic segments such as `[id]` become `:id`; catch-all segments such as
51
- `[...slug]` become `*`.
51
+ `[...slug]` become `*`. Parenthesized directories are route groups and do not
52
+ add a URL segment. Route groups can contain `page`, `layout`, and `loading`
53
+ files just like other directories. For example, `(auth)/sign-in/page.tsx` maps
54
+ to `/sign-in`. If two pages resolve to the same URL, the dev server displays a
55
+ diagnostic page listing the conflicting files and explaining how to fix them.
56
+ Production builds fail with the same diagnostic until the conflicts are fixed.
52
57
 
53
58
  Import the generated route configuration from the `virtual:routes` module in
54
59
  your application:
@@ -60,6 +65,21 @@ import { routes } from "virtual:routes";
60
65
  The virtual module exports a `routes` array with lazy-loaded page and layout
61
66
  components.
62
67
 
68
+ ### TypeScript
69
+
70
+ Make sure your application TypeScript project includes Vite's client types and
71
+ the router package's type declarations. For example, add this file as
72
+ `src/vite-env.d.ts`:
73
+
74
+ ```ts
75
+ /// <reference types="vite/client" />
76
+ /// <reference types="@troyio/vite-file-router" />
77
+ ```
78
+
79
+ If your `tsconfig.json` sets `compilerOptions.types`, include both
80
+ `"vite/client"` and `"@troyio/vite-file-router"` in that list. Then restart
81
+ the TypeScript server in your editor.
82
+
63
83
  ## Build
64
84
 
65
85
  ```sh
@@ -4,4 +4,13 @@ export type GeneratedRoutes = {
4
4
  helpers: string[];
5
5
  routes: string;
6
6
  };
7
+ export type RouteConflict = {
8
+ routePath: string;
9
+ files: string[];
10
+ };
11
+ export declare class RouteConflictError extends Error {
12
+ readonly conflicts: RouteConflict[];
13
+ constructor(conflicts: RouteConflict[]);
14
+ }
15
+ export declare function findPageRouteConflicts(node: Node, pages?: Map<string, string[]>, segments?: string[], root?: boolean): RouteConflict[];
7
16
  export declare function generate(node: Node, root?: boolean, projectRoot?: string): GeneratedRoutes;
package/dist/index.js CHANGED
@@ -3,6 +3,15 @@ import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork
3
3
  import { format } from "prettier";
4
4
  import fs from "node:fs";
5
5
  // src/generator.ts
6
+ var RouteConflictError = class extends Error {
7
+ conflicts;
8
+ constructor(conflicts) {
9
+ 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.`);
11
+ this.conflicts = conflicts;
12
+ this.name = "RouteConflictError";
13
+ }
14
+ };
6
15
  function getImportPath(file, projectRoot) {
7
16
  const relativePath = path.relative(projectRoot, file).split(path.sep).join("/");
8
17
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
@@ -12,6 +21,18 @@ function getRoutePath(directory) {
12
21
  if (segment.startsWith("[...") && segment.endsWith("]")) return "*";
13
22
  return segment.replace(/^\[(.+)\]$/, ":$1");
14
23
  }
24
+ function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segments = [], root = true) {
25
+ const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
26
+ if (node.page) {
27
+ const routePath = `/${routeSegments.map((segment) => segment.startsWith(":") ? ":" : segment).join("/")}`;
28
+ pages.set(routePath, [...pages.get(routePath) ?? [], node.page]);
29
+ }
30
+ for (const child of node.children) findPageRouteConflicts(child, pages, routeSegments, false);
31
+ return [...pages.entries()].filter(([, files]) => files.length > 1).map(([routePath, files]) => ({
32
+ routePath,
33
+ files
34
+ }));
35
+ }
15
36
  function generateImport(file, projectRoot) {
16
37
  return `async () => ({
17
38
  Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
@@ -23,7 +44,7 @@ function generateLazyComponent(file, projectRoot) {
23
44
  function generateNode(node, root, context, inheritedBoundary) {
24
45
  const route = {};
25
46
  const hasBoundary = inheritedBoundary || Boolean(node.loading);
26
- if (!root) route.path = JSON.stringify(getRoutePath(node.directory));
47
+ if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
27
48
  let loadingComponent;
28
49
  if (node.loading) {
29
50
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -53,6 +74,8 @@ function generateNode(node, root, context, inheritedBoundary) {
53
74
  return genObjectFromRaw(route);
54
75
  }
55
76
  function generate(node, root = false, projectRoot = process.cwd()) {
77
+ const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
78
+ if (conflicts.length) throw new RouteConflictError(conflicts);
56
79
  const context = {
57
80
  projectRoot,
58
81
  loadingImports: [],
@@ -85,6 +108,7 @@ function scan(directory) {
85
108
  const node = {
86
109
  name: path.basename(directory),
87
110
  directory,
111
+ routeGroup: /^\(.+\)$/.test(path.basename(directory)),
88
112
  children: []
89
113
  };
90
114
  if (!fs.existsSync(directory)) return node;
@@ -109,16 +133,44 @@ var RESOLVED_ID = `\0${MODULE_ID}`;
109
133
  function routes(directory = "src/pages") {
110
134
  let projectRoot = "";
111
135
  let root = "";
136
+ let dev = false;
137
+ function sortImports(imports) {
138
+ return imports.sort((left, right) => {
139
+ const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
140
+ const rightSource = right.match(/from ["'](.+?)["']/)?.[1] ?? right;
141
+ const leftIsRelative = leftSource.startsWith(".");
142
+ if (leftIsRelative !== rightSource.startsWith(".")) return leftIsRelative ? 1 : -1;
143
+ return leftSource.localeCompare(rightSource);
144
+ });
145
+ }
112
146
  async function execute() {
113
- const generated = generate(scan(root), true, projectRoot);
147
+ const tree = scan(root);
148
+ const conflicts = findPageRouteConflicts(tree);
149
+ if (conflicts.length) {
150
+ const error = new RouteConflictError(conflicts);
151
+ if (!dev) throw error;
152
+ const code = `
153
+ import { createElement } from "react";
154
+
155
+ const message = ${JSON.stringify(error.message)};
156
+
157
+ function RouteConflictPage() {
158
+ return createElement(
159
+ "main",
160
+ { style: { fontFamily: "system-ui, sans-serif", margin: "3rem auto", maxWidth: "60rem", padding: "0 1.5rem" } },
161
+ createElement("h1", null, "Conflicting routes"),
162
+ createElement("p", null, "The file-based router found page files that resolve to the same URL. Fix these conflicts, then the page will reload."),
163
+ createElement("pre", { style: { background: "#f3f4f6", overflowX: "auto", padding: "1rem", whiteSpace: "pre-wrap" } }, message),
164
+ );
165
+ }
166
+
167
+ export const routes = [{ path: "*", Component: RouteConflictPage }];
168
+ `;
169
+ return format(code, { parser: "typescript" });
170
+ }
171
+ const generated = generate(tree, true, projectRoot);
114
172
  const sections = [
115
- generated.imports.sort((left, right) => {
116
- const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
117
- const rightSource = right.match(/from ["'](.+?)["']/)?.[1] ?? right;
118
- const leftIsRelative = leftSource.startsWith(".");
119
- if (leftIsRelative !== rightSource.startsWith(".")) return leftIsRelative ? 1 : -1;
120
- return leftSource.localeCompare(rightSource);
121
- }).join("\n"),
173
+ sortImports(generated.imports).join("\n"),
122
174
  generated.helpers.join("\n"),
123
175
  `export const routes = ${genArrayFromRaw([generated.routes])};`
124
176
  ].filter(Boolean);
@@ -129,6 +181,7 @@ function routes(directory = "src/pages") {
129
181
  configResolved(config) {
130
182
  projectRoot = config.root;
131
183
  root = path.resolve(projectRoot, directory);
184
+ dev = config.command === "serve";
132
185
  },
133
186
  resolveId: (id) => id === MODULE_ID ? RESOLVED_ID : void 0,
134
187
  load: (id) => id === RESOLVED_ID ? execute() : void 0,
package/dist/scanner.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type Node = {
2
2
  name: string;
3
3
  directory: string;
4
+ routeGroup: boolean;
4
5
  page?: string;
5
6
  layout?: string;
6
7
  loading?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,