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.
@@ -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
+ }
@@ -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
+ }