waymark 0.4.0 โ 0.5.0
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 +171 -70
- package/dist/index.d.ts +30 -39
- package/dist/index.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
A
|
|
6
|
+
A type-safe router for React that just works.
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
-
<
|
|
9
|
+
<div align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/waymark">
|
|
11
11
|
<img
|
|
12
12
|
src="https://img.shields.io/npm/v/waymark?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
|
|
@@ -37,28 +37,74 @@
|
|
|
37
37
|
alt="sponsors"
|
|
38
38
|
/>
|
|
39
39
|
</a>
|
|
40
|
-
</
|
|
40
|
+
</div>
|
|
41
41
|
|
|
42
42
|
<p align="center">
|
|
43
|
-
<a href="https://waymarkrouter.com"
|
|
43
|
+
๐ <a href="https://waymarkrouter.com">Documentation</a> ยท ๐ฎ <a href="https://stackblitz.com/edit/waymark-demo?file=src%2Fapp.tsx">Live playground</a>
|
|
44
44
|
</p>
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
48
|
Waymark is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
|
|
49
49
|
|
|
50
|
-
- **Fully type-safe** - Complete TypeScript inference for routes, path params,
|
|
51
|
-
- **Zero config** - No build plugins, no CLI, no codegen, no config files, very low boilerplate
|
|
52
|
-
- **
|
|
53
|
-
- **
|
|
54
|
-
- **Feature packed** - Search param validation, lazy loading, data preloading, SSR, error boundaries, etc.
|
|
55
|
-
- **Not vibe-coded** - Built with careful design and attention to detail by a human
|
|
56
|
-
- **Just works** -
|
|
50
|
+
- ๐ **Fully type-safe** - Complete TypeScript inference for routes, path params, search params, and more
|
|
51
|
+
- โก **Zero config** - No build plugins, no CLI, no codegen, no config files, very low boilerplate
|
|
52
|
+
- ๐ชถ **4kB gzipped** - Extremely lightweight, dependency included
|
|
53
|
+
- ๐ค **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
|
|
54
|
+
- ๐ฏ **Feature packed** - Search param validation, lazy loading, data preloading, SSR, error boundaries, etc.
|
|
55
|
+
- ๐ง **Not vibe-coded** - Built with careful design and attention to detail by a human
|
|
56
|
+
- โจ **Just works** - Simple setup, predictable behavior that never gets in your way
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
# Comparison
|
|
61
|
+
|
|
62
|
+
| Feature | Waymark | React Router | TanStack Router | Wouter |
|
|
63
|
+
| -------------------------------- | :-----: | :----------: | :-------------: | :----: |
|
|
64
|
+
| **Bundle size (gzip)**\* | ~4kB | ~26kB+ | ~19kB+ | ~2.2kB |
|
|
65
|
+
| **Zero config**\* | โ
| โ | โ ๏ธ | โ
|
|
|
66
|
+
| **Full type inference**\* | โ
| โ ๏ธ | โ
| โ |
|
|
67
|
+
| **Nested routes** | โ
| โ
| โ
| โ
|
|
|
68
|
+
| **Search param validation**\* | โ
| โ | โ
| โ |
|
|
69
|
+
| **Lazy loading** | โ
| โ
| โ
| โ |
|
|
70
|
+
| **Data preloading** | โ
| โ
| โ
| โ |
|
|
71
|
+
| **Built-in error boundaries** | โ
| โ
| โ
| โ |
|
|
72
|
+
| **Built-in suspense boundaries** | โ
| โ | โ
| โ |
|
|
73
|
+
| **Link preloading strategies** | โ
| โ
| โ
| โ |
|
|
74
|
+
| **Active link detection** | โ
| โ
| โ
| โ ๏ธ |
|
|
75
|
+
| **Browser/Hash/Memory history** | โ
| โ
| โ
| โ
|
|
|
76
|
+
| **SSR support** | โ
| โ
| โ
| โ
|
|
|
77
|
+
| **Route middlewares**\* | โ
| โ | โ | โ |
|
|
78
|
+
| **Route handles (metadata)** | โ
| โ
| โ
| โ |
|
|
79
|
+
| **Route match ranking**\* | โ
| โ
| โ
| โ |
|
|
80
|
+
| **View transitions** | โ
| โ
| โ
| โ
|
|
|
81
|
+
| **Devtools** | โ
| โ ๏ธ | โ
| โ |
|
|
82
|
+
| **File-based routing** | โ | โ
| โ
| โ |
|
|
83
|
+
| **React Native** | โ | โ
| โ | โ |
|
|
84
|
+
|
|
85
|
+
<details>
|
|
86
|
+
<summary><b>Comparison notes</b></summary>
|
|
87
|
+
|
|
88
|
+
<br />
|
|
89
|
+
|
|
90
|
+
If you believe there's a mistake in the comparison table, please [open an issue](https://github.com/strblr/waymark/issues) or [submit a PR](https://github.com/strblr/waymark/pulls) and it will be fixed.
|
|
91
|
+
|
|
92
|
+
- โ ๏ธ indicates the feature is only partially supported, supported with heavy boilerplate, or requires external libraries.
|
|
93
|
+
- ๐จ indicates the feature is not yet ready but being worked on.
|
|
94
|
+
- **Bundle sizes** are approximate gzipped values. React Router and TanStack Router sizes can vary significantly based on imports and versions; Waymark's ~4kB includes its single ~0.4kB dependency ([regexparam](https://github.com/lukeed/regexparam)), before any tree shaking. Wouter is the smallest option but lacks features.
|
|
95
|
+
- **Zero config** means no CLI tools, build plugins, code generation, or configuration files are required. React Router requires its typegen CLI or bundler plugin for full type safety. Same with TanStack Router for file-based routing. You can use code-based routing but it's more boilerplate.
|
|
96
|
+
- **Full type inference** refers to automatic TypeScript inference for routes, params, search params, and navigation without manual type annotations.
|
|
97
|
+
- **Search params validation** refers to built-in support for validating and typing URL search parameters. Wouter provides `useSearch()` but no validation layer. Same with React Router and `useSearchParams`.
|
|
98
|
+
- **Route middlewares** are reusable configuration bundles (search validation, handles, preload functions, components) that can be applied to multiple routes. This is a Waymark-specific feature.
|
|
99
|
+
- **Route match ranking** automatically picks the most specific route when multiple patterns match (e.g., `/users/new` wins over `/users/:id`). Without ranking, route definition order matters.
|
|
100
|
+
|
|
101
|
+
</details>
|
|
57
102
|
|
|
58
103
|
---
|
|
59
104
|
|
|
60
105
|
# Table of contents
|
|
61
106
|
|
|
107
|
+
- [Comparison](#comparison)
|
|
62
108
|
- [Showcase](#showcase)
|
|
63
109
|
- [Installation](#installation)
|
|
64
110
|
- [Defining routes](#defining-routes)
|
|
@@ -85,6 +131,7 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
85
131
|
- [Middlewares](#middlewares)
|
|
86
132
|
- [Route matching and ranking](#route-matching-and-ranking)
|
|
87
133
|
- [History implementations](#history-implementations)
|
|
134
|
+
- [Devtools](#devtools)
|
|
88
135
|
- [Cookbook](#cookbook)
|
|
89
136
|
- [Quick start example](#quick-start-example)
|
|
90
137
|
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
@@ -150,6 +197,8 @@ declare module "waymark" {
|
|
|
150
197
|
|
|
151
198
|
Everything autocompletes and type-checks automatically. No heavy setup, no magic, just a simple API that gets out of your way.
|
|
152
199
|
|
|
200
|
+
๐ [Try it live in the StackBlitz playground](https://stackblitz.com/edit/waymark-demo?file=src%2Fapp.tsx)
|
|
201
|
+
|
|
153
202
|
---
|
|
154
203
|
|
|
155
204
|
# Installation
|
|
@@ -192,31 +241,25 @@ const files = route("/files/*").component(FileBrowser);
|
|
|
192
241
|
const optional = route("/books/*?").component(FileBrowser);
|
|
193
242
|
```
|
|
194
243
|
|
|
195
|
-
Route building is immutable: every method on a route returns a new route instance
|
|
244
|
+
Route building is immutable: every method on a route returns a new route instance.
|
|
196
245
|
|
|
197
246
|
---
|
|
198
247
|
|
|
199
248
|
# Nested routes and layouts
|
|
200
249
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
Here's how it works. Start with any route:
|
|
250
|
+
Any route can have child routes. Call `.route()` on an existing route to create one:
|
|
204
251
|
|
|
205
252
|
```tsx
|
|
206
253
|
const dashboard = route("/dashboard").component(DashboardLayout);
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
Then create child routes by calling `.route()` on it:
|
|
210
254
|
|
|
211
|
-
```tsx
|
|
212
255
|
const overview = dashboard.route("/").component(Overview);
|
|
213
256
|
const settings = dashboard.route("/settings").component(Settings);
|
|
214
257
|
const profile = dashboard.route("/profile").component(Profile);
|
|
215
258
|
```
|
|
216
259
|
|
|
217
|
-
|
|
260
|
+
Child routes build on their parent's path. So `overview` matches `/dashboard`, `settings` matches `/dashboard/settings`, and `profile` matches `/dashboard/profile`.
|
|
218
261
|
|
|
219
|
-
|
|
262
|
+
They also nest inside the parent's component. The parent renders an `<Outlet />` to mark where child routes should appears:
|
|
220
263
|
|
|
221
264
|
```tsx
|
|
222
265
|
function DashboardLayout() {
|
|
@@ -231,7 +274,7 @@ function DashboardLayout() {
|
|
|
231
274
|
}
|
|
232
275
|
```
|
|
233
276
|
|
|
234
|
-
When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet.
|
|
277
|
+
When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet. This is how you build layouts - shared UI like navigation or sidebars that stays mounted as users navigate between child routes.
|
|
235
278
|
|
|
236
279
|
You can nest as deep as you need:
|
|
237
280
|
|
|
@@ -253,6 +296,8 @@ AppShell
|
|
|
253
296
|
|
|
254
297
|
Each level must include an `<Outlet />` to render the next level.
|
|
255
298
|
|
|
299
|
+
Beyond paths and components, child routes also inherit search param validators, handles, and preload functions from their parent chain. While you can think of nesting as building a tree, every route is self-contained: it carries everything it needs to render, including all parent components.
|
|
300
|
+
|
|
256
301
|
---
|
|
257
302
|
|
|
258
303
|
# Setting up the router
|
|
@@ -319,7 +364,9 @@ declare module "waymark" {
|
|
|
319
364
|
}
|
|
320
365
|
```
|
|
321
366
|
|
|
322
|
-
With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs will know exactly which routes exist and what input they expect
|
|
367
|
+
With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs will know exactly which routes exist and what input they expect.
|
|
368
|
+
|
|
369
|
+
**You're all set up!**
|
|
323
370
|
|
|
324
371
|
---
|
|
325
372
|
|
|
@@ -459,6 +506,9 @@ function SearchPage() {
|
|
|
459
506
|
const [search, setSearch] = useSearch(searchPage);
|
|
460
507
|
// search.q: string
|
|
461
508
|
// search.page: number
|
|
509
|
+
|
|
510
|
+
const [search, setSearch] = useSearch("/search");
|
|
511
|
+
// Also works
|
|
462
512
|
}
|
|
463
513
|
```
|
|
464
514
|
|
|
@@ -1015,6 +1065,16 @@ function UserPage() {
|
|
|
1015
1065
|
}
|
|
1016
1066
|
```
|
|
1017
1067
|
|
|
1068
|
+
For parametrized middlewares, define a function that returns a middleware:
|
|
1069
|
+
|
|
1070
|
+
```tsx
|
|
1071
|
+
const guard = (role: string) =>
|
|
1072
|
+
middleware().handle({ requiredRole: role }).component(RoleGuard);
|
|
1073
|
+
|
|
1074
|
+
const adminPage = route("/admin").use(guard("admin")).component(AdminPage);
|
|
1075
|
+
const editorPage = route("/editor").use(guard("editor")).component(EditorPage);
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1018
1078
|
---
|
|
1019
1079
|
|
|
1020
1080
|
# Route matching and ranking
|
|
@@ -1103,6 +1163,53 @@ All history implementations conform to the `HistoryLike` interface, so you can c
|
|
|
1103
1163
|
|
|
1104
1164
|
---
|
|
1105
1165
|
|
|
1166
|
+
# Devtools
|
|
1167
|
+
|
|
1168
|
+
Waymark has a companion devtools package for inspecting routes, matches, parameters, and navigation state.
|
|
1169
|
+
|
|
1170
|
+
```bash
|
|
1171
|
+
npm install waymark-devtools
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
Render the `Devtools` component anywhere inside your routes. It displays a toggle button that opens a draggable and resizable floating panel:
|
|
1175
|
+
|
|
1176
|
+
```tsx
|
|
1177
|
+
import { Devtools } from "waymark-devtools";
|
|
1178
|
+
|
|
1179
|
+
const layout = route("/").component(Layout);
|
|
1180
|
+
|
|
1181
|
+
function Layout() {
|
|
1182
|
+
return (
|
|
1183
|
+
<div>
|
|
1184
|
+
<Outlet />
|
|
1185
|
+
<Devtools />
|
|
1186
|
+
</div>
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
If you'd rather embed the panel directly into your layout instead of using the floating window, use `DevtoolsPanel`:
|
|
1192
|
+
|
|
1193
|
+
```tsx
|
|
1194
|
+
import { DevtoolsPanel } from "waymark-devtools";
|
|
1195
|
+
|
|
1196
|
+
function DebugSidebar() {
|
|
1197
|
+
return (
|
|
1198
|
+
<aside>
|
|
1199
|
+
<DevtoolsPanel />
|
|
1200
|
+
</aside>
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
To exclude devtools from production builds (Vite example):
|
|
1206
|
+
|
|
1207
|
+
```tsx
|
|
1208
|
+
import.meta.env.DEV && <Devtools />;
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
---
|
|
1212
|
+
|
|
1106
1213
|
# Cookbook
|
|
1107
1214
|
|
|
1108
1215
|
## Quick start example
|
|
@@ -1217,29 +1324,27 @@ function AppLayout() {
|
|
|
1217
1324
|
|
|
1218
1325
|
## Matching a route anywhere
|
|
1219
1326
|
|
|
1220
|
-
Use `useMatch` to check if a route
|
|
1327
|
+
Use `useMatch` to check if a route is part of the current match. You can pass either a route pattern string or a route object, just like with `Link` and `navigate`. This is useful for conditional rendering, styling, access control, and more. It's also used internally by `useParams` and `Link`.
|
|
1328
|
+
|
|
1329
|
+
The hook returns the matched params if there's a match, or `null` otherwise. There are two matching modes:
|
|
1221
1330
|
|
|
1222
|
-
|
|
1331
|
+
- **Loose matching** (default): Matches if you're on the route or any of its child routes.
|
|
1332
|
+
- **Strict matching** (`strict: true`): Matches only if you're on the exact route.
|
|
1223
1333
|
|
|
1224
1334
|
```tsx
|
|
1225
|
-
import { useMatch } from "waymark";
|
|
1335
|
+
import { route, useMatch } from "waymark";
|
|
1226
1336
|
|
|
1227
1337
|
const dashboard = route("/dashboard").component(Dashboard);
|
|
1228
|
-
const settings = route("/settings").component(Settings);
|
|
1338
|
+
const settings = dashboard.route("/settings").component(Settings);
|
|
1229
1339
|
|
|
1230
1340
|
function Sidebar() {
|
|
1231
|
-
//
|
|
1232
|
-
const
|
|
1341
|
+
// Matches /dashboard and any child route like /dashboard/settings
|
|
1342
|
+
const match = useMatch({ from: dashboard });
|
|
1233
1343
|
|
|
1234
|
-
//
|
|
1235
|
-
const
|
|
1344
|
+
// Matches only /dashboard exactly
|
|
1345
|
+
const match = useMatch({ from: dashboard, strict: true });
|
|
1236
1346
|
|
|
1237
|
-
return
|
|
1238
|
-
<nav>
|
|
1239
|
-
{dashboardMatch && <DashboardMenu />}
|
|
1240
|
-
{settingsMatch && <SettingsSubmenu />}
|
|
1241
|
-
</nav>
|
|
1242
|
-
);
|
|
1347
|
+
return <nav>{match && <DashboardMenu />}</nav>;
|
|
1243
1348
|
}
|
|
1244
1349
|
```
|
|
1245
1350
|
|
|
@@ -1407,15 +1512,15 @@ const url = router.createUrl({ to: userProfile, params: { id: "42" } });
|
|
|
1407
1512
|
// Returns "/users/42"
|
|
1408
1513
|
```
|
|
1409
1514
|
|
|
1410
|
-
**`router.match(path,
|
|
1515
|
+
**`router.match(path, route)`** checks if a path matches a specific route.
|
|
1411
1516
|
|
|
1412
1517
|
- `path` - `string` - The path to match against
|
|
1413
|
-
- `
|
|
1518
|
+
- `route` - `Route` - The route object to match against
|
|
1414
1519
|
- Returns: `Match | null` - The match result or null if no match
|
|
1415
1520
|
|
|
1416
1521
|
```tsx
|
|
1417
|
-
const match = router.match("/users/42",
|
|
1418
|
-
// Returns { route, params: { id: "42" } }
|
|
1522
|
+
const match = router.match("/users/42", userRoute);
|
|
1523
|
+
// Returns { route, params: { id: "42" } }
|
|
1419
1524
|
```
|
|
1420
1525
|
|
|
1421
1526
|
**`router.matchAll(path)`** finds the best match from all registered routes.
|
|
@@ -1565,8 +1670,11 @@ const user = route("/users/:id")
|
|
|
1565
1670
|
- Returns: `Middleware` - A new middleware object
|
|
1566
1671
|
|
|
1567
1672
|
```tsx
|
|
1568
|
-
const
|
|
1569
|
-
z.object({
|
|
1673
|
+
const pagination = middleware().search(
|
|
1674
|
+
z.object({
|
|
1675
|
+
page: z.coerce.number().catch(1),
|
|
1676
|
+
limit: z.coerce.number().catch(10)
|
|
1677
|
+
})
|
|
1570
1678
|
);
|
|
1571
1679
|
const auth = middleware()
|
|
1572
1680
|
.handle({ requiresAuth: true })
|
|
@@ -1597,7 +1705,7 @@ navigate(-1);
|
|
|
1597
1705
|
|
|
1598
1706
|
**`useLocation()`** returns the current location, subscribes to changes.
|
|
1599
1707
|
|
|
1600
|
-
- Returns: `
|
|
1708
|
+
- Returns: `HistoryLocation` - The current location with path, parsed search params, and history state
|
|
1601
1709
|
|
|
1602
1710
|
```tsx
|
|
1603
1711
|
const { path, search, state } = useLocation();
|
|
@@ -1635,7 +1743,7 @@ setSearch({ page: 1 }, true); // Replace instead of push
|
|
|
1635
1743
|
**`useMatch(options)`** checks if a route matches the current path.
|
|
1636
1744
|
|
|
1637
1745
|
- `options` - `MatchOptions` - Matching options
|
|
1638
|
-
- Returns: `
|
|
1746
|
+
- Returns: `Params | null` - The extracted path params if matched, or null if no match
|
|
1639
1747
|
|
|
1640
1748
|
```tsx
|
|
1641
1749
|
const match = useMatch({ from: "/users/:id" });
|
|
@@ -1706,31 +1814,15 @@ new MemoryHistory("/initial"); // In-memory only.
|
|
|
1706
1814
|
|
|
1707
1815
|
See [History implementations](#history-implementations) for detailed usage.
|
|
1708
1816
|
|
|
1709
|
-
**`history.
|
|
1710
|
-
|
|
1711
|
-
- Returns: `string` - The current path
|
|
1712
|
-
|
|
1713
|
-
```tsx
|
|
1714
|
-
const path = history.getPath();
|
|
1715
|
-
// Returns "/users/42"
|
|
1716
|
-
```
|
|
1717
|
-
|
|
1718
|
-
**`history.getSearch()`** returns the current search params as a parsed JSON object.
|
|
1817
|
+
**`history.location()`** returns the current location.
|
|
1719
1818
|
|
|
1720
|
-
- Returns: `
|
|
1819
|
+
- Returns: `HistoryLocation` - The current location with path, parsed search params, and history state
|
|
1721
1820
|
|
|
1722
1821
|
```tsx
|
|
1723
|
-
const search = history.
|
|
1724
|
-
//
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
**`history.getState()`** returns the current history state.
|
|
1728
|
-
|
|
1729
|
-
- Returns: `any` - The state passed during navigation, or undefined
|
|
1730
|
-
|
|
1731
|
-
```tsx
|
|
1732
|
-
const state = history.getState();
|
|
1733
|
-
// Returns any state passed during navigation
|
|
1822
|
+
const { path, search, state } = history.location();
|
|
1823
|
+
// path: "/users/42"
|
|
1824
|
+
// search: { tab: "posts", page: 2 }
|
|
1825
|
+
// state: any state passed during navigation
|
|
1734
1826
|
```
|
|
1735
1827
|
|
|
1736
1828
|
**`history.go(delta)`** navigates forward or back in history.
|
|
@@ -1793,6 +1885,16 @@ type NavigateOptions = {
|
|
|
1793
1885
|
};
|
|
1794
1886
|
```
|
|
1795
1887
|
|
|
1888
|
+
**`HistoryLocation`** represents a history location.
|
|
1889
|
+
|
|
1890
|
+
```tsx
|
|
1891
|
+
interface HistoryLocation {
|
|
1892
|
+
path: string; // The current path
|
|
1893
|
+
search: Record<string, unknown>; // Parsed search params
|
|
1894
|
+
state: any; // History state passed during navigation
|
|
1895
|
+
}
|
|
1896
|
+
```
|
|
1897
|
+
|
|
1796
1898
|
**`HistoryPushOptions`** are options for untyped navigation.
|
|
1797
1899
|
|
|
1798
1900
|
```tsx
|
|
@@ -1808,7 +1910,7 @@ interface HistoryPushOptions {
|
|
|
1808
1910
|
```tsx
|
|
1809
1911
|
type MatchOptions = {
|
|
1810
1912
|
from: Pattern | Route; // The route to match against
|
|
1811
|
-
strict?: boolean; //
|
|
1913
|
+
strict?: boolean; // Strict matching mode (default: false)
|
|
1812
1914
|
params?: Partial<Params>; // Optional param values to filter by
|
|
1813
1915
|
};
|
|
1814
1916
|
```
|
|
@@ -1859,9 +1961,8 @@ interface PreloadContext {
|
|
|
1859
1961
|
# Roadmap
|
|
1860
1962
|
|
|
1861
1963
|
- Possibility to pass an arbitrary context to the Router instance for later use in preloads?
|
|
1862
|
-
- Relative path navigation? Not sure it's
|
|
1964
|
+
- Relative path navigation? Not sure it's worth the extra bundle size given that users can export/import route objects and pass them as navigation option.
|
|
1863
1965
|
- Document usage in test environments
|
|
1864
|
-
- Devtools? Let me know if needed.
|
|
1865
1966
|
- Open to suggestions, we can discuss them [here](https://github.com/strblr/waymark/discussions).
|
|
1866
1967
|
|
|
1867
1968
|
---
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react0 from "react";
|
|
2
2
|
import { AnchorHTMLAttributes, CSSProperties, ComponentType, ReactNode, RefAttributes } from "react";
|
|
3
3
|
import { RouteParams } from "regexparam";
|
|
4
4
|
import { EmptyObject, Merge, Simplify } from "type-fest";
|
|
@@ -38,8 +38,8 @@ interface PreloadContext<Ps extends {} = any, S extends {} = any> {
|
|
|
38
38
|
search: S;
|
|
39
39
|
}
|
|
40
40
|
interface RouterOptions {
|
|
41
|
-
basePath?: string;
|
|
42
41
|
routes: RouteList;
|
|
42
|
+
basePath?: string;
|
|
43
43
|
history?: HistoryLike;
|
|
44
44
|
ssrContext?: SSRContext;
|
|
45
45
|
defaultLinkOptions?: LinkOptions;
|
|
@@ -79,19 +79,22 @@ type SSRContext = {
|
|
|
79
79
|
redirect?: string;
|
|
80
80
|
statusCode?: number;
|
|
81
81
|
};
|
|
82
|
-
interface HistoryPushOptions {
|
|
83
|
-
url: string;
|
|
84
|
-
replace?: boolean;
|
|
85
|
-
state?: any;
|
|
86
|
-
}
|
|
87
82
|
interface HistoryLike {
|
|
88
|
-
|
|
89
|
-
getSearch: () => Record<string, unknown>;
|
|
90
|
-
getState: () => any;
|
|
83
|
+
location: () => HistoryLocation;
|
|
91
84
|
go: (delta: number) => void;
|
|
92
85
|
push: (options: HistoryPushOptions) => void;
|
|
93
86
|
subscribe: (listener: () => void) => () => void;
|
|
94
87
|
}
|
|
88
|
+
interface HistoryLocation {
|
|
89
|
+
path: string;
|
|
90
|
+
search: Record<string, unknown>;
|
|
91
|
+
state: any;
|
|
92
|
+
}
|
|
93
|
+
interface HistoryPushOptions {
|
|
94
|
+
url: string;
|
|
95
|
+
replace?: boolean;
|
|
96
|
+
state?: any;
|
|
97
|
+
}
|
|
95
98
|
type Updater<T extends object> = Partial<T> | ((prev: T) => Partial<T>);
|
|
96
99
|
type ComponentLoader = () => Promise<ComponentType | {
|
|
97
100
|
default: ComponentType;
|
|
@@ -105,12 +108,12 @@ declare class Route<P extends string = string, Ps extends {} = any, S extends {}
|
|
|
105
108
|
pattern: P;
|
|
106
109
|
keys: string[];
|
|
107
110
|
regex: RegExp;
|
|
108
|
-
looseRegex: RegExp;
|
|
109
111
|
weights: number[];
|
|
110
112
|
validate: (search: Record<string, unknown>) => S;
|
|
111
113
|
handles: Handle[];
|
|
112
114
|
components: ComponentType[];
|
|
113
115
|
preloads: ((context: PreloadContext) => Promise<any>)[];
|
|
116
|
+
chain: Set<Route>;
|
|
114
117
|
};
|
|
115
118
|
readonly _types: {
|
|
116
119
|
params: Ps;
|
|
@@ -133,15 +136,15 @@ declare class Route<P extends string = string, Ps extends {} = any, S extends {}
|
|
|
133
136
|
//#endregion
|
|
134
137
|
//#region src/router/router.d.ts
|
|
135
138
|
declare class Router {
|
|
136
|
-
readonly basePath: string;
|
|
137
139
|
readonly routes: RouteList;
|
|
140
|
+
readonly basePath: string;
|
|
138
141
|
readonly history: HistoryLike;
|
|
139
142
|
readonly ssrContext?: SSRContext;
|
|
140
143
|
readonly defaultLinkOptions?: LinkOptions;
|
|
141
144
|
private readonly _;
|
|
142
145
|
constructor(options: RouterOptions);
|
|
143
146
|
getRoute: <P extends Pattern>(pattern: P | GetRoute<P>) => GetRoute<P>;
|
|
144
|
-
match: <P extends Pattern>(path: string,
|
|
147
|
+
match: <P extends Pattern>(path: string, route: GetRoute<P>) => Match<P> | null;
|
|
145
148
|
matchAll: (path: string) => Match | null;
|
|
146
149
|
createUrl: <P extends Pattern>(options: NavigateOptions<P>) => string;
|
|
147
150
|
preload: <P extends Pattern>(options: NavigateOptions<P>) => Promise<void>;
|
|
@@ -150,13 +153,10 @@ declare class Router {
|
|
|
150
153
|
//#endregion
|
|
151
154
|
//#region src/router/browser-history.d.ts
|
|
152
155
|
declare class BrowserHistory implements HistoryLike {
|
|
153
|
-
private
|
|
154
|
-
|
|
156
|
+
private _?;
|
|
157
|
+
protected _loc: (path: string, search: string) => HistoryLocation;
|
|
155
158
|
constructor();
|
|
156
|
-
|
|
157
|
-
getPath: () => string;
|
|
158
|
-
getSearch: () => Record<string, unknown>;
|
|
159
|
-
getState: () => any;
|
|
159
|
+
location: () => HistoryLocation;
|
|
160
160
|
go: (delta: number) => void;
|
|
161
161
|
push: (options: HistoryPushOptions) => void;
|
|
162
162
|
subscribe: (listener: () => void) => () => void;
|
|
@@ -168,10 +168,7 @@ declare class MemoryHistory implements HistoryLike {
|
|
|
168
168
|
private index;
|
|
169
169
|
private listeners;
|
|
170
170
|
constructor(url?: string);
|
|
171
|
-
|
|
172
|
-
getPath: () => string;
|
|
173
|
-
getSearch: () => Record<string, unknown>;
|
|
174
|
-
getState: () => any;
|
|
171
|
+
location: () => HistoryLocation;
|
|
175
172
|
go: (delta: number) => void;
|
|
176
173
|
push: (options: HistoryPushOptions) => void;
|
|
177
174
|
subscribe: (listener: () => void) => () => void;
|
|
@@ -179,9 +176,7 @@ declare class MemoryHistory implements HistoryLike {
|
|
|
179
176
|
//#endregion
|
|
180
177
|
//#region src/router/hash-history.d.ts
|
|
181
178
|
declare class HashHistory extends BrowserHistory {
|
|
182
|
-
|
|
183
|
-
getPath: () => string;
|
|
184
|
-
getSearch: () => Record<string, unknown>;
|
|
179
|
+
location: () => HistoryLocation;
|
|
185
180
|
push: (options: HistoryPushOptions) => void;
|
|
186
181
|
}
|
|
187
182
|
//#endregion
|
|
@@ -200,22 +195,18 @@ declare function Link<P extends Pattern>(props: LinkProps<P>): ReactNode;
|
|
|
200
195
|
//#endregion
|
|
201
196
|
//#region src/react/hooks.d.ts
|
|
202
197
|
declare function useRouter(): Router;
|
|
198
|
+
declare function useLocation(): HistoryLocation;
|
|
199
|
+
declare function useMatch<P extends Pattern>(options: MatchOptions<P>): Params<P> | null;
|
|
200
|
+
declare function useOutlet(): ReactNode;
|
|
203
201
|
declare function useNavigate(): <P extends Pattern>(options: number | HistoryPushOptions | NavigateOptions<P>) => void;
|
|
204
|
-
declare function
|
|
205
|
-
path: string;
|
|
206
|
-
search: Record<string, unknown>;
|
|
207
|
-
state: any;
|
|
208
|
-
};
|
|
209
|
-
declare function useOutlet(): react2.ReactNode;
|
|
202
|
+
declare function useHandles(): Handle[];
|
|
210
203
|
declare function useParams<P extends Pattern>(from: P | GetRoute<P>): Params<P>;
|
|
211
204
|
declare function useSearch<P extends Pattern>(from: P | GetRoute<P>): readonly [Search<P>, (update: Updater<Search<P>>, replace?: boolean) => void];
|
|
212
|
-
declare function useMatch<P extends Pattern>(options: MatchOptions<P>): Match<P> | null;
|
|
213
|
-
declare function useHandles(): Handle[];
|
|
214
|
-
declare function useSubscribe<T>(router: Router, getSnapshot: () => T): T;
|
|
215
205
|
//#endregion
|
|
216
206
|
//#region src/react/contexts.d.ts
|
|
217
|
-
declare const RouterContext:
|
|
218
|
-
declare const
|
|
219
|
-
declare const
|
|
207
|
+
declare const RouterContext: react0.Context<Router | null>;
|
|
208
|
+
declare const LocationContext: react0.Context<HistoryLocation | null>;
|
|
209
|
+
declare const MatchContext: react0.Context<Match | null>;
|
|
210
|
+
declare const OutletContext: react0.Context<ReactNode>;
|
|
220
211
|
//#endregion
|
|
221
|
-
export { BrowserHistory, ComponentLoader, GetRoute, Handle, HashHistory, HistoryLike, HistoryPushOptions, Link, LinkOptions, LinkProps, Match, MatchContext, MatchOptions, MemoryHistory, Middleware, Navigate, NavigateOptions, NavigateProps, Outlet, OutletContext, Params, Pattern, PreloadContext, Register, Route, RouteList, Router, RouterContext, RouterOptions, RouterRoot, RouterRootProps, SSRContext, Search, Updater, Validator, middleware, route, useHandles, useLocation, useMatch, useNavigate, useOutlet, useParams, useRouter, useSearch
|
|
212
|
+
export { BrowserHistory, ComponentLoader, GetRoute, Handle, HashHistory, HistoryLike, HistoryLocation, HistoryPushOptions, Link, LinkOptions, LinkProps, LocationContext, Match, MatchContext, MatchOptions, MemoryHistory, Middleware, Navigate, NavigateOptions, NavigateProps, Outlet, OutletContext, Params, Pattern, PreloadContext, Register, Route, RouteList, Router, RouterContext, RouterOptions, RouterRoot, RouterRootProps, SSRContext, Search, Updater, Validator, middleware, route, useHandles, useLocation, useMatch, useNavigate, useOutlet, useParams, useRouter, useSearch };
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Component as e,Suspense as t,cloneElement as n,createContext as r,isValidElement as i,lazy as a,memo as o,useCallback as s,useContext as c,useEffect as l,useInsertionEffect as u,useLayoutEffect as d,useMemo as f,useRef as p,useState as m,useSyncExternalStore as h}from"react";import{inject as g,parse as _}from"regexparam";import{jsx as v}from"react/jsx-runtime";function y(e){return`/${e}`.replaceAll(/\/+/g,`/`).replace(/(.+)\/$/,`$1`)}function b(e){let{keys:t,pattern:n}=_(e);return{pattern:e,keys:t,regex:n,
|
|
1
|
+
import{Component as e,Suspense as t,cloneElement as n,createContext as r,isValidElement as i,lazy as a,memo as o,useCallback as s,useContext as c,useEffect as l,useInsertionEffect as u,useLayoutEffect as d,useMemo as f,useRef as p,useState as m,useSyncExternalStore as h}from"react";import{inject as g,parse as _}from"regexparam";import{jsx as v}from"react/jsx-runtime";function y(e){return`/${e}`.replaceAll(/\/+/g,`/`).replace(/(.+)\/$/,`$1`)}function b(e){let{keys:t,pattern:n}=_(e);return{pattern:e,keys:t,regex:n,weights:e.split(`/`).slice(1).map(e=>e.includes(`*`)?0:e.includes(`:`)?1:2)}}function x(e){return typeof e==`function`?e:t=>{let n=e[`~standard`].validate(t);if(n instanceof Promise)throw Error(`[Waymark] Validation must be synchronous`);if(n.issues)throw Error(`[Waymark] Validation failed`,{cause:n.issues});return n.value}}function S(e){return Object.entries(e).filter(([e,t])=>t!==void 0).map(([e,t])=>`${e}=${encodeURIComponent(w(t))}`).join(`&`)}function C(e){let t=new URLSearchParams(e);return Object.fromEntries([...t.entries()].map(([e,t])=>(t=decodeURIComponent(t),[e,T(t)?JSON.parse(t):t])))}function w(e){return typeof e==`string`&&!T(e)?e:JSON.stringify(e)}function T(e){try{return JSON.parse(e),!0}catch{return!1}}function E(e,t){return y(`${t}/${e}`)}function D(e,t){return(e===t||e.startsWith(`${t}/`))&&(e=e.slice(t.length)||`/`),e}function O(e,t){return[e,S(t)].filter(Boolean).join(`?`)}function k(e){let{pathname:t,search:n}=new URL(e,`http://w`);return{path:t,search:C(n)}}function A(e,t,n,r){let i=e.exec(D(n,r));if(!i)return null;let a={};return t.forEach((e,t)=>{let n=i[t+1];n&&(a[e]=n)}),a}function j(e){return[...e].sort((e,t)=>{let n=e.route._.weights,r=t.route._.weights,i=Math.max(n.length,r.length);for(let e=0;e<i;e++){let t=n[e]??-1,i=r[e]??-1;if(t!==i)return i-t}return 0})}const M=r(null),N=r(null),P=r(null),F=r(null);function I(){let e=c(M);if(e)return e;throw Error(`[Waymark] useRouter must be used within a router context`)}function L(){let e=c(N);if(e)return e;throw Error(`[Waymark] useLocation must be used within a router context`)}function R(e){let t=I(),n=c(P),{from:r,strict:i,params:a}=e,o=t.getRoute(r);return n&&(n.route===o||!i&&n.route._.chain.has(o))&&(!a||Object.keys(a).every(e=>a[e]===n.params[e]))?n.params:null}function z(){return c(F)}function B(){return I().navigate}function V(){let e=c(P);return f(()=>e?.route._.handles??[],[e])}function H(e){let t=R({from:e});if(t)return t;throw Error(`[Waymark] Can't read params for non-matching route: ${e}`)}function U(e){let t=I(),n=L(),r=t.getRoute(e),i=f(()=>r._.validate(n.search),[r,n.search]);return[i,Z((e,r)=>{e=typeof e==`function`?e(i):e;let a={...i,...e},o=O(n.path,a);t.navigate({url:o,replace:r})})]}var W=class{_;_loc=(e,t)=>{let{state:n}=history,[r,i]=this._??[];return i?.path===e&&r===t&&i.state===n?i:(this._=[t,{path:e,search:C(t),state:n}])[1]};constructor(){if(!window[G]){for(let e of[K,q]){let t=history[e];history[e]=function(...n){t.apply(this,n);let r=new Event(e);dispatchEvent(r)}}window[G]=1}}location=()=>this._loc(location.pathname,location.search);go=e=>history.go(e);push=e=>{let{url:t,replace:n,state:r}=e;history[n?q:K](r,``,t)};subscribe=e=>(J.forEach(t=>window.addEventListener(t,e)),()=>{J.forEach(t=>window.removeEventListener(t,e))})};const G=Symbol.for(`wmp01`),K=`pushState`,q=`replaceState`,J=[`popstate`,K,q,`hashchange`];var Y=class{routes;basePath;history;ssrContext;defaultLinkOptions;_;constructor(e){let{routes:t,basePath:n=`/`,history:r,ssrContext:i,defaultLinkOptions:a}=e;this.routes=t,this.basePath=y(n),this.history=r??new W,this.ssrContext=i,this.defaultLinkOptions=a,this._={routeMap:new Map(t.map(e=>[e._.pattern,e]))}}getRoute=e=>{if(typeof e!=`string`)return e;let t=this._.routeMap.get(e);if(!t)throw Error(`[Waymark] Route not found for pattern: ${e}`);return t};match=(e,t)=>{let{regex:n,keys:r}=t._,i=A(n,r,e,this.basePath);return i?{route:t,params:i}:null};matchAll=e=>j(this.routes.map(t=>this.match(e,t)).filter(e=>!!e))[0]??null;createUrl=e=>{let{to:t,params:n={},search:r={}}=e,{pattern:i}=this.getRoute(t)._;return O(E(g(i,n),this.basePath),r)};preload=async e=>{let{to:t,params:n={},search:r={}}=e,{preloads:i}=this.getRoute(t)._;await Promise.all(i.map(e=>e({params:n,search:r})))};navigate=e=>{if(typeof e==`number`)this.history.go(e);else if(`url`in e)this.history.push(e);else{let{replace:t,state:n}=e;this.history.push({url:this.createUrl(e),replace:t,state:n})}}},X=class{stack=[];index=0;listeners=new Set;constructor(e=`/`){this.stack.push({...k(e),state:void 0})}location=()=>this.stack[this.index];go=e=>{let t=this.index+e;this.stack[t]&&(this.index=t,this.listeners.forEach(e=>e()))};push=e=>{let{url:t,replace:n,state:r}=e,i={...k(t),state:r};this.stack=this.stack.slice(0,this.index+1),n?this.stack[this.index]=i:this.index=this.stack.push(i)-1,this.listeners.forEach(e=>e())};subscribe=e=>(this.listeners.add(e),()=>{this.listeners.delete(e)})},ee=class extends W{location=()=>{let{pathname:e,search:t}=new URL(location.hash.slice(1),`http://w`);return this._loc(e,t)};push=e=>{let{url:t,replace:n,state:r}=e;history[n?`replaceState`:`pushState`](r,``,`#${t}`)}};function te(e){let[t]=m(()=>`router`in e?e.router:new Y(e)),{subscribe:n,location:r}=t.history,i=h(n,r,r),a=f(()=>t.matchAll(i.path),[t,i.path]);return a||console.error(`[Waymark] No matching route for path`,i.path),f(()=>v(M.Provider,{value:t,children:v(N.Provider,{value:i,children:v(P.Provider,{value:a,children:a?.route._.components.reduceRight((e,t)=>v(F.Provider,{value:e,children:v(t,{})}),null)})})}),[t,i,a])}function ne(){return z()}function re(e){let t=I();return d(()=>t.navigate(e),[]),t.ssrContext&&(t.ssrContext.redirect=t.createUrl(e)),null}function ie(e){let t=I(),{to:r,replace:a,state:o,params:c,search:u,strict:d,preload:m,preloadDelay:h=50,style:g,className:_,activeStyle:y,activeClassName:b,asChild:x,children:S,...C}={...t.defaultLinkOptions,...e},w=p(null),T=p(null),E=t.createUrl(e),D=!!R({from:r,strict:d,params:c}),O=Z(()=>t.preload(e)),k=s(()=>{clearTimeout(T.current)},[]),A=s(()=>{k(),T.current=setTimeout(O,h)},[h,k]),j=f(()=>({"data-active":D,style:{...g,...D&&y},className:[_,D&&b].filter(Boolean).join(` `)||void 0}),[D,g,_,y,b]);l(()=>{if(m===`render`)A();else if(m===`viewport`&&w.current){let e=new IntersectionObserver(e=>e.forEach(e=>{e.isIntersecting?A():k()}));return e.observe(w.current),()=>{e.disconnect(),k()}}return k},[m,A,k]);let M=e=>{C.onClick?.(e),!(e.ctrlKey||e.metaKey||e.shiftKey||e.altKey||e.button!==0||e.defaultPrevented)&&(e.preventDefault(),t.navigate({url:E,replace:a,state:o}))},N=e=>{C.onFocus?.(e),m===`intent`&&!e.defaultPrevented&&A()},P=e=>{C.onBlur?.(e),m===`intent`&&k()},F=e=>{C.onPointerEnter?.(e),m===`intent`&&!e.defaultPrevented&&A()},L=e=>{C.onPointerLeave?.(e),m===`intent`&&k()},z={...C,...j,ref:ae(w,C.ref),href:E,onClick:M,onFocus:N,onBlur:P,onPointerEnter:F,onPointerLeave:L};return x&&i(S)?n(S,z):v(`a`,{...z,children:S})}function ae(e,t){return t?n=>{e.current=n;let r=typeof t==`function`?t(n):void(t.current=n);return r&&(()=>{e.current=null,r()})}:e}function Z(e){let t=p(e);return u(()=>{t.current=e},[e]),p(((...e)=>t.current(...e))).current}function oe(e){return()=>v(t,{fallback:v(e,{}),children:z()})}function se(t){class n extends e{constructor(e){super(e),this.state={children:e.children,error:null}}static getDerivedStateFromError(e){return{error:[e]}}static getDerivedStateFromProps(e,t){return e.children===t.children?t:{children:e.children,error:null}}render(){return this.state.error?v(t,{error:this.state.error[0]}):this.props.children}}return()=>v(n,{children:z()})}function Q(e){return new $({...b(y(e)),validate:e=>e,handles:[],components:[],preloads:[],chain:new Set})}function ce(){return Q(``)}var $=class e{_;_types;constructor(e){this._=e}route=t=>new e({...this._,...b(y(`${this._.pattern}/${t}`)),chain:new Set([...this._.chain,this])});use=t=>{let{_:n}=t;return new e({...this._,handles:[...this._.handles,...n.handles],components:[...this._.components,...n.components],preloads:[...this._.preloads,...n.preloads]}).search(n.validate)};search=t=>(t=x(t),new e({...this._,validate:e=>{let n=this._.validate(e);return{...n,...t({...e,...n})}}}));handle=t=>new e({...this._,handles:[...this._.handles,t]});preload=t=>new e({...this._,preloads:[...this._.preloads,e=>t({params:e.params,search:this._.validate(e.search)})]});component=t=>new e({...this._,components:[...this._.components,o(t)]});lazy=e=>{let t=a(async()=>{let t=await e();return`default`in t?t:{default:t}});return this.preload(e).component(t)};suspense=e=>this.component(oe(e));error=e=>this.component(se(e));toString=()=>this._.pattern};export{W as BrowserHistory,ee as HashHistory,ie as Link,N as LocationContext,P as MatchContext,X as MemoryHistory,re as Navigate,ne as Outlet,F as OutletContext,$ as Route,Y as Router,M as RouterContext,te as RouterRoot,ce as middleware,Q as route,V as useHandles,L as useLocation,R as useMatch,B as useNavigate,z as useOutlet,H as useParams,I as useRouter,U as useSearch};
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waymark",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "strblr",
|
|
6
|
-
"description": "Type-safe router
|
|
6
|
+
"description": "Type-safe React router that just works - simple setup, full autocomplete, 4kB gzipped",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "dist/index.js",
|
|
9
9
|
"types": "dist/index.d.ts",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "tsc --noEmit && tsdown",
|
|
41
|
+
"dev": "tsdown --watch",
|
|
41
42
|
"prepublishOnly": "bun run build && cp ../../README.md README.md",
|
|
42
43
|
"postpublish": "rm -f README.md"
|
|
43
44
|
},
|