@troyio/vite-file-router 0.2.2 → 0.2.4

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
@@ -28,52 +28,110 @@ project root to use another directory:
28
28
  plugins: [routes("src/routes")];
29
29
  ```
30
30
 
31
- Each `page.jsx` or `page.tsx` becomes a route page, and each `layout.jsx` or
32
- `layout.tsx` becomes a lazy layout for its directory. Directories become nested
33
- routes. Add a `loading.jsx` or `loading.tsx` to provide a Suspense fallback for
34
- that directory's page and nested routes; a nested loading file provides a more
35
- specific fallback. Each file must default-export a React component. If both
36
- extensions exist for the same component, `.tsx` takes precedence. For example:
31
+ The router scans the pages directory recursively. Each directory becomes a
32
+ nested route, and the following files are recognized:
33
+
34
+ | File | Purpose |
35
+ | --- | --- |
36
+ | `page.jsx` / `page.tsx` | The page component for the directory's URL. |
37
+ | `layout.jsx` / `layout.tsx` | A layout around the directory's page and child routes. |
38
+ | `loading.jsx` / `loading.tsx` | A Suspense fallback for the directory's page and child routes. |
39
+
40
+ Each file must default-export a React component. If both extensions exist for
41
+ the same file type in one directory, `.tsx` takes precedence.
42
+
43
+ For example, this structure creates a home page, user pages, and two auth
44
+ pages:
37
45
 
38
46
  ```text
39
47
  src/pages/
40
- layout.jsx
41
- loading.jsx
42
48
  page.jsx
49
+ layout.tsx
50
+ loading.tsx
43
51
  users/
44
- loading.tsx
45
52
  page.tsx
46
53
  [id]/
47
54
  page.tsx
55
+ (auth)/
56
+ layout.tsx
57
+ sign-in/
58
+ page.tsx
59
+ sign-up/
60
+ page.tsx
61
+ [...slug]/
62
+ page.tsx
48
63
  ```
49
64
 
50
- Dynamic segments such as `[id]` become `:id`; catch-all segments such as
51
- `[...slug]` become `*`.
65
+ This maps to `/`, `/users`, `/users/:id`, `/sign-in`, `/sign-up`, and `/*`.
66
+ Square-bracket segments are dynamic parameters: `[id]` becomes `:id`.
67
+ Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
68
+ are route groups; they organize files and can have their own page, layout, and
69
+ loading files, but their names do not add a URL segment.
52
70
 
53
- Import the generated route configuration from the `virtual:routes` module in
54
- your application:
71
+ ### Layouts
55
72
 
56
- ```ts
57
- import { routes } from "virtual:routes";
73
+ The plugin adapts React Router's matched child route to a `children` prop. A
74
+ layout can render that prop directly and does not need to import or render
75
+ `Outlet`:
76
+
77
+ ```tsx
78
+ import type { PropsWithChildren } from "react";
79
+
80
+ export default function Layout({ children }: PropsWithChildren) {
81
+ return <div className="app-shell">{children}</div>;
82
+ }
58
83
  ```
59
84
 
60
- The virtual module exports a `routes` array with lazy-loaded page and layout
61
- components.
85
+ ### Loading fallbacks
86
+
87
+ Add a `loading.jsx` or `loading.tsx` beside a page or layout to show a Suspense
88
+ fallback while that route subtree loads:
89
+
90
+ ```tsx
91
+ export default function Loading() {
92
+ return <p>Loading...</p>;
93
+ }
94
+ ```
95
+
96
+ A nested loading file creates a more specific boundary for that directory.
97
+
98
+ ### Dynamic routes and route groups
99
+
100
+ Two pages must not resolve to the same URL. This includes pages with the same
101
+ path in separate route groups, such as `(auth)/sign-in/page.tsx` and
102
+ `(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
103
+ page listing the conflicting files; production builds fail with the same
104
+ diagnostic until the conflict is fixed.
105
+
106
+ ## Use the generated routes
107
+
108
+ Import `routes` from the virtual module and pass it to React Router:
109
+
110
+ ```tsx
111
+ import { createBrowserRouter, RouterProvider } from "react-router";
112
+ import { routes } from "virtual:routes";
113
+
114
+ const router = createBrowserRouter(routes);
115
+
116
+ export default function App() {
117
+ return <RouterProvider router={router} />;
118
+ }
119
+ ```
62
120
 
63
- ### TypeScript
121
+ ### TypeScript setup
64
122
 
65
- Make sure your application TypeScript project includes Vite's client types and
66
- the router package's type declarations. For example, add this file as
67
- `src/vite-env.d.ts`:
123
+ Add Vite's client types and the package's virtual-module declaration to your
124
+ application TypeScript project. For example, create `src/vite-env.d.ts`:
68
125
 
69
126
  ```ts
70
127
  /// <reference types="vite/client" />
71
128
  /// <reference types="@troyio/vite-file-router" />
72
129
  ```
73
130
 
74
- If your `tsconfig.json` sets `compilerOptions.types`, include both
75
- `"vite/client"` and `"@troyio/vite-file-router"` in that list. Then restart
76
- the TypeScript server in your editor.
131
+ If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
132
+ and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
133
+ `virtual:routes`. Restart your editor's TypeScript server if the declaration
134
+ isn't picked up immediately.
77
135
 
78
136
  ## Build
79
137
 
@@ -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,18 +21,39 @@ 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
18
39
  })`;
19
40
  }
41
+ function generateLayoutImport(file, projectRoot) {
42
+ return `async () => ({
43
+ Component: withOutlet((await import(${JSON.stringify(getImportPath(file, projectRoot))})).default)
44
+ })`;
45
+ }
20
46
  function generateLazyComponent(file, projectRoot) {
21
47
  return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}))`;
22
48
  }
49
+ function generateLazyLayout(file, projectRoot) {
50
+ return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
51
+ }
23
52
  function generateNode(node, root, context, inheritedBoundary) {
24
53
  const route = {};
25
54
  const hasBoundary = inheritedBoundary || Boolean(node.loading);
26
- if (!root) route.path = JSON.stringify(getRoutePath(node.directory));
55
+ if (node.layout) context.hasLayouts = true;
56
+ if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
27
57
  let loadingComponent;
28
58
  if (node.loading) {
29
59
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -31,11 +61,11 @@ function generateNode(node, root, context, inheritedBoundary) {
31
61
  context.hasBoundaries = true;
32
62
  }
33
63
  if (loadingComponent) {
34
- const child = node.layout ? generateLazyComponent(node.layout, context.projectRoot) : "Outlet";
64
+ const child = node.layout ? generateLazyLayout(node.layout, context.projectRoot) : "Outlet";
35
65
  route.Component = `createBoundary(${loadingComponent}, ${child})`;
36
66
  } else if (node.layout) {
37
- if (hasBoundary) route.Component = generateLazyComponent(node.layout, context.projectRoot);
38
- else route.lazy = generateImport(node.layout, context.projectRoot);
67
+ if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
68
+ else route.lazy = generateLayoutImport(node.layout, context.projectRoot);
39
69
  }
40
70
  const children = [];
41
71
  if (node.page) {
@@ -53,18 +83,26 @@ function generateNode(node, root, context, inheritedBoundary) {
53
83
  return genObjectFromRaw(route);
54
84
  }
55
85
  function generate(node, root = false, projectRoot = process.cwd()) {
86
+ const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
87
+ if (conflicts.length) throw new RouteConflictError(conflicts);
56
88
  const context = {
57
89
  projectRoot,
58
90
  loadingImports: [],
59
91
  nextLoadingId: 0,
60
- hasBoundaries: false
92
+ hasBoundaries: false,
93
+ hasLayouts: false
61
94
  };
62
95
  const routes = generateNode(node, root, context, false);
63
96
  const imports = [...context.loadingImports];
64
97
  const helpers = [];
65
- if (context.hasBoundaries) {
66
- imports.unshift(`import { createElement, lazy, Suspense } from "react";`, `import { Outlet } from "react-router";`);
67
- helpers.push(`function createBoundary(Fallback, Component) {
98
+ if (context.hasBoundaries || context.hasLayouts) {
99
+ const reactImports = ["createElement"];
100
+ if (context.hasBoundaries || context.hasLayouts) reactImports.push("lazy");
101
+ if (context.hasBoundaries) reactImports.push("Suspense");
102
+ imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
103
+ if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
104
+ }
105
+ if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
68
106
  return function RouteBoundary() {
69
107
  return createElement(
70
108
  Suspense,
@@ -73,7 +111,11 @@ function generate(node, root = false, projectRoot = process.cwd()) {
73
111
  );
74
112
  };
75
113
  }`);
76
- }
114
+ if (context.hasLayouts) helpers.push(`function withOutlet(Layout) {
115
+ return function LayoutRoute() {
116
+ return createElement(Layout, null, createElement(Outlet));
117
+ };
118
+ }`);
77
119
  return {
78
120
  imports,
79
121
  helpers,
@@ -85,6 +127,7 @@ function scan(directory) {
85
127
  const node = {
86
128
  name: path.basename(directory),
87
129
  directory,
130
+ routeGroup: /^\(.+\)$/.test(path.basename(directory)),
88
131
  children: []
89
132
  };
90
133
  if (!fs.existsSync(directory)) return node;
@@ -109,16 +152,44 @@ var RESOLVED_ID = `\0${MODULE_ID}`;
109
152
  function routes(directory = "src/pages") {
110
153
  let projectRoot = "";
111
154
  let root = "";
155
+ let dev = false;
156
+ function sortImports(imports) {
157
+ return imports.sort((left, right) => {
158
+ const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
159
+ const rightSource = right.match(/from ["'](.+?)["']/)?.[1] ?? right;
160
+ const leftIsRelative = leftSource.startsWith(".");
161
+ if (leftIsRelative !== rightSource.startsWith(".")) return leftIsRelative ? 1 : -1;
162
+ return leftSource.localeCompare(rightSource);
163
+ });
164
+ }
112
165
  async function execute() {
113
- const generated = generate(scan(root), true, projectRoot);
166
+ const tree = scan(root);
167
+ const conflicts = findPageRouteConflicts(tree);
168
+ if (conflicts.length) {
169
+ const error = new RouteConflictError(conflicts);
170
+ if (!dev) throw error;
171
+ const code = `
172
+ import { createElement } from "react";
173
+
174
+ const message = ${JSON.stringify(error.message)};
175
+
176
+ function RouteConflictPage() {
177
+ return createElement(
178
+ "main",
179
+ { style: { fontFamily: "system-ui, sans-serif", margin: "3rem auto", maxWidth: "60rem", padding: "0 1.5rem" } },
180
+ createElement("h1", null, "Conflicting routes"),
181
+ createElement("p", null, "The file-based router found page files that resolve to the same URL. Fix these conflicts, then the page will reload."),
182
+ createElement("pre", { style: { background: "#f3f4f6", overflowX: "auto", padding: "1rem", whiteSpace: "pre-wrap" } }, message),
183
+ );
184
+ }
185
+
186
+ export const routes = [{ path: "*", Component: RouteConflictPage }];
187
+ `;
188
+ return format(code, { parser: "typescript" });
189
+ }
190
+ const generated = generate(tree, true, projectRoot);
114
191
  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"),
192
+ sortImports(generated.imports).join("\n"),
122
193
  generated.helpers.join("\n"),
123
194
  `export const routes = ${genArrayFromRaw([generated.routes])};`
124
195
  ].filter(Boolean);
@@ -129,6 +200,7 @@ function routes(directory = "src/pages") {
129
200
  configResolved(config) {
130
201
  projectRoot = config.root;
131
202
  root = path.resolve(projectRoot, directory);
203
+ dev = config.command === "serve";
132
204
  },
133
205
  resolveId: (id) => id === MODULE_ID ? RESOLVED_ID : void 0,
134
206
  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.2",
3
+ "version": "0.2.4",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,