@typed/navigation 0.1.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.
- package/dist/DOM.d.ts +12 -0
- package/dist/DOM.d.ts.map +1 -0
- package/dist/DOM.js +89 -0
- package/dist/DOM.js.map +1 -0
- package/dist/Memory.d.ts +10 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +55 -0
- package/dist/Memory.js.map +1 -0
- package/dist/Navigation.d.ts +117 -0
- package/dist/Navigation.d.ts.map +1 -0
- package/dist/Navigation.js +36 -0
- package/dist/Navigation.js.map +1 -0
- package/dist/_makeServerWindow.d.ts +16 -0
- package/dist/_makeServerWindow.d.ts.map +1 -0
- package/dist/_makeServerWindow.js +9 -0
- package/dist/_makeServerWindow.js.map +1 -0
- package/dist/cjs/DOM.d.ts +12 -0
- package/dist/cjs/DOM.d.ts.map +1 -0
- package/dist/cjs/DOM.js +118 -0
- package/dist/cjs/DOM.js.map +1 -0
- package/dist/cjs/Memory.d.ts +10 -0
- package/dist/cjs/Memory.d.ts.map +1 -0
- package/dist/cjs/Memory.js +82 -0
- package/dist/cjs/Memory.js.map +1 -0
- package/dist/cjs/Navigation.d.ts +117 -0
- package/dist/cjs/Navigation.d.ts.map +1 -0
- package/dist/cjs/Navigation.js +69 -0
- package/dist/cjs/Navigation.js.map +1 -0
- package/dist/cjs/_makeServerWindow.d.ts +16 -0
- package/dist/cjs/_makeServerWindow.d.ts.map +1 -0
- package/dist/cjs/_makeServerWindow.js +36 -0
- package/dist/cjs/_makeServerWindow.js.map +1 -0
- package/dist/cjs/constant.d.ts +2 -0
- package/dist/cjs/constant.d.ts.map +1 -0
- package/dist/cjs/constant.js +30 -0
- package/dist/cjs/constant.js.map +1 -0
- package/dist/cjs/dom-intent.d.ts +29 -0
- package/dist/cjs/dom-intent.d.ts.map +1 -0
- package/dist/cjs/dom-intent.js +173 -0
- package/dist/cjs/dom-intent.js.map +1 -0
- package/dist/cjs/history.d.ts +31 -0
- package/dist/cjs/history.d.ts.map +1 -0
- package/dist/cjs/history.js +115 -0
- package/dist/cjs/history.js.map +1 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +20 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/json.d.ts +13 -0
- package/dist/cjs/json.d.ts.map +1 -0
- package/dist/cjs/json.js +24 -0
- package/dist/cjs/json.js.map +1 -0
- package/dist/cjs/memory-intent.d.ts +28 -0
- package/dist/cjs/memory-intent.d.ts.map +1 -0
- package/dist/cjs/memory-intent.js +149 -0
- package/dist/cjs/memory-intent.js.map +1 -0
- package/dist/cjs/model.d.ts +22 -0
- package/dist/cjs/model.d.ts.map +1 -0
- package/dist/cjs/model.js +48 -0
- package/dist/cjs/model.js.map +1 -0
- package/dist/cjs/shared-intent.d.ts +15 -0
- package/dist/cjs/shared-intent.d.ts.map +1 -0
- package/dist/cjs/shared-intent.js +82 -0
- package/dist/cjs/shared-intent.js.map +1 -0
- package/dist/cjs/storage.d.ts +19 -0
- package/dist/cjs/storage.d.ts.map +1 -0
- package/dist/cjs/storage.js +101 -0
- package/dist/cjs/storage.js.map +1 -0
- package/dist/cjs/util.d.ts +5 -0
- package/dist/cjs/util.d.ts.map +1 -0
- package/dist/cjs/util.js +39 -0
- package/dist/cjs/util.js.map +1 -0
- package/dist/constant.d.ts +2 -0
- package/dist/constant.d.ts.map +1 -0
- package/dist/constant.js +4 -0
- package/dist/constant.js.map +1 -0
- package/dist/dom-intent.d.ts +29 -0
- package/dist/dom-intent.d.ts.map +1 -0
- package/dist/dom-intent.js +141 -0
- package/dist/dom-intent.js.map +1 -0
- package/dist/history.d.ts +31 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +88 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/intent.d.ts +31 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +157 -0
- package/dist/intent.js.map +1 -0
- package/dist/json.d.ts +13 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +17 -0
- package/dist/json.js.map +1 -0
- package/dist/memory-intent.d.ts +28 -0
- package/dist/memory-intent.d.ts.map +1 -0
- package/dist/memory-intent.js +117 -0
- package/dist/memory-intent.js.map +1 -0
- package/dist/model.d.ts +22 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +21 -0
- package/dist/model.js.map +1 -0
- package/dist/shared-intent.d.ts +15 -0
- package/dist/shared-intent.d.ts.map +1 -0
- package/dist/shared-intent.js +51 -0
- package/dist/shared-intent.js.map +1 -0
- package/dist/storage.d.ts +19 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +73 -0
- package/dist/storage.js.map +1 -0
- package/dist/tsconfig.cjs.build.tsbuildinfo +1 -0
- package/dist/util.d.ts +5 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +12 -0
- package/dist/util.js.map +1 -0
- package/eslintrc.json +3 -0
- package/package.json +38 -0
- package/project.json +43 -0
- package/src/DOM.test.ts +704 -0
- package/src/DOM.ts +165 -0
- package/src/Memory.test.ts +464 -0
- package/src/Memory.ts +102 -0
- package/src/Navigation.ts +192 -0
- package/src/_makeServerWindow.ts +28 -0
- package/src/constant.ts +5 -0
- package/src/dom-intent.ts +276 -0
- package/src/history.ts +141 -0
- package/src/index.ts +3 -0
- package/src/json.ts +31 -0
- package/src/memory-intent.ts +221 -0
- package/src/model.ts +54 -0
- package/src/shared-intent.ts +120 -0
- package/src/storage.ts +101 -0
- package/src/util.ts +20 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.cjs.build.json +22 -0
- package/tsconfig.json +27 -0
- package/vite.config.js +3 -0
package/src/Memory.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { pipe } from '@effect/data/Function'
|
|
2
|
+
import * as Option from '@effect/data/Option'
|
|
3
|
+
import * as Cause from '@effect/io/Cause'
|
|
4
|
+
import * as Effect from '@effect/io/Effect'
|
|
5
|
+
import * as Layer from '@effect/io/Layer'
|
|
6
|
+
|
|
7
|
+
import type { DomNavigationOptions } from './DOM.js'
|
|
8
|
+
import {
|
|
9
|
+
Destination,
|
|
10
|
+
Navigation,
|
|
11
|
+
NavigationError,
|
|
12
|
+
NavigationEvent,
|
|
13
|
+
NavigationType,
|
|
14
|
+
} from './Navigation.js'
|
|
15
|
+
import { makeIntent } from './memory-intent.js'
|
|
16
|
+
import { Model, makeModel } from './model.js'
|
|
17
|
+
import { createKey } from './util.js'
|
|
18
|
+
|
|
19
|
+
export interface MemoryNavigationOptions extends DomNavigationOptions {
|
|
20
|
+
readonly initialUrl: URL
|
|
21
|
+
readonly initialState?: unknown
|
|
22
|
+
readonly base?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function memory(options: MemoryNavigationOptions): Layer.Layer<never, never, Navigation> {
|
|
26
|
+
return Navigation.layerScoped(
|
|
27
|
+
Effect.gen(function* ($) {
|
|
28
|
+
const initial: Destination = {
|
|
29
|
+
key: options.initialKey ?? (yield* $(createKey)),
|
|
30
|
+
url: options.initialUrl,
|
|
31
|
+
state: options.initialState,
|
|
32
|
+
}
|
|
33
|
+
const initialEvent: NavigationEvent = {
|
|
34
|
+
destination: initial,
|
|
35
|
+
hashChange: false,
|
|
36
|
+
navigationType: NavigationType.Push,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const model: Model = yield* $(makeModel([initialEvent], 0))
|
|
40
|
+
const intent = makeIntent(model, options)
|
|
41
|
+
|
|
42
|
+
// Used to ensure ordering of navigation events
|
|
43
|
+
const lock = Effect.unsafeMakeSemaphore(1).withPermits(1)
|
|
44
|
+
|
|
45
|
+
const handleNavigationError =
|
|
46
|
+
(depth: number) =>
|
|
47
|
+
(
|
|
48
|
+
error: NavigationError | Cause.NoSuchElementException,
|
|
49
|
+
): Effect.Effect<never, never, Destination> =>
|
|
50
|
+
Effect.gen(function* ($) {
|
|
51
|
+
if (depth >= 50) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'Too many redirects. You may have an infinite loop of onNavigation handlers that are redirecting.',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
switch (error._tag) {
|
|
58
|
+
case 'NoSuchElementException':
|
|
59
|
+
case 'CancelNavigation':
|
|
60
|
+
return yield* $(model.currentEntry.get)
|
|
61
|
+
case 'RedirectNavigation':
|
|
62
|
+
return yield* $(
|
|
63
|
+
Effect.catchAll(
|
|
64
|
+
intent.navigate(error.url, error),
|
|
65
|
+
handleNavigationError(depth + 1),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const catchNavigationError = <R, A>(
|
|
72
|
+
effect: Effect.Effect<R, NavigationError | Cause.NoSuchElementException, A>,
|
|
73
|
+
) => Effect.catchAll(effect, handleNavigationError(0))
|
|
74
|
+
|
|
75
|
+
// Construct our service
|
|
76
|
+
const navigation: Navigation = {
|
|
77
|
+
back: lock(catchNavigationError(intent.back)),
|
|
78
|
+
base: '/',
|
|
79
|
+
canGoBack: model.canGoBack,
|
|
80
|
+
canGoForward: model.canGoForward,
|
|
81
|
+
currentEntry: model.currentEntry,
|
|
82
|
+
entries: model.entries,
|
|
83
|
+
forward: lock(catchNavigationError(intent.forward)),
|
|
84
|
+
goTo: (n) =>
|
|
85
|
+
pipe(
|
|
86
|
+
n,
|
|
87
|
+
intent.goTo,
|
|
88
|
+
Effect.catchAll((a) => pipe(a, handleNavigationError(0), Effect.map(Option.some))),
|
|
89
|
+
lock,
|
|
90
|
+
),
|
|
91
|
+
navigate: (url, options) => pipe( intent.navigate(url, options), catchNavigationError, lock),
|
|
92
|
+
onNavigation: (handler, options) =>
|
|
93
|
+
pipe(intent.onNavigation(handler, options), catchNavigationError, Effect.asUnit),
|
|
94
|
+
onNavigationEnd: (handler, options) =>
|
|
95
|
+
Effect.asUnit(intent.onNavigationEnd(handler, options)),
|
|
96
|
+
reload: lock(catchNavigationError(intent.reload)),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return navigation
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import * as Brand from '@effect/data/Brand'
|
|
2
|
+
import { Option } from '@effect/data/Option'
|
|
3
|
+
import * as Cause from '@effect/io/Cause'
|
|
4
|
+
import * as Effect from '@effect/io/Effect'
|
|
5
|
+
import * as Scope from '@effect/io/Scope'
|
|
6
|
+
import * as Context from '@typed/context'
|
|
7
|
+
import * as Fx from '@typed/fx'
|
|
8
|
+
|
|
9
|
+
export interface Navigation {
|
|
10
|
+
/**
|
|
11
|
+
* Base path for all navigation entries.
|
|
12
|
+
*/
|
|
13
|
+
readonly base: string
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The list of navigation entries that are currently kept in-memory and
|
|
17
|
+
* saved within Local/Session Storage.
|
|
18
|
+
*/
|
|
19
|
+
readonly entries: Fx.Computed<never, never, readonly Destination[]>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The currently focused navigation entry.
|
|
23
|
+
*/
|
|
24
|
+
readonly currentEntry: Fx.Computed<never, never, Destination>
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Navigate to a new URL. NavigateOptions can be used to control how the
|
|
28
|
+
* navigation is handled via history.pushState or history.replaceState,
|
|
29
|
+
* set/update the state of the navigation entry, or provide a key to use
|
|
30
|
+
* for the navigation entry.
|
|
31
|
+
*/
|
|
32
|
+
readonly navigate: (
|
|
33
|
+
url: string,
|
|
34
|
+
options?: NavigateOptions,
|
|
35
|
+
) => Effect.Effect<never, never, Destination>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Subscribe to navigation events. Any handler can cancel the or redirect
|
|
39
|
+
* the navigation by failing with a CancelNavigation or RedirectNavigation
|
|
40
|
+
* error.
|
|
41
|
+
*/
|
|
42
|
+
readonly onNavigation: <R>(
|
|
43
|
+
handler: (event: NavigationEvent) => Effect.Effect<R, NavigationError, void>,
|
|
44
|
+
options?: OnNavigationOptions,
|
|
45
|
+
) => Effect.Effect<R | Scope.Scope, never, void>
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Subscribe to navigation events after they have been commited.
|
|
49
|
+
*/
|
|
50
|
+
readonly onNavigationEnd: <R>(
|
|
51
|
+
handler: (event: NavigationEvent) => Effect.Effect<R, never, void>,
|
|
52
|
+
options?: OnNavigationOptions,
|
|
53
|
+
) => Effect.Effect<R | Scope.Scope, never, void>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns true if there is a previous navigation entry to navigate to.
|
|
57
|
+
*/
|
|
58
|
+
readonly canGoBack: Fx.Computed<never, never, boolean>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Navigate to the previous navigation entry. If you're on the first entry
|
|
62
|
+
* then this will do nothing.
|
|
63
|
+
*/
|
|
64
|
+
readonly back: Effect.Effect<never, never, Destination>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns true if there is a next navigation entry to navigate to after you have gone back.
|
|
68
|
+
*/
|
|
69
|
+
readonly canGoForward: Fx.Computed<never, never, boolean>
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Navigate to the next navigation entry. If you're on the last entry then
|
|
73
|
+
* this will do nothing.
|
|
74
|
+
*/
|
|
75
|
+
readonly forward: Effect.Effect<never, never, Destination>
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reload the current navigation entry.
|
|
79
|
+
*/
|
|
80
|
+
readonly reload: Effect.Effect<never, never, Destination>
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Navigate to a specific navigation entry by key. If the key does not
|
|
84
|
+
* exist then this will do nothing visible to the user and return Option.none().
|
|
85
|
+
*/
|
|
86
|
+
readonly goTo: (key: DestinationKey) => Effect.Effect<never, never, Option<Destination>>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const Navigation = Context.Tag<Navigation>('Navigation')
|
|
90
|
+
|
|
91
|
+
export const navigate = (url: string, options?: NavigateOptions) =>
|
|
92
|
+
Navigation.withEffect((n) => n.navigate(url, options))
|
|
93
|
+
|
|
94
|
+
export const onNavigation = <R>(
|
|
95
|
+
handler: (event: NavigationEvent) => Effect.Effect<R, never, void>,
|
|
96
|
+
) => Navigation.withEffect((n) => n.onNavigation(handler))
|
|
97
|
+
|
|
98
|
+
export const canGoBack: Effect.Effect<Navigation, Cause.NoSuchElementException, boolean> &
|
|
99
|
+
Fx.Fx<Navigation, never, boolean> = Object.assign(
|
|
100
|
+
Navigation.withEffect((n) => n.canGoBack),
|
|
101
|
+
Navigation.withFx((n) => n.canGoBack),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
export const back = Navigation.withEffect((n) => n.back)
|
|
105
|
+
|
|
106
|
+
export const canGoForward: Effect.Effect<Navigation, Cause.NoSuchElementException, boolean> &
|
|
107
|
+
Fx.Fx<Navigation, never, boolean> = Object.assign(
|
|
108
|
+
Navigation.withEffect((n) => n.canGoForward),
|
|
109
|
+
Navigation.withFx((n) => n.canGoForward),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
export const forward = Navigation.withEffect((n) => n.forward)
|
|
113
|
+
|
|
114
|
+
export const reload = Navigation.withEffect((n) => n.reload)
|
|
115
|
+
|
|
116
|
+
export interface NavigateOptions {
|
|
117
|
+
// State to save to history
|
|
118
|
+
readonly state?: unknown
|
|
119
|
+
// History type
|
|
120
|
+
readonly history?: 'push' | 'replace'
|
|
121
|
+
// Key to use for Navigation entry
|
|
122
|
+
readonly key?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface NavigationEvent {
|
|
126
|
+
readonly destination: Destination
|
|
127
|
+
readonly hashChange: boolean
|
|
128
|
+
readonly navigationType: NavigationType
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function NavigationEvent(
|
|
132
|
+
destination: Destination,
|
|
133
|
+
hashChange: boolean,
|
|
134
|
+
navigationType: NavigationType,
|
|
135
|
+
): NavigationEvent {
|
|
136
|
+
return { destination, hashChange, navigationType }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface Destination {
|
|
140
|
+
readonly key: DestinationKey
|
|
141
|
+
readonly url: URL
|
|
142
|
+
readonly state: unknown
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type DestinationKey = string & Brand.Brand<'DestinationKey'>
|
|
146
|
+
export const DestinationKey = Brand.nominal<DestinationKey>()
|
|
147
|
+
|
|
148
|
+
export function Destination(key: DestinationKey, url: URL, state?: unknown): Destination {
|
|
149
|
+
return { key, url, state }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export enum NavigationType {
|
|
153
|
+
Push = 'push',
|
|
154
|
+
Reload = 'reload',
|
|
155
|
+
Replace = 'replace',
|
|
156
|
+
Back = 'back',
|
|
157
|
+
Forward = 'forward',
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type NavigationError = CancelNavigation | RedirectNavigation
|
|
161
|
+
|
|
162
|
+
export interface CancelNavigation {
|
|
163
|
+
readonly _tag: 'CancelNavigation'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const cancelNavigation = Effect.fail<CancelNavigation>({ _tag: 'CancelNavigation' })
|
|
167
|
+
|
|
168
|
+
export function isCancelNavigation(error: NavigationError): error is CancelNavigation {
|
|
169
|
+
return error._tag === 'CancelNavigation'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RedirectNavigation extends NavigateOptions {
|
|
173
|
+
readonly _tag: 'RedirectNavigation'
|
|
174
|
+
readonly url: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const redirect = (
|
|
178
|
+
url: string,
|
|
179
|
+
options: NavigateOptions = {},
|
|
180
|
+
): Effect.Effect<never, RedirectNavigation, never> =>
|
|
181
|
+
Effect.fail({ _tag: 'RedirectNavigation', url, ...options })
|
|
182
|
+
|
|
183
|
+
export function isRedirectNavigation(error: NavigationError): error is RedirectNavigation {
|
|
184
|
+
return error._tag === 'RedirectNavigation'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface OnNavigationOptions {
|
|
188
|
+
readonly passive?: boolean
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const getCurrentUrl: Effect.Effect<Navigation, Cause.NoSuchElementException, URL> =
|
|
192
|
+
Navigation.withEffect((n) => n.currentEntry.map((d) => d.url))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Window, GlobalThis } from '@typed/dom'
|
|
2
|
+
import * as happyDom from 'happy-dom'
|
|
3
|
+
|
|
4
|
+
export interface ServerWindowOptions {
|
|
5
|
+
readonly url: string
|
|
6
|
+
|
|
7
|
+
readonly innerWidth?: number
|
|
8
|
+
readonly innerHeight?: number
|
|
9
|
+
readonly settings?: {
|
|
10
|
+
readonly disableJavaScriptEvaluation: boolean
|
|
11
|
+
readonly disableJavaScriptFileLoading: boolean
|
|
12
|
+
readonly disableCSSFileLoading: boolean
|
|
13
|
+
readonly enableFileSystemHttpRequests: boolean
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makeServerWindow(
|
|
18
|
+
options?: ServerWindowOptions,
|
|
19
|
+
): Window & GlobalThis & Pick<InstanceType<typeof happyDom.Window>, 'happyDOM'> {
|
|
20
|
+
const win: Window & GlobalThis & Pick<InstanceType<typeof happyDom.Window>, 'happyDOM'> =
|
|
21
|
+
new happyDom.Window({
|
|
22
|
+
...options,
|
|
23
|
+
}) as any
|
|
24
|
+
|
|
25
|
+
return win
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const html5Doctype = '<!DOCTYPE html>'
|
package/src/constant.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Option } from '@effect/data/Option'
|
|
2
|
+
import * as Cause from '@effect/io/Cause'
|
|
3
|
+
import * as Effect from '@effect/io/Effect'
|
|
4
|
+
import { History, Location } from '@typed/dom'
|
|
5
|
+
|
|
6
|
+
import type { DomNavigationOptions } from './DOM.js'
|
|
7
|
+
import {
|
|
8
|
+
Destination,
|
|
9
|
+
NavigateOptions,
|
|
10
|
+
NavigationError,
|
|
11
|
+
NavigationEvent,
|
|
12
|
+
NavigationType,
|
|
13
|
+
} from './Navigation.js'
|
|
14
|
+
import { ServiceId } from './constant.js'
|
|
15
|
+
import { encodeEvent } from './json.js'
|
|
16
|
+
import { Model } from './model.js'
|
|
17
|
+
import {
|
|
18
|
+
Notify,
|
|
19
|
+
NotifyEnd,
|
|
20
|
+
Save,
|
|
21
|
+
makeGoTo,
|
|
22
|
+
makeNotify,
|
|
23
|
+
makeNotifyEnd,
|
|
24
|
+
makeOnNavigation,
|
|
25
|
+
makeOnNavigationEnd,
|
|
26
|
+
} from './shared-intent.js'
|
|
27
|
+
import { saveToStorage } from './storage.js'
|
|
28
|
+
import { createKey, getUrl } from './util.js'
|
|
29
|
+
|
|
30
|
+
// Roughly the number of History entries in a browser anyways
|
|
31
|
+
const DEFAULT_MAX_ENTRIES = 50
|
|
32
|
+
|
|
33
|
+
export type DomIntent = {
|
|
34
|
+
readonly back: (skipHistory: boolean) => ReturnType<ReturnType<typeof makeGo>>
|
|
35
|
+
|
|
36
|
+
readonly forward: (skipHistory: boolean) => ReturnType<ReturnType<typeof makeGo>>
|
|
37
|
+
|
|
38
|
+
readonly push: ReturnType<typeof makePush>
|
|
39
|
+
|
|
40
|
+
readonly replace: ReturnType<typeof makeReplace>
|
|
41
|
+
|
|
42
|
+
readonly navigate: (
|
|
43
|
+
url: string,
|
|
44
|
+
options?: NavigateOptions,
|
|
45
|
+
) => ReturnType<ReturnType<typeof makePush | typeof makeReplace>>
|
|
46
|
+
|
|
47
|
+
readonly notify: Notify
|
|
48
|
+
|
|
49
|
+
readonly go: ReturnType<typeof makeGo>
|
|
50
|
+
|
|
51
|
+
readonly goTo: (
|
|
52
|
+
key: string,
|
|
53
|
+
) => Effect.Effect<
|
|
54
|
+
Storage | History,
|
|
55
|
+
Cause.NoSuchElementException | NavigationError,
|
|
56
|
+
Option<Destination>
|
|
57
|
+
>
|
|
58
|
+
|
|
59
|
+
readonly reload: ReturnType<typeof makeReload>
|
|
60
|
+
|
|
61
|
+
readonly onNavigation: ReturnType<typeof makeOnNavigation>
|
|
62
|
+
|
|
63
|
+
readonly onNavigationEnd: ReturnType<typeof makeOnNavigationEnd>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const makeIntent = (
|
|
67
|
+
model: Model,
|
|
68
|
+
base: string,
|
|
69
|
+
options: DomNavigationOptions,
|
|
70
|
+
): DomIntent => {
|
|
71
|
+
const maxEntries = Math.abs(options.maxEntries ?? DEFAULT_MAX_ENTRIES)
|
|
72
|
+
const notify = makeNotify(model)
|
|
73
|
+
const notifyEnd = makeNotifyEnd(model)
|
|
74
|
+
const save = makeSave(model)
|
|
75
|
+
const go = makeGo(model, notify, notifyEnd, save)
|
|
76
|
+
const replace = makeReplace(model, notify, notifyEnd, save, base)
|
|
77
|
+
const push = makePush(model, notify, notifyEnd, save, base, maxEntries)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
back: (skipHistory: boolean) => go(-1, skipHistory),
|
|
81
|
+
forward: (skipHistory: boolean) => go(1, skipHistory),
|
|
82
|
+
push,
|
|
83
|
+
replace,
|
|
84
|
+
navigate: (url: string, options: NavigateOptions = {}) =>
|
|
85
|
+
options.history === 'replace' ? replace(url, options) : push(url, options),
|
|
86
|
+
notify,
|
|
87
|
+
go: go,
|
|
88
|
+
goTo: makeGoTo(model, go),
|
|
89
|
+
reload: makeReload(model, notify, save),
|
|
90
|
+
onNavigation: makeOnNavigation(model),
|
|
91
|
+
onNavigationEnd: makeOnNavigationEnd(model),
|
|
92
|
+
} as const
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type Intent = ReturnType<typeof makeIntent>
|
|
96
|
+
|
|
97
|
+
export const makeSave =
|
|
98
|
+
(model: Model) =>
|
|
99
|
+
(event: NavigationEvent): Effect.Effect<Storage, Cause.NoSuchElementException, void> =>
|
|
100
|
+
Effect.gen(function* ($) {
|
|
101
|
+
const events = yield* $(model.events)
|
|
102
|
+
const index = yield* $(model.index)
|
|
103
|
+
|
|
104
|
+
// Save to storage
|
|
105
|
+
yield* $(saveToStorage(events, index))
|
|
106
|
+
|
|
107
|
+
// Update current entry
|
|
108
|
+
yield* $(model.currentEntry.set(event.destination))
|
|
109
|
+
|
|
110
|
+
// Update canGoBack
|
|
111
|
+
yield* $(model.canGoBack.set(index > 0))
|
|
112
|
+
|
|
113
|
+
// Update canGoForward
|
|
114
|
+
yield* $(model.canGoForward.set(index < events.length - 1))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export const makeReload = (model: Model, notify: Notify, save: Save<Storage>) =>
|
|
118
|
+
Effect.gen(function* ($) {
|
|
119
|
+
const i = yield* $(model.index.get)
|
|
120
|
+
const e = yield* $(model.events)
|
|
121
|
+
const event = e[i]
|
|
122
|
+
const reloadEvent = { ...event, navigationType: NavigationType.Reload }
|
|
123
|
+
|
|
124
|
+
yield* $(notify(reloadEvent))
|
|
125
|
+
yield* $(save(reloadEvent))
|
|
126
|
+
|
|
127
|
+
const location = yield* $(Location)
|
|
128
|
+
|
|
129
|
+
location.reload()
|
|
130
|
+
|
|
131
|
+
return event.destination
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export const makeReplace =
|
|
135
|
+
(model: Model, notify: Notify, notifyEnd: NotifyEnd, save: Save<Storage>, base: string) =>
|
|
136
|
+
(url: string, options: NavigateOptions = {}, skipHistory = false) =>
|
|
137
|
+
Effect.gen(function* ($) {
|
|
138
|
+
const location = yield* $(Location)
|
|
139
|
+
const entry = yield* $(model.currentEntry.get)
|
|
140
|
+
const destination: Destination = {
|
|
141
|
+
key: entry.key,
|
|
142
|
+
url: getUrl(url, base, location.origin),
|
|
143
|
+
state: options.state,
|
|
144
|
+
}
|
|
145
|
+
const event: NavigationEvent = {
|
|
146
|
+
destination,
|
|
147
|
+
hashChange: entry.url.hash !== destination.url.hash,
|
|
148
|
+
navigationType: NavigationType.Replace,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
yield* $(notify(event))
|
|
152
|
+
|
|
153
|
+
if (!skipHistory) {
|
|
154
|
+
const history = yield* $(History)
|
|
155
|
+
|
|
156
|
+
history.replaceState.call(
|
|
157
|
+
ServiceId,
|
|
158
|
+
{ state: options.state, event: encodeEvent(event) },
|
|
159
|
+
'',
|
|
160
|
+
url,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const currentIndex = yield* $(model.index)
|
|
165
|
+
|
|
166
|
+
yield* $(
|
|
167
|
+
model.events.update((entries) => {
|
|
168
|
+
const updated = entries.slice(0)
|
|
169
|
+
updated[currentIndex] = event
|
|
170
|
+
return updated
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
yield* $(save(event))
|
|
175
|
+
yield* $(notifyEnd(event))
|
|
176
|
+
|
|
177
|
+
return destination
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
export const makePush =
|
|
181
|
+
(
|
|
182
|
+
model: Model,
|
|
183
|
+
notify: Notify,
|
|
184
|
+
notifyEnd: NotifyEnd,
|
|
185
|
+
save: Save<Storage>,
|
|
186
|
+
base: string,
|
|
187
|
+
maxEntries: number,
|
|
188
|
+
) =>
|
|
189
|
+
(url: string, options: NavigateOptions = {}, skipHistory = false) =>
|
|
190
|
+
Effect.gen(function* ($) {
|
|
191
|
+
const location = yield* $(Location)
|
|
192
|
+
const entry = yield* $(model.currentEntry.get)
|
|
193
|
+
const destination: Destination = {
|
|
194
|
+
key: yield* $(createKey),
|
|
195
|
+
url: getUrl(url, base, location.origin),
|
|
196
|
+
state: options.state,
|
|
197
|
+
}
|
|
198
|
+
const event: NavigationEvent = {
|
|
199
|
+
destination,
|
|
200
|
+
hashChange: entry.url.hash !== destination.url.hash,
|
|
201
|
+
navigationType: NavigationType.Push,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Notify event handlers
|
|
205
|
+
yield* $(notify(event))
|
|
206
|
+
|
|
207
|
+
if (!skipHistory) {
|
|
208
|
+
const history = yield* $(History)
|
|
209
|
+
|
|
210
|
+
history.pushState.call(
|
|
211
|
+
ServiceId,
|
|
212
|
+
{ state: options.state, event: encodeEvent(event) },
|
|
213
|
+
'',
|
|
214
|
+
url,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const currentIndex = yield* $(model.index)
|
|
219
|
+
|
|
220
|
+
// Remove all entries after the current index
|
|
221
|
+
// and add the new destination to the end
|
|
222
|
+
yield* $(
|
|
223
|
+
model.events.update((entries) => {
|
|
224
|
+
const updated = entries.slice(0, currentIndex + 1)
|
|
225
|
+
updated.push(event)
|
|
226
|
+
return updated.slice(-maxEntries)
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
// Update the index to the new destination
|
|
231
|
+
yield* $(model.index.update((i) => i + 1))
|
|
232
|
+
|
|
233
|
+
yield* $(save(event))
|
|
234
|
+
yield* $(notifyEnd(event))
|
|
235
|
+
|
|
236
|
+
return destination
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
export const makeGo =
|
|
240
|
+
(model: Model, notify: Notify, notifyEnd: NotifyEnd, save: Save<Storage>) =>
|
|
241
|
+
(delta: number, skipHistory = false) =>
|
|
242
|
+
Effect.gen(function* ($) {
|
|
243
|
+
const currentEntries = yield* $(model.events)
|
|
244
|
+
const totalEntries = currentEntries.length
|
|
245
|
+
const currentIndex = yield* $(model.index)
|
|
246
|
+
|
|
247
|
+
// Nothing to do here
|
|
248
|
+
if (delta === 0) return currentEntries[currentIndex].destination
|
|
249
|
+
|
|
250
|
+
const nextIndex =
|
|
251
|
+
delta > 0
|
|
252
|
+
? Math.min(currentIndex + delta, totalEntries - 1)
|
|
253
|
+
: Math.max(currentIndex + delta, 0)
|
|
254
|
+
const nextEntry = currentEntries[nextIndex]
|
|
255
|
+
|
|
256
|
+
yield* $(
|
|
257
|
+
notify({
|
|
258
|
+
...nextEntry,
|
|
259
|
+
navigationType: nextIndex > currentIndex ? NavigationType.Forward : NavigationType.Back,
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if (!skipHistory) {
|
|
264
|
+
const history = yield* $(History)
|
|
265
|
+
|
|
266
|
+
history.go.call(ServiceId, delta)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
yield* $(model.index.set(nextIndex))
|
|
270
|
+
|
|
271
|
+
yield* $(save(nextEntry))
|
|
272
|
+
|
|
273
|
+
yield* $(notifyEnd(nextEntry))
|
|
274
|
+
|
|
275
|
+
return nextEntry.destination
|
|
276
|
+
})
|