@toapi/router 0.6.2
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 +236 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +14 -0
- package/dist/immutable-search-params.d.ts +7 -0
- package/dist/immutable-search-params.d.ts.map +1 -0
- package/dist/immutable-search-params.js +20 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/link.d.ts +8 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +23 -0
- package/dist/mock-history.d.ts +9 -0
- package/dist/mock-history.d.ts.map +1 -0
- package/dist/mock-history.js +25 -0
- package/dist/path.d.ts +13 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +60 -0
- package/dist/route.d.ts +7 -0
- package/dist/route.d.ts.map +1 -0
- package/dist/route.js +19 -0
- package/dist/router.d.ts +16 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +40 -0
- package/dist/switch.d.ts +8 -0
- package/dist/switch.d.ts.map +1 -0
- package/dist/switch.js +43 -0
- package/dist/use-hash.d.ts +2 -0
- package/dist/use-hash.d.ts.map +1 -0
- package/dist/use-hash.js +5 -0
- package/dist/use-params.d.ts +2 -0
- package/dist/use-params.d.ts.map +1 -0
- package/dist/use-params.js +6 -0
- package/dist/use-pathname.d.ts +2 -0
- package/dist/use-pathname.d.ts.map +1 -0
- package/dist/use-pathname.js +5 -0
- package/dist/use-router.d.ts +4 -0
- package/dist/use-router.d.ts.map +1 -0
- package/dist/use-router.js +25 -0
- package/dist/use-search-params.d.ts +2 -0
- package/dist/use-search-params.d.ts.map +1 -0
- package/dist/use-search-params.js +5 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# @toapi/router Documentation
|
|
2
|
+
|
|
3
|
+
A lightweight, React-based client-side router with support for nested routes, path parameters, and immutable search parameter handling.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The `@toapi/router` package provides a very lightweight routing solution for React applications with the following key features:
|
|
8
|
+
|
|
9
|
+
- **Declarative routing** with React components
|
|
10
|
+
- **Nested routes** with parameter inheritance
|
|
11
|
+
- **Path parameters** with colon syntax (`:id`)
|
|
12
|
+
- **Wildcard routes** for catch-all paths (`*` and `*name`)
|
|
13
|
+
- **Immutable search parameters** for predictable state management
|
|
14
|
+
- **Client-side navigation** with history management
|
|
15
|
+
- **TypeScript support** for better development experience
|
|
16
|
+
- **Testing-friendly** with customizable location and history
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { Router, Route, Link } from "@toapi/router";
|
|
22
|
+
|
|
23
|
+
function App() {
|
|
24
|
+
return (
|
|
25
|
+
<Router>
|
|
26
|
+
<nav>
|
|
27
|
+
<Link href="/">Home</Link>
|
|
28
|
+
<Link href="/users">Users</Link>
|
|
29
|
+
<Link href="/about">About</Link>
|
|
30
|
+
</nav>
|
|
31
|
+
|
|
32
|
+
<main>
|
|
33
|
+
<Route path="/">
|
|
34
|
+
<HomePage />
|
|
35
|
+
</Route>
|
|
36
|
+
|
|
37
|
+
<Route path="/users">
|
|
38
|
+
<UsersLayout />
|
|
39
|
+
<Route exact>
|
|
40
|
+
<UsersList />
|
|
41
|
+
</Route>
|
|
42
|
+
<Route path=":id">
|
|
43
|
+
<UserProfile />
|
|
44
|
+
</Route>
|
|
45
|
+
</Route>
|
|
46
|
+
|
|
47
|
+
<Route path="/about">
|
|
48
|
+
<AboutPage />
|
|
49
|
+
</Route>
|
|
50
|
+
</main>
|
|
51
|
+
</Router>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Components
|
|
57
|
+
|
|
58
|
+
### [Router](./docs/Router.md)
|
|
59
|
+
The root component that provides routing context to your application.
|
|
60
|
+
|
|
61
|
+
- Manages current location state
|
|
62
|
+
- Provides navigation methods
|
|
63
|
+
- Supports custom location and history for testing
|
|
64
|
+
- Normalizes pathnames (removes trailing slashes)
|
|
65
|
+
|
|
66
|
+
### [Route](./docs/Route.md)
|
|
67
|
+
Conditionally renders content based on the current pathname.
|
|
68
|
+
|
|
69
|
+
- Path matching with parameters (`/users/:id`)
|
|
70
|
+
- Wildcard matching (`/files/*` or `/files/*path`)
|
|
71
|
+
- Exact matching option
|
|
72
|
+
- Nested route support
|
|
73
|
+
- Parameter inheritance from parent routes
|
|
74
|
+
|
|
75
|
+
### [Link](./docs/Link.md)
|
|
76
|
+
Declarative navigation component that renders as an anchor element.
|
|
77
|
+
|
|
78
|
+
- Client-side navigation with history management
|
|
79
|
+
- Supports absolute and relative paths
|
|
80
|
+
- Query parameter handling
|
|
81
|
+
|
|
82
|
+
## Hooks
|
|
83
|
+
|
|
84
|
+
### [useRouter](./docs/useRouter.md)
|
|
85
|
+
Provides programmatic navigation methods.
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
const router = useRouter();
|
|
89
|
+
router.push("/users/123"); // Navigate with history
|
|
90
|
+
router.replace("/login"); // Replace current entry
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### [usePathname](./docs/usePathname.md)
|
|
94
|
+
Access the current pathname for conditional rendering and active states.
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
const pathname = usePathname();
|
|
98
|
+
const isActive = pathname === "/users";
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### [useSearchParams](./docs/useSearchParams.md)
|
|
102
|
+
Access and manipulate URL search parameters with immutable methods.
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
const searchParams = useSearchParams();
|
|
106
|
+
const query = searchParams.get("q");
|
|
107
|
+
const newParams = searchParams.set("filter", "active");
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### [useParams](./docs/useParams.md)
|
|
111
|
+
Extract parameters from dynamic route segments.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// Route: /users/:id/posts/:postId
|
|
115
|
+
const params = useParams(); // { id: "123", postId: "456" }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### [useHash](./docs/useHash.md)
|
|
119
|
+
Access the current URL hash fragment for tab navigation and anchor linking.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
const hash = useHash();
|
|
123
|
+
const activeTab = hash.slice(1) || "overview";
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Key Features
|
|
127
|
+
|
|
128
|
+
### Nested Routing
|
|
129
|
+
|
|
130
|
+
Create hierarchical route structures with parameter inheritance:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
<Route path="/organizations/:orgId">
|
|
134
|
+
<OrganizationLayout />
|
|
135
|
+
|
|
136
|
+
<Route path="teams/:teamId">
|
|
137
|
+
<TeamLayout />
|
|
138
|
+
|
|
139
|
+
<Route path="members/:memberId">
|
|
140
|
+
<MemberProfile />
|
|
141
|
+
</Route>
|
|
142
|
+
</Route>
|
|
143
|
+
</Route>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Path Parameters
|
|
147
|
+
|
|
148
|
+
Define dynamic segments with colon syntax:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
<Route path="/users/:id"> {/* /users/123 */}
|
|
152
|
+
<Route path="/posts/:slug"> {/* /posts/hello-world */}
|
|
153
|
+
<Route path="/api/:version"> {/* /api/v1 */}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Wildcard Routes
|
|
157
|
+
|
|
158
|
+
Match arbitrary paths with wildcards:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<Route path="/files/*"> {/* Matches /files/a, /files/a/b/c, etc. */}
|
|
162
|
+
<Route path="/docs/*path"> {/* Matches and captures as params.path */}
|
|
163
|
+
<Route path="/api/:version/*rest"> {/* Combines params with wildcards */}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The `*` wildcard matches everything including slashes, making it perfect for catch-all routes. Use `*name` to capture the matched path as a parameter accessible via `useParams()`.
|
|
167
|
+
|
|
168
|
+
### Immutable Search Parameters
|
|
169
|
+
|
|
170
|
+
Safely update URL search parameters without mutations:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
const searchParams = useSearchParams();
|
|
174
|
+
const withFilter = searchParams.set("category", "electronics");
|
|
175
|
+
const withSort = withFilter.set("sort", "price");
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Testing Support
|
|
179
|
+
|
|
180
|
+
Provide custom location and history for predictable tests:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
<Router
|
|
184
|
+
location={{ pathname: "/users/123", search: "?tab=profile", hash: "#bio" }}
|
|
185
|
+
history={mockHistory}
|
|
186
|
+
>
|
|
187
|
+
<App />
|
|
188
|
+
</Router>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Common Patterns
|
|
192
|
+
|
|
193
|
+
### Active Navigation Links
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
function NavLink({ href, children }) {
|
|
197
|
+
const pathname = usePathname();
|
|
198
|
+
const isActive = pathname === href;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<Link
|
|
202
|
+
href={href}
|
|
203
|
+
className={isActive ? 'nav-link active' : 'nav-link'}
|
|
204
|
+
>
|
|
205
|
+
{children}
|
|
206
|
+
</Link>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Search and Filtering
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
function ProductSearch() {
|
|
215
|
+
const router = useRouter();
|
|
216
|
+
const pathname = usePathname();
|
|
217
|
+
const searchParams = useSearchParams();
|
|
218
|
+
|
|
219
|
+
const updateFilter = (key: string, value: string) => {
|
|
220
|
+
const newParams = searchParams.set(key, value);
|
|
221
|
+
router.push(`${pathname}?${newParams.toString()}`);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<select onChange={(e) => updateFilter("category", e.target.value)}>
|
|
226
|
+
<option value="">All Categories</option>
|
|
227
|
+
<option value="electronics">Electronics</option>
|
|
228
|
+
</select>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
## API Reference
|
|
235
|
+
|
|
236
|
+
For detailed API documentation, see the individual component and hook documentation files linked above.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ImmutableSearchParams } from "./immutable-search-params";
|
|
2
|
+
export declare const PathnameContext: import("react").Context<string>;
|
|
3
|
+
export declare const SearchParamsContext: import("react").Context<ImmutableSearchParams>;
|
|
4
|
+
export declare const HashContext: import("react").Context<string>;
|
|
5
|
+
export declare const RouterContext: import("react").Context<{
|
|
6
|
+
push: (href: string) => void;
|
|
7
|
+
replace: (href: string) => void;
|
|
8
|
+
}>;
|
|
9
|
+
export interface RouteContextValue {
|
|
10
|
+
path: string;
|
|
11
|
+
params: Record<string, string | string[]>;
|
|
12
|
+
matchedPathname: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const RouteContext: import("react").Context<RouteContextValue>;
|
|
15
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAElE,eAAO,MAAM,eAAe,iCAA6B,CAAC;AAE1D,eAAO,MAAM,mBAAmB,gDAE/B,CAAC;AAEF,eAAO,MAAM,WAAW,iCAA4B,CAAC;AAErD,eAAO,MAAM,aAAa;UAClB,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI;aACnB,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI;EAI/B,CAAC;AAEH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,eAAO,MAAM,YAAY,4CAIvB,CAAC"}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import { ImmutableSearchParams } from "./immutable-search-params";
|
|
3
|
+
export const PathnameContext = createContext("/");
|
|
4
|
+
export const SearchParamsContext = createContext(new ImmutableSearchParams());
|
|
5
|
+
export const HashContext = createContext("");
|
|
6
|
+
export const RouterContext = createContext({
|
|
7
|
+
push: () => { },
|
|
8
|
+
replace: () => { },
|
|
9
|
+
});
|
|
10
|
+
export const RouteContext = createContext({
|
|
11
|
+
path: "/",
|
|
12
|
+
params: {},
|
|
13
|
+
matchedPathname: "/",
|
|
14
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare class ImmutableSearchParams extends URLSearchParams {
|
|
2
|
+
set(key: string, value: string): ImmutableSearchParams;
|
|
3
|
+
append(name: string, value: string): ImmutableSearchParams;
|
|
4
|
+
delete(key: string): ImmutableSearchParams;
|
|
5
|
+
get search(): string;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=immutable-search-params.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"immutable-search-params.d.ts","sourceRoot":"","sources":["../src/immutable-search-params.ts"],"names":[],"mappings":"AAAA,qBAAa,qBAAsB,SAAQ,eAAe;IAC/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;IAM9B,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;IAMlC,MAAM,CAAC,GAAG,EAAE,MAAM;IAM3B,IAAI,MAAM,IAAI,MAAM,CAEnB;CACF"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class ImmutableSearchParams extends URLSearchParams {
|
|
2
|
+
set(key, value) {
|
|
3
|
+
const newSearchParams = new URLSearchParams(this);
|
|
4
|
+
newSearchParams.set(key, value);
|
|
5
|
+
return new ImmutableSearchParams(newSearchParams);
|
|
6
|
+
}
|
|
7
|
+
append(name, value) {
|
|
8
|
+
const newSearchParams = new URLSearchParams(this);
|
|
9
|
+
newSearchParams.append(name, value);
|
|
10
|
+
return new ImmutableSearchParams(newSearchParams);
|
|
11
|
+
}
|
|
12
|
+
delete(key) {
|
|
13
|
+
const newSearchParams = new URLSearchParams(this);
|
|
14
|
+
newSearchParams.delete(key);
|
|
15
|
+
return new ImmutableSearchParams(newSearchParams);
|
|
16
|
+
}
|
|
17
|
+
get search() {
|
|
18
|
+
return this.size === 0 ? "" : "?" + this.toString();
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Router } from "./router";
|
|
2
|
+
export { Route } from "./route";
|
|
3
|
+
export { Link } from "./link";
|
|
4
|
+
export { useRouter } from "./use-router";
|
|
5
|
+
export { usePathname } from "./use-pathname";
|
|
6
|
+
export { useSearchParams } from "./use-search-params";
|
|
7
|
+
export { useHash } from "./use-hash";
|
|
8
|
+
export { useParams } from "./use-params";
|
|
9
|
+
export { Switch } from "./switch";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Router } from "./router";
|
|
2
|
+
export { Route } from "./route";
|
|
3
|
+
export { Link } from "./link";
|
|
4
|
+
export { useRouter } from "./use-router";
|
|
5
|
+
export { usePathname } from "./use-pathname";
|
|
6
|
+
export { useSearchParams } from "./use-search-params";
|
|
7
|
+
export { useHash } from "./use-hash";
|
|
8
|
+
export { useParams } from "./use-params";
|
|
9
|
+
export { Switch } from "./switch";
|
package/dist/link.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type HTMLProps } from "react";
|
|
2
|
+
interface Props extends HTMLProps<HTMLAnchorElement> {
|
|
3
|
+
href: string;
|
|
4
|
+
replace?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function Link({ href, replace, children, onClick, ...rawProps }: Props): import("react").JSX.Element;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link.d.ts","sourceRoot":"","sources":["../src/link.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,SAAS,EAAkB,MAAM,OAAO,CAAC;AASrE,UAAU,KAAM,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,QAAQ,EAAE,EAAE,KAAK,+BA6B5E"}
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { use, useMemo } from "react";
|
|
3
|
+
import { PathnameContext, RouteContext, RouterContext, SearchParamsContext, } from "./context";
|
|
4
|
+
import { resolve } from "./path";
|
|
5
|
+
export function Link({ href, replace, children, onClick, ...rawProps }) {
|
|
6
|
+
const { matchedPathname: parentPathname } = use(RouteContext);
|
|
7
|
+
const router = use(RouterContext);
|
|
8
|
+
const pathname = use(PathnameContext);
|
|
9
|
+
const searchParams = use(SearchParamsContext);
|
|
10
|
+
const target = useMemo(() => resolve(href, { pathname, parentPathname, searchParams }), [href, parentPathname, pathname]);
|
|
11
|
+
return (_jsx("a", { href: target, onClick: (event) => {
|
|
12
|
+
onClick?.(event);
|
|
13
|
+
if (event.defaultPrevented)
|
|
14
|
+
return;
|
|
15
|
+
event.preventDefault();
|
|
16
|
+
if (replace) {
|
|
17
|
+
router.replace(target);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
router.push(target);
|
|
21
|
+
}
|
|
22
|
+
}, ...rawProps, children: children }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function mockHistory(pathname?: string): {
|
|
2
|
+
location: URL;
|
|
3
|
+
history: {
|
|
4
|
+
pushState: import("vitest").Mock<(_state: any, _unused: string, url: string) => void>;
|
|
5
|
+
replaceState: import("vitest").Mock<(_state: any, _unused: string, url: string) => void>;
|
|
6
|
+
};
|
|
7
|
+
back(): void;
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=mock-history.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock-history.d.ts","sourceRoot":"","sources":["../src/mock-history.ts"],"names":[],"mappings":"AAEA,wBAAgB,WAAW,CAAC,QAAQ,SAAM;;;kDAOV,GAAG,WAAW,MAAM,OAAO,MAAM;qDAI9B,GAAG,WAAW,MAAM,OAAO,MAAM;;;EAanE"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
export function mockHistory(pathname = "/") {
|
|
3
|
+
const location = new URL(pathname, "http://localhost:3000");
|
|
4
|
+
const stack = [location.href];
|
|
5
|
+
return {
|
|
6
|
+
location,
|
|
7
|
+
history: {
|
|
8
|
+
pushState: vi.fn((_state, _unused, url) => {
|
|
9
|
+
location.href = new URL(url, location.href).href;
|
|
10
|
+
stack.push(location.href);
|
|
11
|
+
}),
|
|
12
|
+
replaceState: vi.fn((_state, _unused, url) => {
|
|
13
|
+
location.href = new URL(url, location.href).href;
|
|
14
|
+
stack[stack.length - 1] = location.href;
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
back() {
|
|
18
|
+
if (stack.length > 1) {
|
|
19
|
+
stack.pop();
|
|
20
|
+
location.href = stack[stack.length - 1];
|
|
21
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/path.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ImmutableSearchParams } from "./immutable-search-params";
|
|
2
|
+
interface Options {
|
|
3
|
+
pathname: string;
|
|
4
|
+
parentPathname: string;
|
|
5
|
+
searchParams: ImmutableSearchParams;
|
|
6
|
+
}
|
|
7
|
+
export declare function resolve(path: string, { pathname, parentPathname, searchParams }: Options): string;
|
|
8
|
+
export declare function removeTrailingSlash(path: string): string;
|
|
9
|
+
export declare function compilePathRegex(path: string): RegExp;
|
|
10
|
+
export declare function compileExactPathRegex(path: string): RegExp;
|
|
11
|
+
export declare function buildFullPath(parentPath: string, path?: string): string;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAElE,UAAU,OAAO;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,qBAAqB,CAAC;CACrC;AAED,wBAAgB,OAAO,CACrB,IAAI,EAAE,MAAM,EACZ,EAAE,QAAQ,EAAE,cAAc,EAAE,YAAY,EAAE,EAAE,OAAO,UAepD;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAiBrD;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAU1D;AAED,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,UAa9D"}
|
package/dist/path.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ImmutableSearchParams } from "./immutable-search-params";
|
|
2
|
+
export function resolve(path, { pathname, parentPathname, searchParams }) {
|
|
3
|
+
if (path.startsWith("/")) {
|
|
4
|
+
return path;
|
|
5
|
+
}
|
|
6
|
+
if (path.startsWith("?")) {
|
|
7
|
+
return pathname + path;
|
|
8
|
+
}
|
|
9
|
+
if (path.startsWith("#")) {
|
|
10
|
+
return pathname + searchParams.search + path;
|
|
11
|
+
}
|
|
12
|
+
if (!path) {
|
|
13
|
+
return parentPathname;
|
|
14
|
+
}
|
|
15
|
+
return parentPathname === "/" ? `/${path}` : `${parentPathname}/${path}`;
|
|
16
|
+
}
|
|
17
|
+
export function removeTrailingSlash(path) {
|
|
18
|
+
if (path === "/")
|
|
19
|
+
return path;
|
|
20
|
+
return path.endsWith("/") ? path.slice(0, -1) : path;
|
|
21
|
+
}
|
|
22
|
+
export function compilePathRegex(path) {
|
|
23
|
+
if (path === "/") {
|
|
24
|
+
return /^\//;
|
|
25
|
+
}
|
|
26
|
+
// Handle wildcards: *name captures as named group, * catches all without capturing
|
|
27
|
+
const pattern = path
|
|
28
|
+
.replaceAll(/\*(\w+)/g, "(?<$1>.+)") // *name -> named capture group
|
|
29
|
+
.replaceAll(/\*/g, ".+") // * -> match everything including /
|
|
30
|
+
.replaceAll(/:(\w+)/g, "(?<$1>[^\\/]+)"); // :param -> named capture group
|
|
31
|
+
// If pattern contains a wildcard, it already matches everything - use exact match
|
|
32
|
+
if (path.includes("*")) {
|
|
33
|
+
return new RegExp(`^(${pattern})$`);
|
|
34
|
+
}
|
|
35
|
+
// For non-wildcard paths, allow optional trailing paths
|
|
36
|
+
return new RegExp(`^(${pattern})(/.*)?$`);
|
|
37
|
+
}
|
|
38
|
+
export function compileExactPathRegex(path) {
|
|
39
|
+
if (path === "/") {
|
|
40
|
+
return /^\/$/;
|
|
41
|
+
}
|
|
42
|
+
// Handle wildcards: *name captures as named group, * catches all without capturing
|
|
43
|
+
const pattern = path
|
|
44
|
+
.replaceAll(/\*(\w+)/g, "(?<$1>.+)") // *name -> named capture group
|
|
45
|
+
.replaceAll(/\*/g, ".+") // * -> match everything including /
|
|
46
|
+
.replaceAll(/:(\w+)/g, "(?<$1>[^\\/]+)"); // :param -> named capture group
|
|
47
|
+
return new RegExp(`^(${pattern})$`);
|
|
48
|
+
}
|
|
49
|
+
export function buildFullPath(parentPath, path) {
|
|
50
|
+
if (path?.startsWith("/")) {
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
if (path) {
|
|
54
|
+
if (parentPath === "/") {
|
|
55
|
+
return "/" + path;
|
|
56
|
+
}
|
|
57
|
+
return parentPath + "/" + path;
|
|
58
|
+
}
|
|
59
|
+
return parentPath;
|
|
60
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../src/route.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,KAAK,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,UAAU,sCA0B1D"}
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { use, useMemo } from "react";
|
|
3
|
+
import { PathnameContext, RouteContext } from "./context";
|
|
4
|
+
import { buildFullPath, compileExactPathRegex, compilePathRegex } from "./path";
|
|
5
|
+
export function Route({ path, exact, children }) {
|
|
6
|
+
const parentRoute = use(RouteContext);
|
|
7
|
+
const pathname = use(PathnameContext);
|
|
8
|
+
const fullPath = useMemo(() => buildFullPath(parentRoute.path, path ?? ""), [parentRoute.path, path]);
|
|
9
|
+
const pathRegex = useMemo(() => exact ? compileExactPathRegex(fullPath) : compilePathRegex(fullPath), [fullPath, exact]);
|
|
10
|
+
const match = useMemo(() => pathname.match(pathRegex), [pathname, pathRegex]);
|
|
11
|
+
const routeContextValue = useMemo(() => ({
|
|
12
|
+
path: fullPath,
|
|
13
|
+
params: match?.groups ?? {},
|
|
14
|
+
matchedPathname: match?.[1] ?? "",
|
|
15
|
+
}), [fullPath, match]);
|
|
16
|
+
if (!match)
|
|
17
|
+
return null;
|
|
18
|
+
return _jsx(RouteContext, { value: routeContextValue, children: children });
|
|
19
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
interface Props {
|
|
3
|
+
children: ReactNode;
|
|
4
|
+
location?: {
|
|
5
|
+
pathname: string;
|
|
6
|
+
search: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
};
|
|
9
|
+
history?: {
|
|
10
|
+
pushState: (state: any, title: string, url: string) => void;
|
|
11
|
+
replaceState: (state: any, title: string, url: string) => void;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare function Router({ history, location, children, }: Props): import("react").JSX.Element;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAUtF,UAAU,KAAK;IACb,QAAQ,EAAE,SAAS,CAAC;IACpB,QAAQ,CAAC,EAAE;QACT,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,OAAO,CAAC,EAAE;QACR,SAAS,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;QAC5D,YAAY,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;KAChE,CAAC;CACH;AAED,wBAAgB,MAAM,CAAC,EACrB,OAAwB,EACxB,QAA0B,EAC1B,QAAQ,GACT,EAAE,KAAK,+BAoDP"}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { startTransition, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { HashContext, PathnameContext, RouterContext, SearchParamsContext, } from "./context";
|
|
4
|
+
import { ImmutableSearchParams } from "./immutable-search-params";
|
|
5
|
+
import { removeTrailingSlash } from "./path";
|
|
6
|
+
export function Router({ history = window.history, location = window.location, children, }) {
|
|
7
|
+
const [pathname, setPathname] = useState(removeTrailingSlash(location.pathname));
|
|
8
|
+
const [searchParams, setSearchParams] = useState(new ImmutableSearchParams(location.search));
|
|
9
|
+
const [hash, setHash] = useState(location.hash);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const handlePopstate = () => {
|
|
12
|
+
startTransition(() => {
|
|
13
|
+
setPathname(removeTrailingSlash(location.pathname));
|
|
14
|
+
setSearchParams(new ImmutableSearchParams(location.search));
|
|
15
|
+
setHash(location.hash);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
window.addEventListener("popstate", handlePopstate);
|
|
19
|
+
return () => window.removeEventListener("popstate", handlePopstate);
|
|
20
|
+
}, [location]);
|
|
21
|
+
const routerContextValue = useMemo(() => ({
|
|
22
|
+
push: (url) => {
|
|
23
|
+
history.pushState(null, "", url);
|
|
24
|
+
startTransition(() => {
|
|
25
|
+
setPathname(removeTrailingSlash(location.pathname));
|
|
26
|
+
setSearchParams(new ImmutableSearchParams(location.search));
|
|
27
|
+
setHash(location.hash);
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
replace: (url) => {
|
|
31
|
+
history.replaceState(null, "", url);
|
|
32
|
+
startTransition(() => {
|
|
33
|
+
setPathname(removeTrailingSlash(location.pathname));
|
|
34
|
+
setSearchParams(new ImmutableSearchParams(location.search));
|
|
35
|
+
setHash(location.hash);
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
}), [location, history]);
|
|
39
|
+
return (_jsx(RouterContext, { value: routerContextValue, children: _jsx(PathnameContext, { value: pathname, children: _jsx(SearchParamsContext, { value: searchParams, children: _jsx(HashContext, { value: hash, children: children }) }) }) }));
|
|
40
|
+
}
|
package/dist/switch.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { Route, RouteProps } from "./route";
|
|
3
|
+
interface Props {
|
|
4
|
+
children: ReactElement<RouteProps, typeof Route> | ReactElement<RouteProps, typeof Route>[];
|
|
5
|
+
}
|
|
6
|
+
export declare function Switch({ children }: Props): import("react").JSX.Element | null;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=switch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"switch.d.ts","sourceRoot":"","sources":["../src/switch.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA0B,KAAK,YAAY,EAAE,MAAM,OAAO,CAAC;AAClE,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAQjD,UAAU,KAAK;IACb,QAAQ,EACJ,YAAY,CAAC,UAAU,EAAE,OAAO,KAAK,CAAC,GACtC,YAAY,CAAC,UAAU,EAAE,OAAO,KAAK,CAAC,EAAE,CAAC;CAC9C;AAED,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE,KAAK,sCAmDzC"}
|
package/dist/switch.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Children, use, useMemo } from "react";
|
|
3
|
+
import { PathnameContext, RouteContext, } from "./context";
|
|
4
|
+
import { buildFullPath, compileExactPathRegex, compilePathRegex } from "./path";
|
|
5
|
+
export function Switch({ children }) {
|
|
6
|
+
const parentRoute = use(RouteContext);
|
|
7
|
+
const pathname = use(PathnameContext);
|
|
8
|
+
const props = Children.map(children, (route) => route.props);
|
|
9
|
+
const routeMeta = useMemo(() => props.map((route) => {
|
|
10
|
+
const path = route.path ?? "";
|
|
11
|
+
const fullPath = buildFullPath(parentRoute.path, path);
|
|
12
|
+
return {
|
|
13
|
+
path,
|
|
14
|
+
fullPath,
|
|
15
|
+
pathRegex: route.exact
|
|
16
|
+
? compileExactPathRegex(fullPath)
|
|
17
|
+
: compilePathRegex(fullPath),
|
|
18
|
+
};
|
|
19
|
+
}), [
|
|
20
|
+
parentRoute.path,
|
|
21
|
+
props.map((route) => (route.exact ? "e" : "l" + route.path)).join(" "),
|
|
22
|
+
]);
|
|
23
|
+
const match = useMemo(() => {
|
|
24
|
+
for (const meta of routeMeta) {
|
|
25
|
+
const match = pathname.match(meta.pathRegex);
|
|
26
|
+
if (match)
|
|
27
|
+
return [
|
|
28
|
+
meta.path,
|
|
29
|
+
{
|
|
30
|
+
path: meta.fullPath,
|
|
31
|
+
params: match?.groups ?? {},
|
|
32
|
+
matchedPathname: match?.[1] ?? "",
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}, [routeMeta, pathname]);
|
|
38
|
+
if (!match) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const [path, context] = match;
|
|
42
|
+
return (_jsx(RouteContext, { value: context, children: props.find((route) => route.path === path)?.children }));
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-hash.d.ts","sourceRoot":"","sources":["../src/use-hash.ts"],"names":[],"mappings":"AAGA,wBAAgB,OAAO,WAEtB"}
|
package/dist/use-hash.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-params.d.ts","sourceRoot":"","sources":["../src/use-params.ts"],"names":[],"mappings":"AAGA,wBAAgB,SAAS,CACvB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,MAAM,CAClD,MAAM,EACN,MAAM,GAAG,MAAM,EAAE,CAClB,KAIgB,CAAC,CACnB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-pathname.d.ts","sourceRoot":"","sources":["../src/use-pathname.ts"],"names":[],"mappings":"AAGA,wBAAgB,WAAW,WAE1B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-router.d.ts","sourceRoot":"","sources":["../src/use-router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgB,MAAM,OAAO,CAAC;AACvD,OAAO,EAGL,aAAa,EAEd,MAAM,WAAW,CAAC;AAGnB,wBAAgB,SAAS,IAAI,WAAW,CAAC,OAAO,aAAa,CAAC,CA6B7D"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { use, useMemo } from "react";
|
|
2
|
+
import { PathnameContext, RouteContext, RouterContext, SearchParamsContext, } from "./context";
|
|
3
|
+
import { resolve } from "./path";
|
|
4
|
+
export function useRouter() {
|
|
5
|
+
const { push, replace } = use(RouterContext);
|
|
6
|
+
const { matchedPathname: parentPathname } = use(RouteContext);
|
|
7
|
+
const pathname = use(PathnameContext);
|
|
8
|
+
const searchParams = use(SearchParamsContext);
|
|
9
|
+
return useMemo(() => ({
|
|
10
|
+
push(href) {
|
|
11
|
+
push(resolve(href, {
|
|
12
|
+
pathname,
|
|
13
|
+
searchParams,
|
|
14
|
+
parentPathname,
|
|
15
|
+
}));
|
|
16
|
+
},
|
|
17
|
+
replace(href) {
|
|
18
|
+
replace(resolve(href, {
|
|
19
|
+
pathname,
|
|
20
|
+
searchParams,
|
|
21
|
+
parentPathname,
|
|
22
|
+
}));
|
|
23
|
+
},
|
|
24
|
+
}), [pathname, searchParams, parentPathname]);
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-search-params.d.ts","sourceRoot":"","sources":["../src/use-search-params.ts"],"names":[],"mappings":"AAGA,wBAAgB,eAAe,8DAE9B"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toapi/router",
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"author": {
|
|
5
|
+
"name": "Michel Smola",
|
|
6
|
+
"email": "michel.smola@farbenmeer.de"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"module": "dist/index.js",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"private": false,
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/farbenmeer/tapi.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@testing-library/dom": "^10.4.1",
|
|
22
|
+
"@types/node": "^25.0.3",
|
|
23
|
+
"@types/react": "^19.2.7",
|
|
24
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
25
|
+
"@vitest/browser-playwright": "^4.0.16",
|
|
26
|
+
"react": "^19.1.1",
|
|
27
|
+
"vitest": "^4.0.16",
|
|
28
|
+
"playwright": "^1.59.1",
|
|
29
|
+
"vitest-browser-react": "^2.0.2"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^19.1.1",
|
|
33
|
+
"typescript": "^5 || ^6.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"release": "pnpm run build && pnpm publish --no-git-checks",
|
|
38
|
+
"test": "vitest"
|
|
39
|
+
}
|
|
40
|
+
}
|