safa-router 1.0.1
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/LICENSE +21 -0
- package/README.md +587 -0
- package/package.json +43 -0
- package/src/HistoryManager.js +95 -0
- package/src/Link.js +97 -0
- package/src/MiddlewareChain.js +47 -0
- package/src/RouteMatcher.js +156 -0
- package/src/RouteTree.js +187 -0
- package/src/SafaRouter.js +464 -0
- package/src/constants.js +47 -0
- package/src/errors.js +46 -0
- package/src/index.js +16 -0
- package/src/link-helper.js +24 -0
- package/src/utils.js +122 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { RouteMatcher } from './RouteMatcher.js'
|
|
2
|
+
import { RouteTree } from './RouteTree.js'
|
|
3
|
+
import { HistoryManager } from './HistoryManager.js'
|
|
4
|
+
import { MiddlewareChain } from './MiddlewareChain.js'
|
|
5
|
+
import { Link } from './Link.js'
|
|
6
|
+
import { normalizePath, parseQuery, emit, createURL, isExternalURL } from './utils.js'
|
|
7
|
+
import { EVENTS, DEFAULT_CONFIG } from './constants.js'
|
|
8
|
+
import { RouteLoadError, SafaError } from './errors.js'
|
|
9
|
+
|
|
10
|
+
export class SafaRouter {
|
|
11
|
+
static version = '1.0.0'
|
|
12
|
+
static VERSION = '1.0.0'
|
|
13
|
+
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.config = { ...DEFAULT_CONFIG, ...options }
|
|
16
|
+
if (this.config.pageDir && !this.config.pagesDir) {
|
|
17
|
+
this.config.pagesDir = this.config.pageDir
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this._events = {}
|
|
21
|
+
for (const key of Object.values(EVENTS)) this._events[key] = []
|
|
22
|
+
|
|
23
|
+
this._matcher = new RouteMatcher()
|
|
24
|
+
this._history = new HistoryManager({
|
|
25
|
+
useHash: this.config.useHash,
|
|
26
|
+
basePath: this.config.basePath,
|
|
27
|
+
})
|
|
28
|
+
this._middleware = new MiddlewareChain()
|
|
29
|
+
this._cache = new Map()
|
|
30
|
+
|
|
31
|
+
this._pathname = '/'
|
|
32
|
+
this._params = {}
|
|
33
|
+
this._query = {}
|
|
34
|
+
this._routeData = null
|
|
35
|
+
this._isLoading = false
|
|
36
|
+
this._started = false
|
|
37
|
+
this._targetEl = null
|
|
38
|
+
|
|
39
|
+
this._globalNotFound = this.config.notFound || null
|
|
40
|
+
this._globalError = this.config.error || null
|
|
41
|
+
this._globalLayout = this.config.layout || null
|
|
42
|
+
|
|
43
|
+
this._boundNav = this._onHistoryChange.bind(this)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(target) {
|
|
47
|
+
if (this._started) return
|
|
48
|
+
if (target) this.config.target = target
|
|
49
|
+
|
|
50
|
+
this._targetEl =
|
|
51
|
+
typeof this.config.target === 'string'
|
|
52
|
+
? document.querySelector(this.config.target)
|
|
53
|
+
: this.config.target
|
|
54
|
+
|
|
55
|
+
if (!this._targetEl) {
|
|
56
|
+
throw new SafaError(
|
|
57
|
+
`Target element "${this.config.target}" not found`,
|
|
58
|
+
'INIT_ERROR'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this._routeTree = new RouteTree(this.config.routes || {})
|
|
63
|
+
this._seedMatcher()
|
|
64
|
+
this._history.init()
|
|
65
|
+
this._unsubHistory = this._history.onChange(this._boundNav)
|
|
66
|
+
await this._resolve(this._history.path, 'replace')
|
|
67
|
+
emit(this._events, EVENTS.READY, { pathname: this._pathname })
|
|
68
|
+
this._started = true
|
|
69
|
+
return this
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isStarted() {
|
|
73
|
+
return this._started
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
destroy() {
|
|
77
|
+
this._started = false
|
|
78
|
+
if (this._unsubHistory) {
|
|
79
|
+
this._unsubHistory()
|
|
80
|
+
this._unsubHistory = null
|
|
81
|
+
}
|
|
82
|
+
this._history.destroy()
|
|
83
|
+
for (const k of Object.keys(this._events)) this._events[k] = []
|
|
84
|
+
this._cache.clear()
|
|
85
|
+
this._targetEl = null
|
|
86
|
+
emit(this._events, EVENTS.DESTROY, {})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async push(url, state = {}) {
|
|
90
|
+
if (isExternalURL(url)) {
|
|
91
|
+
window.location.href = url
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
const u = createURL(url) || new URL(url, location.origin)
|
|
95
|
+
await this._navigate(normalizePath(u.pathname), 'push', parseQuery(u.search), state)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async replace(url, state = {}) {
|
|
99
|
+
if (isExternalURL(url)) {
|
|
100
|
+
window.location.replace(url)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const u = createURL(url) || new URL(url, location.origin)
|
|
104
|
+
await this._navigate(normalizePath(u.pathname), 'replace', parseQuery(u.search), state)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
back() { this._history.back() }
|
|
108
|
+
forward() { this._history.forward() }
|
|
109
|
+
|
|
110
|
+
reload() {
|
|
111
|
+
this._resolve(this._pathname, 'replace')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
navigate(url) { return this.push(url) }
|
|
115
|
+
|
|
116
|
+
getConfig() { return { ...this.config } }
|
|
117
|
+
|
|
118
|
+
get pathname() { return this._pathname }
|
|
119
|
+
get params() { return { ...this._params } }
|
|
120
|
+
get query() { return { ...this._query } }
|
|
121
|
+
get loading() { return this._isLoading }
|
|
122
|
+
|
|
123
|
+
on(name, fn) {
|
|
124
|
+
if (!this._events[name]) this._events[name] = []
|
|
125
|
+
this._events[name].push(fn)
|
|
126
|
+
return () => {
|
|
127
|
+
this._events[name] = this._events[name].filter((f) => f !== fn)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
off(name, fn) {
|
|
132
|
+
if (!this._events[name]) return
|
|
133
|
+
this._events[name] = this._events[name].filter((f) => f !== fn)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
use(fn) { this._middleware.use(fn); return this }
|
|
137
|
+
|
|
138
|
+
onError(fn) { return this.on(EVENTS.ERROR, fn) }
|
|
139
|
+
onNotFound(fn) { return this.on(EVENTS.NOT_FOUND, fn) }
|
|
140
|
+
onRouteChange(fn) { return this.on(EVENTS.ROUTE_CHANGE, fn) }
|
|
141
|
+
onBeforeNavigate(fn) { return this.on(EVENTS.BEFORE_NAVIGATE, fn) }
|
|
142
|
+
|
|
143
|
+
createLink(config) {
|
|
144
|
+
return new Link({ ...config, router: this })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async prefetch(path) {
|
|
148
|
+
const normalized = normalizePath(path)
|
|
149
|
+
if (this._cache.has(normalized)) return
|
|
150
|
+
const page = await this._fetchPage(normalized)
|
|
151
|
+
if (page && this.config.cacheRoutes) {
|
|
152
|
+
this._cache.set(normalized, page)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
clearCache() { this._cache.clear() }
|
|
157
|
+
|
|
158
|
+
_seedMatcher() {
|
|
159
|
+
const walk = (routes, base) => {
|
|
160
|
+
if (!routes || typeof routes !== 'object') return
|
|
161
|
+
for (const [key, val] of Object.entries(routes)) {
|
|
162
|
+
const isGroup = key.startsWith('(') && key.endsWith(')')
|
|
163
|
+
const fp =
|
|
164
|
+
isGroup
|
|
165
|
+
? base
|
|
166
|
+
: base === '/'
|
|
167
|
+
? `/${key}`
|
|
168
|
+
: `${base}/${key}`
|
|
169
|
+
if (typeof val === 'object' && val !== null) {
|
|
170
|
+
if (val.page) this._matcher.add(fp)
|
|
171
|
+
if (val.children) walk(val.children, fp)
|
|
172
|
+
} else if (typeof val === 'function') {
|
|
173
|
+
this._matcher.add(fp)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
walk(this.config.routes || {}, '/')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_resolvePagePath(path) {
|
|
181
|
+
const dir = (this.config.pagesDir || '').replace(/\/+$/, '')
|
|
182
|
+
if (!dir) return null
|
|
183
|
+
const normal = normalizePath(path)
|
|
184
|
+
if (normal === '/') return `${dir}/index.html`
|
|
185
|
+
return `${dir}${normal}.html`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_hasRoutes() {
|
|
189
|
+
return this.config.routes && typeof this.config.routes === 'object' && Object.keys(this.config.routes).length > 0
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async _fetchPage(path) {
|
|
193
|
+
const htmlPath = this._resolvePagePath(path)
|
|
194
|
+
if (!htmlPath) return null
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(htmlPath)
|
|
197
|
+
if (!res.ok) return null
|
|
198
|
+
return res.text()
|
|
199
|
+
} catch {
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async _navigate(path, method, query = {}, state = {}) {
|
|
205
|
+
if (path === this._pathname) return
|
|
206
|
+
await this._resolve(path, method, query, state)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async _resolve(path, method, query = {}, state = {}) {
|
|
210
|
+
this._isLoading = true
|
|
211
|
+
emit(this._events, EVENTS.LOADING, { path, loading: true })
|
|
212
|
+
emit(this._events, EVENTS.BEFORE_NAVIGATE, { path, method })
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const ctx = { path, method, query, cancelled: false, redirect: null }
|
|
216
|
+
await this._middleware.run(ctx)
|
|
217
|
+
if (ctx.redirect) return this._navigate(ctx.redirect, 'replace')
|
|
218
|
+
if (ctx.cancelled) {
|
|
219
|
+
this._isLoading = false
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const routeMatch = this._hasRoutes() ? this._routeTree.resolve(path) : null
|
|
224
|
+
let pageContent, layoutFns = []
|
|
225
|
+
let resolvedPath = path
|
|
226
|
+
|
|
227
|
+
if (routeMatch) {
|
|
228
|
+
if (routeMatch.node.loading) {
|
|
229
|
+
const loadingFn = await this._loadComponent(routeMatch.node.loading)
|
|
230
|
+
if (loadingFn && this._targetEl) {
|
|
231
|
+
this._targetEl.innerHTML = typeof loadingFn === 'function'
|
|
232
|
+
? loadingFn({ path, router: this })
|
|
233
|
+
: loadingFn
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
pageContent = await this._loadComponent(routeMatch.node.page)
|
|
237
|
+
for (const l of routeMatch.layouts) {
|
|
238
|
+
if (!l) continue
|
|
239
|
+
const lfn = await this._loadComponent(l)
|
|
240
|
+
if (lfn) layoutFns.push(lfn)
|
|
241
|
+
}
|
|
242
|
+
if (this._globalLayout) {
|
|
243
|
+
const lfn = await this._loadComponent(this._globalLayout)
|
|
244
|
+
if (lfn) layoutFns.unshift(lfn)
|
|
245
|
+
}
|
|
246
|
+
this._routeData = routeMatch
|
|
247
|
+
} else if (this.config.pagesDir) {
|
|
248
|
+
pageContent = await this._fetchPage(path)
|
|
249
|
+
if (pageContent === null) {
|
|
250
|
+
await this._handleNotFound(path, method)
|
|
251
|
+
this._isLoading = false
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if (this._globalLayout) {
|
|
255
|
+
const lfn = await this._loadComponent(this._globalLayout)
|
|
256
|
+
if (lfn) layoutFns.push(lfn)
|
|
257
|
+
}
|
|
258
|
+
this._routeData = { node: { page: null }, params: {}, layouts: [] }
|
|
259
|
+
} else {
|
|
260
|
+
await this._handleNotFound(path, method)
|
|
261
|
+
this._isLoading = false
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (method === 'push') this._history.push(path, state)
|
|
266
|
+
else if (method === 'replace') this._history.replace(path, state)
|
|
267
|
+
|
|
268
|
+
this._pathname = path
|
|
269
|
+
this._params = routeMatch?.params || {}
|
|
270
|
+
this._query = query
|
|
271
|
+
this._isLoading = false
|
|
272
|
+
|
|
273
|
+
this._render(pageContent, layoutFns)
|
|
274
|
+
|
|
275
|
+
emit(this._events, EVENTS.ROUTE_CHANGE, {
|
|
276
|
+
pathname: path,
|
|
277
|
+
params: this._params,
|
|
278
|
+
query: this._query,
|
|
279
|
+
})
|
|
280
|
+
emit(this._events, EVENTS.AFTER_NAVIGATE, { pathname: path })
|
|
281
|
+
|
|
282
|
+
if (this.config.scrollToTop) {
|
|
283
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this._updateTitle()
|
|
287
|
+
this._focus()
|
|
288
|
+
} catch (err) {
|
|
289
|
+
this._isLoading = false
|
|
290
|
+
await this._handleError(path, err)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async _render(pageContent, layoutFns) {
|
|
295
|
+
if (!this._targetEl) return
|
|
296
|
+
|
|
297
|
+
const html = layoutFns.length > 0
|
|
298
|
+
? await this._renderWithLayouts(pageContent, layoutFns, 0)
|
|
299
|
+
: (typeof pageContent === 'function'
|
|
300
|
+
? pageContent({ params: this._params, query: this._query, router: this })
|
|
301
|
+
: (pageContent || ''))
|
|
302
|
+
|
|
303
|
+
const duration = this.config.transitionDuration
|
|
304
|
+
if (duration > 0) {
|
|
305
|
+
this._targetEl.style.opacity = '0'
|
|
306
|
+
this._targetEl.style.transition = `opacity ${duration}ms ease`
|
|
307
|
+
}
|
|
308
|
+
this._targetEl.innerHTML = html
|
|
309
|
+
this._bindLinks()
|
|
310
|
+
if (duration > 0) {
|
|
311
|
+
requestAnimationFrame(() => {
|
|
312
|
+
this._targetEl.style.opacity = '1'
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async _renderWithLayouts(pageContent, layoutFns, idx) {
|
|
318
|
+
if (idx >= layoutFns.length) {
|
|
319
|
+
if (typeof pageContent === 'function') {
|
|
320
|
+
return pageContent({ params: this._params, query: this._query, router: this })
|
|
321
|
+
}
|
|
322
|
+
return pageContent || ''
|
|
323
|
+
}
|
|
324
|
+
const layoutFn = layoutFns[idx]
|
|
325
|
+
const content = await this._renderWithLayouts(pageContent, layoutFns, idx + 1)
|
|
326
|
+
return layoutFn({ children: content, params: this._params, router: this })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_updateTitle() {
|
|
330
|
+
const template = this.config.titleTemplate
|
|
331
|
+
if (!template) return
|
|
332
|
+
const title = this._routeData?.node?.meta?.title ||
|
|
333
|
+
this._routeData?.node?.segment ||
|
|
334
|
+
this._pathname.replace(/[/-]/g, ' ').trim() ||
|
|
335
|
+
'Home'
|
|
336
|
+
document.title = template.replace('%s', title.charAt(0).toUpperCase() + title.slice(1))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
_focus() {
|
|
340
|
+
if (!this._targetEl) return
|
|
341
|
+
const h1 = this._targetEl.querySelector('h1')
|
|
342
|
+
if (h1) {
|
|
343
|
+
h1.setAttribute('tabindex', '-1')
|
|
344
|
+
h1.focus({ preventScroll: true })
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_bindLinks() {
|
|
349
|
+
if (!this._targetEl) return
|
|
350
|
+
const links = this._targetEl.querySelectorAll('[data-safa-link]')
|
|
351
|
+
for (const el of links) {
|
|
352
|
+
el.addEventListener('click', (e) => {
|
|
353
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
354
|
+
if (e.button !== 0) return
|
|
355
|
+
e.preventDefault()
|
|
356
|
+
const href = el.getAttribute('href')
|
|
357
|
+
if (href) this.push(href)
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async _loadComponent(mod) {
|
|
363
|
+
if (!mod) return null
|
|
364
|
+
try {
|
|
365
|
+
if (typeof mod === 'string') {
|
|
366
|
+
const res = await fetch(mod)
|
|
367
|
+
if (!res.ok) throw new Error(`Failed to load ${mod} (${res.status})`)
|
|
368
|
+
return res.text()
|
|
369
|
+
}
|
|
370
|
+
if (typeof mod === 'function') {
|
|
371
|
+
let result
|
|
372
|
+
try { result = mod() } catch { return mod }
|
|
373
|
+
if (result && typeof result.then === 'function') {
|
|
374
|
+
const resolved = await result
|
|
375
|
+
return resolved && resolved.default ? resolved.default : resolved
|
|
376
|
+
}
|
|
377
|
+
return result !== undefined ? result : mod
|
|
378
|
+
}
|
|
379
|
+
return mod
|
|
380
|
+
} catch (e) {
|
|
381
|
+
throw new RouteLoadError(
|
|
382
|
+
typeof mod === 'string' ? mod : (typeof mod === 'function' ? mod.name || 'anonymous' : 'module'),
|
|
383
|
+
e
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async _handleNotFound(path, method) {
|
|
389
|
+
emit(this._events, EVENTS.NOT_FOUND, { path })
|
|
390
|
+
|
|
391
|
+
const notFound = this._routeData?.node?.notFound || this._globalNotFound
|
|
392
|
+
if (notFound) {
|
|
393
|
+
try {
|
|
394
|
+
const fn = await this._loadComponent(notFound)
|
|
395
|
+
if (method === 'push') this._history.push(path)
|
|
396
|
+
else if (method === 'replace') this._history.replace(path)
|
|
397
|
+
this._pathname = path
|
|
398
|
+
const html = typeof fn === 'function' ? fn({ path, router: this }) : fn
|
|
399
|
+
if (this._targetEl) this._targetEl.innerHTML = html
|
|
400
|
+
return
|
|
401
|
+
} catch { /* fall through */ }
|
|
402
|
+
}
|
|
403
|
+
if (this._targetEl) this._targetEl.innerHTML = this._fallback404(path)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async _handleError(path, err) {
|
|
407
|
+
console.error('[SafaRouter]', err)
|
|
408
|
+
emit(this._events, EVENTS.ERROR, { path, error: err })
|
|
409
|
+
|
|
410
|
+
if (this._globalError) {
|
|
411
|
+
try {
|
|
412
|
+
const fn = await this._loadComponent(this._globalError)
|
|
413
|
+
const html = typeof fn === 'function' ? fn({ error: err, path, router: this }) : fn
|
|
414
|
+
if (this._targetEl) this._targetEl.innerHTML = html
|
|
415
|
+
return
|
|
416
|
+
} catch { /* fall through */ }
|
|
417
|
+
}
|
|
418
|
+
if (this._targetEl) {
|
|
419
|
+
try {
|
|
420
|
+
this._targetEl.innerHTML = this._fallbackError(err)
|
|
421
|
+
} catch {
|
|
422
|
+
this._targetEl.textContent = `Error: ${err.message}`
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_fallback404(path) {
|
|
428
|
+
const homeLink = `<a href="/" style="color:var(--color-accent);text-decoration:underline;">Back to home</a>`
|
|
429
|
+
return [
|
|
430
|
+
'<div class="safa-error safa-404" style="text-align:center;padding:3rem 0;">',
|
|
431
|
+
`<h1 style="font-size:4rem;font-weight:800;margin-bottom:0.5rem;">404</h1>`,
|
|
432
|
+
`<p style="margin-bottom:1rem;">Page not found: <code>${path}</code></p>`,
|
|
433
|
+
homeLink,
|
|
434
|
+
'</div>',
|
|
435
|
+
].join('')
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_fallbackError(err) {
|
|
439
|
+
const homeLink = `<a href="/" style="color:var(--color-accent);text-decoration:underline;">Back to home</a>`
|
|
440
|
+
return [
|
|
441
|
+
'<div class="safa-error" style="text-align:center;padding:3rem 0;">',
|
|
442
|
+
'<h1 style="font-size:2rem;font-weight:800;margin-bottom:0.5rem;">Something went wrong</h1>',
|
|
443
|
+
`<pre style="text-align:left;margin:1rem 0;">${err.message}</pre>`,
|
|
444
|
+
homeLink,
|
|
445
|
+
'</div>',
|
|
446
|
+
].join('')
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
get currentRoute() { return this._routeData }
|
|
450
|
+
get matchedRoute() { return this._matcher.match(this._pathname) }
|
|
451
|
+
|
|
452
|
+
getRoute(path) {
|
|
453
|
+
return this._routeTree.resolve(normalizePath(path))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_onHistoryChange({ path, action, state }) {
|
|
457
|
+
if (action === 'popstate') {
|
|
458
|
+
this._resolve(path, 'replace')
|
|
459
|
+
if (state && state._scrollY !== undefined && !this.config.scrollToTop) {
|
|
460
|
+
window.scrollTo(0, state._scrollY)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Navigation lifecycle event names */
|
|
2
|
+
export const EVENTS = {
|
|
3
|
+
BEFORE_NAVIGATE: 'beforenavigate',
|
|
4
|
+
NAVIGATE: 'navigate',
|
|
5
|
+
ROUTE_CHANGE: 'routechange',
|
|
6
|
+
AFTER_NAVIGATE: 'afternavigate',
|
|
7
|
+
ERROR: 'error',
|
|
8
|
+
NOT_FOUND: 'notfound',
|
|
9
|
+
LOADING: 'loading',
|
|
10
|
+
READY: 'ready',
|
|
11
|
+
DESTROY: 'destroy',
|
|
12
|
+
LINK_CLICK: 'linkclick',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SEGMENT_TYPES = {
|
|
16
|
+
STATIC: 'static',
|
|
17
|
+
DYNAMIC: 'dynamic',
|
|
18
|
+
CATCH_ALL: 'catch-all',
|
|
19
|
+
OPTIONAL_CATCH_ALL: 'optional-catch-all',
|
|
20
|
+
GROUP: 'group',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PARAM_PATTERNS = {
|
|
24
|
+
DYNAMIC: /^\[([^\]]+)\]$/,
|
|
25
|
+
CATCH_ALL: /^\[\.\.\.([^\]]+)\]$/,
|
|
26
|
+
OPTIONAL_CATCH_ALL: /^\[\[\.\.\.[^\]]+\]\]$/,
|
|
27
|
+
GROUP: /^\(([^)]+)\)$/,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PATTERN_SCORES = {
|
|
31
|
+
[SEGMENT_TYPES.STATIC]: 100,
|
|
32
|
+
[SEGMENT_TYPES.DYNAMIC]: 10,
|
|
33
|
+
[SEGMENT_TYPES.CATCH_ALL]: 1,
|
|
34
|
+
[SEGMENT_TYPES.OPTIONAL_CATCH_ALL]: 0,
|
|
35
|
+
[SEGMENT_TYPES.GROUP]: 0,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CONFIG = {
|
|
39
|
+
target: '#app',
|
|
40
|
+
basePath: '',
|
|
41
|
+
useHash: false,
|
|
42
|
+
scrollToTop: true,
|
|
43
|
+
prefetch: true,
|
|
44
|
+
cacheRoutes: true,
|
|
45
|
+
titleTemplate: '%s — SafaRouter',
|
|
46
|
+
transitionDuration: 0,
|
|
47
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class SafaError extends Error {
|
|
2
|
+
constructor(message, code = 'UNKNOWN') {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = 'SafaError'
|
|
5
|
+
this.code = code
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
toJSON() {
|
|
9
|
+
return { name: this.name, message: this.message, code: this.code }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RouteNotFoundError extends SafaError {
|
|
14
|
+
constructor(pathname) {
|
|
15
|
+
super(`No route matched: ${pathname}`, 'ROUTE_NOT_FOUND')
|
|
16
|
+
this.name = 'RouteNotFoundError'
|
|
17
|
+
this.pathname = pathname
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class NavigationError extends SafaError {
|
|
22
|
+
constructor(message, pathname) {
|
|
23
|
+
super(message, 'NAVIGATION_ERROR')
|
|
24
|
+
this.name = 'NavigationError'
|
|
25
|
+
this.pathname = pathname
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class RouteLoadError extends SafaError {
|
|
30
|
+
constructor(pathname, original) {
|
|
31
|
+
super(
|
|
32
|
+
`Failed to load route "${pathname}": ${original.message}`,
|
|
33
|
+
'ROUTE_LOAD_ERROR'
|
|
34
|
+
)
|
|
35
|
+
this.name = 'RouteLoadError'
|
|
36
|
+
this.pathname = pathname
|
|
37
|
+
this.original = original
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NavigationAbortError extends SafaError {
|
|
42
|
+
constructor(reason = 'Navigation cancelled by middleware') {
|
|
43
|
+
super(reason, 'NAVIGATION_ABORTED')
|
|
44
|
+
this.name = 'NavigationAbortError'
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { SafaRouter } from './SafaRouter.js'
|
|
2
|
+
export { RouteTree } from './RouteTree.js'
|
|
3
|
+
export { RouteMatcher } from './RouteMatcher.js'
|
|
4
|
+
export { HistoryManager } from './HistoryManager.js'
|
|
5
|
+
export { MiddlewareChain } from './MiddlewareChain.js'
|
|
6
|
+
export { Link } from './Link.js'
|
|
7
|
+
export {
|
|
8
|
+
SafaError,
|
|
9
|
+
RouteNotFoundError,
|
|
10
|
+
NavigationError,
|
|
11
|
+
RouteLoadError,
|
|
12
|
+
NavigationAbortError,
|
|
13
|
+
} from './errors.js'
|
|
14
|
+
export { normalizePath, parseQuery, joinPaths, createURL, isExternalURL, isSamePath, isDynamicSegment, isCatchAllSegment, isOptionalCatchAll, isRouteGroupSegment, useRouter } from './utils.js'
|
|
15
|
+
export { bindLinks, prefetchOnHover } from './link-helper.js'
|
|
16
|
+
export { EVENTS, DEFAULT_CONFIG } from './constants.js'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function bindLinks(router, scope = document) {
|
|
2
|
+
scope.addEventListener('click', (e) => {
|
|
3
|
+
const link = e.target.closest('[data-safa-link]')
|
|
4
|
+
if (!link) return
|
|
5
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
6
|
+
if (e.button !== 0) return
|
|
7
|
+
const href = link.getAttribute('href')
|
|
8
|
+
if (!href) return
|
|
9
|
+
e.preventDefault()
|
|
10
|
+
router.push(href)
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function prefetchOnHover(router) {
|
|
15
|
+
let timer
|
|
16
|
+
document.addEventListener('mouseenter', (e) => {
|
|
17
|
+
const link = e.target.closest('[data-safa-link]')
|
|
18
|
+
if (!link) return
|
|
19
|
+
const href = link.getAttribute('href')
|
|
20
|
+
if (!href) return
|
|
21
|
+
clearTimeout(timer)
|
|
22
|
+
timer = setTimeout(() => router.prefetch(href), 100)
|
|
23
|
+
}, true)
|
|
24
|
+
}
|