navgo 3.0.3
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/index.d.ts +110 -0
- package/index.js +641 -0
- package/package.json +74 -0
- package/readme.md +351 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route parameter bag. Keys come from named parameters or `'*'` for wildcards.
|
|
3
|
+
* For string patterns, missing optional params are `null`.
|
|
4
|
+
* For RegExp named groups, missing groups may be `undefined`.
|
|
5
|
+
*/
|
|
6
|
+
export type Params = Record<string, string | null | undefined>
|
|
7
|
+
|
|
8
|
+
/** Built-in validator helpers shape. */
|
|
9
|
+
export interface ValidatorHelpers {
|
|
10
|
+
int(opts?: {
|
|
11
|
+
min?: number | null
|
|
12
|
+
max?: number | null
|
|
13
|
+
}): (value: string | null | undefined) => boolean
|
|
14
|
+
oneOf(values: Iterable<string>): (value: string | null | undefined) => boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Optional per-route hooks recognized by Navaid. */
|
|
18
|
+
export interface Hooks {
|
|
19
|
+
/** Validate params with custom per-param validators. Return `false` to skip a match. */
|
|
20
|
+
param_validators?: Record<string, (value: string | null | undefined) => boolean>
|
|
21
|
+
/** Load data for a route before navigation. May return a Promise or an array of values/promises. */
|
|
22
|
+
loaders?(params: Params): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
|
|
23
|
+
/** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
|
|
24
|
+
validate?(params: Params): boolean | Promise<boolean>
|
|
25
|
+
/** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
|
|
26
|
+
beforeRouteLeave?(nav: Navigation): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NavigationTarget {
|
|
30
|
+
url: URL
|
|
31
|
+
params: Params
|
|
32
|
+
/** The matched route tuple from your original `routes` list; `null` when unmatched (e.g. external). */
|
|
33
|
+
route: RouteTuple | null
|
|
34
|
+
/** Optional data from route loaders when available. */
|
|
35
|
+
data?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Navigation {
|
|
39
|
+
type: 'link' | 'goto' | 'popstate' | 'leave'
|
|
40
|
+
from: NavigationTarget | null
|
|
41
|
+
to: NavigationTarget | null
|
|
42
|
+
willUnload: boolean
|
|
43
|
+
cancelled: boolean
|
|
44
|
+
/** The original browser event that initiated navigation, when available. */
|
|
45
|
+
event?: Event
|
|
46
|
+
cancel(): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A route tuple: [pattern, data?]. The `data` may include {@link Hooks}. */
|
|
50
|
+
export type RouteTuple<T = unknown> = [pattern: string | RegExp, data: T]
|
|
51
|
+
|
|
52
|
+
/** Result of calling `router.match(url)` */
|
|
53
|
+
export interface MatchResult<T = unknown> {
|
|
54
|
+
route: RouteTuple<T>
|
|
55
|
+
params: Params
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Router<T = unknown> {
|
|
59
|
+
/** Format `url` relative to the configured base. */
|
|
60
|
+
format(url: string): string | false
|
|
61
|
+
/** SvelteKit-like navigation that runs loaders before updating the URL. */
|
|
62
|
+
goto(url: string, opts?: { replace?: boolean }): Promise<void>
|
|
63
|
+
/** Shallow push — updates URL/state without triggering handlers. */
|
|
64
|
+
pushState(url?: string | URL, state?: any): void
|
|
65
|
+
/** Shallow replace — updates URL/state without triggering handlers. */
|
|
66
|
+
replaceState(url?: string | URL, state?: any): void
|
|
67
|
+
/** Manually preload loaders for a URL (deduped). */
|
|
68
|
+
preload(url: string): Promise<unknown | void>
|
|
69
|
+
/** Try to match `url`; returns route tuple and params or `null`. Supports async `validate`. */
|
|
70
|
+
match(url: string): Promise<MatchResult<T> | null>
|
|
71
|
+
/** Attach history + click listeners and immediately process current location. */
|
|
72
|
+
init(): Promise<void>
|
|
73
|
+
/** Remove listeners installed by `init()`. */
|
|
74
|
+
destroy(): void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Router metadata stored under `history.state.__navaid`. */
|
|
78
|
+
export interface NavaidHistoryMeta {
|
|
79
|
+
/** Monotonic index of the current history entry for scroll restoration. */
|
|
80
|
+
idx: number
|
|
81
|
+
/** Present when the entry was created via shallow `pushState`/`replaceState`. */
|
|
82
|
+
shallow?: boolean
|
|
83
|
+
/** Origin of the navigation that created this entry. */
|
|
84
|
+
type?: 'link' | 'goto' | 'popstate'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Options {
|
|
88
|
+
/** App base path. Default '/' */
|
|
89
|
+
base?: string
|
|
90
|
+
/** Delay before hover preloading in milliseconds. Default 20. */
|
|
91
|
+
preload_delay?: number
|
|
92
|
+
/** Disable hover/touch preloading when `false`. Default true. */
|
|
93
|
+
preload_on_hover?: boolean
|
|
94
|
+
/** Global hook fired after per-route `beforeRouteLeave`, before loaders/history change. Can cancel. */
|
|
95
|
+
before_navigate?(nav: Navigation): void
|
|
96
|
+
/** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
|
|
97
|
+
after_navigate?(nav: Navigation): void
|
|
98
|
+
/** Global hook fired whenever the URL changes.
|
|
99
|
+
* Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
|
|
100
|
+
* Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
|
|
101
|
+
*/
|
|
102
|
+
url_changed?(payload: any): void
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Navaid default export: class-based router. */
|
|
106
|
+
export default class Navgo<T = unknown> implements Router<T> {
|
|
107
|
+
constructor(routes?: Array<RouteTuple<T>>, opts?: Options)
|
|
108
|
+
/** Built-in validator helpers (namespaced). */
|
|
109
|
+
static validators: ValidatorHelpers
|
|
110
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { parse } from 'regexparam'
|
|
2
|
+
|
|
3
|
+
const ℹ = (...args) => console.debug(...args)
|
|
4
|
+
|
|
5
|
+
export default class Navgo {
|
|
6
|
+
#opts = {
|
|
7
|
+
base: '/',
|
|
8
|
+
preload_delay: 20,
|
|
9
|
+
preload_on_hover: true,
|
|
10
|
+
before_navigate: undefined,
|
|
11
|
+
after_navigate: undefined,
|
|
12
|
+
url_changed: undefined,
|
|
13
|
+
}
|
|
14
|
+
#routes = []
|
|
15
|
+
#base = '/'
|
|
16
|
+
#base_rgx = /^\/+/
|
|
17
|
+
// preload cache: href -> { promise, data, error }
|
|
18
|
+
#preloads = new Map()
|
|
19
|
+
// last matched route info
|
|
20
|
+
#current = { url: null, route: null, params: {} }
|
|
21
|
+
#route_idx = 0
|
|
22
|
+
#scroll = new Map()
|
|
23
|
+
#hash_navigating = false
|
|
24
|
+
// Latest-wins nav guard: monotonic id and currently active id
|
|
25
|
+
#nav_seq = 0
|
|
26
|
+
#nav_active = 0
|
|
27
|
+
|
|
28
|
+
//
|
|
29
|
+
// Event listeners
|
|
30
|
+
//
|
|
31
|
+
#click = e => {
|
|
32
|
+
ℹ('[🧭 event:click]', { type: e?.type, target: e?.target })
|
|
33
|
+
const info = this.#link_from_event(e, true)
|
|
34
|
+
if (!info) return
|
|
35
|
+
|
|
36
|
+
const url = new URL(info.href, location.href)
|
|
37
|
+
|
|
38
|
+
// Hash-only navigation on same path: let browser handle, but track index
|
|
39
|
+
if (url.hash && url.pathname === this.#current.url.pathname) {
|
|
40
|
+
const cur_hash = location.href.split('#')[1]
|
|
41
|
+
const next_hash = url.href.split('#')[1] ?? ''
|
|
42
|
+
if (cur_hash === next_hash) {
|
|
43
|
+
// same hash: just scroll without history churn
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
if (next_hash === '' || (next_hash === 'top' && !document.getElementById('top'))) {
|
|
46
|
+
scrollTo({ top: 0 })
|
|
47
|
+
} else {
|
|
48
|
+
this.#scroll_to_hash('#' + next_hash)
|
|
49
|
+
}
|
|
50
|
+
ℹ('[🧭 hash]', 'same-hash scroll')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// different hash on same path — let browser update URL + scroll
|
|
55
|
+
this.#hash_navigating = true
|
|
56
|
+
this.#save_scroll()
|
|
57
|
+
ℹ('[🧭 hash]', 'navigate', { href: url.href })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
ℹ('[🧭 link]', 'intercept', { href: info.href })
|
|
63
|
+
this.goto(info.href, { replace: false }, 'link', e)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#on_popstate = ev => {
|
|
67
|
+
// ignore popstate while a hash-originating nav is in flight
|
|
68
|
+
if (this.#hash_navigating) return
|
|
69
|
+
|
|
70
|
+
const st = ev?.state?.__navaid
|
|
71
|
+
ℹ('[🧭 event:popstate]', st)
|
|
72
|
+
// Hash-only or state-only change: pathname+search unchanged -> skip loaders
|
|
73
|
+
const cur = this.#current.url
|
|
74
|
+
const target = new URL(location.href)
|
|
75
|
+
if (cur && target.pathname === cur.pathname) {
|
|
76
|
+
this.#current.url = target
|
|
77
|
+
ℹ(' - [🧭 event:popstate]', 'same path+search; skip loaders')
|
|
78
|
+
this.#apply_scroll(ev)
|
|
79
|
+
this.#opts.url_changed?.(this.#current)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
// Explicit shallow entries (pushState/replaceState) regardless of path
|
|
83
|
+
if (st?.shallow) {
|
|
84
|
+
this.#current.url = target
|
|
85
|
+
ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loaders')
|
|
86
|
+
this.#apply_scroll(ev)
|
|
87
|
+
this.#opts.url_changed?.(this.#current)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ℹ(' - [🧭 event:popstate]', { idx: st?.idx })
|
|
92
|
+
this.goto(location.href, { replace: true }, 'popstate', ev)
|
|
93
|
+
}
|
|
94
|
+
#on_hashchange = () => {
|
|
95
|
+
// if hashchange originated from a click we tracked, bump our index and persist it
|
|
96
|
+
if (this.#hash_navigating) {
|
|
97
|
+
this.#hash_navigating = false
|
|
98
|
+
const prev = history.state && typeof history.state == 'object' ? history.state : {}
|
|
99
|
+
const next_idx = this.#route_idx + 1
|
|
100
|
+
const next_state = { ...prev, __navaid: { ...prev.__navaid, idx: next_idx } }
|
|
101
|
+
history.replaceState(next_state, '', location.href)
|
|
102
|
+
this.#route_idx = next_idx
|
|
103
|
+
ℹ('[🧭 event:hashchange]', { idx: next_idx, href: location.href })
|
|
104
|
+
}
|
|
105
|
+
// update current URL snapshot and notify
|
|
106
|
+
this.#current.url = new URL(location.href)
|
|
107
|
+
this.#opts.url_changed?.(this.#current)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#hover_timer = null
|
|
111
|
+
#maybe_preload = ev => {
|
|
112
|
+
const info = this.#link_from_event(ev, ev.type === 'mousedown')
|
|
113
|
+
if (info) {
|
|
114
|
+
ℹ('[🧭 preload]', 'link hover/tap', { href: info.href })
|
|
115
|
+
this.preload(info.href)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
#mouse_move = ev => {
|
|
119
|
+
clearTimeout(this.#hover_timer)
|
|
120
|
+
this.#hover_timer = setTimeout(() => this.#maybe_preload(ev), this.#opts.preload_delay)
|
|
121
|
+
}
|
|
122
|
+
#tap = ev => this.#maybe_preload(ev)
|
|
123
|
+
|
|
124
|
+
#before_unload = ev => {
|
|
125
|
+
// persist scroll for refresh / session restore
|
|
126
|
+
try {
|
|
127
|
+
sessionStorage.setItem(
|
|
128
|
+
`__navaid_scroll:${location.href}`,
|
|
129
|
+
JSON.stringify({ x: scrollX, y: scrollY }),
|
|
130
|
+
)
|
|
131
|
+
} catch {}
|
|
132
|
+
|
|
133
|
+
ℹ('[🧭 event:beforeunload]', 'persist scroll + guard')
|
|
134
|
+
|
|
135
|
+
const nav = this.#make_nav({
|
|
136
|
+
type: 'leave',
|
|
137
|
+
to: null,
|
|
138
|
+
willUnload: true,
|
|
139
|
+
event: ev,
|
|
140
|
+
})
|
|
141
|
+
this.#current.route?.[1]?.beforeRouteLeave?.(nav)
|
|
142
|
+
if (nav.cancelled) {
|
|
143
|
+
ℹ('[🧭 navigate]', 'cancelled by beforeRouteLeave during unload')
|
|
144
|
+
ev.preventDefault()
|
|
145
|
+
ev.returnValue = ''
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//
|
|
150
|
+
// Helpers
|
|
151
|
+
//
|
|
152
|
+
#normalize(url) {
|
|
153
|
+
return '/' + (url || '').replace(/^\/|\/$/g, '')
|
|
154
|
+
}
|
|
155
|
+
/** @param {string} url @returns {string|false} */
|
|
156
|
+
format(url) {
|
|
157
|
+
if (!url) return url
|
|
158
|
+
url = this.#normalize(url)
|
|
159
|
+
const out = this.#base_rgx.test(url) && url.replace(this.#base_rgx, '/')
|
|
160
|
+
ℹ('[🧭 format]', { in: url, out })
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
#resolve_url_and_path(url_raw) {
|
|
164
|
+
if (url_raw[0] == '/' && !this.#base_rgx.test(url_raw)) url_raw = this.#base + url_raw
|
|
165
|
+
const url = new URL(url_raw, location.href)
|
|
166
|
+
const path = this.format(url.pathname)?.match(/[^?#]*/)?.[0]
|
|
167
|
+
ℹ('[🧭 resolve]', { url_in: url_raw, url: url.href, path })
|
|
168
|
+
return path ? { url, path } : null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#link_from_event(e, check_button = false) {
|
|
172
|
+
// prettier-ignore
|
|
173
|
+
if (!e || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || (check_button && e.button))
|
|
174
|
+
return null
|
|
175
|
+
const a = (e.composedPath()[0] || e.target)?.closest?.('a')
|
|
176
|
+
const href = a?.getAttribute?.('href')
|
|
177
|
+
return href &&
|
|
178
|
+
!a.target &&
|
|
179
|
+
!a.download &&
|
|
180
|
+
a.host === location.host &&
|
|
181
|
+
(href[0] != '/' || this.#base_rgx.test(href))
|
|
182
|
+
? { a, href }
|
|
183
|
+
: null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#check_param_validators(param_validators, params) {
|
|
187
|
+
for (const k in param_validators) {
|
|
188
|
+
const fn = param_validators[k]
|
|
189
|
+
if (typeof fn !== 'function') continue
|
|
190
|
+
if (!fn(params[k])) return false
|
|
191
|
+
}
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #run_loaders(route, params) {
|
|
196
|
+
const ret_val = route[1].loaders?.(params)
|
|
197
|
+
return Array.isArray(ret_val) ? Promise.all(ret_val) : ret_val
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @returns {Navigation}
|
|
202
|
+
*/
|
|
203
|
+
#make_nav({ type, from = undefined, to = undefined, willUnload = false, event = undefined }) {
|
|
204
|
+
const from_obj =
|
|
205
|
+
from !== undefined
|
|
206
|
+
? from
|
|
207
|
+
: this.#current.url
|
|
208
|
+
? {
|
|
209
|
+
url: this.#current.url,
|
|
210
|
+
params: this.#current.params || {},
|
|
211
|
+
route: this.#current.route,
|
|
212
|
+
}
|
|
213
|
+
: null
|
|
214
|
+
return {
|
|
215
|
+
type, // 'link' | 'goto' | 'popstate' | 'leave'
|
|
216
|
+
from: from_obj,
|
|
217
|
+
to,
|
|
218
|
+
willUnload,
|
|
219
|
+
cancelled: false,
|
|
220
|
+
event,
|
|
221
|
+
cancel() {
|
|
222
|
+
this.cancelled = true
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Programmatic navigation that runs loaders before changing URL.
|
|
229
|
+
* Also used by popstate to unify the flow.
|
|
230
|
+
* @param {string} [url_raw]
|
|
231
|
+
* @param {{ replace?: boolean }} [opts]
|
|
232
|
+
* @param {'goto'|'link'|'popstate'} [nav_type]
|
|
233
|
+
* @param {Event} [ev_param]
|
|
234
|
+
* @returns {Promise<void>}
|
|
235
|
+
*/
|
|
236
|
+
async goto(url_raw = location.href, opts = {}, nav_type = 'goto', ev_param = undefined) {
|
|
237
|
+
const nav_id = ++this.#nav_seq
|
|
238
|
+
this.#nav_active = nav_id
|
|
239
|
+
const info = this.#resolve_url_and_path(url_raw)
|
|
240
|
+
if (!info) {
|
|
241
|
+
ℹ('[🧭 goto]', 'invalid url', { url: url_raw })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
const { url, path } = info
|
|
245
|
+
|
|
246
|
+
const is_popstate = nav_type === 'popstate'
|
|
247
|
+
let nav = this.#make_nav({ type: nav_type, to: null, event: ev_param })
|
|
248
|
+
ℹ('[🧭 goto]', 'start', {
|
|
249
|
+
type: nav_type,
|
|
250
|
+
path,
|
|
251
|
+
replace: !!opts.replace,
|
|
252
|
+
popstate: is_popstate,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
//
|
|
256
|
+
// beforeRouteLeave
|
|
257
|
+
//
|
|
258
|
+
this.#current.route?.[1]?.beforeRouteLeave?.(nav)
|
|
259
|
+
if (nav.cancelled) {
|
|
260
|
+
// use history.go to cancel the nav, and jump back to where we are
|
|
261
|
+
if (is_popstate) {
|
|
262
|
+
const new_idx = ev_param?.state?.__navaid?.idx
|
|
263
|
+
if (new_idx != null) {
|
|
264
|
+
const delta = new_idx - this.#route_idx
|
|
265
|
+
if (delta) {
|
|
266
|
+
ℹ('[🧭 goto]', 'cancel popstate; correcting history', {
|
|
267
|
+
delta,
|
|
268
|
+
})
|
|
269
|
+
history.go(-delta)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
ℹ('[🧭 goto]', 'cancelled by beforeRouteLeave')
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
//
|
|
278
|
+
// #
|
|
279
|
+
//
|
|
280
|
+
this.#opts.before_navigate?.(nav)
|
|
281
|
+
ℹ('[🧭 hooks]', 'before_navigate', {
|
|
282
|
+
from: nav.from?.url?.href,
|
|
283
|
+
to: url.href,
|
|
284
|
+
})
|
|
285
|
+
this.#save_scroll()
|
|
286
|
+
const hit = await this.match(path)
|
|
287
|
+
if (nav_id !== this.#nav_active) return
|
|
288
|
+
|
|
289
|
+
//
|
|
290
|
+
// loaders
|
|
291
|
+
//
|
|
292
|
+
let data
|
|
293
|
+
if (hit) {
|
|
294
|
+
const pre = this.#preloads.get(path)
|
|
295
|
+
data =
|
|
296
|
+
pre?.data ??
|
|
297
|
+
(await (pre?.promise || this.#run_loaders(hit.route, hit.params)).catch(e => ({
|
|
298
|
+
__error: e,
|
|
299
|
+
})))
|
|
300
|
+
this.#preloads.delete(path)
|
|
301
|
+
ℹ('[🧭 loaders]', pre ? 'using preloaded data' : 'loaded', {
|
|
302
|
+
path,
|
|
303
|
+
preloaded: !!pre,
|
|
304
|
+
has_error: !!data?.__error,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
if (nav_id !== this.#nav_active) return
|
|
308
|
+
|
|
309
|
+
//
|
|
310
|
+
// change URL (skip if popstate as browser changes, or first goto())
|
|
311
|
+
//
|
|
312
|
+
if (!is_popstate && !(nav_type === 'goto' && this.#current.url == null)) {
|
|
313
|
+
const next_idx = this.#route_idx + (opts.replace ? 0 : 1)
|
|
314
|
+
const prev_state =
|
|
315
|
+
history.state && typeof history.state == 'object' ? history.state : {}
|
|
316
|
+
const next_state = {
|
|
317
|
+
...prev_state,
|
|
318
|
+
__navaid: { ...prev_state.__navaid, idx: next_idx, type: nav_type },
|
|
319
|
+
}
|
|
320
|
+
history[(opts.replace ? 'replace' : 'push') + 'State'](next_state, null, url.href)
|
|
321
|
+
ℹ('[🧭 history]', opts.replace ? 'replaceState' : 'pushState', {
|
|
322
|
+
idx: next_idx,
|
|
323
|
+
href: url.href,
|
|
324
|
+
})
|
|
325
|
+
this.#route_idx = next_idx
|
|
326
|
+
if (!opts.replace) this.#clear_onward_history()
|
|
327
|
+
}
|
|
328
|
+
if (nav_id !== this.#nav_active) return
|
|
329
|
+
|
|
330
|
+
const prev = this.#current
|
|
331
|
+
this.#current = { url, route: hit?.route || null, params: hit?.params || {} }
|
|
332
|
+
|
|
333
|
+
// Build a completion nav using the previous route as `from`
|
|
334
|
+
nav = this.#make_nav({
|
|
335
|
+
type: nav_type,
|
|
336
|
+
from: prev?.url
|
|
337
|
+
? {
|
|
338
|
+
url: prev.url,
|
|
339
|
+
params: prev.params || {},
|
|
340
|
+
route: prev.route,
|
|
341
|
+
}
|
|
342
|
+
: null,
|
|
343
|
+
to: {
|
|
344
|
+
url: new URL(location.href),
|
|
345
|
+
params: hit?.params || {},
|
|
346
|
+
route: hit?.route || null,
|
|
347
|
+
data: hit ? data : { __error: { status: 404 } },
|
|
348
|
+
},
|
|
349
|
+
event: ev_param,
|
|
350
|
+
})
|
|
351
|
+
// await so that apply_scroll is after potential async work
|
|
352
|
+
await this.#opts.after_navigate?.(nav)
|
|
353
|
+
if (nav_id !== this.#nav_active) return
|
|
354
|
+
ℹ('[🧭 navigate]', hit ? 'done' : 'done (404)', {
|
|
355
|
+
from: nav.from?.url?.href,
|
|
356
|
+
to: nav.to?.url?.href,
|
|
357
|
+
type: nav.type,
|
|
358
|
+
idx: this.#route_idx,
|
|
359
|
+
})
|
|
360
|
+
this.#apply_scroll(nav)
|
|
361
|
+
this.#opts.url_changed?.(this.#current)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Shallow push — updates the URL/state but DOES NOT call handlers or loaders.
|
|
366
|
+
* URL changes, content stays put until a real nav.
|
|
367
|
+
*/
|
|
368
|
+
#commit_shallow(url, state, replace) {
|
|
369
|
+
const u = new URL(url || location.href, location.href)
|
|
370
|
+
// save scroll for current index before shallow change
|
|
371
|
+
this.#save_scroll()
|
|
372
|
+
const idx = this.#route_idx + (replace ? 0 : 1)
|
|
373
|
+
const st = { ...state, __navaid: { ...state?.__navaid, shallow: true, idx } }
|
|
374
|
+
history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
|
|
375
|
+
ℹ('[🧭 history]', replace ? 'replaceState(shallow)' : 'pushState(shallow)', {
|
|
376
|
+
idx,
|
|
377
|
+
href: u.href,
|
|
378
|
+
})
|
|
379
|
+
// Popstate handler checks state.__navaid.shallow and skips router processing
|
|
380
|
+
this.#route_idx = idx
|
|
381
|
+
// carry forward current scroll position for the shallow entry so Forward restores correctly
|
|
382
|
+
this.#scroll.set(idx, { x: scrollX, y: scrollY })
|
|
383
|
+
if (!replace) this.#clear_onward_history()
|
|
384
|
+
// update current URL snapshot and notify
|
|
385
|
+
this.#current.url = u
|
|
386
|
+
this.#opts.url_changed?.(this.#current)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @param {string|URL} [url] @param {any} [state] */
|
|
390
|
+
pushState(url, state) {
|
|
391
|
+
this.#commit_shallow(url, state, false)
|
|
392
|
+
}
|
|
393
|
+
/** @param {string|URL} [url] @param {any} [state] */
|
|
394
|
+
replaceState(url, state) {
|
|
395
|
+
this.#commit_shallow(url, state, true)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Preload loaders for a URL (e.g. to prime cache).
|
|
400
|
+
* Dedupes concurrent preloads for the same path.
|
|
401
|
+
*/
|
|
402
|
+
/** @param {string} url_raw @returns {Promise<unknown|void>} */
|
|
403
|
+
async preload(url_raw) {
|
|
404
|
+
const { path } = this.#resolve_url_and_path(url_raw) || {}
|
|
405
|
+
if (!path) {
|
|
406
|
+
ℹ('[🧭 preload]', 'invalid url', { url: url_raw })
|
|
407
|
+
return Promise.resolve()
|
|
408
|
+
}
|
|
409
|
+
// Do not preload if we're already at this path
|
|
410
|
+
if (this.format(this.#current.url?.pathname) === path) {
|
|
411
|
+
ℹ('[🧭 preload]', 'skip current path', { path })
|
|
412
|
+
return Promise.resolve()
|
|
413
|
+
}
|
|
414
|
+
const hit = await this.match(path)
|
|
415
|
+
if (!hit) {
|
|
416
|
+
ℹ('[🧭 preload]', 'no route', { path })
|
|
417
|
+
return Promise.resolve()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.#preloads.has(path)) {
|
|
421
|
+
const p = this.#preloads.get(path)
|
|
422
|
+
ℹ('[🧭 preload]', 'dedupe', { path })
|
|
423
|
+
return p.promise || Promise.resolve(p.data)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const entry = {}
|
|
427
|
+
entry.promise = this.#run_loaders(hit.route, hit.params).then(data => {
|
|
428
|
+
entry.data = data
|
|
429
|
+
delete entry.promise
|
|
430
|
+
ℹ('[🧭 preload]', 'done', { path })
|
|
431
|
+
return data
|
|
432
|
+
})
|
|
433
|
+
this.#preloads.set(path, entry)
|
|
434
|
+
return entry.promise
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
//
|
|
438
|
+
// Core matching
|
|
439
|
+
//
|
|
440
|
+
/** @param {string} url_raw @returns {Promise<MatchResult|null>} */
|
|
441
|
+
async match(url_raw) {
|
|
442
|
+
ℹ('[🧭 match]', 'start', { url: url_raw })
|
|
443
|
+
let arr, obj
|
|
444
|
+
for (let i = 0; i < this.#routes.length; i++) {
|
|
445
|
+
obj = this.#routes[i]
|
|
446
|
+
if (!(arr = obj.pattern.exec(url_raw))) continue
|
|
447
|
+
const params = {}
|
|
448
|
+
if (obj.keys?.length) {
|
|
449
|
+
for (let j = 0; j < obj.keys.length; ) {
|
|
450
|
+
params[obj.keys[j]] = arr[++j] || null
|
|
451
|
+
}
|
|
452
|
+
} else if (arr.groups) {
|
|
453
|
+
for (const k in arr.groups) params[k] = arr.groups[k]
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// per-route validators and optional async validate()
|
|
457
|
+
const hooks = obj.data[1]
|
|
458
|
+
if (
|
|
459
|
+
hooks.param_validators &&
|
|
460
|
+
!this.#check_param_validators(hooks.param_validators, params)
|
|
461
|
+
) {
|
|
462
|
+
ℹ('[🧭 match]', 'skip: param_validators', {
|
|
463
|
+
pattern: obj.data?.[0],
|
|
464
|
+
})
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
if (hooks.validate && !(await hooks.validate(params))) {
|
|
468
|
+
ℹ('[🧭 match]', 'skip: validate', { pattern: obj.data?.[0] })
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
ℹ('[🧭 match]', 'hit', { pattern: obj.data?.[0], params })
|
|
473
|
+
return { route: obj.data || null, params }
|
|
474
|
+
}
|
|
475
|
+
ℹ('[🧭 match]', 'miss', { url: url_raw })
|
|
476
|
+
return null
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** @param {RouteTuple[]} [routes] @param {Options} [opts] */
|
|
480
|
+
constructor(routes = [], opts) {
|
|
481
|
+
this.#opts = { ...this.#opts, ...opts }
|
|
482
|
+
this.#base = this.#normalize(this.#opts.base || '/')
|
|
483
|
+
this.#base_rgx =
|
|
484
|
+
this.#base == '/' ? /^\/+/ : new RegExp('^\\' + this.#base + '(?=\\/|$)\\/?', 'i')
|
|
485
|
+
|
|
486
|
+
this.#routes = routes.map(r => {
|
|
487
|
+
const pat_or_rx = r[0]
|
|
488
|
+
const pat =
|
|
489
|
+
pat_or_rx instanceof RegExp ? { pattern: pat_or_rx, keys: null } : parse(pat_or_rx)
|
|
490
|
+
pat.data = r // keep original tuple: [pattern, hooks, ...]
|
|
491
|
+
return pat
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
ℹ('[🧭 init]', {
|
|
495
|
+
base: this.#base,
|
|
496
|
+
routes: this.#routes.length,
|
|
497
|
+
preload_on_hover: this.#opts.preload_on_hover,
|
|
498
|
+
preload_delay: this.#opts.preload_delay,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
//
|
|
503
|
+
// Lifecycle hooks
|
|
504
|
+
//
|
|
505
|
+
init() {
|
|
506
|
+
history.scrollRestoration = 'manual'
|
|
507
|
+
ℹ('[🧭 init]', 'attach listeners; scrollRestoration=manual')
|
|
508
|
+
|
|
509
|
+
addEventListener('popstate', this.#on_popstate)
|
|
510
|
+
addEventListener('click', this.#click)
|
|
511
|
+
addEventListener('beforeunload', this.#before_unload)
|
|
512
|
+
addEventListener('hashchange', this.#on_hashchange)
|
|
513
|
+
|
|
514
|
+
if (this.#opts.preload_on_hover) {
|
|
515
|
+
// @ts-expect-error
|
|
516
|
+
if (!navigator.connection?.saveData) addEventListener('mousemove', this.#mouse_move)
|
|
517
|
+
addEventListener('touchstart', this.#tap, { passive: true })
|
|
518
|
+
addEventListener('mousedown', this.#tap)
|
|
519
|
+
ℹ('[🧭 init]', 'hover preloading enabled', {
|
|
520
|
+
delay: this.#opts.preload_delay,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ensure current history state carries our index
|
|
525
|
+
const cur_idx = history.state?.__navaid?.idx
|
|
526
|
+
if (cur_idx == null) {
|
|
527
|
+
const prev = history.state && typeof history.state == 'object' ? history.state : {}
|
|
528
|
+
const next_state = { ...prev, __navaid: { ...prev.__navaid, idx: this.#route_idx } }
|
|
529
|
+
history.replaceState(next_state, '', location.href)
|
|
530
|
+
ℹ('[🧭 history]', 'init idx', { idx: this.#route_idx })
|
|
531
|
+
} else {
|
|
532
|
+
this.#route_idx = cur_idx
|
|
533
|
+
ℹ('[🧭 history]', 'restore idx', { idx: this.#route_idx })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
ℹ('[🧭 init]', 'initial goto')
|
|
537
|
+
return this.goto()
|
|
538
|
+
}
|
|
539
|
+
destroy() {
|
|
540
|
+
removeEventListener('popstate', this.#on_popstate)
|
|
541
|
+
removeEventListener('click', this.#click)
|
|
542
|
+
removeEventListener('mousemove', this.#mouse_move)
|
|
543
|
+
removeEventListener('touchstart', this.#tap)
|
|
544
|
+
removeEventListener('mousedown', this.#tap)
|
|
545
|
+
removeEventListener('beforeunload', this.#before_unload)
|
|
546
|
+
removeEventListener('hashchange', this.#on_hashchange)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
//
|
|
550
|
+
// Scroll
|
|
551
|
+
//
|
|
552
|
+
#save_scroll() {
|
|
553
|
+
this.#scroll.set(this.#route_idx, { x: scrollX, y: scrollY })
|
|
554
|
+
ℹ('[🧭 scroll]', 'save', { idx: this.#route_idx, x: scrollX, y: scrollY })
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#clear_onward_history() {
|
|
558
|
+
for (const k of this.#scroll.keys()) if (k > this.#route_idx) this.#scroll.delete(k)
|
|
559
|
+
ℹ('[🧭 scroll]', 'clear onward', { upto: this.#route_idx })
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
#apply_scroll(ctx) {
|
|
563
|
+
const hash = location.hash
|
|
564
|
+
const t = ctx?.type || ctx?.event?.type
|
|
565
|
+
requestAnimationFrame(() => {
|
|
566
|
+
// 0) Initial (first) navigation: prefer restoring session scroll
|
|
567
|
+
const is_initial = ctx && 'from' in ctx ? ctx.from == null : !t
|
|
568
|
+
if (is_initial) {
|
|
569
|
+
try {
|
|
570
|
+
const k = `__navaid_scroll:${location.href}`
|
|
571
|
+
const { x, y } = JSON.parse(sessionStorage.getItem(k))
|
|
572
|
+
sessionStorage.removeItem(k)
|
|
573
|
+
scrollTo(x, y)
|
|
574
|
+
ℹ('[🧭 scroll]', 'restore session', { x, y })
|
|
575
|
+
return
|
|
576
|
+
} catch {}
|
|
577
|
+
}
|
|
578
|
+
// 1) On back/forward, restore saved position if available
|
|
579
|
+
if (t === 'popstate') {
|
|
580
|
+
const ev_state = ctx?.state ?? ctx?.event?.state
|
|
581
|
+
const idx = ev_state?.__navaid?.idx
|
|
582
|
+
const target_idx = typeof idx === 'number' ? idx : this.#route_idx - 1
|
|
583
|
+
this.#route_idx = target_idx
|
|
584
|
+
const pos = this.#scroll.get(target_idx)
|
|
585
|
+
if (pos) {
|
|
586
|
+
scrollTo(pos.x, pos.y)
|
|
587
|
+
ℹ('[🧭 scroll]', 'restore popstate', {
|
|
588
|
+
idx: target_idx,
|
|
589
|
+
...pos,
|
|
590
|
+
})
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// 2) If there is a hash, prefer anchor scroll
|
|
595
|
+
if (hash && this.#scroll_to_hash(hash)) {
|
|
596
|
+
ℹ('[🧭 scroll]', 'hash')
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
// 3) Default: scroll to top for new navigations
|
|
600
|
+
scrollTo(0, 0)
|
|
601
|
+
ℹ('[🧭 scroll]', 'top')
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#scroll_to_hash(hash) {
|
|
606
|
+
let id = hash.slice(1)
|
|
607
|
+
if (!id) return false
|
|
608
|
+
try {
|
|
609
|
+
id = decodeURIComponent(id)
|
|
610
|
+
} catch {}
|
|
611
|
+
const el =
|
|
612
|
+
document.getElementById(id) || document.querySelector(`[name="${CSS.escape(id)}"]`)
|
|
613
|
+
el?.scrollIntoView()
|
|
614
|
+
ℹ('[🧭 scroll]', 'anchor', { id, found: !!el })
|
|
615
|
+
return !!el
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @type {ValidatorHelpers} */
|
|
619
|
+
static validators = {
|
|
620
|
+
int(opts = {}) {
|
|
621
|
+
const { min = null, max = null } = opts
|
|
622
|
+
return v => {
|
|
623
|
+
if (typeof v !== 'string' || !/^-?\d+$/.test(v)) return false
|
|
624
|
+
const n = Number(v)
|
|
625
|
+
if (min != null && n < min) return false
|
|
626
|
+
if (max != null && n > max) return false
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
oneOf(values) {
|
|
631
|
+
const set = new Set(values)
|
|
632
|
+
return v => set.has(v)
|
|
633
|
+
},
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** @typedef {import('./index.d.ts').Options} Options */
|
|
638
|
+
/** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
|
|
639
|
+
/** @typedef {import('./index.d.ts').MatchResult} MatchResult */
|
|
640
|
+
/** @typedef {import('./index.d.ts').ValidatorHelpers} ValidatorHelpers */
|
|
641
|
+
/** @typedef {import('./index.d.ts').Navigation} Navigation */
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "navgo",
|
|
3
|
+
"version": "3.0.3",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/mustafa0x/navgo.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "Adavanced router",
|
|
9
|
+
"module": "./index.js",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": "./index.js",
|
|
12
|
+
"types": "index.d.ts",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "mustafa j"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"pretest": "pnpm run build",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:e2e": "playwright test",
|
|
21
|
+
"test:all": "pnpm run build && vitest run && playwright test",
|
|
22
|
+
"types": "pnpm --package=typescript@5.6.3 dlx tsc -p test/types"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"*.d.ts",
|
|
26
|
+
"index.js"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"browser",
|
|
30
|
+
"client-side",
|
|
31
|
+
"router"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"regexparam": "^3.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": "^9.36.0",
|
|
38
|
+
"@playwright/test": "^1.55.1",
|
|
39
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
40
|
+
"@tailwindcss/vite": "^4.1.13",
|
|
41
|
+
"@tsconfig/svelte": "^5.0.5",
|
|
42
|
+
"@types/node": "^24.6.0",
|
|
43
|
+
"eslint": "^9.36.0",
|
|
44
|
+
"eslint-plugin-svelte": "^3.12.4",
|
|
45
|
+
"globals": "^16.4.0",
|
|
46
|
+
"jiti": "^2.6.0",
|
|
47
|
+
"lightningcss": "^1.30.2",
|
|
48
|
+
"prettier": "^3.6.2",
|
|
49
|
+
"prettier-plugin-svelte": "^3.4.0",
|
|
50
|
+
"prettier-plugin-tailwindcss": "^0.6.14",
|
|
51
|
+
"svelte": "^5.39.7",
|
|
52
|
+
"tailwindcss": "^4.1.13",
|
|
53
|
+
"terser": "5.44.0",
|
|
54
|
+
"typescript-eslint": "^8.45.0",
|
|
55
|
+
"vite": "^7.1.7",
|
|
56
|
+
"vitest": "^2.1.3"
|
|
57
|
+
},
|
|
58
|
+
"pnpm": {
|
|
59
|
+
"onlyBuiltDependencies": [
|
|
60
|
+
"@tailwindcss/oxide",
|
|
61
|
+
"esbuild"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"prettier": {
|
|
65
|
+
"semi": false,
|
|
66
|
+
"singleQuote": true,
|
|
67
|
+
"tabWidth": 4,
|
|
68
|
+
"printWidth": 100,
|
|
69
|
+
"quoteProps": "as-needed",
|
|
70
|
+
"bracketSpacing": true,
|
|
71
|
+
"arrowParens": "avoid",
|
|
72
|
+
"useTabs": true
|
|
73
|
+
}
|
|
74
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
## Install
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
$ pnpm install --dev navaid
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import Navgo from 'navgo'
|
|
11
|
+
|
|
12
|
+
// Define routes up front (strings or RegExp)
|
|
13
|
+
const routes = [
|
|
14
|
+
['/', {}],
|
|
15
|
+
['/users/:username', {}],
|
|
16
|
+
['/books/*', {}],
|
|
17
|
+
[/articles\/(?<year>[0-9]{4})/, {}],
|
|
18
|
+
[/privacy|privacy-policy/, {}],
|
|
19
|
+
[
|
|
20
|
+
'/admin',
|
|
21
|
+
{
|
|
22
|
+
// constrain params with built-ins or your own
|
|
23
|
+
param_validators: {
|
|
24
|
+
/* id: Navaid.validators.int({ min: 1 }) */
|
|
25
|
+
},
|
|
26
|
+
// load data before URL changes; result goes to after_navigate(...)
|
|
27
|
+
loaders: params => fetch('/api/admin').then(r => r.json()),
|
|
28
|
+
// per-route guard; cancel synchronously to block nav
|
|
29
|
+
beforeRouteLeave(nav) {
|
|
30
|
+
if ((nav.type === 'link' || nav.type === 'nav') && !confirm('Enter admin?')) {
|
|
31
|
+
nav.cancel()
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
// Create router with options + callbacks
|
|
39
|
+
const router = new Navgo(routes, {
|
|
40
|
+
base: '/',
|
|
41
|
+
before_navigate(nav) {
|
|
42
|
+
// app-level hook before loaders/URL update; may cancel
|
|
43
|
+
console.log('before_navigate', nav.type, '→', nav.to?.url.pathname)
|
|
44
|
+
},
|
|
45
|
+
after_navigate(nav) {
|
|
46
|
+
// called after routing completes; nav.to.data holds loader result
|
|
47
|
+
if (nav.to?.data?.__error?.status === 404) {
|
|
48
|
+
console.log('404 for', nav.to.url.pathname)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
|
|
53
|
+
},
|
|
54
|
+
url_changed(cur) {
|
|
55
|
+
// fires on shallow/hash/popstate-shallow/404 and full navigations
|
|
56
|
+
// `cur` is the router snapshot: { url: URL, route, params }
|
|
57
|
+
console.log('url_changed', cur.url.href)
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Long-lived router: history + <a> bindings
|
|
62
|
+
// Also immediately processes the current location
|
|
63
|
+
router.init()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### new Navgo(routes?, options?)
|
|
69
|
+
|
|
70
|
+
Returns: `Router`
|
|
71
|
+
|
|
72
|
+
#### `routes`
|
|
73
|
+
|
|
74
|
+
Type: `Array<[pattern: string | RegExp, data: any]>`
|
|
75
|
+
|
|
76
|
+
Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). Pass `{}` when no hooks are needed. Navgo returns this tuple back to you unchanged via `onRoute`.
|
|
77
|
+
|
|
78
|
+
Supported pattern types:
|
|
79
|
+
|
|
80
|
+
- static (`/users`)
|
|
81
|
+
- named parameters (`/users/:id`)
|
|
82
|
+
- nested parameters (`/users/:id/books/:title`)
|
|
83
|
+
- optional parameters (`/users/:id?/books/:title?`)
|
|
84
|
+
- wildcards (`/users/*`)
|
|
85
|
+
- RegExp patterns (with optional named groups)
|
|
86
|
+
|
|
87
|
+
Notes:
|
|
88
|
+
|
|
89
|
+
- Pattern strings are matched relative to the [`base`](#base) path.
|
|
90
|
+
- RegExp patterns are used as-is. Named capture groups (e.g. `(?<year>\d{4})`) become `params` keys; unnamed groups are ignored.
|
|
91
|
+
|
|
92
|
+
#### `options`
|
|
93
|
+
|
|
94
|
+
- `base`: `string` (default `'/'`)
|
|
95
|
+
- App base pathname. With or without leading/trailing slashes is accepted.
|
|
96
|
+
- `before_navigate`: `(nav: Navigation) => void`
|
|
97
|
+
- App-level hook called once per navigation attempt after the per-route guard and before loaders/URL update. May call `nav.cancel()` synchronously to prevent navigation.
|
|
98
|
+
- `after_navigate`: `(nav: Navigation) => void`
|
|
99
|
+
- App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data.
|
|
100
|
+
- `url_changed`: `(snapshot: any) => void`
|
|
101
|
+
- Fires on every URL change — shallow `pushState`/`replaceState`, hash changes, `popstate` shallow entries, 404s, and full navigations.
|
|
102
|
+
- Receives the router's current snapshot: an object like `{ url: URL, route: RouteTuple|null, params: Params }`.
|
|
103
|
+
- The snapshot type is intentionally `any` and may evolve without a breaking change.
|
|
104
|
+
- `preload_delay`: `number` (default `20`)
|
|
105
|
+
- Delay in ms before hover preloading triggers.
|
|
106
|
+
- `preload_on_hover`: `boolean` (default `true`)
|
|
107
|
+
- When `false`, disables hover/touch preloading.
|
|
108
|
+
|
|
109
|
+
Important: Navaid only processes routes that match your `base` path.
|
|
110
|
+
|
|
111
|
+
### Route Hooks
|
|
112
|
+
|
|
113
|
+
- param_validators?: `Record<string, (value: string|null|undefined) => boolean>`
|
|
114
|
+
- Validate params (e.g., `id: Navaid.validators.int({ min: 1 })`). Any `false` result skips the route.
|
|
115
|
+
- loaders?(params): `unknown | Promise | Array<unknown|Promise>`
|
|
116
|
+
- Run before URL changes on `link`/`nav`. Results are cached per formatted path and forwarded to `after_navigate`.
|
|
117
|
+
- validate?(params): `boolean | Promise<boolean>`
|
|
118
|
+
- Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
|
|
119
|
+
- beforeRouteLeave?(nav): `(nav: Navigation) => void`
|
|
120
|
+
- Guard called once per navigation attempt on the current route (leave). Call `nav.cancel()` synchronously to prevent navigation. For `popstate`, cancellation auto-reverts the history jump.
|
|
121
|
+
|
|
122
|
+
The `Navigation` object contains:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
{
|
|
126
|
+
type: 'link' | 'nav' | 'popstate' | 'leave',
|
|
127
|
+
from: { url, params, route } | null,
|
|
128
|
+
to: { url, params, route } | null,
|
|
129
|
+
willUnload: boolean,
|
|
130
|
+
cancelled: boolean,
|
|
131
|
+
event?: Event,
|
|
132
|
+
cancel(): void
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### Order & cancellation:
|
|
137
|
+
|
|
138
|
+
- Router calls `before_navigate` on the current route (leave).
|
|
139
|
+
- Call `nav.cancel()` synchronously to cancel.
|
|
140
|
+
- For `link`/`nav`, it stops before URL change.
|
|
141
|
+
- For `popstate`, cancellation causes an automatic `history.go(...)` to revert to the previous index.
|
|
142
|
+
- For `leave`, cancellation triggers the native “Leave site?” dialog (behavior is browser-controlled).
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
const routes = [
|
|
148
|
+
[
|
|
149
|
+
'/admin',
|
|
150
|
+
{
|
|
151
|
+
param_validators: {
|
|
152
|
+
/* ... */
|
|
153
|
+
},
|
|
154
|
+
loaders: params => fetch('/api/admin/stats').then(r => r.json()),
|
|
155
|
+
beforeRouteLeave(nav) {
|
|
156
|
+
if (nav.type === 'link' || nav.type === 'nav') {
|
|
157
|
+
if (!confirm('Enter admin area?')) nav.cancel()
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
['/', {}],
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
const router = new Navgo(routes, { base: '/app' })
|
|
166
|
+
router.init()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Methods
|
|
170
|
+
|
|
171
|
+
### format(uri)
|
|
172
|
+
|
|
173
|
+
Returns: `String` or `false`
|
|
174
|
+
|
|
175
|
+
Formats and returns a pathname relative to the [`base`](#base) path.
|
|
176
|
+
|
|
177
|
+
If the `uri` **does not** begin with the `base`, then `false` will be returned instead.<br>
|
|
178
|
+
Otherwise, the return value will always lead with a slash (`/`).
|
|
179
|
+
|
|
180
|
+
> **Note:** This is called automatically within the [`init()`](#init) method.
|
|
181
|
+
|
|
182
|
+
#### uri
|
|
183
|
+
|
|
184
|
+
Type: `String`
|
|
185
|
+
|
|
186
|
+
The path to format.
|
|
187
|
+
|
|
188
|
+
> **Note:** Much like [`base`](#base), paths with or without leading and trailing slashes are handled identically.
|
|
189
|
+
|
|
190
|
+
### goto(uri, options?)
|
|
191
|
+
|
|
192
|
+
Returns: `Promise<void>`
|
|
193
|
+
|
|
194
|
+
Runs any matching route `loaders` before updating the URL and then updates history. Route processing triggers `after_navigate`. Use `replace: true` to replace the current history entry.
|
|
195
|
+
|
|
196
|
+
#### uri
|
|
197
|
+
|
|
198
|
+
Type: `String`
|
|
199
|
+
|
|
200
|
+
The desired path to navigate. If it begins with `/` and does not match the configured [`base`](#base), it will be prefixed automatically.
|
|
201
|
+
|
|
202
|
+
#### options
|
|
203
|
+
|
|
204
|
+
Type: `Object`
|
|
205
|
+
|
|
206
|
+
- replace: `Boolean` (default `false`)
|
|
207
|
+
- When `true`, uses `history.replaceState`; otherwise `history.pushState`.
|
|
208
|
+
|
|
209
|
+
### init()
|
|
210
|
+
|
|
211
|
+
Attaches global listeners to synchronize your router with URL changes, which allows Navgo to respond consistently to your browser's <kbd>BACK</kbd> and <kbd>FORWARD</kbd> buttons.
|
|
212
|
+
|
|
213
|
+
Events:
|
|
214
|
+
|
|
215
|
+
- Responds to: `popstate` only. No synthetic events are emitted.
|
|
216
|
+
|
|
217
|
+
Navgo will also bind to any `click` event(s) on anchor tags (`<a href="" />`) so long as the link has a valid `href` that matches the [`base`](#base) path. Navgo **will not** intercept links that have _any_ `target` attribute or if the link was clicked with a special modifier (<kbd>ALT</kbd>, <kbd>SHIFT</kbd>, <kbd>CMD</kbd>, or <kbd>CTRL</kbd>).
|
|
218
|
+
|
|
219
|
+
While listening, link clicks are intercepted and translated into `goto()` navigations. You can also call `goto()` programmatically.
|
|
220
|
+
|
|
221
|
+
In addition, `init()` wires preloading listeners (enabled by default) so route data can be fetched early:
|
|
222
|
+
|
|
223
|
+
- `mousemove` (hover) — after a short delay, hovering an in-app link triggers `preload(href)`.
|
|
224
|
+
- `touchstart` and `mousedown` (tap) — tapping or pressing on an in-app link also triggers `preload(href)`.
|
|
225
|
+
|
|
226
|
+
Preloading applies only to in-app anchors that match the configured [`base`](#base). You can tweak this behavior with the `preload_delay` and `preload_on_hover` options.
|
|
227
|
+
|
|
228
|
+
Notes:
|
|
229
|
+
|
|
230
|
+
- `preload(uri)` is a no-op when `uri` formats to the current route's path (already loaded).
|
|
231
|
+
|
|
232
|
+
### Scroll persistence
|
|
233
|
+
|
|
234
|
+
On `beforeunload`, the current scroll position is saved to `sessionStorage` and restored on the next load of the same URL (e.g., refresh or tab restore).
|
|
235
|
+
|
|
236
|
+
### preload(uri)
|
|
237
|
+
|
|
238
|
+
Returns: `Promise<unknown | void>`
|
|
239
|
+
|
|
240
|
+
Preload a route's `loaders` data for a given `uri` without navigating. Concurrent calls for the same path are deduped.
|
|
241
|
+
Note: Resolves to `undefined` when the matched route has no `loaders`.
|
|
242
|
+
|
|
243
|
+
### pushState(url?, state?)
|
|
244
|
+
|
|
245
|
+
Returns: `void`
|
|
246
|
+
|
|
247
|
+
Perform a shallow history push: updates the URL/state without triggering route processing.
|
|
248
|
+
|
|
249
|
+
### replaceState(url?, state?)
|
|
250
|
+
|
|
251
|
+
Returns: `void`
|
|
252
|
+
|
|
253
|
+
Perform a shallow history replace: updates the URL/state without triggering route processing.
|
|
254
|
+
|
|
255
|
+
### destroy()
|
|
256
|
+
|
|
257
|
+
Detach all listeners initialized by [`init()`](#init).
|
|
258
|
+
|
|
259
|
+
## Semantics
|
|
260
|
+
|
|
261
|
+
This section explains, in detail, how navigation is processed: matching, hooks, data loading, shallow routing, history behavior, and scroll restoration. The design takes cues from SvelteKit's client router (see: kit/documentation/docs/30-advanced/10-advanced-routing.md and kit/documentation/docs/30-advanced/67-shallow-routing.md).
|
|
262
|
+
|
|
263
|
+
### Navigation Types
|
|
264
|
+
|
|
265
|
+
- `link` — user clicked an in-app `<a>` that matches `base`.
|
|
266
|
+
- `goto` — programmatic navigation via `router.goto(...)`.
|
|
267
|
+
- `popstate` — browser back/forward.
|
|
268
|
+
- `leave` — page is unloading (refresh, external navigation, tab close) via `beforeunload`.
|
|
269
|
+
- `pushState` (shallow)?
|
|
270
|
+
|
|
271
|
+
The router passes the type to your route-level `beforeRouteLeave(nav)` hook.
|
|
272
|
+
|
|
273
|
+
### Matching and Params
|
|
274
|
+
|
|
275
|
+
- A route is a `[pattern, data?]` tuple.
|
|
276
|
+
- `pattern` can be a string (compiled with `regexparam`) or a `RegExp`.
|
|
277
|
+
- Named params from string patterns populate `params` with `string` values; optional params that do not appear are `null`.
|
|
278
|
+
- Wildcards use the `'*'` key.
|
|
279
|
+
- RegExp named groups also populate `params`; omitted groups can be `undefined`.
|
|
280
|
+
- If `data.param_validators` is present, each `params[k]` is validated; any `false` result skips that route.
|
|
281
|
+
- If `data.validate(params)` returns or resolves to `false`, the route is also skipped.
|
|
282
|
+
|
|
283
|
+
### Data Flow
|
|
284
|
+
|
|
285
|
+
For `link` and `goto` navigations that match a route:
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
[click <a>] or [router.goto()]
|
|
289
|
+
→ beforeRouteLeave({ type }) // per-route guard
|
|
290
|
+
→ before_navigate(nav) // app-level start
|
|
291
|
+
→ cancelled? yes → stop
|
|
292
|
+
→ no → run loaders(params) // may be value, Promise, or Promise[]
|
|
293
|
+
→ cache data by formatted path
|
|
294
|
+
→ history.push/replaceState(new URL)
|
|
295
|
+
→ after_navigate(nav)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- If a loader throws/rejects, navigation continues and `after_navigate(..., with nav.to.data = { __error })` is delivered so UI can render an error state.
|
|
299
|
+
- For `popstate`, loaders run before completion so content matches the target entry; this improves scroll restoration. Errors are delivered via `after_navigate` with `nav.to.data = { __error }`.
|
|
300
|
+
|
|
301
|
+
### Shallow Routing
|
|
302
|
+
|
|
303
|
+
Use `pushState(url, state?)` or `replaceState(url, state?)` to update the URL/state without re-running routing logic.
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
pushState/replaceState (shallow)
|
|
307
|
+
→ updates history.state and URL
|
|
308
|
+
→ router does not process routing on shallow operations
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
This lets you reflect UI state in the URL while deferring route transitions until a future navigation.
|
|
312
|
+
|
|
313
|
+
### History Index & popstate Cancellation
|
|
314
|
+
|
|
315
|
+
To enable `popstate` cancellation, Navaid stores a monotonic `idx` in `history.state.__navaid.idx`. On `popstate`, a cancelled navigation computes the delta between the target and current `idx` and calls `history.go(-delta)` to return to the prior entry.
|
|
316
|
+
|
|
317
|
+
### Scroll Restoration
|
|
318
|
+
|
|
319
|
+
Navgo manages scroll manually (sets `history.scrollRestoration = 'manual'`) and applies SvelteKit-like behavior:
|
|
320
|
+
|
|
321
|
+
- Saves the current scroll position for the active history index.
|
|
322
|
+
- On `link`/`nav` (after route commit):
|
|
323
|
+
- If the URL has a `#hash`, scroll to the matching element `id` or `[name="..."]`.
|
|
324
|
+
- Otherwise, scroll to the top `(0, 0)`.
|
|
325
|
+
- On `popstate`: restore the saved position for the target history index; if not found but there is a `#hash`, scroll to the anchor instead.
|
|
326
|
+
- Shallow `pushState`/`replaceState` never adjust scroll (routing is skipped).
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
scroll flow
|
|
330
|
+
├─ on any nav: save current scroll for current idx
|
|
331
|
+
├─ link/goto: after navigate → hash? anchor : scroll(0,0)
|
|
332
|
+
└─ popstate: after navigate → restore saved idx position (fallback: anchor)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Method-by-Method Semantics
|
|
336
|
+
|
|
337
|
+
- `format(uri)` — normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
|
|
338
|
+
- `match(uri)` — returns a Promise of `{ route, params } | null` using string/RegExp patterns and validators. Awaits an async `validate(params)` if provided.
|
|
339
|
+
- `goto(uri, { replace? })` — fires route-level `beforeRouteLeave('goto')`, calls global `before_navigate`, saves scroll, runs loaders, pushes/replaces, and completes via `after_navigate`.
|
|
340
|
+
- `init()` — wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
|
|
341
|
+
- `destroy()` — removes listeners added by `init()`.
|
|
342
|
+
- `preload(uri)` — pre-executes a route's `loaders` for a path and caches the result; concurrent calls are deduped.
|
|
343
|
+
- `pushState(url?, state?)` — shallow push that updates the URL and `history.state` without route processing.
|
|
344
|
+
- `replaceState(url?, state?)` — shallow replace that updates the URL and `history.state` without route processing.
|
|
345
|
+
|
|
346
|
+
### Built-in Validators
|
|
347
|
+
|
|
348
|
+
- `Navgo.validators.int({ min?, max? })` — `true` iff the value is an integer within optional bounds.
|
|
349
|
+
- `Navgo.validators.oneOf(iterable)` — `true` iff the value is in the provided set.
|
|
350
|
+
|
|
351
|
+
Attach validators via a route tuple's `data.param_validators` to constrain matches.
|