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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark" width="400" />
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://bundlephobia.com/package/waymark"><img src="https://img.shields.io/bundlephobia/minzip/waymark?style=flat-square&color=000&labelColor=000" alt="bundle size" /></a>
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.github.io/waymark">📖 Documentation</a>
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 queries
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.5kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so less than 4kB total
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 queries - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
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, preserving any state it holds.
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 when using `Link` or `navigate`. The intermediate routes still exist - they're part of the hierarchy - they just aren't directly navigable.
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, or when you don't want to bother with `useRouter` / `useNavigate`:
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
- router.navigate({ to: userProfile, params: { id: "42" } });
674
+ navigate({ to: userProfile, params: { id: "42" } });
502
675
 
503
676
  // Unsafe navigation - no type checking
504
- router.navigate({ url: "/some/unknown/path" });
505
- router.navigate({ url: "/callback", replace: true, state: { data: 123 } });
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, minus the visual and interaction properties. You can pass route patterns, params, search parameters, and state:
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 seamlessly with nesting. Child routes inherit the lazy-loaded parent's components:
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
- ## Cookbook
943
+ ## Server-side rendering (SSR)
883
944
 
884
- ### Scroll to top on navigation
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
- Create a component that scrolls to top when the path changes and include it in your layout:
947
+ On the server, create a router with `MemoryHistory` initialized to the request URL:
887
948
 
888
949
  ```tsx
889
- import { useLocation } from "waymark";
890
- import { useEffect } from "react";
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 ScrollToTop() {
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. This is useful for consistent styling and preload behavior across your app:
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
- Use the View Transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
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 route params and search queries to preloader functions, enabling data fetching based on the target route's dynamic segments
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<S & 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>;
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 te(){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 R(e){let t=N(),n=I(t,t.history.getPath);return u(()=>t.match(n,e),[t,n,e])}function z(e){let t=R({from:e});if(!t)throw Error(`[Waymark] Can't read params for non-matching route: ${e}`);return t.params}function B(e){let t=N(),n=t.getRoute(e),r=s(e=>n._.mapSearch(e),[n]),i=I(t,t.history.getSearch);return[u(()=>r(i),[r,i]),s((e,n)=>{let i=r(t.history.getSearch());e=typeof e==`function`?e(i):e;let a=E(t.history.getPath(),{...i,...e});t.navigate({url:a,replace:n})},[t,r])]}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;defaultLinkOptions;_;constructor(e){let{basePath:t=`/`,routes:n,history:r,defaultLinkOptions:i}=e;this.basePath=_(t),this.routes=n,this.history=r??new V,this.defaultLinkOptions=i,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=L();return l(()=>t(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=!!R({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(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,te as useLocation,R as useMatch,L as useNavigate,F as useOutlet,z as useParams,N as useRouter,B as useSearch,I as useSubscribe};
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.1",
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://github.com/strblr/waymark",
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
- "copy-readme": "cp ../../README.md README.md",
44
- "prepublishOnly": "bun run build && bun run copy-readme"
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",
package/tsdown.config.ts DELETED
@@ -1,8 +0,0 @@
1
- import { defineConfig } from "tsdown";
2
-
3
- export default defineConfig({
4
- entry: ["./src/index.ts"],
5
- report: {
6
- brotli: true
7
- }
8
- });