@typed/router 0.0.2
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/dist/Link.d.ts +14 -0
- package/dist/Link.d.ts.map +1 -0
- package/dist/Link.js +19 -0
- package/dist/Link.js.map +1 -0
- package/dist/RouteMatch.d.ts +16 -0
- package/dist/RouteMatch.d.ts.map +1 -0
- package/dist/RouteMatch.js +15 -0
- package/dist/RouteMatch.js.map +1 -0
- package/dist/RouteMatcher.d.ts +38 -0
- package/dist/RouteMatcher.d.ts.map +1 -0
- package/dist/RouteMatcher.js +103 -0
- package/dist/RouteMatcher.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +67 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +123 -0
- package/dist/router.js.map +1 -0
- package/eslintrc.json +3 -0
- package/package.json +37 -0
- package/project.json +26 -0
- package/src/Link.ts +36 -0
- package/src/RouteMatch.ts +56 -0
- package/src/RouteMatcher.ts +253 -0
- package/src/index.ts +4 -0
- package/src/router.ts +279 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/ban-types */
|
|
2
|
+
import * as Layer from '@effect/io/Layer'
|
|
3
|
+
import { flow } from '@fp-ts/data/Function'
|
|
4
|
+
import * as Context from '@typed/context'
|
|
5
|
+
import * as Fx from '@typed/fx'
|
|
6
|
+
import * as html from '@typed/html'
|
|
7
|
+
import * as Path from '@typed/path'
|
|
8
|
+
import * as Route from '@typed/route'
|
|
9
|
+
|
|
10
|
+
export interface RouteMatch<R, E, P extends string> {
|
|
11
|
+
readonly route: Route.Route<R, P>
|
|
12
|
+
|
|
13
|
+
readonly layout?: Fx.Fx<R, E, html.Renderable>
|
|
14
|
+
|
|
15
|
+
readonly match: (params: Fx.Fx<never, never, Path.ParamsOf<P>>) => Fx.Fx<R, E, html.Renderable>
|
|
16
|
+
|
|
17
|
+
readonly provideEnvironment: (environment: Context.Context<R>) => RouteMatch<never, E, P>
|
|
18
|
+
|
|
19
|
+
readonly provideService: <S>(tag: Context.Tag<S>, service: S) => RouteMatch<Exclude<R, S>, E, P>
|
|
20
|
+
|
|
21
|
+
readonly provideLayer: <R2, S>(
|
|
22
|
+
layer: Layer.Layer<R2, never, S>,
|
|
23
|
+
) => RouteMatch<R2 | Exclude<R, S>, E, P>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function RouteMatch<R, P extends string, R2, E2, R3, E3>(
|
|
27
|
+
route: Route.Route<R, P>,
|
|
28
|
+
match: (params: Fx.Fx<never, never, Path.ParamsOf<P>>) => Fx.Fx<R2, E2, html.Renderable>,
|
|
29
|
+
layout?: Fx.Fx<R3, E3, html.Renderable>,
|
|
30
|
+
): RouteMatch<R | R2 | R3, E2 | E3, P> {
|
|
31
|
+
const routeMatch: RouteMatch<R | R2 | R3, E2 | E3, P> = {
|
|
32
|
+
route,
|
|
33
|
+
match,
|
|
34
|
+
layout,
|
|
35
|
+
provideEnvironment: (env) =>
|
|
36
|
+
RouteMatch(
|
|
37
|
+
Route.provideEnvironment(env)(route),
|
|
38
|
+
flow(match, Fx.provideSomeEnvironment(env)),
|
|
39
|
+
layout ? Fx.provideEnvironment(env)(layout) : undefined,
|
|
40
|
+
),
|
|
41
|
+
provideService: (tag, service) =>
|
|
42
|
+
RouteMatch(
|
|
43
|
+
Route.provideService(tag, service)(route),
|
|
44
|
+
flow(match, Fx.provideService(tag)(service)),
|
|
45
|
+
layout ? Fx.provideService(tag)(service)(layout) : undefined,
|
|
46
|
+
),
|
|
47
|
+
provideLayer: (layer) =>
|
|
48
|
+
RouteMatch(
|
|
49
|
+
Route.provideLayer(layer)(route),
|
|
50
|
+
flow(match, Fx.provideSomeLayer(layer)),
|
|
51
|
+
layout ? Fx.provideSomeLayer(layer)(layout) : undefined,
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return routeMatch
|
|
56
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/ban-types */
|
|
2
|
+
import * as Effect from '@effect/io/Effect'
|
|
3
|
+
import * as Fiber from '@effect/io/Fiber'
|
|
4
|
+
import * as Layer from '@effect/io/Layer'
|
|
5
|
+
import { pipe } from '@fp-ts/data/Function'
|
|
6
|
+
import * as Option from '@fp-ts/data/Option'
|
|
7
|
+
import * as Context from '@typed/context'
|
|
8
|
+
import * as Fx from '@typed/fx'
|
|
9
|
+
import * as html from '@typed/html'
|
|
10
|
+
import { RenderContext } from '@typed/html'
|
|
11
|
+
import * as Path from '@typed/path'
|
|
12
|
+
import * as Route from '@typed/route'
|
|
13
|
+
|
|
14
|
+
import { RouteMatch } from './RouteMatch.js'
|
|
15
|
+
import { currentPath, Redirect, redirectTo, Router } from './router.js'
|
|
16
|
+
|
|
17
|
+
export interface RouteMatcher<R = never, E = never> {
|
|
18
|
+
// Where things are actually stored immutably
|
|
19
|
+
readonly routes: ReadonlyMap<Route.Route<any, any>, RouteMatch<any, any, any>>
|
|
20
|
+
|
|
21
|
+
// Add Routes
|
|
22
|
+
|
|
23
|
+
readonly match: <R2, P extends string, R3, E3>(
|
|
24
|
+
route: Route.Route<R2, P>,
|
|
25
|
+
f: (params: Path.ParamsOf<P>) => Fx.Fx<R3, E3, html.Renderable>,
|
|
26
|
+
) => RouteMatcher<R | R2, E | E3>
|
|
27
|
+
|
|
28
|
+
readonly matchFx: <R2, P extends string, R3, E3>(
|
|
29
|
+
route: Route.Route<R2, P>,
|
|
30
|
+
f: (params: Fx.Fx<never, never, Path.ParamsOf<P>>) => Fx.Fx<R3, E3, html.Renderable>,
|
|
31
|
+
) => RouteMatcher<R | R2, E | E3>
|
|
32
|
+
|
|
33
|
+
readonly matchEffect: <R2, P extends string, R3, E3>(
|
|
34
|
+
route: Route.Route<R2, P>,
|
|
35
|
+
f: (params: Path.ParamsOf<P>) => Effect.Effect<R3, E3, html.Renderable>,
|
|
36
|
+
) => RouteMatcher<R | R2, E | E3>
|
|
37
|
+
|
|
38
|
+
// Add Layout
|
|
39
|
+
|
|
40
|
+
readonly withLayout: <R2, E2>(fx: Fx.Fx<R2, E2, html.Renderable>) => RouteMatcher<R | R2, E | E2>
|
|
41
|
+
|
|
42
|
+
// Provide resources
|
|
43
|
+
|
|
44
|
+
readonly provideEnvironment: <R2>(
|
|
45
|
+
environment: Context.Context<R2>,
|
|
46
|
+
) => RouteMatcher<Exclude<R, R2>, E>
|
|
47
|
+
|
|
48
|
+
readonly provideService: <R2>(
|
|
49
|
+
tag: Context.Tag<R2>,
|
|
50
|
+
service: R2,
|
|
51
|
+
) => RouteMatcher<Exclude<R, R2>, E>
|
|
52
|
+
|
|
53
|
+
readonly provideLayer: <R2, S>(
|
|
54
|
+
layer: Layer.Layer<R2, never, S>,
|
|
55
|
+
) => RouteMatcher<Exclude<R, S> | R2, E>
|
|
56
|
+
|
|
57
|
+
// Runners that turn a RouterMatcher back into an Fx.
|
|
58
|
+
// Error handling should be handled after converting to an Fx for maximum flexibility.
|
|
59
|
+
|
|
60
|
+
readonly notFound: <R2, E2, R3 = never, E3 = never>(
|
|
61
|
+
f: (path: string) => Fx.Fx<R2, E2, html.Renderable>,
|
|
62
|
+
options?: FallbackOptions<R3, E3>,
|
|
63
|
+
) => Fx.Fx<Router | R | R2 | R3, E | E2 | E3, html.Renderable>
|
|
64
|
+
|
|
65
|
+
readonly notFoundEffect: <R2, E2, R3 = never, E3 = never>(
|
|
66
|
+
f: (path: string) => Effect.Effect<R2, E2, html.Renderable>,
|
|
67
|
+
options?: FallbackOptions<R3, E3>,
|
|
68
|
+
) => Fx.Fx<Router | R | R2 | R3, E | E2 | E3, html.Renderable>
|
|
69
|
+
|
|
70
|
+
readonly redirectTo: <R2, P extends string>(
|
|
71
|
+
route: Route.Route<R2, P>,
|
|
72
|
+
...params: [keyof Path.ParamsOf<P>] extends [never]
|
|
73
|
+
? // eslint-disable-next-line @typescript-eslint/ban-types
|
|
74
|
+
[{}?]
|
|
75
|
+
: [(path: string) => Path.ParamsOf<P>]
|
|
76
|
+
) => Fx.Fx<Router | R | R2, E | Redirect, html.Renderable>
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
81
|
+
readonly run: Fx.Fx<Router | R, E | Redirect, html.Renderable | null>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface FallbackOptions<R, E> {
|
|
85
|
+
readonly layout?: Fx.Fx<R, E, html.Renderable>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function RouteMatcher<R, E>(routes: RouteMatcher<R, E>['routes']): RouteMatcher<R, E> {
|
|
89
|
+
const matcher: RouteMatcher<R, E> = {
|
|
90
|
+
routes,
|
|
91
|
+
matchFx: (route, f) => RouteMatcher(new Map(routes).set(route, RouteMatch(route, f))),
|
|
92
|
+
match: (route, f) =>
|
|
93
|
+
RouteMatcher(new Map(routes).set(route, RouteMatch(route, Fx.switchMap(f)))),
|
|
94
|
+
matchEffect: (route, f) =>
|
|
95
|
+
RouteMatcher(new Map(routes).set(route, RouteMatch(route, Fx.switchMapEffect(f)))),
|
|
96
|
+
withLayout: (layout) =>
|
|
97
|
+
RouteMatcher(
|
|
98
|
+
new Map(
|
|
99
|
+
Array.from(routes).map(([k, match]) => [
|
|
100
|
+
k,
|
|
101
|
+
RouteMatch(match.route, match.match, match.layout ?? layout),
|
|
102
|
+
]),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
provideEnvironment: (environment) =>
|
|
106
|
+
RouteMatcher(
|
|
107
|
+
new Map(Array.from(routes).map(([k, v]) => [k, v.provideEnvironment(environment)])),
|
|
108
|
+
),
|
|
109
|
+
provideService: (tag, service) =>
|
|
110
|
+
RouteMatcher(
|
|
111
|
+
new Map(Array.from(routes).map(([k, v]) => [k, v.provideService(tag, service)])),
|
|
112
|
+
),
|
|
113
|
+
provideLayer: (layer) =>
|
|
114
|
+
RouteMatcher(new Map(Array.from(routes).map(([k, v]) => [k, v.provideLayer(layer)]))),
|
|
115
|
+
notFound: <R2, E2, R3, E3>(
|
|
116
|
+
f: (path: string) => Fx.Fx<R2, E2, html.Renderable>,
|
|
117
|
+
options: FallbackOptions<R3, E3> = {},
|
|
118
|
+
) =>
|
|
119
|
+
Router.withFx((router) =>
|
|
120
|
+
Fx.gen(function* ($) {
|
|
121
|
+
const { environment } = yield* $(RenderContext.get)
|
|
122
|
+
// Create stable references to the route matchers
|
|
123
|
+
const matchers = Array.from(routes.values()).map(
|
|
124
|
+
(v) => [v, runRouteMatch(router, v)] as const,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const renderFallback = pipe(currentPath, Fx.switchMap(f))
|
|
128
|
+
const fallbackMatch = RouteMatch(Route.base, () => renderFallback, options?.layout)
|
|
129
|
+
|
|
130
|
+
let previousFiber: Fiber.RuntimeFiber<any, any> | undefined
|
|
131
|
+
let previousLayout: Fx.Fx<any, any, html.Renderable> | undefined
|
|
132
|
+
let previousRender: Fx.Fx<any, any, html.Renderable> | undefined
|
|
133
|
+
|
|
134
|
+
const samplePreviousValues = () => ({
|
|
135
|
+
fiber: previousFiber,
|
|
136
|
+
layout: previousLayout,
|
|
137
|
+
render: previousRender,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// This function helps us to ensure shared layouts are only rendered once
|
|
141
|
+
// and the outlet content is changed
|
|
142
|
+
const verifyShouldRerender = (
|
|
143
|
+
match: RouteMatch<any, any, any>,
|
|
144
|
+
render: Fx.Fx<any, any, html.Renderable>,
|
|
145
|
+
): Effect.Effect<any, any, Option.Option<Fx.Fx<any, any, html.Renderable>>> =>
|
|
146
|
+
Effect.gen(function* ($) {
|
|
147
|
+
const previous = samplePreviousValues()
|
|
148
|
+
|
|
149
|
+
// Update the previous values
|
|
150
|
+
previousRender = render
|
|
151
|
+
previousLayout = match.layout
|
|
152
|
+
previousFiber = undefined
|
|
153
|
+
|
|
154
|
+
// Skip rerendering if the render function is the same
|
|
155
|
+
if (previous.render === render) {
|
|
156
|
+
return Option.none
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Interrupt the previous fiber if it exists
|
|
160
|
+
if (previous.fiber) {
|
|
161
|
+
yield* $(Fiber.interrupt(previous.fiber))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// If we have a layout, we need to render it and use the route outlet.
|
|
165
|
+
if (match.layout) {
|
|
166
|
+
// Render into the route outlet
|
|
167
|
+
previousFiber = yield* $(
|
|
168
|
+
pipe(render, Fx.observe(router.outlet.set), Effect.forkScoped),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return Option.some(match.layout)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// If we don't have a layout, but we did, we need to clear the outlet
|
|
175
|
+
if (previous.layout) {
|
|
176
|
+
yield* $(router.outlet.set(null))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Otherwise use the render function directly
|
|
180
|
+
return Option.some(render)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return pipe(
|
|
184
|
+
router.currentPath,
|
|
185
|
+
environment === 'browser' ? Fx.skipRepeats : Fx.take(1),
|
|
186
|
+
Fx.switchMapEffect((path) =>
|
|
187
|
+
Effect.gen(function* ($) {
|
|
188
|
+
yield* $(Effect.logDebug(`[@typed/router] Matching path: ${path}.`))
|
|
189
|
+
|
|
190
|
+
// Attempt to find the best match
|
|
191
|
+
for (const [match, render] of matchers) {
|
|
192
|
+
yield* $(
|
|
193
|
+
Effect.logDebug(`[@typed/router] Matching against: ${match.route.path}.`),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const result = yield* $(match.route.match(path))
|
|
197
|
+
|
|
198
|
+
if (Option.isSome(result)) {
|
|
199
|
+
yield* $(
|
|
200
|
+
Effect.logDebug(`[@typed/router] Matched against: ${match.route.path}.`),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return yield* $(verifyShouldRerender(match, render))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
yield* $(Effect.logDebug(`[@typed/router] Rendering fallback.`))
|
|
208
|
+
|
|
209
|
+
// If we didn't find a match, render the not found page
|
|
210
|
+
return yield* $(verifyShouldRerender(fallbackMatch, renderFallback))
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
Fx.compact,
|
|
214
|
+
Fx.skipRepeats, // Stable render references are used to avoid mounting the same component twice
|
|
215
|
+
Fx.switchLatest,
|
|
216
|
+
)
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
notFoundEffect: (f) => matcher.notFound((path) => Fx.fromEffect(f(path))),
|
|
220
|
+
redirectTo: ((route, ...params) =>
|
|
221
|
+
matcher.notFound(() => redirectTo.fx(route, ...params))) as RouteMatcher<R, E>['redirectTo'],
|
|
222
|
+
run: Fx.suspend(() => matcher.notFoundEffect(Effect.never)),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return matcher
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export namespace RouteMatcher {
|
|
229
|
+
export const empty = RouteMatcher<never, never>(new Map())
|
|
230
|
+
|
|
231
|
+
export const concat = <R, E, R2, E2>(
|
|
232
|
+
matcher: RouteMatcher<R, E>,
|
|
233
|
+
matcher2: RouteMatcher<R2, E2>,
|
|
234
|
+
): RouteMatcher<R | R2, E | E2> => RouteMatcher(new Map([...matcher.routes, ...matcher2.routes]))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const { matchFx, match, matchEffect } = RouteMatcher<never, never>(new Map())
|
|
238
|
+
|
|
239
|
+
function runRouteMatch<R, E, P extends string>(
|
|
240
|
+
router: Router,
|
|
241
|
+
{ route, match }: RouteMatch<R, E, P>,
|
|
242
|
+
): Fx.Fx<R, E, html.Renderable> {
|
|
243
|
+
return Fx.gen(function* ($) {
|
|
244
|
+
const env = yield* $(Effect.environment<R>())
|
|
245
|
+
const nestedRouter = router.define(route)
|
|
246
|
+
const params = pipe(nestedRouter.params, Fx.provideEnvironment(env))
|
|
247
|
+
|
|
248
|
+
return pipe(
|
|
249
|
+
match(params as unknown as Fx.Fx<never, never, Path.ParamsOf<P>>),
|
|
250
|
+
Router.provideFx(nestedRouter as Router),
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
}
|
package/src/index.ts
ADDED
package/src/router.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/ban-types */
|
|
2
|
+
import * as Effect from '@effect/io/Effect'
|
|
3
|
+
import * as Layer from '@effect/io/Layer'
|
|
4
|
+
import * as Scope from '@effect/io/Scope'
|
|
5
|
+
import { pipe } from '@fp-ts/data/Function'
|
|
6
|
+
import * as Option from '@fp-ts/data/Option'
|
|
7
|
+
import * as Context from '@typed/context'
|
|
8
|
+
import { Location, History, addWindowListener } from '@typed/dom'
|
|
9
|
+
import * as Fx from '@typed/fx'
|
|
10
|
+
import * as html from '@typed/html'
|
|
11
|
+
import { RenderContext } from '@typed/html'
|
|
12
|
+
import * as Path from '@typed/path'
|
|
13
|
+
import * as Route from '@typed/route'
|
|
14
|
+
|
|
15
|
+
export interface Router<out R = never, in out P extends string = string> {
|
|
16
|
+
/**
|
|
17
|
+
* The base route the Router is starting from.
|
|
18
|
+
*/
|
|
19
|
+
readonly route: Route.Route<R, P>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The current path of the application
|
|
23
|
+
*/
|
|
24
|
+
readonly currentPath: Fx.RefSubject<string>
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The current matched params of the router
|
|
28
|
+
*/
|
|
29
|
+
readonly params: Fx.Fx<R, never, Path.ParamsOf<P>>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The current outlet of this Router
|
|
33
|
+
*/
|
|
34
|
+
readonly outlet: Fx.RefSubject<html.Renderable>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Helper for constructing a path from a route relative to the router.
|
|
38
|
+
*/
|
|
39
|
+
readonly createPath: <R2 extends Route.Route<any, string>, P extends Route.ParamsOf<R2>>(
|
|
40
|
+
route: R2,
|
|
41
|
+
...[params]: [keyof P] extends [never] ? [] : [P]
|
|
42
|
+
) => Effect.Effect<
|
|
43
|
+
R,
|
|
44
|
+
never,
|
|
45
|
+
Path.PathJoin<
|
|
46
|
+
[Path.Interpolate<Route.PathOf<R>, Route.ParamsOf<R>>, Path.Interpolate<Route.PathOf<R2>, P>]
|
|
47
|
+
>
|
|
48
|
+
>
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Helper for constructing a nested router
|
|
52
|
+
*/
|
|
53
|
+
readonly define: <R2, Path2 extends string>(
|
|
54
|
+
route: Route.Route<R2, Path2>,
|
|
55
|
+
) => Router<R | R2, Path.PathJoin<[P, Path2]>>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Provide all the resources needed for a Router
|
|
59
|
+
*/
|
|
60
|
+
readonly provideEnvironment: (environment: Context.Context<R>) => Router<never, P>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const Router = Object.assign(function makeRouter<R = never, P extends string = string>(
|
|
64
|
+
route: Route.Route<R, P>,
|
|
65
|
+
currentPath: Fx.RefSubject<string>,
|
|
66
|
+
): Router<R, P> {
|
|
67
|
+
const outlet = Fx.RefSubject.unsafeMake((): html.Renderable => null)
|
|
68
|
+
|
|
69
|
+
const createPath = <R2 extends Route.Route<any, string>, P extends Route.ParamsOf<R2>>(
|
|
70
|
+
other: R2,
|
|
71
|
+
...[params]: [keyof P] extends [never] ? [] : [P]
|
|
72
|
+
): Effect.Effect<
|
|
73
|
+
R,
|
|
74
|
+
never,
|
|
75
|
+
Path.PathJoin<
|
|
76
|
+
[Path.Interpolate<Route.PathOf<R>, Route.ParamsOf<R>>, Path.Interpolate<Route.PathOf<R2>, P>]
|
|
77
|
+
>
|
|
78
|
+
> =>
|
|
79
|
+
Effect.gen(function* ($) {
|
|
80
|
+
const path = yield* $(currentPath.get)
|
|
81
|
+
const baseParams = yield* $(route.match(path))
|
|
82
|
+
|
|
83
|
+
if (Option.isNone(baseParams)) {
|
|
84
|
+
return yield* $(
|
|
85
|
+
Effect.dieMessage(
|
|
86
|
+
`Can not create path when the parent can not be matched.
|
|
87
|
+
Parent Route: ${route.path}
|
|
88
|
+
Current Route: ${other.path}
|
|
89
|
+
Current Path: ${path}`,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return route.concat(other).make({ ...baseParams.value, ...params } as any) as any
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const router: Router<R, P> = {
|
|
98
|
+
route,
|
|
99
|
+
currentPath,
|
|
100
|
+
params: pipe(currentPath, Fx.switchMapEffect(route.match), Fx.compact, Fx.skipRepeats),
|
|
101
|
+
outlet,
|
|
102
|
+
createPath: createPath as Router<R, P>['createPath'],
|
|
103
|
+
define: <R2, Path2 extends string>(other: Route.Route<R2, Path2>) =>
|
|
104
|
+
makeRouter(route.concat(other), currentPath),
|
|
105
|
+
provideEnvironment: (env) => provideEnvironment(env)(router),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return router
|
|
109
|
+
},
|
|
110
|
+
Context.Tag<Router>())
|
|
111
|
+
|
|
112
|
+
export const outlet: Fx.Fx<RenderContext | Router, never, html.Renderable> = RenderContext.withFx(
|
|
113
|
+
({ environment }) =>
|
|
114
|
+
Router.withFx((r) =>
|
|
115
|
+
environment === 'browser'
|
|
116
|
+
? r.outlet
|
|
117
|
+
: pipe(
|
|
118
|
+
r.outlet,
|
|
119
|
+
Fx.skipUntil((x) => x !== null),
|
|
120
|
+
Fx.take(1),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
export const currentPath: Fx.Fx<Router, never, string> = Router.withFx((r) => r.currentPath)
|
|
126
|
+
|
|
127
|
+
export function provideEnvironment<R>(environment: Context.Context<R>) {
|
|
128
|
+
return <P extends string>(router: Router<R, P>): Router<never, P> => {
|
|
129
|
+
const provided: Router<never, P> = {
|
|
130
|
+
...router,
|
|
131
|
+
params: pipe(router.params, Fx.provideEnvironment(environment)),
|
|
132
|
+
route: Route.provideEnvironment(environment)(router.route),
|
|
133
|
+
createPath: ((other, ...params) =>
|
|
134
|
+
Effect.provideEnvironment<R>(environment)(router.createPath(other, ...params))) as Router<
|
|
135
|
+
never,
|
|
136
|
+
P
|
|
137
|
+
>['createPath'],
|
|
138
|
+
provideEnvironment: (env) => provideEnvironment(env)(provided),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return provided
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface Redirect {
|
|
146
|
+
readonly _tag: 'Redirect'
|
|
147
|
+
readonly path: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export namespace Redirect {
|
|
151
|
+
export const make = (path: string): Redirect => ({ _tag: 'Redirect', path })
|
|
152
|
+
|
|
153
|
+
export const is = (r: unknown): r is Redirect =>
|
|
154
|
+
typeof r === 'object' && r !== null && '_tag' in r && r._tag === 'Redirect'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function redirect(path: string) {
|
|
158
|
+
return Effect.fail<Redirect>(Redirect.make(path))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
redirect.fx = (path: string) => Fx.fail<Redirect>(Redirect.make(path))
|
|
162
|
+
|
|
163
|
+
export const redirectTo = <R, P extends string>(
|
|
164
|
+
route: Route.Route<R, P>,
|
|
165
|
+
...[params]: [keyof Path.ParamsOf<P>] extends [never] ? [{}?] : [Path.ParamsOf<P>]
|
|
166
|
+
): Effect.Effect<Router, Redirect, never> =>
|
|
167
|
+
pipe(
|
|
168
|
+
Router.withEffect((r) => r.createPath(route as any, params as any)),
|
|
169
|
+
Effect.flatMap(redirect),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
redirectTo.fx = <R, P extends string>(
|
|
173
|
+
route: Route.Route<R, P>,
|
|
174
|
+
...params: [keyof Path.ParamsOf<P>] extends [never] ? [{}?] : [(path: string) => Path.ParamsOf<P>]
|
|
175
|
+
): Fx.Fx<Router, Redirect, never> =>
|
|
176
|
+
pipe(
|
|
177
|
+
Router.withEffect((r) => r.createPath(route as any, params as any)),
|
|
178
|
+
Fx.fromEffect,
|
|
179
|
+
Fx.switchMap(redirect.fx),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
export const makeRouter = (
|
|
183
|
+
currentPath?: Fx.RefSubject<string>,
|
|
184
|
+
): Effect.Effect<Location | History | Window | Document | Scope.Scope, never, Router> =>
|
|
185
|
+
Effect.gen(function* ($) {
|
|
186
|
+
const history = yield* $(History.get)
|
|
187
|
+
const location = yield* $(Location.get)
|
|
188
|
+
|
|
189
|
+
if (!currentPath) {
|
|
190
|
+
currentPath = Fx.RefSubject.unsafeMake(() => getCurrentPath(location))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Patch history events to emit an event when the path changes
|
|
194
|
+
const historyEvents = yield* $(patchHistory)
|
|
195
|
+
|
|
196
|
+
// Update the current path when events occur:
|
|
197
|
+
// - popstate
|
|
198
|
+
// - hashchange
|
|
199
|
+
// - history events
|
|
200
|
+
yield* $(
|
|
201
|
+
pipe(
|
|
202
|
+
Fx.mergeAll(addWindowListener('popstate'), addWindowListener('hashchange'), historyEvents),
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
204
|
+
Fx.switchMapEffect(() => currentPath!.set(getCurrentPath(location))),
|
|
205
|
+
Fx.forkScoped,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
// Listen to path changes and update the current history location, if necessary
|
|
210
|
+
yield* $(
|
|
211
|
+
pipe(
|
|
212
|
+
currentPath,
|
|
213
|
+
Fx.skipRepeats,
|
|
214
|
+
Fx.observe((path) =>
|
|
215
|
+
Effect.sync(() => {
|
|
216
|
+
if (path !== getCurrentPath(location)) {
|
|
217
|
+
history.pushState({}, '', path)
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
Effect.forkScoped,
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
// Make our base router
|
|
226
|
+
return Router(Route.base, currentPath) as Router
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
export const live = (
|
|
230
|
+
currentPath?: Fx.RefSubject<string>,
|
|
231
|
+
): Layer.Layer<Location | History | Window | Document, never, Router<never, string>> =>
|
|
232
|
+
Router.layerSoped(makeRouter(currentPath))
|
|
233
|
+
|
|
234
|
+
export function getCurrentPath(location: Location | HTMLAnchorElement) {
|
|
235
|
+
return location.pathname + location.search + location.hash
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const patchHistory = Effect.gen(function* ($) {
|
|
239
|
+
const history = yield* $(History.get)
|
|
240
|
+
const historyEvents = Fx.Subject.unsafeMake<never, void>()
|
|
241
|
+
const runtime = yield* $(Effect.runtime<never>())
|
|
242
|
+
|
|
243
|
+
patchHistory_(history, () => runtime.unsafeRun(historyEvents.event()))
|
|
244
|
+
|
|
245
|
+
return historyEvents
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
function patchHistory_(history: History, sendEvent: () => void) {
|
|
249
|
+
const pushState = history.pushState.bind(history)
|
|
250
|
+
const replaceState = history.replaceState.bind(history)
|
|
251
|
+
const go = history.go.bind(history)
|
|
252
|
+
const back = history.back.bind(history)
|
|
253
|
+
const forward = history.forward.bind(history)
|
|
254
|
+
|
|
255
|
+
history.pushState = function (state, title, url) {
|
|
256
|
+
pushState(state, title, url)
|
|
257
|
+
sendEvent()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
history.replaceState = function (state, title, url) {
|
|
261
|
+
replaceState(state, title, url)
|
|
262
|
+
sendEvent()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
history.go = function (delta) {
|
|
266
|
+
go(delta)
|
|
267
|
+
sendEvent()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
history.back = function () {
|
|
271
|
+
back()
|
|
272
|
+
sendEvent()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
history.forward = function () {
|
|
276
|
+
forward()
|
|
277
|
+
sendEvent()
|
|
278
|
+
}
|
|
279
|
+
}
|