waymark 0.2.1 → 0.2.3
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 +385 -267
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/tsdown.config.ts +0 -8
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark"
|
|
2
|
+
<img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
@@ -8,23 +8,25 @@
|
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/v/waymark?style=flat-square&color=000&labelColor=000" alt="npm version" /></a>
|
|
11
|
-
<a href="https://
|
|
11
|
+
<a href="https://www.npmjs.com/package/waymark"><img src="https://img.badgesize.io/https://unpkg.com/waymark/dist/index.js?compression=gzip&label=gzip&style=flat-square&color=000&labelColor=000" alt="gzip size" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/dm/waymark?style=flat-square&color=000&labelColor=000" alt="downloads" /></a>
|
|
13
13
|
<a href="https://github.com/strblr/waymark/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/waymark?style=flat-square&color=000&labelColor=000" alt="license" /></a>
|
|
14
|
+
<a href="https://github.com/sponsors/strblr"><img src="https://img.shields.io/github/sponsors/strblr?style=flat-square&color=000&labelColor=000" alt="sponsors" /></a>
|
|
14
15
|
</p>
|
|
15
16
|
|
|
16
17
|
<p align="center">
|
|
17
|
-
<a href="https://strblr.
|
|
18
|
+
<a href="https://waymark.strblr.workers.dev">📖 Documentation</a>
|
|
18
19
|
</p>
|
|
19
20
|
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
Waymark is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
|
|
23
24
|
|
|
24
|
-
- **Fully type-safe** - Complete TypeScript inference for routes, params, and search
|
|
25
|
+
- **Fully type-safe** - Complete TypeScript inference for routes, path params, and search params
|
|
25
26
|
- **Zero config** - No build plugins, no CLI tools, no configuration files, very low boilerplate
|
|
26
27
|
- **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
|
|
27
|
-
- **3.
|
|
28
|
+
- **3.6kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so around 4kB total
|
|
29
|
+
- **Not vibe-coded** - Built with careful design and attention to detail by a human
|
|
28
30
|
- **Just works** - Define routes, get autocomplete everywhere
|
|
29
31
|
|
|
30
32
|
---
|
|
@@ -37,20 +39,25 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
37
39
|
- [Nested routes and layouts](#nested-routes-and-layouts)
|
|
38
40
|
- [Setting up the router](#setting-up-the-router)
|
|
39
41
|
- [Code organization](#code-organization)
|
|
42
|
+
- [Path params](#path-params)
|
|
43
|
+
- [Search params](#search-params)
|
|
44
|
+
- [Basic usage](#basic-usage)
|
|
45
|
+
- [JSON-first approach](#json-first-approach)
|
|
46
|
+
- [Inheritance](#inheritance)
|
|
47
|
+
- [Idempotency requirement](#idempotency-requirement)
|
|
40
48
|
- [Navigation](#navigation)
|
|
41
49
|
- [The Link component](#the-link-component)
|
|
42
50
|
- [Active state detection](#active-state-detection)
|
|
43
51
|
- [Link preloading](#link-preloading)
|
|
44
52
|
- [Programmatic navigation](#programmatic-navigation)
|
|
45
53
|
- [Declarative navigation](#declarative-navigation)
|
|
46
|
-
- [Path parameters](#path-parameters)
|
|
47
|
-
- [Search queries](#search-queries)
|
|
48
54
|
- [Lazy loading](#lazy-loading)
|
|
49
55
|
- [Error boundaries](#error-boundaries)
|
|
50
56
|
- [Suspense boundaries](#suspense-boundaries)
|
|
51
57
|
- [Route handles](#route-handles)
|
|
52
58
|
- [Route matching and ranking](#route-matching-and-ranking)
|
|
53
59
|
- [History implementations](#history-implementations)
|
|
60
|
+
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
54
61
|
- [Cookbook](#cookbook)
|
|
55
62
|
- [Scroll to top on navigation](#scroll-to-top-on-navigation)
|
|
56
63
|
- [Global link configuration](#global-link-configuration)
|
|
@@ -58,12 +65,12 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
58
65
|
- [View transitions](#view-transitions)
|
|
59
66
|
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
60
67
|
- [API reference](#api-reference)
|
|
61
|
-
- [Types](#types)
|
|
62
68
|
- [Router class](#router-class)
|
|
63
|
-
- [History interface](#history-interface)
|
|
64
69
|
- [Route class](#route-class)
|
|
70
|
+
- [History interface](#history-interface)
|
|
65
71
|
- [Hooks](#hooks)
|
|
66
72
|
- [Components](#components)
|
|
73
|
+
- [Types](#types)
|
|
67
74
|
- [Roadmap](#roadmap)
|
|
68
75
|
- [License](#license)
|
|
69
76
|
|
|
@@ -106,7 +113,7 @@ declare module "waymark" {
|
|
|
106
113
|
}
|
|
107
114
|
```
|
|
108
115
|
|
|
109
|
-
Links, navigation, params, search
|
|
116
|
+
Links, navigation, path params, search params - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
|
|
110
117
|
|
|
111
118
|
---
|
|
112
119
|
|
|
@@ -189,7 +196,7 @@ function DashboardLayout() {
|
|
|
189
196
|
}
|
|
190
197
|
```
|
|
191
198
|
|
|
192
|
-
When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet. The layout stays mounted (and doesn't even rerender) as users navigate between child routes
|
|
199
|
+
When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet. The layout stays mounted (and doesn't even rerender) as users navigate between child routes.
|
|
193
200
|
|
|
194
201
|
You can nest as deep as you need:
|
|
195
202
|
|
|
@@ -229,7 +236,7 @@ const about = layout.route("/about").component(About);
|
|
|
229
236
|
const routes = [home, about]; // ✅ Don't include `layout`
|
|
230
237
|
```
|
|
231
238
|
|
|
232
|
-
This keeps your route list clean and makes sure that only actual pages can be matched and appear in autocomplete
|
|
239
|
+
This keeps your route list clean and makes sure that only actual pages can be matched and appear in autocomplete. The intermediate routes still exist as part of the hierarchy, they just aren't directly navigable.
|
|
233
240
|
|
|
234
241
|
The `RouterRoot` component is the entry point to Waymark. It listens to URL changes, matches the current path against your routes, and renders the matching route's component hierarchy.
|
|
235
242
|
|
|
@@ -251,7 +258,7 @@ You can also pass a `basePath` if your app lives under a subpath:
|
|
|
251
258
|
<RouterRoot routes={routes} basePath="/my-app" />
|
|
252
259
|
```
|
|
253
260
|
|
|
254
|
-
The second approach is to create a `Router` instance outside of React. This is useful when you need to access the router from anywhere in your code, for example to navigate programmatically from a non-React context
|
|
261
|
+
The second approach is to create a `Router` instance outside of React. This is useful when you need to access the router from anywhere in your code, for example to navigate programmatically from a non-React context:
|
|
255
262
|
|
|
256
263
|
```tsx
|
|
257
264
|
import { Router, RouterRoot } from "waymark";
|
|
@@ -334,6 +341,174 @@ But again, this is just one approach. You could keep all routes in a single file
|
|
|
334
341
|
|
|
335
342
|
---
|
|
336
343
|
|
|
344
|
+
## Path params
|
|
345
|
+
|
|
346
|
+
Dynamic segments in route patterns become typed path params. Define them with a colon prefix. They can also be made optional.
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
const post = route("/posts/:id").component(PostPage);
|
|
350
|
+
const comment = route("/posts/:postId/comments/:commentId?").component(
|
|
351
|
+
CommentPage
|
|
352
|
+
);
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Access parameters with `useParams`, passing the route pattern or object as an argument:
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
function PostPage() {
|
|
359
|
+
const { id } = useParams(post);
|
|
360
|
+
// id is typed as string
|
|
361
|
+
|
|
362
|
+
const { id } = useParams("/posts/:id");
|
|
363
|
+
// Also works
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function CommentPage() {
|
|
367
|
+
const { postId, commentId } = useParams(comment);
|
|
368
|
+
// postId: string
|
|
369
|
+
// commentId?: string | undefined
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
|
|
374
|
+
|
|
375
|
+
```tsx
|
|
376
|
+
const files = route("/files/*").component(FileBrowser);
|
|
377
|
+
|
|
378
|
+
function FileBrowser() {
|
|
379
|
+
const params = useParams(files);
|
|
380
|
+
const path = params["*"]; // e.g., "documents/report.pdf"
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Search params
|
|
387
|
+
|
|
388
|
+
### Basic usage
|
|
389
|
+
|
|
390
|
+
Search params (the `?key=value` part of URLs) can be typed and validated using the `.search()` method on a route. You can pass either a [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) validator like Zod, or a plain mapping function.
|
|
391
|
+
|
|
392
|
+
With Zod:
|
|
393
|
+
|
|
394
|
+
```tsx
|
|
395
|
+
import { z } from "zod";
|
|
396
|
+
|
|
397
|
+
const searchPage = route("/search")
|
|
398
|
+
.search(
|
|
399
|
+
z.object({
|
|
400
|
+
q: z.string().catch(""),
|
|
401
|
+
page: z.coerce.number().catch(1)
|
|
402
|
+
})
|
|
403
|
+
)
|
|
404
|
+
.component(SearchPage);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
With a plain function:
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
const searchPage = route("/search")
|
|
411
|
+
.search(raw => ({
|
|
412
|
+
q: String(raw.q ?? ""),
|
|
413
|
+
page: Number(raw.page ?? 1)
|
|
414
|
+
}))
|
|
415
|
+
.component(SearchPage);
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
function SearchPage() {
|
|
422
|
+
const [search, setSearch] = useSearch(searchPage);
|
|
423
|
+
// search.q: string
|
|
424
|
+
// search.page: number
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The setter merges your updates with existing values:
|
|
429
|
+
|
|
430
|
+
```tsx
|
|
431
|
+
setSearch({ page: 2 }); // Only updates page
|
|
432
|
+
setSearch(prev => ({ page: prev.page + 1 })); // Increment page
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Pass `true` as the second argument to replace the history entry instead of pushing:
|
|
436
|
+
|
|
437
|
+
```tsx
|
|
438
|
+
setSearch({ page: 1 }, true);
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### JSON-first approach
|
|
442
|
+
|
|
443
|
+
Waymark uses a JSON-first approach for search params, similar to TanStack Router. When serializing and deserializing values from the URL:
|
|
444
|
+
|
|
445
|
+
- Plain strings that aren't valid JSON are kept as-is: `"John"` → `?name=John` → `"John"`
|
|
446
|
+
- Everything else is JSON-encoded (and URL-encoded):
|
|
447
|
+
- `true` → `?enabled=true` → `true`
|
|
448
|
+
- `"true"` → `?enabled=%22true%22` → `"true"`
|
|
449
|
+
- `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
|
|
450
|
+
- `42` → `count=42` → `42`
|
|
451
|
+
|
|
452
|
+
This means you can store complex data structures like arrays and objects in search params without manual serialization. When reading from the URL, Waymark automatically parses JSON values back to their original types.
|
|
453
|
+
|
|
454
|
+
The resulting parsed object is what gets passed to the `.search()` function or schema on the route builder. It's typed as `Record<string, unknown>`, which is why validation with Zod or a mapping function is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
|
|
455
|
+
|
|
456
|
+
### Inheritance
|
|
457
|
+
|
|
458
|
+
When you define search params with a validator on a route, all child routes automatically inherit that validator along with its typing.
|
|
459
|
+
|
|
460
|
+
Here's how it works. Start with a parent route that defines a search param:
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
const dashboard = route("/dashboard")
|
|
464
|
+
.search(
|
|
465
|
+
z.object({
|
|
466
|
+
view: z.enum(["grid", "list"]).catch("grid")
|
|
467
|
+
})
|
|
468
|
+
)
|
|
469
|
+
.component(DashboardLayout);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Any child route created from `dashboard` inherits the `view` search param and its validation:
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
const projects = dashboard.route("/projects").component(ProjectsPage);
|
|
476
|
+
|
|
477
|
+
function ProjectsPage() {
|
|
478
|
+
const [search] = useSearch(projects);
|
|
479
|
+
// search.view is typed as "grid" | "list"
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
If a child route needs additional search params, define a new validator with `.search()`. Your validator receives the raw params from the URL along with the parent's already-validated params. After validation, your result is combined with the parent's validated params to produce the final search params object.
|
|
484
|
+
|
|
485
|
+
In practice, this means you only need to validate the new params you're adding - the parent's params are automatically included in the final result:
|
|
486
|
+
|
|
487
|
+
```tsx
|
|
488
|
+
const projects = dashboard
|
|
489
|
+
.route("/projects")
|
|
490
|
+
.search(
|
|
491
|
+
z.object({
|
|
492
|
+
status: z.enum(["active", "archived"]).catch("active")
|
|
493
|
+
})
|
|
494
|
+
)
|
|
495
|
+
.component(ProjectsPage);
|
|
496
|
+
|
|
497
|
+
function ProjectsPage() {
|
|
498
|
+
const [search] = useSearch(projects);
|
|
499
|
+
// search.view: "grid" | "list" (from parent)
|
|
500
|
+
// search.status: "active" | "archived" (from child)
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Idempotency requirement
|
|
505
|
+
|
|
506
|
+
The validation function or schema you pass to `.search()` must be **idempotent**, meaning `fn(fn(x))` should equal `fn(x)`.
|
|
507
|
+
|
|
508
|
+
When you read search params, the values are passed through your validator. When you update search params, the navigation APIs expect values in that same validated format, which are then JSON-encoded back into the URL. On the next read, those encoded values are decoded and passed through your validator again - meaning your validator may receive its own output as input.
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
337
512
|
## Navigation
|
|
338
513
|
|
|
339
514
|
### The Link component
|
|
@@ -457,7 +632,7 @@ userProfile.preload();
|
|
|
457
632
|
|
|
458
633
|
### Programmatic navigation
|
|
459
634
|
|
|
460
|
-
For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
|
|
635
|
+
For navigation triggered by code rather than user clicks, use the `useNavigate` hook (or `router.navigate`):
|
|
461
636
|
|
|
462
637
|
```tsx
|
|
463
638
|
import { useNavigate } from "waymark";
|
|
@@ -474,7 +649,7 @@ function LoginForm() {
|
|
|
474
649
|
}
|
|
475
650
|
```
|
|
476
651
|
|
|
477
|
-
The navigate function accepts the same options as `Link`:
|
|
652
|
+
The navigate function accepts the same navigation options as `Link`:
|
|
478
653
|
|
|
479
654
|
```tsx
|
|
480
655
|
navigate({ to: userProfile, params: { id: "42" }, search: { tab: "posts" } });
|
|
@@ -495,14 +670,12 @@ You can also access the router directly via `useRouter()` (or import the router
|
|
|
495
670
|
For unsafe navigation that bypasses type checking, you can pass `url` instead of `to`, `params` and `search`. This is useful when you don't know the target URL statically (e.g. external redirects):
|
|
496
671
|
|
|
497
672
|
```tsx
|
|
498
|
-
const router = useRouter();
|
|
499
|
-
|
|
500
673
|
// Type-safe navigation
|
|
501
|
-
|
|
674
|
+
navigate({ to: userProfile, params: { id: "42" } });
|
|
502
675
|
|
|
503
676
|
// Unsafe navigation - no type checking
|
|
504
|
-
|
|
505
|
-
|
|
677
|
+
navigate({ url: "/some/unknown/path" });
|
|
678
|
+
navigate({ url: "/callback", replace: true, state: { data: 123 } });
|
|
506
679
|
```
|
|
507
680
|
|
|
508
681
|
### Declarative navigation
|
|
@@ -523,7 +696,7 @@ function ProtectedPage() {
|
|
|
523
696
|
}
|
|
524
697
|
```
|
|
525
698
|
|
|
526
|
-
The `Navigate` component accepts the same props as the `Link` component
|
|
699
|
+
The `Navigate` component accepts the same navigation props as the `Link` component. You can pass route patterns, path params, search params, and state:
|
|
527
700
|
|
|
528
701
|
```tsx
|
|
529
702
|
<Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
|
|
@@ -535,118 +708,6 @@ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation
|
|
|
535
708
|
|
|
536
709
|
---
|
|
537
710
|
|
|
538
|
-
## Path parameters
|
|
539
|
-
|
|
540
|
-
Dynamic segments in route patterns become typed path parameters. Define them with a colon prefix. They can also be made optional.
|
|
541
|
-
|
|
542
|
-
```tsx
|
|
543
|
-
const post = route("/posts/:id").component(PostPage);
|
|
544
|
-
const comment = route("/posts/:postId/comments/:commentId?").component(
|
|
545
|
-
CommentPage
|
|
546
|
-
);
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
Access parameters with `useParams`, passing the route pattern or object as an argument:
|
|
550
|
-
|
|
551
|
-
```tsx
|
|
552
|
-
function PostPage() {
|
|
553
|
-
const { id } = useParams(post);
|
|
554
|
-
// id is typed as string
|
|
555
|
-
|
|
556
|
-
const { id } = useParams("/posts/:id");
|
|
557
|
-
// Also works
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function CommentPage() {
|
|
561
|
-
const { postId, commentId } = useParams(comment);
|
|
562
|
-
// postId: string
|
|
563
|
-
// commentId?: string | undefined
|
|
564
|
-
}
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
|
|
568
|
-
|
|
569
|
-
```tsx
|
|
570
|
-
const files = route("/files/*").component(FileBrowser);
|
|
571
|
-
|
|
572
|
-
function FileBrowser() {
|
|
573
|
-
const params = useParams(files);
|
|
574
|
-
const path = params["*"]; // e.g., "documents/report.pdf"
|
|
575
|
-
}
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
---
|
|
579
|
-
|
|
580
|
-
## Search queries
|
|
581
|
-
|
|
582
|
-
Search parameters (the `?key=value` part of URLs) can be typed and validated using the `.search()` method on a route. You can pass either a [Standard Schema](https://github.com/standard-schema/standard-schema) validator like Zod, or a plain mapping function.
|
|
583
|
-
|
|
584
|
-
With Zod:
|
|
585
|
-
|
|
586
|
-
```tsx
|
|
587
|
-
import { z } from "zod";
|
|
588
|
-
|
|
589
|
-
const searchPage = route("/search")
|
|
590
|
-
.search(
|
|
591
|
-
z.object({
|
|
592
|
-
q: z.string().catch(""),
|
|
593
|
-
page: z.coerce.number().catch(1)
|
|
594
|
-
})
|
|
595
|
-
)
|
|
596
|
-
.component(SearchPage);
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
With a plain function:
|
|
600
|
-
|
|
601
|
-
```tsx
|
|
602
|
-
const searchPage = route("/search")
|
|
603
|
-
.search(raw => ({
|
|
604
|
-
q: String(raw.q ?? ""),
|
|
605
|
-
page: Number(raw.page ?? 1)
|
|
606
|
-
}))
|
|
607
|
-
.component(SearchPage);
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
|
|
611
|
-
|
|
612
|
-
```tsx
|
|
613
|
-
function SearchPage() {
|
|
614
|
-
const [search, setSearch] = useSearch(searchPage);
|
|
615
|
-
// search.q: string
|
|
616
|
-
// search.page: number
|
|
617
|
-
}
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
The setter merges your updates with existing values:
|
|
621
|
-
|
|
622
|
-
```tsx
|
|
623
|
-
setSearch({ page: 2 }); // Only updates page
|
|
624
|
-
setSearch(prev => ({ page: prev.page + 1 })); // Increment page
|
|
625
|
-
```
|
|
626
|
-
|
|
627
|
-
Pass `true` as the second argument to replace the history entry instead of pushing:
|
|
628
|
-
|
|
629
|
-
```tsx
|
|
630
|
-
setSearch({ page: 1 }, true);
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
**JSON-first search params**
|
|
634
|
-
|
|
635
|
-
Waymark uses a JSON-first approach for search parameters, similar to TanStack Router. When serializing and deserializing values from the URL:
|
|
636
|
-
|
|
637
|
-
- Plain strings that aren't valid JSON are kept as-is: `"John"` → `?name=John` → `"John"`
|
|
638
|
-
- Everything else is JSON-encoded (and URL-encoded):
|
|
639
|
-
- `true` → `?enabled=true` → `true`
|
|
640
|
-
- `"true"` → `?enabled=%22true%22` → `"true"`
|
|
641
|
-
- `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
|
|
642
|
-
- `42` → `count=42` → `42`
|
|
643
|
-
|
|
644
|
-
This means you can store complex data structures like arrays and objects in search params without manual serialization. When reading from the URL, Waymark automatically parses JSON values back to their original types.
|
|
645
|
-
|
|
646
|
-
The resulting parsed object is what gets passed to the `.search()` function or schema on the route builder. It's typed as `Record<string, unknown>`, which is why validation with Zod or a mapping function is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
|
|
647
|
-
|
|
648
|
-
---
|
|
649
|
-
|
|
650
711
|
## Lazy loading
|
|
651
712
|
|
|
652
713
|
Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
|
|
@@ -673,7 +734,7 @@ const analytics = route("/analytics").lazy(() =>
|
|
|
673
734
|
export function AnalyticsPage() { ... }
|
|
674
735
|
```
|
|
675
736
|
|
|
676
|
-
Lazy routes work
|
|
737
|
+
Lazy routes work like any other route. Child routes inherit the parent's lazy-loaded components:
|
|
677
738
|
|
|
678
739
|
```tsx
|
|
679
740
|
const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
|
|
@@ -867,7 +928,7 @@ import { HashHistory } from "waymark";
|
|
|
867
928
|
<RouterRoot routes={routes} history={new HashHistory()} />;
|
|
868
929
|
```
|
|
869
930
|
|
|
870
|
-
**MemoryHistory** keeps the history in memory without touching the URL. It also doesn't rely on any browser API. Perfect for testing, server-side rendering, or embedded applications:
|
|
931
|
+
**MemoryHistory** keeps the history in memory without touching the URL. It also doesn't rely on any browser API. Perfect for testing, server-side rendering (SSR), or embedded applications:
|
|
871
932
|
|
|
872
933
|
```tsx
|
|
873
934
|
import { MemoryHistory } from "waymark";
|
|
@@ -879,17 +940,64 @@ All history implementations conform to the `HistoryLike` interface, so you can c
|
|
|
879
940
|
|
|
880
941
|
---
|
|
881
942
|
|
|
882
|
-
##
|
|
943
|
+
## Server-side rendering (SSR)
|
|
883
944
|
|
|
884
|
-
|
|
945
|
+
Waymark supports server-side rendering using `MemoryHistory`. The key is to use `MemoryHistory` on the server (initialized with the request URL) and `BrowserHistory` on the client.
|
|
885
946
|
|
|
886
|
-
|
|
947
|
+
On the server, create a router with `MemoryHistory` initialized to the request URL:
|
|
887
948
|
|
|
888
949
|
```tsx
|
|
889
|
-
|
|
890
|
-
import {
|
|
950
|
+
// server.tsx
|
|
951
|
+
import { renderToString } from "react-dom/server";
|
|
952
|
+
import { RouterRoot, MemoryHistory, type SSRContext } from "waymark";
|
|
953
|
+
import { routes } from "./routes";
|
|
891
954
|
|
|
892
|
-
function
|
|
955
|
+
function handleRequest(req: Request) {
|
|
956
|
+
const ssrContext: SSRContext = {};
|
|
957
|
+
const html = renderToString(
|
|
958
|
+
<RouterRoot
|
|
959
|
+
routes={routes}
|
|
960
|
+
history={new MemoryHistory(req.url)}
|
|
961
|
+
ssrContext={ssrContext}
|
|
962
|
+
/>
|
|
963
|
+
);
|
|
964
|
+
if (ssrContext.redirect) {
|
|
965
|
+
return Response.redirect(ssrContext.redirect);
|
|
966
|
+
}
|
|
967
|
+
return new Response(html, {
|
|
968
|
+
headers: { "Content-Type": "text/html" }
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
The `ssrContext` object captures information during server rendering. When a `Navigate` component renders on the server (typically from conditional logic), it populates `ssrContext.redirect` with the target URL. Your server can then return an HTTP redirect instead of the rendered HTML.
|
|
974
|
+
|
|
975
|
+
On the client, use the default (`BrowserHistory`) for hydration:
|
|
976
|
+
|
|
977
|
+
```tsx
|
|
978
|
+
// client.tsx
|
|
979
|
+
import { hydrateRoot } from "react-dom/client";
|
|
980
|
+
import { RouterRoot } from "waymark";
|
|
981
|
+
import { routes } from "./routes";
|
|
982
|
+
|
|
983
|
+
hydrateRoot(document.getElementById("root")!, <RouterRoot routes={routes} />);
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
You can also manually set `ssrContext.statusCode` in your components during SSR to control the response status (like 404 for not found pages).
|
|
987
|
+
|
|
988
|
+
---
|
|
989
|
+
|
|
990
|
+
## Cookbook
|
|
991
|
+
|
|
992
|
+
### Scroll to top on navigation
|
|
993
|
+
|
|
994
|
+
Create a component that scrolls to top when the path changes and include it in your layout:
|
|
995
|
+
|
|
996
|
+
```tsx
|
|
997
|
+
import { useLocation } from "waymark";
|
|
998
|
+
import { useEffect } from "react";
|
|
999
|
+
|
|
1000
|
+
function ScrollToTop() {
|
|
893
1001
|
const { path } = useLocation();
|
|
894
1002
|
useEffect(() => window.scrollTo(0, 0), [path]);
|
|
895
1003
|
return null;
|
|
@@ -908,7 +1016,7 @@ function AppLayout() {
|
|
|
908
1016
|
|
|
909
1017
|
### Global link configuration
|
|
910
1018
|
|
|
911
|
-
Set defaults for all `Link` components using `defaultLinkOptions` on the router.
|
|
1019
|
+
Set defaults for all `Link` components using `defaultLinkOptions` on the router. Useful for consistent styling and preload behavior across your app:
|
|
912
1020
|
|
|
913
1021
|
```tsx
|
|
914
1022
|
<RouterRoot
|
|
@@ -964,7 +1072,7 @@ const router = new Router({
|
|
|
964
1072
|
|
|
965
1073
|
### View transitions
|
|
966
1074
|
|
|
967
|
-
|
|
1075
|
+
You can use the view transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
|
|
968
1076
|
|
|
969
1077
|
```tsx
|
|
970
1078
|
import { flushSync } from "react-dom";
|
|
@@ -1047,62 +1155,6 @@ if (adminMatch) {
|
|
|
1047
1155
|
|
|
1048
1156
|
## API reference
|
|
1049
1157
|
|
|
1050
|
-
### Types
|
|
1051
|
-
|
|
1052
|
-
**`NavigateOptions<P>`** is the main type for type-safe navigation:
|
|
1053
|
-
|
|
1054
|
-
```tsx
|
|
1055
|
-
type NavigateOptions<P extends Pattern> = {
|
|
1056
|
-
to: P | Route<P>; // Route pattern or route object
|
|
1057
|
-
params?: Params<P>; // Required if route has dynamic segments
|
|
1058
|
-
search?: Search<P>; // Search params if route defines them
|
|
1059
|
-
replace?: boolean; // Replace history instead of push
|
|
1060
|
-
state?: any; // Arbitrary state to pass
|
|
1061
|
-
};
|
|
1062
|
-
```
|
|
1063
|
-
|
|
1064
|
-
**`HistoryPushOptions`** is for untyped navigation:
|
|
1065
|
-
|
|
1066
|
-
```tsx
|
|
1067
|
-
interface HistoryPushOptions {
|
|
1068
|
-
url: string; // The URL to navigate to
|
|
1069
|
-
replace?: boolean; // Replace history instead of push
|
|
1070
|
-
state?: any; // Arbitrary state to pass
|
|
1071
|
-
}
|
|
1072
|
-
```
|
|
1073
|
-
|
|
1074
|
-
**`MatchOptions<P>`** is used for route matching:
|
|
1075
|
-
|
|
1076
|
-
```tsx
|
|
1077
|
-
type MatchOptions<P extends Pattern> = {
|
|
1078
|
-
from: P | Route<P>; // Route to match against
|
|
1079
|
-
strict?: boolean; // Require exact match (not just prefix)
|
|
1080
|
-
params?: Partial<Params<P>>; // Filter by specific param values
|
|
1081
|
-
};
|
|
1082
|
-
```
|
|
1083
|
-
|
|
1084
|
-
**`Match<P>`** is the result of a successful match:
|
|
1085
|
-
|
|
1086
|
-
```tsx
|
|
1087
|
-
type Match<P extends Pattern> = {
|
|
1088
|
-
route: Route<P>; // The matched route
|
|
1089
|
-
params: Params<P>; // Extracted parameters
|
|
1090
|
-
};
|
|
1091
|
-
```
|
|
1092
|
-
|
|
1093
|
-
**`LinkOptions`** controls link behavior and styling:
|
|
1094
|
-
|
|
1095
|
-
```tsx
|
|
1096
|
-
interface LinkOptions {
|
|
1097
|
-
strict?: boolean; // Strict active matching
|
|
1098
|
-
preload?: "intent" | "render" | "viewport" | false;
|
|
1099
|
-
style?: CSSProperties;
|
|
1100
|
-
className?: string;
|
|
1101
|
-
activeStyle?: CSSProperties;
|
|
1102
|
-
activeClassName?: string;
|
|
1103
|
-
}
|
|
1104
|
-
```
|
|
1105
|
-
|
|
1106
1158
|
### Router class
|
|
1107
1159
|
|
|
1108
1160
|
The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
|
|
@@ -1111,9 +1163,10 @@ The `Router` class is the core of Waymark. You can create an instance directly o
|
|
|
1111
1163
|
|
|
1112
1164
|
```tsx
|
|
1113
1165
|
const router = new Router({
|
|
1114
|
-
routes: Route[], // Required: array of routes
|
|
1115
1166
|
basePath: string, // Optional: base path prefix (default: "/")
|
|
1167
|
+
routes: Route[], // Required: array of routes
|
|
1116
1168
|
history: HistoryLike, // Optional: history implementation (default: BrowserHistory)
|
|
1169
|
+
ssrContext: SSRContext, // Optional: SSR context
|
|
1117
1170
|
defaultLinkOptions: LinkOptions // Optional: defaults for all Links
|
|
1118
1171
|
});
|
|
1119
1172
|
```
|
|
@@ -1123,6 +1176,7 @@ const router = new Router({
|
|
|
1123
1176
|
- `router.basePath` - The configured base path
|
|
1124
1177
|
- `router.routes` - The array of routes
|
|
1125
1178
|
- `router.history` - The history instance
|
|
1179
|
+
- `router.ssrContext` - The SSR context (if provided)
|
|
1126
1180
|
- `router.defaultLinkOptions` - Default link options
|
|
1127
1181
|
|
|
1128
1182
|
**Methods:**
|
|
@@ -1168,71 +1222,6 @@ const match = router.matchAll("/users/42");
|
|
|
1168
1222
|
const route = router.getRoute("/users/:id");
|
|
1169
1223
|
```
|
|
1170
1224
|
|
|
1171
|
-
### History interface
|
|
1172
|
-
|
|
1173
|
-
The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1174
|
-
|
|
1175
|
-
**Interface:**
|
|
1176
|
-
|
|
1177
|
-
```tsx
|
|
1178
|
-
interface HistoryLike {
|
|
1179
|
-
getPath: () => string;
|
|
1180
|
-
getSearch: () => Record<string, unknown>;
|
|
1181
|
-
getState: () => any;
|
|
1182
|
-
go: (delta: number) => void;
|
|
1183
|
-
push: (options: HistoryPushOptions) => void;
|
|
1184
|
-
subscribe: (listener: () => void) => () => void;
|
|
1185
|
-
}
|
|
1186
|
-
```
|
|
1187
|
-
|
|
1188
|
-
**Methods:**
|
|
1189
|
-
|
|
1190
|
-
`history.getPath()` returns the current pathname:
|
|
1191
|
-
|
|
1192
|
-
```tsx
|
|
1193
|
-
const path = history.getPath();
|
|
1194
|
-
// Returns "/users/42"
|
|
1195
|
-
```
|
|
1196
|
-
|
|
1197
|
-
`history.getSearch()` returns the current search parameters as a parsed object:
|
|
1198
|
-
|
|
1199
|
-
```tsx
|
|
1200
|
-
const search = history.getSearch();
|
|
1201
|
-
// Returns { tab: "posts", page: 2 }
|
|
1202
|
-
```
|
|
1203
|
-
|
|
1204
|
-
`history.getState()` returns the current history state:
|
|
1205
|
-
|
|
1206
|
-
```tsx
|
|
1207
|
-
const state = history.getState();
|
|
1208
|
-
// Returns any state passed during navigation
|
|
1209
|
-
```
|
|
1210
|
-
|
|
1211
|
-
`history.go(delta)` navigates forward or back in history:
|
|
1212
|
-
|
|
1213
|
-
```tsx
|
|
1214
|
-
history.go(-1); // Go back
|
|
1215
|
-
history.go(1); // Go forward
|
|
1216
|
-
history.go(-2); // Go back two steps
|
|
1217
|
-
```
|
|
1218
|
-
|
|
1219
|
-
`history.push(options)` pushes or replaces a history entry:
|
|
1220
|
-
|
|
1221
|
-
```tsx
|
|
1222
|
-
history.push({ url: "/users/42", state: { from: "list" } });
|
|
1223
|
-
history.push({ url: "/login", replace: true });
|
|
1224
|
-
```
|
|
1225
|
-
|
|
1226
|
-
`history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
|
|
1227
|
-
|
|
1228
|
-
```tsx
|
|
1229
|
-
const unsubscribe = history.subscribe(() => {
|
|
1230
|
-
console.log("Navigation occurred");
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
// Later: unsubscribe()
|
|
1234
|
-
```
|
|
1235
|
-
|
|
1236
1225
|
### Route class
|
|
1237
1226
|
|
|
1238
1227
|
Routes are created with the `route()` function and configured by chaining methods.
|
|
@@ -1304,6 +1293,71 @@ const users = route("/users").preloader(async () => {
|
|
|
1304
1293
|
await userProfile.preload();
|
|
1305
1294
|
```
|
|
1306
1295
|
|
|
1296
|
+
### History interface
|
|
1297
|
+
|
|
1298
|
+
The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1299
|
+
|
|
1300
|
+
**Interface:**
|
|
1301
|
+
|
|
1302
|
+
```tsx
|
|
1303
|
+
interface HistoryLike {
|
|
1304
|
+
getPath: () => string;
|
|
1305
|
+
getSearch: () => Record<string, unknown>;
|
|
1306
|
+
getState: () => any;
|
|
1307
|
+
go: (delta: number) => void;
|
|
1308
|
+
push: (options: HistoryPushOptions) => void;
|
|
1309
|
+
subscribe: (listener: () => void) => () => void;
|
|
1310
|
+
}
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
**Methods:**
|
|
1314
|
+
|
|
1315
|
+
`history.getPath()` returns the current pathname:
|
|
1316
|
+
|
|
1317
|
+
```tsx
|
|
1318
|
+
const path = history.getPath();
|
|
1319
|
+
// Returns "/users/42"
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
`history.getSearch()` returns the current search params as a parsed object:
|
|
1323
|
+
|
|
1324
|
+
```tsx
|
|
1325
|
+
const search = history.getSearch();
|
|
1326
|
+
// Returns { tab: "posts", page: 2 }
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
`history.getState()` returns the current history state:
|
|
1330
|
+
|
|
1331
|
+
```tsx
|
|
1332
|
+
const state = history.getState();
|
|
1333
|
+
// Returns any state passed during navigation
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
`history.go(delta)` navigates forward or back in history:
|
|
1337
|
+
|
|
1338
|
+
```tsx
|
|
1339
|
+
history.go(-1); // Go back
|
|
1340
|
+
history.go(1); // Go forward
|
|
1341
|
+
history.go(-2); // Go back two steps
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
`history.push(options)` pushes or replaces a history entry:
|
|
1345
|
+
|
|
1346
|
+
```tsx
|
|
1347
|
+
history.push({ url: "/users/42", state: { from: "list" } });
|
|
1348
|
+
history.push({ url: "/login", replace: true });
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
`history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
|
|
1352
|
+
|
|
1353
|
+
```tsx
|
|
1354
|
+
const unsubscribe = history.subscribe(() => {
|
|
1355
|
+
console.log("Navigation occurred");
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
// Later: unsubscribe()
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1307
1361
|
### Hooks
|
|
1308
1362
|
|
|
1309
1363
|
**`useRouter()`** returns the Router instance:
|
|
@@ -1396,14 +1450,78 @@ function Layout() {
|
|
|
1396
1450
|
<Navigate to="/login" replace />
|
|
1397
1451
|
```
|
|
1398
1452
|
|
|
1453
|
+
### Types
|
|
1454
|
+
|
|
1455
|
+
**`NavigateOptions<P>`** is the main type for type-safe navigation:
|
|
1456
|
+
|
|
1457
|
+
```tsx
|
|
1458
|
+
type NavigateOptions<P extends Pattern> = {
|
|
1459
|
+
to: P | Route<P>; // Route pattern or route object
|
|
1460
|
+
params?: Params<P>; // Required if route has dynamic segments
|
|
1461
|
+
search?: Search<P>; // Search params if route defines them
|
|
1462
|
+
replace?: boolean; // Replace history instead of push
|
|
1463
|
+
state?: any; // Arbitrary state to pass
|
|
1464
|
+
};
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
**`HistoryPushOptions`** is for untyped navigation:
|
|
1468
|
+
|
|
1469
|
+
```tsx
|
|
1470
|
+
interface HistoryPushOptions {
|
|
1471
|
+
url: string; // The URL to navigate to
|
|
1472
|
+
replace?: boolean; // Replace history instead of push
|
|
1473
|
+
state?: any; // Arbitrary state to pass
|
|
1474
|
+
}
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
**`MatchOptions<P>`** is used for route matching:
|
|
1478
|
+
|
|
1479
|
+
```tsx
|
|
1480
|
+
type MatchOptions<P extends Pattern> = {
|
|
1481
|
+
from: P | Route<P>; // Route to match against
|
|
1482
|
+
strict?: boolean; // Require exact match (not just prefix)
|
|
1483
|
+
params?: Partial<Params<P>>; // Match by specific param values
|
|
1484
|
+
};
|
|
1485
|
+
```
|
|
1486
|
+
|
|
1487
|
+
**`Match<P>`** is the result of a successful match:
|
|
1488
|
+
|
|
1489
|
+
```tsx
|
|
1490
|
+
type Match<P extends Pattern> = {
|
|
1491
|
+
route: Route<P>; // The matched route
|
|
1492
|
+
params: Params<P>; // Extracted parameters
|
|
1493
|
+
};
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
**`LinkOptions`** controls link behavior and styling:
|
|
1497
|
+
|
|
1498
|
+
```tsx
|
|
1499
|
+
interface LinkOptions {
|
|
1500
|
+
strict?: boolean; // Strict active matching
|
|
1501
|
+
preload?: "intent" | "render" | "viewport" | false;
|
|
1502
|
+
style?: CSSProperties;
|
|
1503
|
+
className?: string;
|
|
1504
|
+
activeStyle?: CSSProperties;
|
|
1505
|
+
activeClassName?: string;
|
|
1506
|
+
}
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
**`SSRContext`** captures context during SSR (like redirects):
|
|
1510
|
+
|
|
1511
|
+
```tsx
|
|
1512
|
+
type SSRContext = {
|
|
1513
|
+
redirect?: string; // Set by Navigate component during SSR
|
|
1514
|
+
statusCode?: number; // Can be set manually in your components during SSR
|
|
1515
|
+
};
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1399
1518
|
---
|
|
1400
1519
|
|
|
1401
1520
|
## Roadmap
|
|
1402
1521
|
|
|
1403
1522
|
Future improvements planned for Waymark:
|
|
1404
1523
|
|
|
1405
|
-
- **Preloader context** - Pass
|
|
1406
|
-
- **Server-side rendering guide** - Add documentation for using Waymark in SSR environments
|
|
1524
|
+
- **Preloader context** - Pass path params and search params to preloader functions, enabling loading logic based on the target route's dynamic data
|
|
1407
1525
|
|
|
1408
1526
|
---
|
|
1409
1527
|
|
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,10 @@ type NavigateOptions<P extends Pattern> = {
|
|
|
39
39
|
replace?: boolean;
|
|
40
40
|
state?: any;
|
|
41
41
|
} & MaybeKey<"params", Params<P>> & MaybeKey<"search", Search<P>>;
|
|
42
|
+
type SSRContext = {
|
|
43
|
+
redirect?: string;
|
|
44
|
+
statusCode?: number;
|
|
45
|
+
};
|
|
42
46
|
interface HistoryPushOptions {
|
|
43
47
|
url: string;
|
|
44
48
|
replace?: boolean;
|
|
@@ -76,7 +80,7 @@ declare class Route<P extends string = string, Ps extends {} = any, S extends {}
|
|
|
76
80
|
};
|
|
77
81
|
constructor(pattern: P, mapSearch: (search: Record<string, unknown>) => S, handles: Handle[], components: ComponentType[], preloaders: (() => Promise<any>)[]);
|
|
78
82
|
route<P2 extends string>(subPattern: P2): Route<NormalizePath<`${P}/${P2}`>, regexparam0.RouteParams<NormalizePath<`${P}/${P2}`>> extends infer T ? { [KeyType in keyof T]: T[KeyType] } : never, S>;
|
|
79
|
-
search<S2 extends {}>(mapper: ((search: S & Record<string, unknown>) => S2) | StandardSchemaV1<
|
|
83
|
+
search<S2 extends {}>(mapper: ((search: S & Record<string, unknown>) => S2) | StandardSchemaV1<Record<string, unknown>, S2>): Route<P, Ps, (type_fest0.PickIndexSignature<S> extends infer T_1 ? { [Key in keyof T_1 as Key extends keyof type_fest0.PickIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K] } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined } extends infer T_2 ? { [KeyType_1 in keyof T_2]: T_2[KeyType_1] } : never> ? never : Key]: T_1[Key] } : never) & type_fest0.PickIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K] } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined } extends infer T_2 ? { [KeyType_1 in keyof T_2]: T_2[KeyType_1] } : never> & (type_fest0.OmitIndexSignature<S> extends infer T_3 ? { [Key_1 in keyof T_3 as Key_1 extends keyof type_fest0.OmitIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K] } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined } extends infer T_4 ? { [KeyType_1 in keyof T_4]: T_4[KeyType_1] } : never> ? never : Key_1]: T_3[Key_1] } : never) & type_fest0.OmitIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K] } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined } extends infer T_4 ? { [KeyType_1 in keyof T_4]: T_4[KeyType_1] } : never> extends infer T ? { [KeyType in keyof T]: T[KeyType] } : never>;
|
|
80
84
|
handle(handle: Handle): Route<P, Ps, S>;
|
|
81
85
|
preloader(preloader: () => Promise<any>): Route<P, Ps, S>;
|
|
82
86
|
component(component: ComponentType): Route<P, Ps, S>;
|
|
@@ -135,12 +139,14 @@ interface RouterOptions {
|
|
|
135
139
|
basePath?: string;
|
|
136
140
|
routes: RouteList;
|
|
137
141
|
history?: HistoryLike;
|
|
142
|
+
ssrContext?: SSRContext;
|
|
138
143
|
defaultLinkOptions?: LinkOptions;
|
|
139
144
|
}
|
|
140
145
|
declare class Router {
|
|
141
146
|
readonly basePath: string;
|
|
142
147
|
readonly routes: RouteList;
|
|
143
148
|
readonly history: HistoryLike;
|
|
149
|
+
readonly ssrContext?: SSRContext;
|
|
144
150
|
readonly defaultLinkOptions?: LinkOptions;
|
|
145
151
|
private readonly _;
|
|
146
152
|
constructor(options: RouterOptions);
|
|
@@ -193,4 +199,4 @@ declare class HashHistory extends BrowserHistory {
|
|
|
193
199
|
push: (options: HistoryPushOptions) => void;
|
|
194
200
|
}
|
|
195
201
|
//#endregion
|
|
196
|
-
export { BrowserHistory, ComponentLoader, GetRoute, Handle, HashHistory, HistoryLike, HistoryPushOptions, Link, LinkOptions, LinkProps, Match, MatchContext, MatchOptions, MemoryHistory, MemoryLocation, Navigate, NavigateOptions, NavigateProps, Outlet, OutletContext, Params, Pattern, Register, Route, RouteList, Router, RouterContext, RouterOptions, RouterRoot, RouterRootProps, Search, Updater, route, useHandles, useLocation, useMatch, useNavigate, useOutlet, useParams, useRouter, useSearch, useSubscribe };
|
|
202
|
+
export { BrowserHistory, ComponentLoader, GetRoute, Handle, HashHistory, HistoryLike, HistoryPushOptions, Link, LinkOptions, LinkProps, Match, MatchContext, MatchOptions, MemoryHistory, MemoryLocation, Navigate, NavigateOptions, NavigateProps, Outlet, OutletContext, Params, Pattern, Register, Route, RouteList, Router, RouterContext, RouterOptions, RouterRoot, RouterRootProps, SSRContext, Search, Updater, route, useHandles, useLocation, useMatch, useNavigate, useOutlet, useParams, useRouter, useSearch, useSubscribe };
|
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 ee,useLayoutEffect as l,useMemo as u,useRef as d,useState as f,useSyncExternalStore as p}from"react";import{inject as m,parse as h}from"regexparam";import{jsx as g}from"react/jsx-runtime";function _(e){return`/${e}`.replaceAll(/\/+/g,`/`).replace(/(.+)\/$/,`$1`)}function v(e){return e.split(`/`).slice(1).map(e=>e.includes(`*`)?0:e.includes(`:`)?1:2)}function y(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 b(e){return Object.entries(e).filter(([e,t])=>t!==void 0).map(([e,t])=>`${e}=${encodeURIComponent(S(t))}`).join(`&`)}function x(e){let t=new URLSearchParams(e);return Object.fromEntries([...t.entries()].map(([e,t])=>(t=decodeURIComponent(t),[e,C(t)?JSON.parse(t):t])))}function S(e){return typeof e==`string`&&!C(e)?e:JSON.stringify(e)}function C(e){try{return JSON.parse(e),!0}catch{return!1}}function w(e,t){return _(`${t}/${e}`)}function T(e,t){return(e===t||e.startsWith(`${t}/`))&&(e=e.slice(t.length)||`/`),e}function E(e,t){return[e,b(t)].filter(Boolean).join(`?`)}function D(e){let{pathname:t,search:n}=new URL(e,`http://w`);return{path:t,search:x(n)}}function O(e,t,n,r){let i=e.exec(T(n,r));if(!i)return null;let a={};return t.forEach((e,t)=>{let n=i[t+1];n&&(a[e]=n)}),a}function k(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 A=r(null),j=r(null),M=r(null);function N(){let e=c(A);if(!e)throw Error(`[Waymark] useRouter must be used within a router context`);return e}function P(){let e=c(j);return u(()=>e?.route._.handles??[],[e])}function F(){return c(M)}function I(e,t){return p(e.history.subscribe,t,t)}function L(){let e=N();return u(()=>e.navigate.bind(e),[e])}function
|
|
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 ee,useLayoutEffect as l,useMemo as u,useRef as d,useState as f,useSyncExternalStore as p}from"react";import{inject as m,parse as h}from"regexparam";import{jsx as g}from"react/jsx-runtime";function _(e){return`/${e}`.replaceAll(/\/+/g,`/`).replace(/(.+)\/$/,`$1`)}function v(e){return e.split(`/`).slice(1).map(e=>e.includes(`*`)?0:e.includes(`:`)?1:2)}function y(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 b(e){return Object.entries(e).filter(([e,t])=>t!==void 0).map(([e,t])=>`${e}=${encodeURIComponent(S(t))}`).join(`&`)}function x(e){let t=new URLSearchParams(e);return Object.fromEntries([...t.entries()].map(([e,t])=>(t=decodeURIComponent(t),[e,C(t)?JSON.parse(t):t])))}function S(e){return typeof e==`string`&&!C(e)?e:JSON.stringify(e)}function C(e){try{return JSON.parse(e),!0}catch{return!1}}function w(e,t){return _(`${t}/${e}`)}function T(e,t){return(e===t||e.startsWith(`${t}/`))&&(e=e.slice(t.length)||`/`),e}function E(e,t){return[e,b(t)].filter(Boolean).join(`?`)}function D(e){let{pathname:t,search:n}=new URL(e,`http://w`);return{path:t,search:x(n)}}function O(e,t,n,r){let i=e.exec(T(n,r));if(!i)return null;let a={};return t.forEach((e,t)=>{let n=i[t+1];n&&(a[e]=n)}),a}function k(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 A=r(null),j=r(null),M=r(null);function N(){let e=c(A);if(!e)throw Error(`[Waymark] useRouter must be used within a router context`);return e}function P(){let e=c(j);return u(()=>e?.route._.handles??[],[e])}function F(){return c(M)}function I(e,t){return p(e.history.subscribe,t,t)}function L(){let e=N();return u(()=>e.navigate.bind(e),[e])}function R(){let e=N(),t=I(e,e.history.getPath),n=I(e,e.history.getSearch),r=I(e,e.history.getState);return u(()=>({path:t,search:n,state:r}),[t,n,r])}function z(e){let t=N(),n=I(t,t.history.getPath);return u(()=>t.match(n,e),[t,n,e])}function B(e){let t=z({from:e});if(!t)throw Error(`[Waymark] Can't read params for non-matching route: ${e}`);return t.params}function te(e){let t=N(),n=t.getRoute(e),r=I(t,t.history.getSearch);return[u(()=>n._.mapSearch(r),[n,r]),s((e,r)=>{let i=n._.mapSearch(t.history.getSearch());e=typeof e==`function`?e(i):e;let a=E(t.history.getPath(),{...i,...e});t.navigate({url:a,replace:r})},[t,n])]}var V=class e{static patchKey=Symbol.for(`waymark_history_patch_v01`);memo;constructor(){if(typeof history<`u`&&!(e.patchKey in window)){for(let e of[H,U]){let t=history[e];history[e]=function(...n){let r=t.apply(this,n),i=new Event(e);return i.arguments=n,dispatchEvent(i),r}}window[e.patchKey]=!0}}getSearchMemo=e=>this.memo?.search===e?this.memo.parsed:(this.memo={search:e,parsed:x(e)}).parsed;getPath=()=>location.pathname;getSearch=()=>this.getSearchMemo(location.search);getState=()=>history.state;go=e=>history.go(e);push=e=>{let{url:t,replace:n,state:r}=e;history[n?U:H](r,``,t)};subscribe=e=>(W.forEach(t=>window.addEventListener(t,e)),()=>{W.forEach(t=>window.removeEventListener(t,e))})};const H=`pushState`,U=`replaceState`,W=[`popstate`,H,U,`hashchange`];var G=class{basePath;routes;history;ssrContext;defaultLinkOptions;_;constructor(e){let{basePath:t=`/`,routes:n,history:r,ssrContext:i,defaultLinkOptions:a}=e;this.basePath=_(t),this.routes=n,this.history=r??new V,this.ssrContext=i,this.defaultLinkOptions=a,this._={routeMap:new Map(n.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{from:n,strict:r,params:i}=t,a=this.getRoute(n),o=O(r?a._.regex:a._.looseRegex,a._.keys,e,this.basePath);return!o||i&&Object.keys(i).some(e=>i[e]!==o[e])?null:{route:a,params:o}}matchAll(e){return k(this.routes.map(t=>this.match(e,{from:t,strict:!0})).filter(e=>!!e))[0]??null}createUrl(e){let{to:t,params:n={},search:r={}}=e,{pattern:i}=this.getRoute(t);return E(w(m(i,n),this.basePath),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})}}},K=class{stack=[];index=0;listeners=new Set;constructor(e=`/`){this.stack.push({...D(e),state:void 0})}getCurrent=()=>this.stack[this.index];getPath=()=>this.getCurrent().path;getSearch=()=>this.getCurrent().search;getState=()=>this.getCurrent().state;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={...D(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)})},q=class extends V{getHashUrl=()=>new URL(location.hash.slice(1),`http://w`);getPath=()=>this.getHashUrl().pathname;getSearch=()=>this.getSearchMemo(this.getHashUrl().search);push=e=>{let{url:t,replace:n,state:r}=e;history[n?`replaceState`:`pushState`](r,``,`#${t}`)}};function J(e){let[t]=f(()=>`router`in e?e.router:new G(e)),n=I(t,t.history.getPath),r=u(()=>t.matchAll(n),[t,n]);return r||console.error(`[Waymark] No matching route found for path:`,n),u(()=>g(A.Provider,{value:t,children:g(j.Provider,{value:r,children:r?.route._.components.reduceRight((e,t)=>g(M.Provider,{value:e,children:g(t,{})}),null)})}),[t,r])}function Y(){return F()}function X(e){let t=N();return l(()=>t.navigate(e),[]),t.ssrContext&&(t.ssrContext.redirect=t.createUrl(e)),null}function Z(e){let t=N(),{to:r,replace:a,state:o,params:s,search:c,strict:l,preload:f,style:p,className:m,activeStyle:h,activeClassName:_,asChild:v,children:y,...b}={...t.defaultLinkOptions,...e},x=d(null),S=t.createUrl(e),C=u(()=>t.getRoute(e.to),[t,e.to]),w=!!z({from:C,strict:l,params:s}),T=u(()=>({"data-active":w,style:{...p,...w&&h},className:[m,w&&_].filter(Boolean).join(` `)||void 0}),[w,p,m,h,_]);ee(()=>{if(f===`render`)C.preload();else if(f===`viewport`&&x.current){let e=new IntersectionObserver(t=>{t.forEach(t=>{t.isIntersecting&&(C.preload(),e.disconnect())})});return e.observe(x.current),()=>e.disconnect()}},[f,C]);let E=e=>{b.onClick?.(e),!(e.ctrlKey||e.metaKey||e.shiftKey||e.altKey||e.button!==0||e.defaultPrevented)&&(e.preventDefault(),t.navigate({url:S,replace:a,state:o}))},D=e=>{b.onFocus?.(e),f===`intent`&&!e.defaultPrevented&&C.preload()},O=e=>{b.onPointerEnter?.(e),f===`intent`&&!e.defaultPrevented&&C.preload()},k={...b,...T,ref:ne(b.ref,x),href:S,onClick:E,onFocus:D,onPointerEnter:O};return v&&i(y)?n(y,k):g(`a`,{...k,children:y})}function ne(...e){let t=e.filter(e=>!!e);return t.length<=1?t[0]??null:e=>{let n=[];for(let r of t){let t=Q(r,e);n.push(t??(()=>Q(r,null)))}return()=>n.forEach(e=>e())}}function Q(e,t){if(typeof e==`function`)return e(t);e&&(e.current=t)}function re(e){return()=>g(t,{fallback:g(e,{}),children:F()})}function ie(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?g(t,{error:this.state.error[0]}):this.props.children}}return()=>g(n,{children:F()})}function ae(e){return new $(_(e),e=>e,[],[],[])}var $=class e{pattern;_;constructor(e,t,n,r,i){let{keys:a,pattern:o}=h(e),s=h(e,!0).pattern,c=v(e);this.pattern=e,this._={keys:a,regex:o,looseRegex:s,weights:c,mapSearch:t,handles:n,components:r,preloaded:!1,preloaders:i}}route(t){let{mapSearch:n,handles:r,components:i,preloaders:a}=this._;return new e(_(`${this.pattern}/${t}`),n,r,i,a)}search(t){let{mapSearch:n,handles:r,components:i,preloaders:a}=this._;return t=y(t),new e(this.pattern,e=>{let r=n(e);return{...r,...t({...e,...r})}},r,i,a)}handle(t){let{mapSearch:n,handles:r,components:i,preloaders:a}=this._;return new e(this.pattern,n,[...r,t],i,a)}preloader(t){let{mapSearch:n,handles:r,components:i,preloaders:a}=this._;return new e(this.pattern,n,r,i,[...a,t])}component(t){let{mapSearch:n,handles:r,components:i,preloaders:a}=this._;return new e(this.pattern,n,r,[...i,o(t)],a)}lazy(e){let t=a(async()=>{let t=await e();return{default:o(`default`in t?t.default:t)}});return this.preloader(e).component(t)}suspense(e){return this.component(re(e))}error(e){return this.component(ie(e))}async preload(){let{preloaded:e,preloaders:t}=this._;e||(this._.preloaded=!0,await Promise.all(t.map(e=>e())))}toString(){return this.pattern}};export{V as BrowserHistory,q as HashHistory,Z as Link,j as MatchContext,K as MemoryHistory,X as Navigate,Y as Outlet,M as OutletContext,$ as Route,G as Router,A as RouterContext,J as RouterRoot,ae as route,P as useHandles,R as useLocation,z as useMatch,L as useNavigate,F as useOutlet,B as useParams,N as useRouter,te as useSearch,I as useSubscribe};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waymark",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "strblr",
|
|
6
6
|
"description": "Lightweight type-safe router for React",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
},
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/strblr/waymark"
|
|
18
|
+
"url": "git+https://github.com/strblr/waymark.git"
|
|
19
19
|
},
|
|
20
|
-
"homepage": "https://
|
|
20
|
+
"homepage": "https://waymark.strblr.workers.dev",
|
|
21
21
|
"bugs": "https://github.com/strblr/waymark/issues",
|
|
22
22
|
"keywords": [
|
|
23
23
|
"react",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
],
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc --noEmit && tsdown --minify --platform browser",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
43
|
+
"prepublishOnly": "bun run build && cp ../../README.md README.md",
|
|
44
|
+
"postpublish": "rm -f README.md"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/bun": "^1.3.6",
|