waymark 0.2.2 → 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
@@ -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" } });
@@ -521,7 +696,7 @@ function ProtectedPage() {
521
696
  }
522
697
  ```
523
698
 
524
- 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:
525
700
 
526
701
  ```tsx
527
702
  <Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
@@ -533,118 +708,6 @@ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation
533
708
 
534
709
  ---
535
710
 
536
- ## Path parameters
537
-
538
- Dynamic segments in route patterns become typed path parameters. Define them with a colon prefix. They can also be made optional.
539
-
540
- ```tsx
541
- const post = route("/posts/:id").component(PostPage);
542
- const comment = route("/posts/:postId/comments/:commentId?").component(
543
- CommentPage
544
- );
545
- ```
546
-
547
- Access parameters with `useParams`, passing the route pattern or object as an argument:
548
-
549
- ```tsx
550
- function PostPage() {
551
- const { id } = useParams(post);
552
- // id is typed as string
553
-
554
- const { id } = useParams("/posts/:id");
555
- // Also works
556
- }
557
-
558
- function CommentPage() {
559
- const { postId, commentId } = useParams(comment);
560
- // postId: string
561
- // commentId?: string | undefined
562
- }
563
- ```
564
-
565
- Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
566
-
567
- ```tsx
568
- const files = route("/files/*").component(FileBrowser);
569
-
570
- function FileBrowser() {
571
- const params = useParams(files);
572
- const path = params["*"]; // e.g., "documents/report.pdf"
573
- }
574
- ```
575
-
576
- ---
577
-
578
- ## Search queries
579
-
580
- 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.
581
-
582
- With Zod:
583
-
584
- ```tsx
585
- import { z } from "zod";
586
-
587
- const searchPage = route("/search")
588
- .search(
589
- z.object({
590
- q: z.string().catch(""),
591
- page: z.coerce.number().catch(1)
592
- })
593
- )
594
- .component(SearchPage);
595
- ```
596
-
597
- With a plain function:
598
-
599
- ```tsx
600
- const searchPage = route("/search")
601
- .search(raw => ({
602
- q: String(raw.q ?? ""),
603
- page: Number(raw.page ?? 1)
604
- }))
605
- .component(SearchPage);
606
- ```
607
-
608
- Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
609
-
610
- ```tsx
611
- function SearchPage() {
612
- const [search, setSearch] = useSearch(searchPage);
613
- // search.q: string
614
- // search.page: number
615
- }
616
- ```
617
-
618
- The setter merges your updates with existing values:
619
-
620
- ```tsx
621
- setSearch({ page: 2 }); // Only updates page
622
- setSearch(prev => ({ page: prev.page + 1 })); // Increment page
623
- ```
624
-
625
- Pass `true` as the second argument to replace the history entry instead of pushing:
626
-
627
- ```tsx
628
- setSearch({ page: 1 }, true);
629
- ```
630
-
631
- **JSON-first search params**
632
-
633
- Waymark uses a JSON-first approach for search parameters, similar to TanStack Router. When serializing and deserializing values from the URL:
634
-
635
- - Plain strings that aren't valid JSON are kept as-is: `"John"` → `?name=John` → `"John"`
636
- - Everything else is JSON-encoded (and URL-encoded):
637
- - `true` → `?enabled=true` → `true`
638
- - `"true"` → `?enabled=%22true%22` → `"true"`
639
- - `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
640
- - `42` → `count=42` → `42`
641
-
642
- 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.
643
-
644
- 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.
645
-
646
- ---
647
-
648
711
  ## Lazy loading
649
712
 
650
713
  Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
@@ -671,7 +734,7 @@ const analytics = route("/analytics").lazy(() =>
671
734
  export function AnalyticsPage() { ... }
672
735
  ```
673
736
 
674
- 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:
675
738
 
676
739
  ```tsx
677
740
  const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
@@ -865,7 +928,7 @@ import { HashHistory } from "waymark";
865
928
  <RouterRoot routes={routes} history={new HashHistory()} />;
866
929
  ```
867
930
 
868
- **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:
869
932
 
870
933
  ```tsx
871
934
  import { MemoryHistory } from "waymark";
@@ -877,19 +940,66 @@ All history implementations conform to the `HistoryLike` interface, so you can c
877
940
 
878
941
  ---
879
942
 
880
- ## Cookbook
943
+ ## Server-side rendering (SSR)
881
944
 
882
- ### 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.
883
946
 
884
- 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:
885
948
 
886
949
  ```tsx
887
- import { useLocation } from "waymark";
888
- 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";
889
954
 
890
- function ScrollToTop() {
891
- const { path } = useLocation();
892
- useEffect(() => window.scrollTo(0, 0), [path]);
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() {
1001
+ const { path } = useLocation();
1002
+ useEffect(() => window.scrollTo(0, 0), [path]);
893
1003
  return null;
894
1004
  }
895
1005
 
@@ -906,7 +1016,7 @@ function AppLayout() {
906
1016
 
907
1017
  ### Global link configuration
908
1018
 
909
- 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:
910
1020
 
911
1021
  ```tsx
912
1022
  <RouterRoot
@@ -962,7 +1072,7 @@ const router = new Router({
962
1072
 
963
1073
  ### View transitions
964
1074
 
965
- 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:
966
1076
 
967
1077
  ```tsx
968
1078
  import { flushSync } from "react-dom";
@@ -1045,62 +1155,6 @@ if (adminMatch) {
1045
1155
 
1046
1156
  ## API reference
1047
1157
 
1048
- ### Types
1049
-
1050
- **`NavigateOptions<P>`** is the main type for type-safe navigation:
1051
-
1052
- ```tsx
1053
- type NavigateOptions<P extends Pattern> = {
1054
- to: P | Route<P>; // Route pattern or route object
1055
- params?: Params<P>; // Required if route has dynamic segments
1056
- search?: Search<P>; // Search params if route defines them
1057
- replace?: boolean; // Replace history instead of push
1058
- state?: any; // Arbitrary state to pass
1059
- };
1060
- ```
1061
-
1062
- **`HistoryPushOptions`** is for untyped navigation:
1063
-
1064
- ```tsx
1065
- interface HistoryPushOptions {
1066
- url: string; // The URL to navigate to
1067
- replace?: boolean; // Replace history instead of push
1068
- state?: any; // Arbitrary state to pass
1069
- }
1070
- ```
1071
-
1072
- **`MatchOptions<P>`** is used for route matching:
1073
-
1074
- ```tsx
1075
- type MatchOptions<P extends Pattern> = {
1076
- from: P | Route<P>; // Route to match against
1077
- strict?: boolean; // Require exact match (not just prefix)
1078
- params?: Partial<Params<P>>; // Filter by specific param values
1079
- };
1080
- ```
1081
-
1082
- **`Match<P>`** is the result of a successful match:
1083
-
1084
- ```tsx
1085
- type Match<P extends Pattern> = {
1086
- route: Route<P>; // The matched route
1087
- params: Params<P>; // Extracted parameters
1088
- };
1089
- ```
1090
-
1091
- **`LinkOptions`** controls link behavior and styling:
1092
-
1093
- ```tsx
1094
- interface LinkOptions {
1095
- strict?: boolean; // Strict active matching
1096
- preload?: "intent" | "render" | "viewport" | false;
1097
- style?: CSSProperties;
1098
- className?: string;
1099
- activeStyle?: CSSProperties;
1100
- activeClassName?: string;
1101
- }
1102
- ```
1103
-
1104
1158
  ### Router class
1105
1159
 
1106
1160
  The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
@@ -1109,9 +1163,10 @@ The `Router` class is the core of Waymark. You can create an instance directly o
1109
1163
 
1110
1164
  ```tsx
1111
1165
  const router = new Router({
1112
- routes: Route[], // Required: array of routes
1113
1166
  basePath: string, // Optional: base path prefix (default: "/")
1167
+ routes: Route[], // Required: array of routes
1114
1168
  history: HistoryLike, // Optional: history implementation (default: BrowserHistory)
1169
+ ssrContext: SSRContext, // Optional: SSR context
1115
1170
  defaultLinkOptions: LinkOptions // Optional: defaults for all Links
1116
1171
  });
1117
1172
  ```
@@ -1121,6 +1176,7 @@ const router = new Router({
1121
1176
  - `router.basePath` - The configured base path
1122
1177
  - `router.routes` - The array of routes
1123
1178
  - `router.history` - The history instance
1179
+ - `router.ssrContext` - The SSR context (if provided)
1124
1180
  - `router.defaultLinkOptions` - Default link options
1125
1181
 
1126
1182
  **Methods:**
@@ -1166,71 +1222,6 @@ const match = router.matchAll("/users/42");
1166
1222
  const route = router.getRoute("/users/:id");
1167
1223
  ```
1168
1224
 
1169
- ### History interface
1170
-
1171
- The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
1172
-
1173
- **Interface:**
1174
-
1175
- ```tsx
1176
- interface HistoryLike {
1177
- getPath: () => string;
1178
- getSearch: () => Record<string, unknown>;
1179
- getState: () => any;
1180
- go: (delta: number) => void;
1181
- push: (options: HistoryPushOptions) => void;
1182
- subscribe: (listener: () => void) => () => void;
1183
- }
1184
- ```
1185
-
1186
- **Methods:**
1187
-
1188
- `history.getPath()` returns the current pathname:
1189
-
1190
- ```tsx
1191
- const path = history.getPath();
1192
- // Returns "/users/42"
1193
- ```
1194
-
1195
- `history.getSearch()` returns the current search parameters as a parsed object:
1196
-
1197
- ```tsx
1198
- const search = history.getSearch();
1199
- // Returns { tab: "posts", page: 2 }
1200
- ```
1201
-
1202
- `history.getState()` returns the current history state:
1203
-
1204
- ```tsx
1205
- const state = history.getState();
1206
- // Returns any state passed during navigation
1207
- ```
1208
-
1209
- `history.go(delta)` navigates forward or back in history:
1210
-
1211
- ```tsx
1212
- history.go(-1); // Go back
1213
- history.go(1); // Go forward
1214
- history.go(-2); // Go back two steps
1215
- ```
1216
-
1217
- `history.push(options)` pushes or replaces a history entry:
1218
-
1219
- ```tsx
1220
- history.push({ url: "/users/42", state: { from: "list" } });
1221
- history.push({ url: "/login", replace: true });
1222
- ```
1223
-
1224
- `history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
1225
-
1226
- ```tsx
1227
- const unsubscribe = history.subscribe(() => {
1228
- console.log("Navigation occurred");
1229
- });
1230
-
1231
- // Later: unsubscribe()
1232
- ```
1233
-
1234
1225
  ### Route class
1235
1226
 
1236
1227
  Routes are created with the `route()` function and configured by chaining methods.
@@ -1302,6 +1293,71 @@ const users = route("/users").preloader(async () => {
1302
1293
  await userProfile.preload();
1303
1294
  ```
1304
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
+
1305
1361
  ### Hooks
1306
1362
 
1307
1363
  **`useRouter()`** returns the Router instance:
@@ -1394,14 +1450,78 @@ function Layout() {
1394
1450
  <Navigate to="/login" replace />
1395
1451
  ```
1396
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
+
1397
1518
  ---
1398
1519
 
1399
1520
  ## Roadmap
1400
1521
 
1401
1522
  Future improvements planned for Waymark:
1402
1523
 
1403
- - **Preloader context** - Pass route params and search queries to preloader functions, enabling data fetching based on the target route's dynamic segments
1404
- - **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
1405
1525
 
1406
1526
  ---
1407
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 l,useLayoutEffect as u,useMemo as d,useRef as f,useState as p,useSyncExternalStore as m}from"react";import{inject as h,parse as g}from"regexparam";import{jsx as _}from"react/jsx-runtime";function v(e){return`/${e}`.replaceAll(/\/+/g,`/`).replace(/(.+)\/$/,`$1`)}function y(e){return e.split(`/`).slice(1).map(e=>e.includes(`*`)?0:e.includes(`:`)?1:2)}function b(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 x(e){return Object.entries(e).filter(([e,t])=>t!==void 0).map(([e,t])=>`${e}=${encodeURIComponent(C(t))}`).join(`&`)}function S(e){let t=new URLSearchParams(e);return Object.fromEntries([...t.entries()].map(([e,t])=>(t=decodeURIComponent(t),[e,w(t)?JSON.parse(t):t])))}function C(e){return typeof e==`string`&&!w(e)?e:JSON.stringify(e)}function w(e){try{return JSON.parse(e),!0}catch{return!1}}function T(e,t){return v(`${t}/${e}`)}function E(e,t){return(e===t||e.startsWith(`${t}/`))&&(e=e.slice(t.length)||`/`),e}function D(e,t){return[e,x(t)].filter(Boolean).join(`?`)}function O(e){let{pathname:t,search:n}=new URL(e,`http://w`);return{path:t,search:S(n)}}function k(e,t,n,r){let i=e.exec(E(n,r));if(!i)return null;let a={};return t.forEach((e,t)=>{let n=i[t+1];n&&(a[e]=n)}),a}function A(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 j=r(null),M=r(null),N=r(null);function P(){let e=c(j);if(!e)throw Error(`[Waymark] useRouter must be used within a router context`);return e}function F(){let e=c(M);return d(()=>e?.route._.handles??[],[e])}function I(){return c(N)}function L(e,t){return m(e.history.subscribe,t,t)}function R(){let e=P();return d(()=>e.navigate.bind(e),[e])}function z(){let e=P(),t=L(e,e.history.getPath),n=L(e,e.history.getSearch),r=L(e,e.history.getState);return d(()=>({path:t,search:n,state:r}),[t,n,r])}function B(e){let t=P(),n=L(t,t.history.getPath);return d(()=>t.match(n,e),[t,n,e])}function V(e){let t=B({from:e});if(!t)throw Error(`[Waymark] Can't read params for non-matching route: ${e}`);return t.params}function H(e){let t=P(),n=t.getRoute(e),r=L(t,t.history.getSearch);return[d(()=>n._.mapSearch(r),[n,r]),s((e,r)=>{let i=n._.mapSearch(t.history.getSearch());e=typeof e==`function`?e(i):e;let a=D(t.history.getPath(),{...i,...e});t.navigate({url:a,replace:r})},[t,n])]}var U=class e{static patchKey=Symbol.for(`waymark_history_patch_v01`);memo;constructor(){if(typeof history<`u`&&!(e.patchKey in window)){for(let e of[W,G]){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:S(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?G:W](r,``,t)};subscribe=e=>(K.forEach(t=>window.addEventListener(t,e)),()=>{K.forEach(t=>window.removeEventListener(t,e))})};const W=`pushState`,G=`replaceState`,K=[`popstate`,W,G,`hashchange`];var q=class{basePath;routes;history;defaultLinkOptions;_;constructor(e){let{basePath:t=`/`,routes:n,history:r,defaultLinkOptions:i}=e;this.basePath=v(t),this.routes=n,this.history=r??new U,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=k(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 A(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 D(T(h(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})}}},J=class{stack=[];index=0;listeners=new Set;constructor(e=`/`){this.stack.push({...O(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={...O(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)})},Y=class extends U{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 X(e){let[t]=p(()=>`router`in e?e.router:new q(e)),n=L(t,t.history.getPath),r=d(()=>t.matchAll(n),[t,n]);return r||console.error(`[Waymark] No matching route found for path:`,n),d(()=>_(j.Provider,{value:t,children:_(M.Provider,{value:r,children:r?.route._.components.reduceRight((e,t)=>_(N.Provider,{value:e,children:_(t,{})}),null)})}),[t,r])}function Z(){return I()}function ee(e){let t=R();return u(()=>t(e),[]),null}function te(e){let t=P(),{to:r,replace:a,state:o,params:s,search:c,strict:u,preload:p,style:m,className:h,activeStyle:g,activeClassName:v,asChild:y,children:b,...x}={...t.defaultLinkOptions,...e},S=f(null),C=t.createUrl(e),w=d(()=>t.getRoute(e.to),[t,e.to]),T=!!B({from:w,strict:u,params:s}),E=d(()=>({"data-active":T,style:{...m,...T&&g},className:[h,T&&v].filter(Boolean).join(` `)||void 0}),[T,m,h,g,v]);l(()=>{if(p===`render`)w.preload();else if(p===`viewport`&&S.current){let e=new IntersectionObserver(t=>{t.forEach(t=>{t.isIntersecting&&(w.preload(),e.disconnect())})});return e.observe(S.current),()=>e.disconnect()}},[p,w]);let D=e=>{x.onClick?.(e),!(e.ctrlKey||e.metaKey||e.shiftKey||e.altKey||e.button!==0||e.defaultPrevented)&&(e.preventDefault(),t.navigate({url:C,replace:a,state:o}))},O=e=>{x.onFocus?.(e),p===`intent`&&!e.defaultPrevented&&w.preload()},k=e=>{x.onPointerEnter?.(e),p===`intent`&&!e.defaultPrevented&&w.preload()},A={...x,...E,ref:ne(x.ref,S),href:C,onClick:D,onFocus:O,onPointerEnter:k};return y&&i(b)?n(b,A):_(`a`,{...A,children:b})}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()=>_(t,{fallback:_(e,{}),children:I()})}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?_(t,{error:this.state.error[0]}):this.props.children}}return()=>_(n,{children:I()})}function ae(e){return new $(v(e),e=>e,[],[],[])}var $=class e{pattern;_;constructor(e,t,n,r,i){let{keys:a,pattern:o}=g(e),s=g(e,!0).pattern,c=y(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(v(`${this.pattern}/${t}`),n,r,i,a)}search(t){let{mapSearch:n,handles:r,components:i,preloaders:a}=this._;return t=b(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{U as BrowserHistory,Y as HashHistory,te as Link,M as MatchContext,J as MemoryHistory,ee as Navigate,Z as Outlet,N as OutletContext,$ as Route,q as Router,j as RouterContext,X as RouterRoot,ae as route,F as useHandles,z as useLocation,B as useMatch,R as useNavigate,I as useOutlet,V as useParams,P as useRouter,H as useSearch,L 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.2",
3
+ "version": "0.2.3",
4
4
  "license": "MIT",
5
5
  "author": "strblr",
6
6
  "description": "Lightweight type-safe router for React",
@@ -17,7 +17,7 @@
17
17
  "type": "git",
18
18
  "url": "git+https://github.com/strblr/waymark.git"
19
19
  },
20
- "homepage": "https://strblr.github.io/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,7 @@
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",
45
44
  "postpublish": "rm -f README.md"
46
45
  },
47
46
  "devDependencies": {
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
- });