@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.
Files changed (44) hide show
  1. package/README.md +236 -0
  2. package/dist/context.d.ts +15 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +14 -0
  5. package/dist/immutable-search-params.d.ts +7 -0
  6. package/dist/immutable-search-params.d.ts.map +1 -0
  7. package/dist/immutable-search-params.js +20 -0
  8. package/dist/index.d.ts +10 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +9 -0
  11. package/dist/link.d.ts +8 -0
  12. package/dist/link.d.ts.map +1 -0
  13. package/dist/link.js +23 -0
  14. package/dist/mock-history.d.ts +9 -0
  15. package/dist/mock-history.d.ts.map +1 -0
  16. package/dist/mock-history.js +25 -0
  17. package/dist/path.d.ts +13 -0
  18. package/dist/path.d.ts.map +1 -0
  19. package/dist/path.js +60 -0
  20. package/dist/route.d.ts +7 -0
  21. package/dist/route.d.ts.map +1 -0
  22. package/dist/route.js +19 -0
  23. package/dist/router.d.ts +16 -0
  24. package/dist/router.d.ts.map +1 -0
  25. package/dist/router.js +40 -0
  26. package/dist/switch.d.ts +8 -0
  27. package/dist/switch.d.ts.map +1 -0
  28. package/dist/switch.js +43 -0
  29. package/dist/use-hash.d.ts +2 -0
  30. package/dist/use-hash.d.ts.map +1 -0
  31. package/dist/use-hash.js +5 -0
  32. package/dist/use-params.d.ts +2 -0
  33. package/dist/use-params.d.ts.map +1 -0
  34. package/dist/use-params.js +6 -0
  35. package/dist/use-pathname.d.ts +2 -0
  36. package/dist/use-pathname.d.ts.map +1 -0
  37. package/dist/use-pathname.js +5 -0
  38. package/dist/use-router.d.ts +4 -0
  39. package/dist/use-router.d.ts.map +1 -0
  40. package/dist/use-router.js +25 -0
  41. package/dist/use-search-params.d.ts +2 -0
  42. package/dist/use-search-params.d.ts.map +1 -0
  43. package/dist/use-search-params.js +5 -0
  44. 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"}
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ export interface RouteProps {
2
+ path?: string;
3
+ exact?: boolean;
4
+ children: React.ReactNode;
5
+ }
6
+ export declare function Route({ path, exact, children }: RouteProps): import("react").JSX.Element | null;
7
+ //# sourceMappingURL=route.d.ts.map
@@ -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
+ }
@@ -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
+ }
@@ -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,2 @@
1
+ export declare function useHash(): string;
2
+ //# sourceMappingURL=use-hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-hash.d.ts","sourceRoot":"","sources":["../src/use-hash.ts"],"names":[],"mappings":"AAGA,wBAAgB,OAAO,WAEtB"}
@@ -0,0 +1,5 @@
1
+ import { use } from "react";
2
+ import { HashContext } from "./context";
3
+ export function useHash() {
4
+ return use(HashContext);
5
+ }
@@ -0,0 +1,2 @@
1
+ export declare function useParams<T extends Record<string, string | string[]> = Record<string, string | string[]>>(): T;
2
+ //# sourceMappingURL=use-params.d.ts.map
@@ -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,6 @@
1
+ import { use } from "react";
2
+ import { RouteContext } from "./context";
3
+ export function useParams() {
4
+ const { params } = use(RouteContext);
5
+ return params;
6
+ }
@@ -0,0 +1,2 @@
1
+ export declare function usePathname(): string;
2
+ //# sourceMappingURL=use-pathname.d.ts.map
@@ -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,5 @@
1
+ import { use } from "react";
2
+ import { PathnameContext } from "./context";
3
+ export function usePathname() {
4
+ return use(PathnameContext);
5
+ }
@@ -0,0 +1,4 @@
1
+ import { type ContextType } from "react";
2
+ import { RouterContext } from "./context";
3
+ export declare function useRouter(): ContextType<typeof RouterContext>;
4
+ //# sourceMappingURL=use-router.d.ts.map
@@ -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,2 @@
1
+ export declare function useSearchParams(): import("./immutable-search-params").ImmutableSearchParams;
2
+ //# sourceMappingURL=use-search-params.d.ts.map
@@ -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"}
@@ -0,0 +1,5 @@
1
+ import { use } from "react";
2
+ import { SearchParamsContext } from "./context";
3
+ export function useSearchParams() {
4
+ return use(SearchParamsContext);
5
+ }
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
+ }