@troyio/vite-file-router 0.2.3 → 0.2.5

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,57 +28,144 @@ 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
+ | `not-found.jsx` / `not-found.tsx` | A not-found page for unmatched URLs within the directory's route subtree. |
40
+ | `error.jsx` / `error.tsx` | A React Router error boundary for the directory's route subtree. |
41
+
42
+ Each file must default-export a React component. If both extensions exist for
43
+ the same file type in one directory, `.tsx` takes precedence.
44
+
45
+ For example, this structure creates a home page, user pages, and two auth
46
+ pages:
37
47
 
38
48
  ```text
39
49
  src/pages/
40
- layout.jsx
41
- loading.jsx
42
50
  page.jsx
51
+ layout.tsx
52
+ loading.tsx
43
53
  users/
44
- loading.tsx
45
54
  page.tsx
46
55
  [id]/
47
56
  page.tsx
57
+ (auth)/
58
+ layout.tsx
59
+ sign-in/
60
+ page.tsx
61
+ sign-up/
62
+ page.tsx
63
+ [...slug]/
64
+ page.tsx
48
65
  ```
49
66
 
50
- Dynamic segments such as `[id]` become `:id`; catch-all segments such as
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.
67
+ This maps to `/`, `/users`, `/users/:id`, `/sign-in`, `/sign-up`, and `/*`.
68
+ Square-bracket segments are dynamic parameters: `[id]` becomes `:id`.
69
+ Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
70
+ are route groups; they organize files and can have their own page, layout, and
71
+ loading files, but their names do not add a URL segment.
57
72
 
58
- Import the generated route configuration from the `virtual:routes` module in
59
- your application:
73
+ ### Layouts
60
74
 
61
- ```ts
62
- import { routes } from "virtual:routes";
75
+ The plugin adapts React Router's matched child route to a `children` prop. A
76
+ layout can render that prop directly and does not need to import or render
77
+ `Outlet`:
78
+
79
+ ```tsx
80
+ import type { PropsWithChildren } from "react";
81
+
82
+ export default function Layout({ children }: PropsWithChildren) {
83
+ return <div className="app-shell">{children}</div>;
84
+ }
63
85
  ```
64
86
 
65
- The virtual module exports a `routes` array with lazy-loaded page and layout
66
- components.
87
+ ### Loading fallbacks
88
+
89
+ Add a `loading.jsx` or `loading.tsx` beside a page or layout to show a Suspense
90
+ fallback while that route subtree loads:
91
+
92
+ ```tsx
93
+ export default function Loading() {
94
+ return <p>Loading...</p>;
95
+ }
96
+ ```
97
+
98
+ A nested loading file creates a more specific boundary for that directory.
99
+
100
+ ### Not-found pages
101
+
102
+ Add a `not-found.jsx` or `not-found.tsx` file to handle unmatched URLs. The plugin generates
103
+ a catch-all child route, and the most specific matching route subtree's
104
+ not-found page is used:
105
+
106
+ ```text
107
+ src/pages/
108
+ not-found.tsx
109
+ users/
110
+ not-found.tsx
111
+ page.tsx
112
+ ```
113
+
114
+ The root not-found page handles unknown URLs generally, while `users/not-found.tsx`
115
+ handles unmatched URLs under `/users`.
116
+
117
+ ### Route error boundaries
118
+
119
+ Add an `error.jsx` or `error.tsx` file to handle errors from that route and its
120
+ descendants. The nearest route boundary is used, following React Router's error
121
+ handling behavior. Use React Router's `useRouteError` hook to read the error:
122
+
123
+ ```tsx
124
+ import { useRouteError } from "react-router";
125
+
126
+ export default function ErrorPage() {
127
+ const error = useRouteError();
128
+ return <main>Something went wrong: {String(error)}</main>;
129
+ }
130
+ ```
131
+
132
+ ### Dynamic routes and route groups
133
+
134
+ Two pages must not resolve to the same URL. This includes pages with the same
135
+ path in separate route groups, such as `(auth)/sign-in/page.tsx` and
136
+ `(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
137
+ page listing the conflicting files; production builds fail with the same
138
+ diagnostic until the conflict is fixed.
139
+
140
+ ## Use the generated routes
141
+
142
+ Import `routes` from the virtual module and pass it to React Router:
143
+
144
+ ```tsx
145
+ import { createBrowserRouter, RouterProvider } from "react-router";
146
+ import { routes } from "virtual:routes";
147
+
148
+ const router = createBrowserRouter(routes);
149
+
150
+ export default function App() {
151
+ return <RouterProvider router={router} />;
152
+ }
153
+ ```
67
154
 
68
- ### TypeScript
155
+ ### TypeScript setup
69
156
 
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`:
157
+ Add Vite's client types and the package's virtual-module declaration to your
158
+ application TypeScript project. For example, create `src/vite-env.d.ts`:
73
159
 
74
160
  ```ts
75
161
  /// <reference types="vite/client" />
76
162
  /// <reference types="@troyio/vite-file-router" />
77
163
  ```
78
164
 
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.
165
+ If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
166
+ and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
167
+ `virtual:routes`. Restart your editor's TypeScript server if the declaration
168
+ isn't picked up immediately.
82
169
 
83
170
  ## Build
84
171
 
package/dist/index.js CHANGED
@@ -33,18 +33,36 @@ function findPageRouteConflicts(node, pages = /* @__PURE__ */ new Map(), segment
33
33
  files
34
34
  }));
35
35
  }
36
+ function validateRouteGroupNotFound(node, segments = [], root = true) {
37
+ const routeSegments = root || node.routeGroup ? segments : [...segments, getRoutePath(node.directory)];
38
+ if (node.routeGroup && node.notFound && routeSegments.length === 0) throw new Error(`Route-group not-found page "${node.notFound}" has no URL subtree to match. Move it into a directory with a URL segment so it is scoped to a distinct route subtree.`);
39
+ for (const child of node.children) validateRouteGroupNotFound(child, routeSegments, false);
40
+ }
36
41
  function generateImport(file, projectRoot) {
37
42
  return `async () => ({
38
43
  Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
39
44
  })`;
40
45
  }
46
+ function generateRouteLazy(node, projectRoot, includeLayout) {
47
+ const properties = [];
48
+ if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
49
+ return `async () => ({ ${properties.join(", ")} })`;
50
+ }
41
51
  function generateLazyComponent(file, projectRoot) {
42
52
  return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}))`;
43
53
  }
54
+ function generateLazyLayout(file, projectRoot) {
55
+ return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
56
+ }
44
57
  function generateNode(node, root, context, inheritedBoundary) {
45
58
  const route = {};
46
59
  const hasBoundary = inheritedBoundary || Boolean(node.loading);
60
+ if (node.layout) context.hasLayouts = true;
47
61
  if (!root && !node.routeGroup) route.path = JSON.stringify(getRoutePath(node.directory));
62
+ if (node.error) {
63
+ context.hasErrorBoundaries = true;
64
+ route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
65
+ }
48
66
  let loadingComponent;
49
67
  if (node.loading) {
50
68
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -52,12 +70,12 @@ function generateNode(node, root, context, inheritedBoundary) {
52
70
  context.hasBoundaries = true;
53
71
  }
54
72
  if (loadingComponent) {
55
- const child = node.layout ? generateLazyComponent(node.layout, context.projectRoot) : "Outlet";
73
+ const child = node.layout ? generateLazyLayout(node.layout, context.projectRoot) : "Outlet";
56
74
  route.Component = `createBoundary(${loadingComponent}, ${child})`;
57
75
  } else if (node.layout) {
58
- if (hasBoundary) route.Component = generateLazyComponent(node.layout, context.projectRoot);
59
- else route.lazy = generateImport(node.layout, context.projectRoot);
76
+ if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
60
77
  }
78
+ if (node.layout && !hasBoundary) route.lazy = generateRouteLazy(node, context.projectRoot, true);
61
79
  const children = [];
62
80
  if (node.page) {
63
81
  const page = hasBoundary ? {
@@ -70,24 +88,43 @@ function generateNode(node, root, context, inheritedBoundary) {
70
88
  children.push(genObjectFromRaw(page));
71
89
  }
72
90
  for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
91
+ if (node.notFound) {
92
+ const notFoundRoute = hasBoundary ? {
93
+ path: JSON.stringify("*"),
94
+ Component: generateLazyComponent(node.notFound, context.projectRoot)
95
+ } : {
96
+ path: JSON.stringify("*"),
97
+ lazy: generateImport(node.notFound, context.projectRoot)
98
+ };
99
+ children.push(genObjectFromRaw(notFoundRoute));
100
+ }
73
101
  if (children.length) route.children = genArrayFromRaw(children);
74
102
  return genObjectFromRaw(route);
75
103
  }
76
104
  function generate(node, root = false, projectRoot = process.cwd()) {
105
+ validateRouteGroupNotFound(node, [], root);
77
106
  const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
78
107
  if (conflicts.length) throw new RouteConflictError(conflicts);
79
108
  const context = {
80
109
  projectRoot,
81
110
  loadingImports: [],
82
111
  nextLoadingId: 0,
83
- hasBoundaries: false
112
+ hasBoundaries: false,
113
+ hasLayouts: false,
114
+ hasErrorBoundaries: false
84
115
  };
85
116
  const routes = generateNode(node, root, context, false);
86
117
  const imports = [...context.loadingImports];
87
118
  const helpers = [];
88
- if (context.hasBoundaries) {
89
- imports.unshift(`import { createElement, lazy, Suspense } from "react";`, `import { Outlet } from "react-router";`);
90
- helpers.push(`function createBoundary(Fallback, Component) {
119
+ if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
120
+ const reactImports = [];
121
+ if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("createElement");
122
+ if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
123
+ if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
124
+ imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
125
+ if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
126
+ }
127
+ if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
91
128
  return function RouteBoundary() {
92
129
  return createElement(
93
130
  Suspense,
@@ -96,7 +133,20 @@ function generate(node, root = false, projectRoot = process.cwd()) {
96
133
  );
97
134
  };
98
135
  }`);
99
- }
136
+ if (context.hasLayouts) helpers.push(`function withOutlet(Layout) {
137
+ return function LayoutRoute() {
138
+ return createElement(Layout, null, createElement(Outlet));
139
+ };
140
+ }`);
141
+ if (context.hasErrorBoundaries) helpers.push(`function createErrorBoundary(Component) {
142
+ return function RouteErrorBoundary() {
143
+ return createElement(
144
+ Suspense,
145
+ { fallback: createElement("p", null, "Loading error page...") },
146
+ createElement(Component),
147
+ );
148
+ };
149
+ }`);
100
150
  return {
101
151
  imports,
102
152
  helpers,
@@ -119,10 +169,10 @@ function scan(directory) {
119
169
  node.children.push(scan(file));
120
170
  continue;
121
171
  }
122
- const match = /^(page|layout|loading)\.(jsx|tsx)$/.exec(entry.name);
172
+ const match = /^(page|layout|loading|not-found|error)\.(jsx|tsx)$/.exec(entry.name);
123
173
  if (!match) continue;
124
174
  const [, name, extension] = match;
125
- const key = name;
175
+ const key = name === "not-found" ? "notFound" : name;
126
176
  if (!node[key] || extension === "tsx") node[key] = file;
127
177
  }
128
178
  return node;
package/dist/scanner.d.ts CHANGED
@@ -5,6 +5,8 @@ export type Node = {
5
5
  page?: string;
6
6
  layout?: string;
7
7
  loading?: string;
8
+ notFound?: string;
9
+ error?: string;
8
10
  children: Node[];
9
11
  };
10
12
  export declare function scan(directory: string): Node;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,