-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.tsx
266 lines (221 loc) · 7.37 KB
/
index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
import { type Plugin, state } from 'epic-state'
import { createBrowserHistory, createMemoryHistory } from 'history'
import { create } from 'logua'
import queryString from 'query-string'
import type React from 'react'
import join from 'url-join'
import type { NavigateListener, PageComponent, Pages, Parameters, RouterState } from './types'
export const log = create('epic-router', 'yellow')
let router: RouterState<Parameters> = {} as RouterState<Parameters>
const pages: Pages = {}
const createHistory = () => {
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'test') {
return createBrowserHistory()
}
// No URL for ReactNative and Testing (happy-dom doesn't work otherwise).
return createMemoryHistory()
}
export const history = createHistory()
export const getRouter = () => router
export type WithRouter<T extends object> = {
router: { route: string; parameters: T }
}
const navigateListeners: NavigateListener[] = []
export function onNavigate(listener: NavigateListener) {
navigateListeners.push(listener)
}
function notifyNavigateListeners(initial = false) {
for (const listener of navigateListeners) {
listener(router.route, router.parameters, initial)
}
}
const removeSlashRegex = /^\/*/
const removeLeadingSlash = (path: string) => path.replace(removeSlashRegex, '')
function Code({ children }: { children: string | string[] }) {
return (
<span
style={{
fontFamily: 'monospace',
color: 'initial',
background: 'lightgray',
borderRadius: 5,
paddingLeft: 2,
paddingRight: 2,
paddingBottom: 1,
}}
>
{children}
</span>
)
}
function ErrorPage(message: React.JSX.Element): PageComponent {
return () => <div style={{ color: 'red', fontWeight: 'bold' }}>{message}</div>
}
function pathnameToRoute(location = history.location) {
let name = location.pathname
const publicUrl = removeLeadingSlash((typeof process !== 'undefined' && process.env.PUBLIC_URL) || '')
name = removeLeadingSlash(name) // Cleanup slash.
if (publicUrl) {
// Cleanup public url part.
name = removeLeadingSlash(name.replace(publicUrl, ''))
}
if (name === '' && router && router.initialRoute) {
return router.initialRoute
}
return name !== '' ? name : undefined
}
function getSearchParameters<T extends Parameters>(location = history.location) {
const { search } = location
if (!search || search.length === 0) {
return {} as T
}
return queryString.parse(search) as T
}
function writePath(route: string) {
const publicUrl = removeLeadingSlash(process.env.PUBLIC_URL ?? '')
if (route === getHomeRoute()) {
// biome-ignore lint/style/noParameterAssign: Much easier in this case.
route = '/'
}
if (publicUrl) {
return join('/', publicUrl, route)
}
// join will not work properly in this case.
if (route === '') {
return '/'
}
return join('/', route)
}
const getInitialRoute = () => (router.initialRoute || Object.keys(pages)[0]) ?? ''
const getHomeRoute = () => (router.homeRoute || Object.keys(pages)[0]) ?? ''
export function configure<T extends Parameters>(initialRoute?: string, homeRoute?: string, initialParameters?: T, connect?: Plugin) {
router = state<RouterState<T>>({
// Configuration.
initialRoute, // First rendered if URL empty.
homeRoute: homeRoute ?? initialRoute, // Home route where URL === '/'.
// State
route: pathnameToRoute() ?? initialRoute ?? '',
parameters: initialParameters ?? getSearchParameters<T>(),
// Plugins, connect state to React.
plugin: connect,
// Retrieve current state from history, was private.
listener({ location }) {
const route = pathnameToRoute(location) ?? getInitialRoute()
const parameters = Object.assign(getSearchParameters(location), location.state ?? {})
if (router.parameters !== parameters) {
// TODO implement deep object compare in epic-jsx.
// router.parameters = Object.assign(getSearchParameters(location), location.state ?? {})
}
if (router.route !== route) {
router.route = route
}
},
// Derivations
get page() {
if (process.env.NODE_ENV !== 'production' && !getInitialRoute()) {
return ErrorPage(
<span>
No <Code>pages</Code> or <Code>initialRoute</Code> configured, configure with <Code>router.setPages(pages, initialRoute)</Code>.
</span>,
)
}
if (router.route === '') {
return pages[getInitialRoute()] as PageComponent
}
if (!pages[router.route]) {
const userErrorPage = pages['404']
if (typeof userErrorPage !== 'undefined') {
return pages['404'] as PageComponent
}
return ErrorPage(
process.env.NODE_ENV === 'production' ? (
<span>Page not found!</span>
) : (
<span>
Route <Code>/{router.route}</Code> has no associated page!
</span>
),
)
}
return pages[router.route] as PageComponent
},
})
const removeListener = history.listen(router.listener)
setTimeout(() => {
notifyNavigateListeners(true)
}, 0)
return { router: router as RouterState<T>, removeListener }
}
export function addPage(name: string, markup: PageComponent) {
if (!name || typeof name !== 'string') {
log('Invalid page name provided to addPage(name: string, markup: JSX).', 'warning')
return
}
pages[name] = markup
// Use the first page as the initial route if not provided.
if (!router.initialRoute) {
router.initialRoute = name
}
}
export function go(route: string, parameters: Parameters = {}, historyState: object = {}, replace = false) {
router.route = route
router.parameters = parameters
const hasParameters = Object.keys(parameters).length
const searchParameters = hasParameters ? `?${queryString.stringify(parameters)}` : ''
if (route === router.initialRoute && !hasParameters) {
// biome-ignore lint/style/noParameterAssign: Existing logic, might be improved.
route = ''
}
const historyAction = replace ? history.replace : history.push
// WORKAROUND https://github.com/ReactTraining/history/issues/814
historyAction(
{
hash: '',
search: searchParameters,
pathname: writePath(route),
},
historyState,
)
notifyNavigateListeners()
}
export function back() {
history.back()
notifyNavigateListeners()
}
export function forward() {
history.forward()
notifyNavigateListeners()
}
// <a href="/" onClick={click('overview')}>Homepage</a>
export function click(route: string, parameters?: Parameters) {
return ((event) => {
event.preventDefault()
go(route, parameters)
}) as React.MouseEventHandler<HTMLAnchorElement>
}
export function initial() {
router.route = getInitialRoute()
history.push(writePath(router.route))
notifyNavigateListeners()
}
export function reset() {
for (const key of Object.keys(pages)) {
delete pages[key]
}
router.initialRoute = ''
router.route = ''
}
export function route() {
return router.route
}
export function parameters() {
return router.parameters
}
export function Page(props: React.ComponentPropsWithoutRef<'div'>) {
const Page = router.page
if (typeof Page !== 'function') {
return Page as React.ReactElement
}
// biome-ignore lint/suspicious/noExplicitAny: Need to convert epic-jsx JSX to a namespace.
return (<Page {...props} router={router} />) as any
}