@typed/router 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/Link.d.ts +26 -11
  2. package/dist/Link.d.ts.map +1 -1
  3. package/dist/Link.js +47 -23
  4. package/dist/Link.js.map +1 -1
  5. package/dist/Match.d.ts +33 -0
  6. package/dist/Match.d.ts.map +1 -0
  7. package/dist/Match.js +16 -0
  8. package/dist/Match.js.map +1 -0
  9. package/dist/Matcher.d.ts +28 -0
  10. package/dist/Matcher.d.ts.map +1 -0
  11. package/dist/Matcher.js +24 -0
  12. package/dist/Matcher.js.map +1 -0
  13. package/dist/Navigation.d.ts +10 -0
  14. package/dist/Navigation.d.ts.map +1 -0
  15. package/dist/Navigation.js +7 -0
  16. package/dist/Navigation.js.map +1 -0
  17. package/dist/Redirect.d.ts +29 -0
  18. package/dist/Redirect.d.ts.map +1 -0
  19. package/dist/Redirect.js +17 -0
  20. package/dist/Redirect.js.map +1 -0
  21. package/dist/RouteOutlet.d.ts +3 -0
  22. package/dist/RouteOutlet.d.ts.map +1 -0
  23. package/dist/RouteOutlet.js +2 -0
  24. package/dist/RouteOutlet.js.map +1 -0
  25. package/dist/ScrollRestoration.d.ts +19 -0
  26. package/dist/ScrollRestoration.d.ts.map +1 -0
  27. package/dist/ScrollRestoration.js +64 -0
  28. package/dist/ScrollRestoration.js.map +1 -0
  29. package/dist/cjs/Link.d.ts +26 -11
  30. package/dist/cjs/Link.d.ts.map +1 -1
  31. package/dist/cjs/Link.js +47 -22
  32. package/dist/cjs/Link.js.map +1 -1
  33. package/dist/cjs/Match.d.ts +33 -0
  34. package/dist/cjs/Match.d.ts.map +1 -0
  35. package/dist/cjs/Match.js +43 -0
  36. package/dist/cjs/Match.js.map +1 -0
  37. package/dist/cjs/Matcher.d.ts +28 -0
  38. package/dist/cjs/Matcher.d.ts.map +1 -0
  39. package/dist/cjs/Matcher.js +52 -0
  40. package/dist/cjs/Matcher.js.map +1 -0
  41. package/dist/cjs/Navigation.d.ts +10 -0
  42. package/dist/cjs/Navigation.d.ts.map +1 -0
  43. package/dist/cjs/Navigation.js +34 -0
  44. package/dist/cjs/Navigation.js.map +1 -0
  45. package/dist/cjs/Redirect.d.ts +29 -0
  46. package/dist/cjs/Redirect.d.ts.map +1 -0
  47. package/dist/cjs/Redirect.js +44 -0
  48. package/dist/cjs/Redirect.js.map +1 -0
  49. package/dist/cjs/ScrollRestoration.d.ts +19 -0
  50. package/dist/cjs/ScrollRestoration.d.ts.map +1 -0
  51. package/dist/cjs/ScrollRestoration.js +91 -0
  52. package/dist/cjs/ScrollRestoration.js.map +1 -0
  53. package/dist/cjs/index.d.ts +7 -3
  54. package/dist/cjs/index.d.ts.map +1 -1
  55. package/dist/cjs/index.js +7 -3
  56. package/dist/cjs/index.js.map +1 -1
  57. package/dist/cjs/matchRoutes.d.ts +8 -0
  58. package/dist/cjs/matchRoutes.d.ts.map +1 -0
  59. package/dist/cjs/matchRoutes.js +77 -0
  60. package/dist/cjs/matchRoutes.js.map +1 -0
  61. package/dist/cjs/router.d.ts +24 -63
  62. package/dist/cjs/router.d.ts.map +1 -1
  63. package/dist/cjs/router.js +22 -160
  64. package/dist/cjs/router.js.map +1 -1
  65. package/dist/index.d.ts +7 -3
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +7 -3
  68. package/dist/index.js.map +1 -1
  69. package/dist/matchRoutes.d.ts +8 -0
  70. package/dist/matchRoutes.d.ts.map +1 -0
  71. package/dist/matchRoutes.js +50 -0
  72. package/dist/matchRoutes.js.map +1 -0
  73. package/dist/router.d.ts +24 -63
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +18 -153
  76. package/dist/router.js.map +1 -1
  77. package/dist/tsconfig.cjs.build.tsbuildinfo +1 -1
  78. package/package.json +13 -11
  79. package/project.json +12 -10
  80. package/src/Link.ts +129 -39
  81. package/src/Match.ts +114 -0
  82. package/src/Matcher.ts +139 -0
  83. package/src/Navigation.ts +24 -0
  84. package/src/Redirect.ts +21 -0
  85. package/src/ScrollRestoration.ts +110 -0
  86. package/src/index.ts +7 -3
  87. package/src/matchRoutes.ts +112 -0
  88. package/src/router.ts +53 -311
  89. package/tsconfig.build.json +5 -1
  90. package/tsconfig.build.tsbuildinfo +1 -1
  91. package/tsconfig.cjs.build.json +6 -0
  92. package/tsconfig.json +6 -0
  93. package/vite.config.js +3 -0
  94. package/src/RouteMatch.ts +0 -56
  95. package/src/RouteMatcher.ts +0 -264
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typed/router",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -19,18 +19,20 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@effect/data": "^0.12.2",
23
- "@effect/io": "^0.25.11",
24
- "@typed/context": "0.13.0",
25
- "@typed/dom": "8.13.0",
26
- "@typed/fx": "1.13.0",
27
- "@typed/html": "3.13.0",
28
- "@typed/path": "0.4.0",
29
- "@typed/route": "0.10.0"
22
+ "@effect/data": "^0.16.0",
23
+ "@effect/io": "^0.35.1",
24
+ "@typed/context": "0.15.0",
25
+ "@typed/dom": "8.15.0",
26
+ "@typed/html": "3.15.0",
27
+ "@typed/navigation": "0.2.0",
28
+ "@typed/fx": "1.15.0",
29
+ "@typed/error": "0.15.0",
30
+ "@typed/route": "0.12.0",
31
+ "@typed/path": "0.5.0"
30
32
  },
31
- "gitHead": "14059cee732408d38a141b5295a999c43c6aa94b",
33
+ "gitHead": "781662109038798bdc35ec7a6de3551ec16aaa0a",
32
34
  "publishConfig": {
33
35
  "access": "public"
34
36
  },
35
37
  "sideEffects": false
36
- }
38
+ }
package/project.json CHANGED
@@ -11,20 +11,24 @@
11
11
  }
12
12
  ]
13
13
  },
14
- "outputs": [
15
- "{projectRoot}/dist"
16
- ]
14
+ "outputs": ["{projectRoot}/dist"]
17
15
  },
18
16
  "lint": {
19
17
  "executor": "@nrwl/linter:eslint",
20
18
  "options": {
21
- "lintFilePatterns": [
22
- "packages/router/src/**/*.ts"
23
- ]
19
+ "lintFilePatterns": ["packages/router/src/**/*.ts"]
24
20
  }
25
21
  },
26
22
  "test": {
27
- "executor": "@nrwl/vite:test"
23
+ "executor": "@nrwl/vite:test",
24
+ "options": {
25
+ "globals": true
26
+ },
27
+ "configurations": {
28
+ "watch": {
29
+ "watch": true
30
+ }
31
+ }
28
32
  },
29
33
  "build:cjs": {
30
34
  "executor": "nx:run-commands",
@@ -36,9 +40,7 @@
36
40
  ],
37
41
  "parallel": false
38
42
  },
39
- "outputs": [
40
- "{projectRoot}/dist/cjs"
41
- ]
43
+ "outputs": ["{projectRoot}/dist/cjs"]
42
44
  }
43
45
  }
44
46
  }
package/src/Link.ts CHANGED
@@ -1,48 +1,138 @@
1
1
  import * as Effect from '@effect/io/Effect'
2
2
  import * as Scope from '@effect/io/Scope'
3
- import { assign } from '@typed/dom'
4
3
  import * as Fx from '@typed/fx'
5
- import { EventHandler, html, RenderContext, type Placeholder } from '@typed/html'
4
+ import { Placeholder } from '@typed/html'
5
+ import * as Navigation from '@typed/navigation'
6
6
  import { pathJoin } from '@typed/path'
7
7
 
8
- import { Router, getBasePath } from './router.js'
9
-
10
- export function Link<R = never, E = never, R2 = never>(
11
- props: LinkProps<R, E, R2>,
12
- ): Fx.Fx<R | R2 | Document | RenderContext | Router | Scope.Scope, E, HTMLAnchorElement> {
13
- return Fx.gen(function* ($) {
14
- const useBase = props.useBase ?? true
15
- const href = useBase ? pathJoin(yield* $(getBasePath), props.href) || '/' : props.href
16
- const router = yield* $(Router)
17
- const clickHandler = (event: MouseEvent & { currentTarget: HTMLAnchorElement }) =>
18
- Effect.gen(function* ($) {
19
- if (props.fullReload) {
20
- yield* $(assign(href))
21
- } else {
22
- yield* $(router.currentPath.set(href))
23
- }
24
-
25
- if (props.onClick) {
26
- yield* $(props.onClick(event))
27
- }
28
- })
29
-
30
- return html.as<HTMLAnchorElement>()`<a
31
- class=${props.className}
32
- href=${href}
33
- onclick=${EventHandler.preventDefault(clickHandler)}
34
- >${props.label}</a>`
35
- })
8
+ import { Router, getCurrentPathFromUrl } from './router.js'
9
+
10
+ export interface UseLinkParams<
11
+ R = never,
12
+ E = never,
13
+ R2 = never,
14
+ E2 = never,
15
+ R3 = never,
16
+ E3 = never,
17
+ R4 = never,
18
+ E4 = never,
19
+ R5 = never,
20
+ E5 = never,
21
+ > {
22
+ readonly to: Placeholder<R, E, string>
23
+ readonly replace?: Placeholder<R2, E2, boolean>
24
+ readonly state?: Placeholder<R3, E3, unknown> | unknown
25
+ readonly relative?: Placeholder<R4, E4, boolean>
26
+ readonly key?: Placeholder<R5, E5, string>
27
+ }
28
+
29
+ export namespace UseLinkParams {
30
+ export type Any = UseLinkParams<any, any, any, any, any, any, any, any, any, any>
31
+
32
+ export type Context<T extends Any> = Placeholder.ResourcesOf<
33
+ T['to'] | T['replace'] | T['state'] | T['relative'] | T['key']
34
+ >
35
+
36
+ export type Error<T extends Any> = Placeholder.ErrorsOf<
37
+ T['to'] | T['replace'] | T['state'] | T['relative'] | T['key']
38
+ >
39
+ }
40
+
41
+ export interface UseLink<E> {
42
+ readonly url: Fx.Computed<Router, E, string>
43
+ readonly options: Fx.Computed<never, E, Navigation.NavigateOptions>
44
+ readonly navigate: Effect.Effect<Router, E, Navigation.Destination>
45
+ readonly active: Fx.Computed<Router, E, boolean>
46
+ }
47
+
48
+ export namespace UseLink {
49
+ export type FromParams<T extends UseLinkParams.Any> = [UseLink<UseLinkParams.Error<T>>] extends [
50
+ infer U,
51
+ ]
52
+ ? U
53
+ : never
54
+ }
55
+
56
+ export function useLink<Params extends UseLinkParams.Any>(
57
+ params: Params,
58
+ ): Effect.Effect<UseLinkParams.Context<Params> | Scope.Scope, never, UseLink.FromParams<Params>> {
59
+ return Effect.map(
60
+ Effect.all([
61
+ Placeholder.asRef(params.to),
62
+ Placeholder.asRef(params.replace ?? false),
63
+ Placeholder.asRef(params.state ?? null),
64
+ Placeholder.asRef(params.key),
65
+ Placeholder.asRef(params.relative ?? true),
66
+ ] as const),
67
+ ([to, replace, state, key, relative]) => {
68
+ const url = Fx.RefSubject.tuple(to, relative).mapEffect(([to, relative]) =>
69
+ Effect.gen(function* ($) {
70
+ let url = to
71
+
72
+ // Check if we should make the URL relative to the current route
73
+ if (relative) {
74
+ const { route, params } = yield* $(Router)
75
+ const matched = yield* $(params)
76
+ const basePath = route.make(matched)
77
+
78
+ url = pathJoin(basePath, url)
79
+ }
80
+
81
+ return url
82
+ }),
83
+ )
84
+ const options = Fx.RefSubject.tuple(replace, state, key).map(
85
+ ([replace, state, key]): Navigation.NavigateOptions => ({
86
+ history: replace ? 'replace' : 'push',
87
+ state,
88
+ key: key ?? undefined,
89
+ }),
90
+ )
91
+
92
+ const active: Fx.Computed<Router, UseLinkParams.Error<Params>, boolean> = url.mapEffect(
93
+ (url) =>
94
+ Effect.gen(function* ($) {
95
+ const {
96
+ navigation: { currentEntry },
97
+ } = yield* $(Router)
98
+
99
+ return isActive(url, (yield* $(currentEntry)).url)
100
+ }),
101
+ )
102
+
103
+ const navigate: Effect.Effect<
104
+ Router,
105
+ UseLinkParams.Error<Params>,
106
+ Navigation.Destination
107
+ > = Effect.flatMap(Effect.all([url, options] as const), ([url, options]) =>
108
+ Router.withEffect((r) => r.navigation.navigate(url, options)),
109
+ )
110
+
111
+ return {
112
+ url,
113
+ options,
114
+ navigate,
115
+ active,
116
+ } satisfies UseLink.FromParams<Params>
117
+ },
118
+ )
119
+ }
120
+
121
+ export function Link<Params extends UseLinkParams.Any, R, E, A>(
122
+ params: Params,
123
+ render: (use: UseLink.FromParams<Params>) => Fx.Fx<R, E, A>,
124
+ ): Fx.Fx<Scope.Scope | R | UseLinkParams.Context<Params>, E, A> {
125
+ return Fx.fromFxEffect(Effect.map(useLink(params), render))
36
126
  }
37
127
 
38
- export interface LinkProps<R, E, R2> {
39
- readonly href: string
40
- readonly label: string | Placeholder<R, E>
41
- readonly className?: string
42
- readonly onClick?: (
43
- event: MouseEvent & { currentTarget: HTMLAnchorElement },
44
- ) => Effect.Effect<R2, never, unknown>
128
+ function isActive(url: string, current: URL): boolean {
129
+ const { pathname } = current
45
130
 
46
- readonly useBase?: boolean
47
- readonly fullReload?: boolean
131
+ return (
132
+ url === current.href ||
133
+ url === pathname ||
134
+ url === pathname + current.search ||
135
+ url === pathname + current.hash ||
136
+ url === getCurrentPathFromUrl(current)
137
+ )
48
138
  }
package/src/Match.ts ADDED
@@ -0,0 +1,114 @@
1
+ import * as Effect from '@effect/io/Effect'
2
+ import * as Fx from '@typed/fx'
3
+ import { NavigationError } from '@typed/navigation'
4
+ import { ParamsOf } from '@typed/path'
5
+ import { Route } from '@typed/route'
6
+
7
+ export type MatchOptions<
8
+ P extends string,
9
+ Guard extends Effect.Effect<any, NavigationError, boolean> = Effect.Effect<
10
+ never,
11
+ NavigationError,
12
+ boolean
13
+ >,
14
+ Matched extends Effect.Effect<any, any, any> = Effect.Effect<never, never, unknown>,
15
+ > = {
16
+ readonly guard?: (params: ParamsOf<P>) => Guard
17
+ readonly onMatch?: (params: ParamsOf<P>) => Matched
18
+ }
19
+
20
+ export namespace MatchOptions {
21
+ export type Any<P extends string> =
22
+ | MatchOptions<P, any, any>
23
+ | MatchOptions<P, never, never>
24
+ | MatchOptions<P, any, never>
25
+ | MatchOptions<P, never, any>
26
+ }
27
+
28
+ export type Match<
29
+ in out P extends string,
30
+ out Rendered extends Fx.Fx<any, any, any>,
31
+ in out Options extends MatchOptions.Any<P> = MatchOptions<P>,
32
+ > = {
33
+ readonly route: Route<P>
34
+ readonly render: (params: Fx.Filtered<never, never, ParamsOf<P>>) => Rendered
35
+ readonly options?: Options
36
+ }
37
+
38
+ export namespace Match {
39
+ export type Any = Match<any, any, any> | Match<any, any, never>
40
+
41
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
42
+ export type Rendered<M extends Any> = M extends Match<any, infer R, infer _> ? R : never
43
+
44
+ export type Options<M extends Any> = M extends Match<any, any, infer Options> ? Options : never
45
+
46
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
47
+ export type Guard<M extends Any> = [Options<M>] extends [MatchOptions<any, infer Guard, infer _>]
48
+ ? Guard
49
+ : never
50
+
51
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
52
+ export type Matched<M extends Any> = Options<M> extends MatchOptions<any, infer _, infer Matched>
53
+ ? Matched
54
+ : never
55
+
56
+ export type Context<M extends Any> =
57
+ | Fx.Fx.ResourcesOf<Rendered<M>>
58
+ | ([Guard<M>] extends [never] ? never : Effect.Effect.Context<Guard<M>>)
59
+ | ([Matched<M>] extends [never] ? never : Effect.Effect.Context<Matched<M>>)
60
+
61
+ export type Error<M extends Any> =
62
+ | Fx.Fx.ErrorsOf<Rendered<M>>
63
+ | ([Guard<M>] extends [never] ? never : Exclude<Effect.Effect.Error<Guard<M>>, NavigationError>)
64
+ | ([Matched<M>] extends [never] ? never : Effect.Effect.Error<Matched<M>>)
65
+
66
+ export type Success<M extends Any> = Fx.Fx.OutputOf<Rendered<M>>
67
+ }
68
+
69
+ export function Match<P extends string, Rendered extends Fx.Fx<any, any, any>>(
70
+ route: Route<P>,
71
+ render: (params: Fx.Filtered<never, never, ParamsOf<P>>) => Rendered,
72
+ ): Match<P, Rendered, MatchOptions<P>>
73
+
74
+ export function Match<
75
+ P extends string,
76
+ Rendered extends Fx.Fx<any, any, any>,
77
+ Options extends MatchOptions.Any<P>,
78
+ >(
79
+ route: Route<P>,
80
+ render: (params: Fx.Filtered<never, never, ParamsOf<P>>) => Rendered,
81
+ options: Options,
82
+ ): Match<P, Rendered, Options>
83
+
84
+ export function Match<
85
+ P extends string,
86
+ Rendered extends Fx.Fx<any, any, any>,
87
+ Options extends MatchOptions.Any<P> = MatchOptions<P, never, never>,
88
+ >(
89
+ route: Route<P>,
90
+ render: (params: Fx.Filtered<never, never, ParamsOf<P>>) => Rendered,
91
+ options?: Options,
92
+ ): Match<P, Rendered, Options> | Match<P, Rendered, never> {
93
+ return {
94
+ route,
95
+ render,
96
+ options,
97
+ }
98
+ }
99
+
100
+ Match.lazy = function <
101
+ P extends string,
102
+ Rendered extends Fx.Fx<any, any, any>,
103
+ Options extends MatchOptions.Any<P> = MatchOptions<P>,
104
+ >(
105
+ route: Route<P>,
106
+ render: () => Promise<(params: Fx.Filtered<never, never, ParamsOf<P>>) => Rendered>,
107
+ options?: Options,
108
+ ): Match<P, Rendered, Options> | Match<P, Rendered, never> {
109
+ return {
110
+ route,
111
+ render: (params) => Fx.promiseFx(() => render().then((f) => f(params))) as Rendered,
112
+ options,
113
+ }
114
+ }
package/src/Matcher.ts ADDED
@@ -0,0 +1,139 @@
1
+ import * as Chunk from '@effect/data/Chunk'
2
+ import * as Debug from '@effect/data/Function'
3
+ import * as Effect from '@effect/io/Effect'
4
+ import * as Fx from '@typed/fx'
5
+ import { RenderContext } from '@typed/html'
6
+ import { ParamsOf } from '@typed/path'
7
+ import { Route } from '@typed/route'
8
+
9
+ import { Match, MatchOptions } from './Match.js'
10
+ import { Redirect } from './Redirect.js'
11
+ import { matchRoutes } from './matchRoutes.js'
12
+ import { Router } from './router.js'
13
+
14
+ export interface Matcher<Matches extends ReadonlyArray<Match.Any>> {
15
+ /** @internal */
16
+ readonly matches: Chunk.Chunk<Match.Any>
17
+
18
+ readonly match: <P extends string, R, E, A, Opts extends MatchOptions.Any<P> = MatchOptions<P>>(
19
+ route: Route<P>,
20
+ render: (params: Fx.Filtered<never, never, ParamsOf<P>>) => Fx.Fx<R, E, A>,
21
+ options?: Opts,
22
+ ) => Matcher<readonly [...Matches, Match<P, Fx.Fx<R, E, A>, Opts>]>
23
+
24
+ readonly matchLazy: <
25
+ P extends string,
26
+ R,
27
+ E,
28
+ A,
29
+ Opts extends MatchOptions.Any<P> = MatchOptions<P>,
30
+ >(
31
+ route: Route<P>,
32
+ render: () => Promise<(params: Fx.Filtered<never, never, ParamsOf<P>>) => Fx.Fx<R, E, A>>,
33
+ options?: Opts,
34
+ ) => Matcher<readonly [...Matches, Match<P, Fx.Fx<R, E, A>, Opts>]>
35
+
36
+ readonly notFound: <R, E, A>(
37
+ render: (params: Fx.Filtered<never, never, Readonly<Record<string, string>>>) => Fx.Fx<R, E, A>,
38
+ ) => Fx.Fx<
39
+ Router | RenderContext | R | Match.Context<Matches[number]>,
40
+ Exclude<E | Match.Error<Matches[number]>, Redirect>,
41
+ A | Match.Success<Matches[number]>
42
+ >
43
+
44
+ readonly concat: <OtherMatches extends ReadonlyArray<Match.Any>>(
45
+ other: Matcher<OtherMatches>,
46
+ ) => Matcher<readonly [...Matches, ...OtherMatches]>
47
+ }
48
+
49
+ export function Matcher<const Matches extends ReadonlyArray<Match.Any>>(
50
+ matches: Chunk.Chunk<Match.Any>,
51
+ ): Matcher<Matches> {
52
+ return {
53
+ matches,
54
+
55
+ match<P extends string, R, E, A, Opts extends MatchOptions.Any<P> = never>(
56
+ route: Route<P>,
57
+ render: (params: Fx.Filtered<never, never, ParamsOf<P>>) => Fx.Fx<R, E, A>,
58
+ options?: Opts,
59
+ ): Matcher<readonly [...Matches, Match<P, Fx.Fx<R, E, A>, Opts>]> {
60
+ return Matcher(Chunk.append(matches, Match(route, render, options || {})))
61
+ },
62
+
63
+ matchLazy<P extends string, R, E, A, Opts extends MatchOptions.Any<P> = never>(
64
+ route: Route<P>,
65
+ render: () => Promise<(params: Fx.Filtered<never, never, ParamsOf<P>>) => Fx.Fx<R, E, A>>,
66
+ options?: Opts,
67
+ ): Matcher<readonly [...Matches, Match<P, Fx.Fx<R, E, A>, Opts>]> {
68
+ return Matcher(Chunk.append(matches, Match.lazy(route, render, options || {})))
69
+ },
70
+
71
+ notFound: <R, E, A>(
72
+ render: (
73
+ params: Fx.Filtered<never, never, Readonly<Record<string, string>>>,
74
+ ) => Fx.Fx<R, E, A>,
75
+ ) => Fx.scoped(matchRoutes(Chunk.toReadonlyArray(matches) as Matches, render)),
76
+
77
+ concat: <OtherMatches extends ReadonlyArray<Match.Any>>(
78
+ other: Matcher<OtherMatches>,
79
+ ): Matcher<readonly [...Matches, ...OtherMatches]> => {
80
+ return Matcher(Chunk.appendAll(matches, other.matches))
81
+ },
82
+ }
83
+ }
84
+
85
+ export const { match, matchLazy } = Matcher<readonly []>(Chunk.empty())
86
+
87
+ export const notFound: {
88
+ <R, E, A>(
89
+ render: (params: Fx.Filtered<never, never, Readonly<Record<string, string>>>) => Fx.Fx<R, E, A>,
90
+ ): <Matches extends readonly Match.Any[]>(
91
+ matcher: Matcher<Matches>,
92
+ ) => Fx.Fx<
93
+ Router | R | Match.Context<Matches[number]>,
94
+ Exclude<E | Match.Error<Matches[number]>, Redirect>,
95
+ A | Fx.Fx.OutputOf<Match.Rendered<Matches[number]>>
96
+ >
97
+
98
+ <Matches extends readonly Match.Any[], R, E, A>(
99
+ matcher: Matcher<Matches>,
100
+ render: (params: Fx.Filtered<never, never, Readonly<Record<string, string>>>) => Fx.Fx<R, E, A>,
101
+ ): Fx.Fx<
102
+ Router | R | Match.Context<Matches[number]>,
103
+ Exclude<E | Match.Error<Matches[number]>, Redirect>,
104
+ A | Fx.Fx.OutputOf<Match.Rendered<Matches[number]>>
105
+ >
106
+ } = Debug.dual(
107
+ 2,
108
+ <Matches extends readonly Match.Any[], R, E, A>(
109
+ matcher: Matcher<Matches>,
110
+ render: (params: Fx.Filtered<never, never, Readonly<Record<string, string>>>) => Fx.Fx<R, E, A>,
111
+ ) => matcher.notFound(render),
112
+ )
113
+
114
+ export const redirectEffect: {
115
+ <R, E>(
116
+ effect: Effect.Effect<R, E, never>,
117
+ ): <Matches extends readonly Match.Any[]>(
118
+ matcher: Matcher<Matches>,
119
+ ) => Fx.Fx<
120
+ Router | R | Match.Context<Matches[number]>,
121
+ Exclude<E | Match.Error<Matches[number]>, Redirect>,
122
+ Match.Success<Matches[number]>
123
+ >
124
+
125
+ <Matches extends readonly Match.Any[], R, E>(
126
+ matcher: Matcher<Matches>,
127
+ effect: Effect.Effect<R, E, never>,
128
+ ): Fx.Fx<
129
+ Router | R | Match.Context<Matches[number]>,
130
+ Exclude<E | Match.Error<Matches[number]>, Redirect>,
131
+ Match.Success<Matches[number]>
132
+ >
133
+ } = Debug.dual(
134
+ 2,
135
+ <Matches extends readonly Match.Any[], R, E>(
136
+ matcher: Matcher<Matches>,
137
+ effect: Effect.Effect<R, E, never>,
138
+ ) => notFound(matcher, () => Fx.fromEffect(effect)),
139
+ )
@@ -0,0 +1,24 @@
1
+ import * as Fx from '@typed/fx'
2
+ import { Placeholder } from '@typed/html'
3
+ import { NavigateOptions } from '@typed/navigation'
4
+
5
+ import { Redirect } from './Redirect.js'
6
+
7
+ export interface NavigationParams<R, E, R2, E2> {
8
+ // Configure the URL to navigate to
9
+ readonly url: Placeholder<R, E, string>
10
+ // Configure the options for the navigation, using @typed/navigation
11
+ readonly options?: Placeholder<R2, E2, NavigateOptions>
12
+ }
13
+
14
+ export function Navigation<R = never, E = never, R2 = never, E2 = never>(
15
+ params: NavigationParams<R, E, R2, E2>,
16
+ ): Fx.Fx<R | R2, E | E2 | Redirect, null> {
17
+ return Fx.startWith(
18
+ Fx.switchMapEffect(
19
+ Fx.combine(Placeholder.asFx(params.url), Placeholder.asFx(params.options)),
20
+ ([url, options]) => Redirect.redirect(url, options),
21
+ ),
22
+ null,
23
+ )
24
+ }
@@ -0,0 +1,21 @@
1
+ import * as Effect from '@effect/io/Effect'
2
+ import * as E from '@typed/error'
3
+ import * as Fx from '@typed/fx'
4
+ import { NavigateOptions } from '@typed/navigation'
5
+
6
+ export class Redirect extends E.tagged('@typed/router/Redirect') {
7
+ constructor(
8
+ readonly url: string,
9
+ readonly options?: NavigateOptions,
10
+ ) {
11
+ super(`Redirect to ${url}`)
12
+ }
13
+
14
+ static redirect(url: string, options?: NavigateOptions): Effect.Effect<never, Redirect, never> {
15
+ return Effect.fail(new Redirect(url, options))
16
+ }
17
+
18
+ static redirectFx(url: string, options?: NavigateOptions): Fx.Fx<never, Redirect, never> {
19
+ return Fx.fail(new Redirect(url, options))
20
+ }
21
+ }
@@ -0,0 +1,110 @@
1
+ import * as Duration from '@effect/data/Duration'
2
+ import { pipe } from '@effect/data/Function'
3
+ import * as Effect from '@effect/io/Effect'
4
+ import * as Scope from '@effect/io/Scope'
5
+ import * as Fx from '@typed/fx'
6
+ import { ElementRef } from '@typed/html'
7
+ import * as Navigation from '@typed/navigation'
8
+
9
+ export interface ScrollRestorationParams<A extends HTMLElement> {
10
+ readonly ref: ElementRef<A>
11
+ readonly behavior?: ScrollBehavior
12
+ readonly retries?: number
13
+ readonly retryDelay?: (depth: number) => Duration.Duration
14
+ }
15
+
16
+ export type ScrollRestorationState = {
17
+ readonly scrollRestoration?: {
18
+ readonly scrollLeft: number
19
+ readonly scrollTop: number
20
+ }
21
+ }
22
+
23
+ const defaultDelay = (depth: number) => Duration.millis(10 * depth)
24
+
25
+ export function ScrollRestoration<A extends HTMLElement>(
26
+ params: ScrollRestorationParams<A>,
27
+ ): Fx.Fx<Navigation.Navigation | Scope.Scope, never, null> {
28
+ return Fx.gen(function* ($) {
29
+ const { retries = 3, retryDelay = defaultDelay } = params
30
+ const { onNavigation, onNavigationEnd } = yield* $(Navigation.Navigation)
31
+
32
+ yield* $(
33
+ onNavigation((ev) =>
34
+ Effect.catchTag(
35
+ Effect.gen(function* ($) {
36
+ const state = (ev.destination.state ?? {}) as ScrollRestorationState
37
+
38
+ // If ScrollRestoration is not set, set it
39
+ if (!state.scrollRestoration) {
40
+ const el = yield* $(params.ref.element)
41
+ const scrollLeft = el.scrollLeft
42
+ const scrollTop = el.scrollTop
43
+
44
+ return yield* $(
45
+ Navigation.redirect(ev.destination.url.href, {
46
+ history:
47
+ ev.navigationType === Navigation.NavigationType.Replace ? 'replace' : 'push',
48
+ state: {
49
+ ...state,
50
+ scrollRestoration: {
51
+ scrollLeft,
52
+ scrollTop,
53
+ },
54
+ },
55
+ }),
56
+ )
57
+ }
58
+ }),
59
+ // If there is not Element to scroll, ignore the error
60
+ 'NoSuchElementException',
61
+ Effect.succeed,
62
+ ),
63
+ ),
64
+ )
65
+
66
+ yield* $(
67
+ onNavigationEnd(function restoreScroll(ev, depth = 0): Effect.Effect<never, never, void> {
68
+ return pipe(
69
+ Effect.gen(function* ($) {
70
+ if (depth > retries) {
71
+ return
72
+ }
73
+
74
+ const state = (ev.destination.state ?? {}) as ScrollRestorationState
75
+
76
+ // Restore scroll position on back/forward navigation
77
+ if (
78
+ ev.navigationType === Navigation.NavigationType.Back ||
79
+ ev.navigationType === Navigation.NavigationType.Forward
80
+ ) {
81
+ const scrollRestoration = state?.scrollRestoration
82
+
83
+ if (scrollRestoration) {
84
+ const el = yield* $(params.ref.element)
85
+
86
+ el.scroll({
87
+ left: scrollRestoration.scrollLeft,
88
+ top: scrollRestoration.scrollTop,
89
+ behavior: params.behavior || 'smooth',
90
+ })
91
+ }
92
+ }
93
+ }),
94
+ // HACK: This isn't great, we should have some kind of way of letting the
95
+ // navigation know when an external render has finished.
96
+ Effect.catchTag(
97
+ // If there is not Element to scroll, attempt to retry a few times.
98
+ 'NoSuchElementException',
99
+ () => {
100
+ const d = depth + 1
101
+ return Effect.delay(restoreScroll(ev, d), retryDelay(d))
102
+ },
103
+ ),
104
+ )
105
+ }),
106
+ )
107
+
108
+ return Fx.succeed(null)
109
+ })
110
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,8 @@
1
- export * from './router.js'
2
1
  export * from './Link.js'
3
- export * from './RouteMatcher.js'
4
- export * from './RouteMatch.js'
2
+ export * from './Match.js'
3
+ export * from './Matcher.js'
4
+ export * from './matchRoutes.js'
5
+ export * from './Navigation.js'
6
+ export * from './Redirect.js'
7
+ export * from './router.js'
8
+ export * from './ScrollRestoration.js'