@ovineko/react-router 0.0.1
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 +287 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +303 -0
- package/dist/types.d.ts +47 -0
- package/dist/utils.d.ts +17 -0
- package/dist/validation.d.ts +11 -0
- package/dist/validation.test.d.ts +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# @ovineko/react-router
|
|
2
|
+
|
|
3
|
+
Type-safe wrapper for React Router v7 with valibot schema validation, automatic error handling, and typed params.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @ovineko/react-router valibot
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies: `react@^19`, `react-router@^7`, `valibot@^1`.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- ✅ **Valibot schema validation** - Runtime validation for URL params and search params
|
|
16
|
+
- ✅ **Two hook variants** - Normal hooks (auto-redirect on error) and Raw hooks (manual error handling)
|
|
17
|
+
- ✅ **Smart validation** - Ignores extra query params and invalid optional params
|
|
18
|
+
- ✅ **Global error handling** - Configure fallback redirect URLs globally or per-route
|
|
19
|
+
- ✅ **Type-safe** - Full TypeScript support with inferred types
|
|
20
|
+
- ✅ **Hash support** - Generate paths with hash fragments
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { createRouteWithParams } from "@ovineko/react-router";
|
|
26
|
+
import * as v from "valibot";
|
|
27
|
+
|
|
28
|
+
const userRoute = createRouteWithParams("/users/:id", {
|
|
29
|
+
params: v.object({ id: v.pipe(v.string(), v.uuid()) }),
|
|
30
|
+
searchParams: v.object({
|
|
31
|
+
tab: v.optional(v.string()),
|
|
32
|
+
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
|
|
33
|
+
}),
|
|
34
|
+
errorRedirect: "/404", // Optional: redirect on validation error
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Normal hooks - auto-redirect on validation error
|
|
38
|
+
function UserPage() {
|
|
39
|
+
const params = userRoute.useParams(); // Readonly<{ id: string }>
|
|
40
|
+
const [searchParams, setSearchParams] = userRoute.useSearchParams();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div>
|
|
44
|
+
User {params.id}, Page {searchParams.page ?? 1}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Raw hooks - manual error handling
|
|
50
|
+
function AdvancedUserPage() {
|
|
51
|
+
const [params, error] = userRoute.useParamsRaw();
|
|
52
|
+
const { data, error: searchError } = userRoute.useSearchParamsRaw();
|
|
53
|
+
|
|
54
|
+
if (error) return <div>Invalid user ID</div>;
|
|
55
|
+
if (searchError) console.warn("Invalid search params", searchError);
|
|
56
|
+
|
|
57
|
+
return <div>User {params?.id}</div>;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Global Error Redirect
|
|
62
|
+
|
|
63
|
+
Configure a global fallback URL for validation errors:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { setGlobalErrorRedirect } from "@ovineko/react-router";
|
|
67
|
+
|
|
68
|
+
// In your app entry point
|
|
69
|
+
setGlobalErrorRedirect("/error");
|
|
70
|
+
|
|
71
|
+
// Priority: route-level > global > default "/"
|
|
72
|
+
const route = createRouteWithParams("/users/:id", {
|
|
73
|
+
params: v.object({ id: v.string() }),
|
|
74
|
+
errorRedirect: "/404", // Overrides global
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Validation Behavior
|
|
79
|
+
|
|
80
|
+
### Path Params
|
|
81
|
+
|
|
82
|
+
- Always strict validation
|
|
83
|
+
- Redirect on any validation error
|
|
84
|
+
|
|
85
|
+
### Search Params
|
|
86
|
+
|
|
87
|
+
- **Extra params** (not in schema) → ignored
|
|
88
|
+
- **Invalid optional params** → ignored (treated as undefined)
|
|
89
|
+
- **Invalid required params** → error (triggers redirect)
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
const route = createRouteWithParams("/search", {
|
|
93
|
+
params: v.object({}),
|
|
94
|
+
searchParams: v.object({
|
|
95
|
+
q: v.string(), // required
|
|
96
|
+
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// URL: /search?q=react&page=invalid&debug=true
|
|
101
|
+
// Result: { q: "react", page: undefined }
|
|
102
|
+
// - page=invalid ignored (optional + invalid)
|
|
103
|
+
// - debug=true ignored (not in schema)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## API Reference
|
|
107
|
+
|
|
108
|
+
### Route Creation
|
|
109
|
+
|
|
110
|
+
#### `createRouteWithParams`
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
const route = createRouteWithParams<TParams, TSearchParams>(pattern, config);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Config:**
|
|
117
|
+
|
|
118
|
+
- `params` - Valibot schema for URL params (required)
|
|
119
|
+
- `searchParams?` - Valibot schema for search params (optional)
|
|
120
|
+
- `errorRedirect?` - Redirect URL on validation error (optional)
|
|
121
|
+
|
|
122
|
+
**Returns:**
|
|
123
|
+
|
|
124
|
+
- `path(params, searchParams?, hash?)` - Generate URL path
|
|
125
|
+
- `parseURLParams(url)` - Parse and validate URL params
|
|
126
|
+
- `Link` - Type-safe Link component
|
|
127
|
+
- `useParams()` - Get validated params (auto-redirect on error)
|
|
128
|
+
- `useParamsRaw()` - Get params with error info `[data, error]`
|
|
129
|
+
- `useSearchParams()` - Get validated search params (auto-redirect on error)
|
|
130
|
+
- `useSearchParamsRaw()` - Get search params with error info `{data, error, setter}`
|
|
131
|
+
- `pattern` - Original route pattern
|
|
132
|
+
|
|
133
|
+
#### `createRouteWithoutParams`
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
const route = createRouteWithoutParams<TSearchParams>(pattern, config?);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Config:**
|
|
140
|
+
|
|
141
|
+
- `searchParams?` - Valibot schema for search params
|
|
142
|
+
- `errorRedirect?` - Redirect URL on validation error
|
|
143
|
+
|
|
144
|
+
**Returns:**
|
|
145
|
+
|
|
146
|
+
- `path(searchParams?, hash?)` - Generate URL path
|
|
147
|
+
- `Link` - Type-safe Link component
|
|
148
|
+
- `useSearchParams()` - Get validated search params
|
|
149
|
+
- `useSearchParamsRaw()` - Get search params with error info
|
|
150
|
+
- `pattern` - Original route pattern
|
|
151
|
+
|
|
152
|
+
### Examples
|
|
153
|
+
|
|
154
|
+
#### Path Generation
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
const userRoute = createRouteWithParams("/users/:id", {
|
|
158
|
+
params: v.object({ id: v.string() }),
|
|
159
|
+
searchParams: v.object({
|
|
160
|
+
tab: v.optional(v.string()),
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
userRoute.path({ id: "42" });
|
|
165
|
+
// "/users/42"
|
|
166
|
+
|
|
167
|
+
userRoute.path({ id: "42" }, { tab: "settings" });
|
|
168
|
+
// "/users/42?tab=settings"
|
|
169
|
+
|
|
170
|
+
userRoute.path({ id: "42" }, { tab: "settings" }, "profile");
|
|
171
|
+
// "/users/42?tab=settings#profile"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Type-safe Links
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
<userRoute.Link params={{ id: "42" }} queryParams={{ tab: "profile" }}>
|
|
178
|
+
View Profile
|
|
179
|
+
</userRoute.Link>
|
|
180
|
+
|
|
181
|
+
// Automatically includes prevPath in navigation state
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### Parse URL Params
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
const params = userRoute.parseURLParams("https://example.com/users/42");
|
|
188
|
+
// { id: "42" }
|
|
189
|
+
|
|
190
|
+
// Throws URLParseError on invalid URL or validation error
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### Update Search Params
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
const [searchParams, setSearchParams] = route.useSearchParams();
|
|
197
|
+
|
|
198
|
+
// Set new params
|
|
199
|
+
setSearchParams({ q: "react", page: 1 });
|
|
200
|
+
|
|
201
|
+
// Update based on previous
|
|
202
|
+
setSearchParams((prev) => ({ ...prev, page: prev.page + 1 }));
|
|
203
|
+
|
|
204
|
+
// With navigation options
|
|
205
|
+
setSearchParams({ q: "vue" }, { replace: true });
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Utilities
|
|
209
|
+
|
|
210
|
+
#### `setGlobalErrorRedirect(url)`
|
|
211
|
+
|
|
212
|
+
Set global fallback redirect URL for validation errors.
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
setGlobalErrorRedirect("/error");
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### `replaceState(state)`
|
|
219
|
+
|
|
220
|
+
Update browser history state without navigation.
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
import { replaceState } from "@ovineko/react-router";
|
|
224
|
+
|
|
225
|
+
replaceState({ scrollTop: window.scrollY });
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
#### `URLParseError`
|
|
229
|
+
|
|
230
|
+
Error class thrown when URL parsing fails.
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
try {
|
|
234
|
+
route.parseURLParams("invalid-url");
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error instanceof URLParseError) {
|
|
237
|
+
console.log(error.context); // { pattern, url, ... }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## TypeScript
|
|
243
|
+
|
|
244
|
+
All types are automatically inferred from valibot schemas:
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
const route = createRouteWithParams("/users/:id", {
|
|
248
|
+
params: v.object({ id: v.string() }),
|
|
249
|
+
searchParams: v.object({
|
|
250
|
+
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Inferred types:
|
|
255
|
+
const params = route.useParams(); // Readonly<{ id: string }>
|
|
256
|
+
const [searchParams] = route.useSearchParams(); // Readonly<{ page?: number }>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Migration from v0.x
|
|
260
|
+
|
|
261
|
+
The library now uses valibot for validation instead of TypeScript generics:
|
|
262
|
+
|
|
263
|
+
**Before:**
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
const route = createRouteWithParams<{ id: string }, { q: string }>("/users/:id");
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**After:**
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
const route = createRouteWithParams("/users/:id", {
|
|
273
|
+
params: v.object({ id: v.string() }),
|
|
274
|
+
searchParams: v.object({ q: v.string() }),
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Benefits:**
|
|
279
|
+
|
|
280
|
+
- Runtime validation
|
|
281
|
+
- Automatic error handling
|
|
282
|
+
- Schema transformations (e.g., string → number)
|
|
283
|
+
- Better type inference
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import type { InferSearchParams, RouteWithoutParams, RouteWithParams, State } from "./types";
|
|
3
|
+
export type { RouteBase, RouteWithoutParams, RouteWithParams, State, UseParamsRawResult, UseSearchParamsRawResult, } from "./types";
|
|
4
|
+
export { URLParseError } from "./validation";
|
|
5
|
+
export declare const setGlobalErrorRedirect: (url: string) => void;
|
|
6
|
+
export declare const createRouteWithParams: <TParams extends v.GenericSchema, TSearchParams extends undefined | v.GenericSchema = undefined>(pattern: string, config: {
|
|
7
|
+
errorRedirect?: string;
|
|
8
|
+
params: TParams;
|
|
9
|
+
searchParams?: TSearchParams;
|
|
10
|
+
}) => Readonly<RouteWithParams<v.InferOutput<TParams>, InferSearchParams<TSearchParams>>>;
|
|
11
|
+
export declare const createRouteWithoutParams: <TSearchParams extends undefined | v.GenericSchema = undefined>(pattern: string, config?: {
|
|
12
|
+
errorRedirect?: string;
|
|
13
|
+
searchParams?: TSearchParams;
|
|
14
|
+
}) => Readonly<RouteWithoutParams<InferSearchParams<TSearchParams>>>;
|
|
15
|
+
export declare const replaceState: (state: State) => void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// src/index.tsx
|
|
2
|
+
import { memo, useCallback, useEffect, useMemo } from "react";
|
|
3
|
+
import {
|
|
4
|
+
generatePath,
|
|
5
|
+
Link,
|
|
6
|
+
useLocation,
|
|
7
|
+
useNavigate,
|
|
8
|
+
useParams,
|
|
9
|
+
useSearchParams
|
|
10
|
+
} from "react-router";
|
|
11
|
+
import * as v2 from "valibot";
|
|
12
|
+
|
|
13
|
+
// src/utils.ts
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
|
|
16
|
+
// src/validation.ts
|
|
17
|
+
var URLParseError = class extends Error {
|
|
18
|
+
context;
|
|
19
|
+
constructor(message, context = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "URLParseError";
|
|
22
|
+
this.context = context;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function safeDecodeURIComponent(value) {
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(value);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.warn(`Failed to decode URI component: ${value}`, error);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/utils.ts
|
|
35
|
+
var queryParamsToString = (params) => {
|
|
36
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
const query = new URLSearchParams();
|
|
40
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
41
|
+
if (value === void 0 || value === null) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
value.forEach((el) => {
|
|
46
|
+
if (el !== void 0 && el !== null) {
|
|
47
|
+
query.append(key, String(el));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
query.set(key, String(value));
|
|
53
|
+
});
|
|
54
|
+
const queryString = query.toString();
|
|
55
|
+
return queryString ? `?${queryString}` : "";
|
|
56
|
+
};
|
|
57
|
+
var searchParamsToObject = (params) => {
|
|
58
|
+
const obj = {};
|
|
59
|
+
for (const key of new Set(params.keys())) {
|
|
60
|
+
const values = params.getAll(key);
|
|
61
|
+
obj[key] = values.length > 1 ? values : values[0];
|
|
62
|
+
}
|
|
63
|
+
return obj;
|
|
64
|
+
};
|
|
65
|
+
var objectToSearchParams = (obj) => {
|
|
66
|
+
const params = new URLSearchParams();
|
|
67
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
68
|
+
if (value === void 0 || value === null) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
value.forEach((item) => {
|
|
73
|
+
if (item !== void 0 && item !== null) {
|
|
74
|
+
params.append(key, String(item));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
params.set(key, String(value));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return params;
|
|
82
|
+
};
|
|
83
|
+
var parseURLParamsRaw = (pattern, url) => {
|
|
84
|
+
let pathname;
|
|
85
|
+
try {
|
|
86
|
+
pathname = new URL(url).pathname;
|
|
87
|
+
} catch {
|
|
88
|
+
throw new URLParseError(`Invalid URL: "${url}"`, { pattern, url });
|
|
89
|
+
}
|
|
90
|
+
const urlParts = pathname.split("/").filter(Boolean);
|
|
91
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
92
|
+
const params = {};
|
|
93
|
+
for (const [i, patternPart] of patternParts.entries()) {
|
|
94
|
+
const urlValue = urlParts[i];
|
|
95
|
+
if (patternPart.startsWith(":")) {
|
|
96
|
+
const paramKey = patternPart.slice(1);
|
|
97
|
+
if (!urlValue) {
|
|
98
|
+
throw new URLParseError(`Missing required parameter "${paramKey}" in URL "${pathname}"`, {
|
|
99
|
+
paramKey,
|
|
100
|
+
pathname,
|
|
101
|
+
pattern,
|
|
102
|
+
url
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
params[paramKey] = safeDecodeURIComponent(urlValue);
|
|
106
|
+
} else if (patternPart !== urlValue) {
|
|
107
|
+
throw new URLParseError(
|
|
108
|
+
`URL path "${pathname}" doesn't match pattern "${pattern}": expected "${patternPart}" at position ${i}, got "${urlValue}"`,
|
|
109
|
+
{ actual: urlValue, expected: patternPart, pathname, pattern, position: i, url }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return params;
|
|
114
|
+
};
|
|
115
|
+
var filterBySchemaKeys = (obj, schema) => {
|
|
116
|
+
const entries = schema.entries;
|
|
117
|
+
const filtered = {};
|
|
118
|
+
for (const key in entries) {
|
|
119
|
+
if (key in obj) {
|
|
120
|
+
filtered[key] = obj[key];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return filtered;
|
|
124
|
+
};
|
|
125
|
+
var cleanOptionalParams = (obj, schema) => {
|
|
126
|
+
const entries = schema.entries;
|
|
127
|
+
const cleaned = {};
|
|
128
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
129
|
+
const fieldSchema = entries[key];
|
|
130
|
+
if (fieldSchema) {
|
|
131
|
+
const isOptional = fieldSchema.type === "optional";
|
|
132
|
+
if (isOptional) {
|
|
133
|
+
const result = v.safeParse(fieldSchema, value);
|
|
134
|
+
if (result.success) {
|
|
135
|
+
cleaned[key] = value;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
cleaned[key] = value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return cleaned;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/index.tsx
|
|
146
|
+
import { jsx } from "react/jsx-runtime";
|
|
147
|
+
var globalErrorRedirect = "/";
|
|
148
|
+
var setGlobalErrorRedirect = (url) => {
|
|
149
|
+
globalErrorRedirect = url;
|
|
150
|
+
};
|
|
151
|
+
var getErrorRedirectUrl = (routeLevel) => {
|
|
152
|
+
return routeLevel ?? globalErrorRedirect;
|
|
153
|
+
};
|
|
154
|
+
var createSearchParamsHook = (schema) => {
|
|
155
|
+
const useSearchParamsRaw = () => {
|
|
156
|
+
const [rawSearchParams, setRawSearchParams] = useSearchParams();
|
|
157
|
+
const { data, error } = useMemo(() => {
|
|
158
|
+
const obj = searchParamsToObject(rawSearchParams);
|
|
159
|
+
const filtered = filterBySchemaKeys(obj, schema);
|
|
160
|
+
const cleaned = cleanOptionalParams(filtered, schema);
|
|
161
|
+
const result = v2.safeParse(schema, cleaned);
|
|
162
|
+
if (result.success) {
|
|
163
|
+
return { data: result.output, error: void 0 };
|
|
164
|
+
}
|
|
165
|
+
return { data: void 0, error: result.issues };
|
|
166
|
+
}, [rawSearchParams]);
|
|
167
|
+
const setter = useCallback(
|
|
168
|
+
(nextInit, navigateOpts) => {
|
|
169
|
+
if (typeof nextInit === "function") {
|
|
170
|
+
setRawSearchParams((prev) => {
|
|
171
|
+
const prevObj = searchParamsToObject(prev);
|
|
172
|
+
const filtered = filterBySchemaKeys(
|
|
173
|
+
prevObj,
|
|
174
|
+
schema
|
|
175
|
+
);
|
|
176
|
+
const cleaned = cleanOptionalParams(
|
|
177
|
+
filtered,
|
|
178
|
+
schema
|
|
179
|
+
);
|
|
180
|
+
const result = v2.safeParse(schema, cleaned);
|
|
181
|
+
const prevParsed = result.success ? result.output : {};
|
|
182
|
+
const nextObj = nextInit(prevParsed);
|
|
183
|
+
return objectToSearchParams(nextObj);
|
|
184
|
+
}, navigateOpts);
|
|
185
|
+
} else {
|
|
186
|
+
setRawSearchParams(
|
|
187
|
+
objectToSearchParams(nextInit),
|
|
188
|
+
navigateOpts
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[setRawSearchParams]
|
|
193
|
+
);
|
|
194
|
+
return { data, error, setter };
|
|
195
|
+
};
|
|
196
|
+
const useSearchParamsHook = (errorRedirectUrl) => {
|
|
197
|
+
const navigate = useNavigate();
|
|
198
|
+
const { data, error, setter } = useSearchParamsRaw();
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (error) {
|
|
201
|
+
const redirectUrl = getErrorRedirectUrl(errorRedirectUrl);
|
|
202
|
+
navigate(redirectUrl, { replace: true });
|
|
203
|
+
}
|
|
204
|
+
}, [error, navigate, errorRedirectUrl]);
|
|
205
|
+
return [data ?? {}, setter];
|
|
206
|
+
};
|
|
207
|
+
return { useSearchParamsHook, useSearchParamsRaw };
|
|
208
|
+
};
|
|
209
|
+
var createRouteWithParams = (pattern, config) => {
|
|
210
|
+
const getPath = (params, searchParams, hash) => `${generatePath(pattern, params)}${queryParamsToString(searchParams)}${hash ? `#${hash}` : ""}`;
|
|
211
|
+
const searchParamsHooks = config.searchParams ? createSearchParamsHook(config.searchParams) : null;
|
|
212
|
+
const LinkComponent = memo(
|
|
213
|
+
({
|
|
214
|
+
children,
|
|
215
|
+
params,
|
|
216
|
+
queryParams,
|
|
217
|
+
state,
|
|
218
|
+
...props
|
|
219
|
+
}) => {
|
|
220
|
+
const { pathname } = useLocation();
|
|
221
|
+
const mergedState = useMemo(
|
|
222
|
+
() => ({ prevPath: pathname, ...state }),
|
|
223
|
+
[pathname, state]
|
|
224
|
+
);
|
|
225
|
+
return /* @__PURE__ */ jsx(Link, { ...props, state: mergedState, to: getPath(params, queryParams), children });
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
const useParamsRaw = () => {
|
|
229
|
+
const params = useParams();
|
|
230
|
+
const result = v2.safeParse(config.params, params);
|
|
231
|
+
if (result.success) {
|
|
232
|
+
return [result.output, void 0];
|
|
233
|
+
}
|
|
234
|
+
return [void 0, result.issues];
|
|
235
|
+
};
|
|
236
|
+
const useParamsHook = () => {
|
|
237
|
+
const navigate = useNavigate();
|
|
238
|
+
const [params, error] = useParamsRaw();
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (error) {
|
|
241
|
+
const redirectUrl = getErrorRedirectUrl(config.errorRedirect);
|
|
242
|
+
navigate(redirectUrl, { replace: true });
|
|
243
|
+
}
|
|
244
|
+
}, [error, navigate]);
|
|
245
|
+
return params ?? {};
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
Link: LinkComponent,
|
|
249
|
+
parseURLParams: (url) => {
|
|
250
|
+
const rawParams = parseURLParamsRaw(pattern, url);
|
|
251
|
+
return v2.parse(config.params, rawParams);
|
|
252
|
+
},
|
|
253
|
+
path: getPath,
|
|
254
|
+
pattern,
|
|
255
|
+
useParams: useParamsHook,
|
|
256
|
+
useParamsRaw,
|
|
257
|
+
useSearchParams: searchParamsHooks ? () => searchParamsHooks.useSearchParamsHook(config.errorRedirect) : void 0,
|
|
258
|
+
useSearchParamsRaw: searchParamsHooks ? searchParamsHooks.useSearchParamsRaw : void 0
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
var createRouteWithoutParams = (pattern, config) => {
|
|
262
|
+
const getPath = (searchParams, hash) => `${pattern}${queryParamsToString(searchParams)}${hash ? `#${hash}` : ""}`;
|
|
263
|
+
const searchParamsHooks = config?.searchParams ? createSearchParamsHook(config.searchParams) : null;
|
|
264
|
+
const LinkComponent = memo(
|
|
265
|
+
({ children, queryParams, state, ...props }) => {
|
|
266
|
+
const { pathname } = useLocation();
|
|
267
|
+
const mergedState = useMemo(
|
|
268
|
+
() => ({ prevPath: pathname, ...state }),
|
|
269
|
+
[pathname, state]
|
|
270
|
+
);
|
|
271
|
+
return /* @__PURE__ */ jsx(Link, { ...props, state: mergedState, to: getPath(queryParams), children });
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
return {
|
|
275
|
+
Link: LinkComponent,
|
|
276
|
+
path: getPath,
|
|
277
|
+
pattern,
|
|
278
|
+
useSearchParams: searchParamsHooks ? () => searchParamsHooks.useSearchParamsHook(config?.errorRedirect) : void 0,
|
|
279
|
+
useSearchParamsRaw: searchParamsHooks ? searchParamsHooks.useSearchParamsRaw : void 0
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
var replaceState = (state) => {
|
|
283
|
+
if (!globalThis.window) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
globalThis.window.history.replaceState(
|
|
287
|
+
{
|
|
288
|
+
...globalThis.window.history.state,
|
|
289
|
+
usr: {
|
|
290
|
+
...globalThis.window.history.state?.usr,
|
|
291
|
+
...state
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
""
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
export {
|
|
298
|
+
URLParseError,
|
|
299
|
+
createRouteWithParams,
|
|
300
|
+
createRouteWithoutParams,
|
|
301
|
+
replaceState,
|
|
302
|
+
setGlobalErrorRedirect
|
|
303
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { LinkProps as LinkPropsLib, NavigateOptions } from "react-router";
|
|
2
|
+
import type * as v from "valibot";
|
|
3
|
+
export type InferSearchParams<T> = T extends v.GenericSchema ? v.InferOutput<T> : undefined;
|
|
4
|
+
export interface LinkPropsWithoutParams<SearchParams> extends Omit<LinkPropsLib, "to"> {
|
|
5
|
+
children: React.ReactNode | string;
|
|
6
|
+
className?: string;
|
|
7
|
+
queryParams?: SearchParams;
|
|
8
|
+
state?: State;
|
|
9
|
+
style?: React.CSSProperties;
|
|
10
|
+
}
|
|
11
|
+
export interface LinkPropsWithParams<Params, SearchParams> extends LinkPropsWithoutParams<SearchParams> {
|
|
12
|
+
params: Params;
|
|
13
|
+
}
|
|
14
|
+
export interface RouteBase {
|
|
15
|
+
pattern: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RouteWithoutParams<SearchParams = undefined> extends RouteBase {
|
|
18
|
+
Link: (props: LinkPropsWithoutParams<SearchParams>) => React.ReactNode;
|
|
19
|
+
path: (searchParams?: SearchParams, hash?: string) => string;
|
|
20
|
+
useSearchParams: SearchParamsHook<SearchParams>;
|
|
21
|
+
useSearchParamsRaw: SearchParamsHook<SearchParams> extends undefined ? undefined : () => UseSearchParamsRawResult<SearchParams>;
|
|
22
|
+
}
|
|
23
|
+
export interface RouteWithParams<Params, SearchParams = undefined> extends RouteBase {
|
|
24
|
+
Link: (props: LinkPropsWithParams<Params, SearchParams>) => React.ReactNode;
|
|
25
|
+
parseURLParams: (url: string) => Params;
|
|
26
|
+
path: (params: Params, searchParams?: SearchParams, hash?: string) => string;
|
|
27
|
+
useParams: () => Readonly<Params>;
|
|
28
|
+
useParamsRaw: () => UseParamsRawResult<Params>;
|
|
29
|
+
useSearchParams: SearchParamsHook<SearchParams>;
|
|
30
|
+
useSearchParamsRaw: SearchParamsHook<SearchParams> extends undefined ? undefined : () => UseSearchParamsRawResult<SearchParams>;
|
|
31
|
+
}
|
|
32
|
+
export type SearchParamsHook<SearchParams> = SearchParams extends undefined ? undefined : () => readonly [Readonly<SearchParams>, SetSearchParams<SearchParams>];
|
|
33
|
+
export type SearchParamsInput = Record<string, string | string[]>;
|
|
34
|
+
export type SetSearchParams<SearchParams> = (nextInit: ((prev: Readonly<SearchParams>) => SearchParams) | SearchParams, navigateOpts?: NavigateOptions) => void;
|
|
35
|
+
export interface State {
|
|
36
|
+
prevPath?: string;
|
|
37
|
+
scrollTop?: number;
|
|
38
|
+
}
|
|
39
|
+
export type UseParamsRawResult<Params> = readonly [
|
|
40
|
+
Readonly<Params> | undefined,
|
|
41
|
+
undefined | v.BaseIssue<unknown>[]
|
|
42
|
+
];
|
|
43
|
+
export interface UseSearchParamsRawResult<SearchParams> {
|
|
44
|
+
data: Readonly<SearchParams> | undefined;
|
|
45
|
+
error: undefined | v.BaseIssue<unknown>[];
|
|
46
|
+
setter: SetSearchParams<SearchParams>;
|
|
47
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import type { SearchParamsInput } from "./types";
|
|
3
|
+
export declare const queryParamsToString: (params?: SearchParamsInput) => string;
|
|
4
|
+
export declare const searchParamsToObject: (params: URLSearchParams) => Record<string, string | string[]>;
|
|
5
|
+
export declare const objectToSearchParams: (obj: Record<string, unknown>) => URLSearchParams;
|
|
6
|
+
export declare const parseURLParamsRaw: (pattern: string, url: string) => Record<string, string>;
|
|
7
|
+
/**
|
|
8
|
+
* Filters an object to only include keys that exist in the valibot object schema.
|
|
9
|
+
* Ignores extra query parameters not defined in the schema.
|
|
10
|
+
*/
|
|
11
|
+
export declare const filterBySchemaKeys: <T extends v.ObjectSchema<any, any>>(obj: Record<string, unknown>, schema: T) => Record<string, unknown>;
|
|
12
|
+
/**
|
|
13
|
+
* Removes invalid optional parameters from an object.
|
|
14
|
+
* Optional parameters with invalid values are ignored (treated as undefined).
|
|
15
|
+
* Required parameters are kept as-is for subsequent validation.
|
|
16
|
+
*/
|
|
17
|
+
export declare const cleanOptionalParams: <T extends v.ObjectSchema<any, any>>(obj: Record<string, unknown>, schema: T) => Record<string, unknown>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error class for URL parsing failures
|
|
3
|
+
*/
|
|
4
|
+
export declare class URLParseError extends Error {
|
|
5
|
+
readonly context: Record<string, unknown>;
|
|
6
|
+
constructor(message: string, context?: Record<string, unknown>);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Decodes URL component safely, handling special characters
|
|
10
|
+
*/
|
|
11
|
+
export declare function safeDecodeURIComponent(value: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ovineko/react-router",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Type-safe wrapper for React Router v7 with typed params, query params, and Link components",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"react-router",
|
|
8
|
+
"router",
|
|
9
|
+
"type-safe",
|
|
10
|
+
"typed-routes",
|
|
11
|
+
"typed-params",
|
|
12
|
+
"query-params",
|
|
13
|
+
"routing",
|
|
14
|
+
"typescript",
|
|
15
|
+
"react19",
|
|
16
|
+
"link"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/ovineko/ovineko/tree/main/packages/react-router",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/ovineko/ovineko/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/ovineko/ovineko.git",
|
|
25
|
+
"directory": "packages/react-router"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"author": "Alexander Svinarev <shibanet0@gmail.com> (shibanet0.com)",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": "^19",
|
|
42
|
+
"react-router": "^7",
|
|
43
|
+
"valibot": "^1"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|