navgo 6.0.5 → 6.0.8
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/changelog.md +49 -31
- package/index.d.ts +64 -7
- package/index.js +317 -145
- package/llms.txt +13 -4
- package/package.json +1 -1
- package/readme.md +55 -22
package/changelog.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v6.0.8
|
|
4
|
+
|
|
5
|
+
- breaking: make search-schema transitions atomic by publishing `router.route` before global `router.search_params`, with sync guard across both writes
|
|
6
|
+
- note: `router.route.subscribe(...)` now fires before `router.search_params` updates on search-schema transitions
|
|
7
|
+
|
|
8
|
+
## v6.0.7
|
|
9
|
+
|
|
10
|
+
- add `nav.status` as the formal HTTP-like status for completed navigations
|
|
11
|
+
- add route-level `ssr = {serve_shell, refresh_every}` exports
|
|
12
|
+
- add bidirectional `rewrite` hooks so apps can map public URLs to a canonical internal route tree and back again
|
|
13
|
+
- add `router.href()` for building public in-app links from canonical internal targets
|
|
14
|
+
- expose `internal_url`, `path`, and `context` on navigation targets, the route store, and loader/search hook contexts
|
|
15
|
+
|
|
16
|
+
## v6.0.6
|
|
17
|
+
|
|
18
|
+
- add `data.__meta.preloads` for executed LoadPlans so consumers can reuse same-origin request URLs
|
|
19
|
+
for SSR preload headers
|
|
20
|
+
|
|
3
21
|
## v6.0.5
|
|
4
22
|
|
|
5
23
|
- fix hash-only back scroll restoration when a pending same-page hash click is cancelled before its `hashchange` settles
|
|
@@ -51,18 +69,18 @@ Before:
|
|
|
51
69
|
```js
|
|
52
70
|
// manual orchestration (old style)
|
|
53
71
|
const routes = [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
[
|
|
73
|
+
'/dashboard',
|
|
74
|
+
{
|
|
75
|
+
async loader({ fetch }) {
|
|
76
|
+
const [user, posts] = await Promise.all([
|
|
77
|
+
fetch('/api/user').then(r => r.json()),
|
|
78
|
+
fetch('/api/posts?limit=10').then(r => r.json()),
|
|
79
|
+
])
|
|
80
|
+
return { user, posts }
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
66
84
|
]
|
|
67
85
|
```
|
|
68
86
|
|
|
@@ -71,17 +89,17 @@ After:
|
|
|
71
89
|
```js
|
|
72
90
|
// LoadPlan
|
|
73
91
|
const routes = [
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
[
|
|
93
|
+
'/dashboard',
|
|
94
|
+
{
|
|
95
|
+
loader() {
|
|
96
|
+
return {
|
|
97
|
+
user: '/api/user',
|
|
98
|
+
posts: { request: '/api/posts?limit=10', cache: { strategy: 'swr', ttl: 5_000 } },
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
85
103
|
]
|
|
86
104
|
```
|
|
87
105
|
|
|
@@ -91,20 +109,20 @@ Layout/group loaders now run for every matched child. Read their data from
|
|
|
91
109
|
Before:
|
|
92
110
|
|
|
93
111
|
```js
|
|
94
|
-
const routes = [
|
|
95
|
-
['/account/:id', { loader: ctx => ctx.fetch('/api/account').then(r => r.json()) }],
|
|
96
|
-
]
|
|
112
|
+
const routes = [['/account/:id', { loader: ctx => ctx.fetch('/api/account').then(r => r.json()) }]]
|
|
97
113
|
```
|
|
98
114
|
|
|
99
115
|
After:
|
|
100
116
|
|
|
101
117
|
```js
|
|
102
118
|
const routes = [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
{
|
|
120
|
+
layout: { default: 'AccountLayout' },
|
|
121
|
+
loader: async ctx => ctx.fetch('/api/session').then(r => r.json()),
|
|
122
|
+
routes: [
|
|
123
|
+
['/account/:id', { loader: async ctx => ctx.fetch('/api/account').then(r => r.json()) }],
|
|
124
|
+
],
|
|
125
|
+
},
|
|
108
126
|
]
|
|
109
127
|
|
|
110
128
|
// in after_navigate:
|
package/index.d.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface LoadPlanDefaults {
|
|
|
46
46
|
cache?: CacheOptions
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export type LoadPlanSource = 'network' | 'cache' | 'stale' | 'revalidated'
|
|
50
|
+
|
|
51
|
+
export interface LoadPlanMeta {
|
|
52
|
+
source: Record<string, LoadPlanSource>
|
|
53
|
+
at: number
|
|
54
|
+
preloads?: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
export interface SearchOptions {
|
|
50
58
|
show_defaults?: boolean
|
|
51
59
|
debounce?: number
|
|
@@ -74,9 +82,28 @@ export interface SearchOptions {
|
|
|
74
82
|
>)
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
export interface RewriteResult {
|
|
86
|
+
url?: string | URL
|
|
87
|
+
context?: unknown
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RewriteContext<T = unknown> {
|
|
91
|
+
url: URL
|
|
92
|
+
current: NavigationTarget<T> | null
|
|
93
|
+
context?: unknown
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Rewrite<T = unknown> {
|
|
97
|
+
input?(ctx: RewriteContext<T>): string | URL | RewriteResult | void
|
|
98
|
+
output?(ctx: RewriteContext<T>): string | URL | RewriteResult | void
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
export interface LoaderContext {
|
|
78
102
|
route_entry: RouteTuple
|
|
79
103
|
url: URL
|
|
104
|
+
internal_url: URL
|
|
105
|
+
path: string
|
|
106
|
+
context?: unknown
|
|
80
107
|
params: Params
|
|
81
108
|
search_params: Record<string, unknown>
|
|
82
109
|
signal: AbortSignal
|
|
@@ -93,7 +120,7 @@ export interface Match<T = unknown> {
|
|
|
93
120
|
layout?: any
|
|
94
121
|
/** Present when `type === 'route'`. */
|
|
95
122
|
route?: RouteTuple<T>
|
|
96
|
-
/** Loader result for this match when available (e.g. on navigation completion). */
|
|
123
|
+
/** Loader result for this match when available (e.g. on navigation completion). LoadPlan results include `__meta`. */
|
|
97
124
|
data?: unknown
|
|
98
125
|
}
|
|
99
126
|
|
|
@@ -129,12 +156,19 @@ export interface PreloadBundle<T = unknown> {
|
|
|
129
156
|
data?: unknown
|
|
130
157
|
}
|
|
131
158
|
|
|
159
|
+
export interface SsrOptions {
|
|
160
|
+
serve_shell?: boolean
|
|
161
|
+
refresh_every?: number
|
|
162
|
+
}
|
|
163
|
+
|
|
132
164
|
/** Optional per-route hooks recognized by Navgo. */
|
|
133
165
|
export interface Hooks {
|
|
134
166
|
/** Validate and/or coerce params (schema runs before coercer). */
|
|
135
167
|
param_rules?: Record<string, ParamRule>
|
|
136
168
|
/** Load data for a route before navigation. Return a LoadPlan (object) or a Promise for arbitrary data. */
|
|
137
169
|
loader?(ctx: LoaderContext): LoadPlan | Promise<unknown>
|
|
170
|
+
/** Optional SSR metadata exposed on completed navigations as `nav.ssr`. */
|
|
171
|
+
ssr?: SsrOptions
|
|
138
172
|
/** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
|
|
139
173
|
validate?(params: Params): boolean | Promise<boolean>
|
|
140
174
|
/** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
|
|
@@ -145,6 +179,9 @@ export interface Hooks {
|
|
|
145
179
|
|
|
146
180
|
export interface NavigationTarget<T = unknown> {
|
|
147
181
|
url: URL
|
|
182
|
+
internal_url: URL
|
|
183
|
+
path: string
|
|
184
|
+
context?: unknown
|
|
148
185
|
params: Params
|
|
149
186
|
/** The matched route tuple from your original `routes` list; `null` when unmatched (e.g. external). */
|
|
150
187
|
route: RouteTuple<T> | null
|
|
@@ -152,7 +189,7 @@ export interface NavigationTarget<T = unknown> {
|
|
|
152
189
|
matches?: Match<T>[]
|
|
153
190
|
/** Keyed lookup into matched layout/group wrappers by `RouteGroup.id`. */
|
|
154
191
|
layouts?: LayoutsMap<T>
|
|
155
|
-
/** Optional data from route loader when available. */
|
|
192
|
+
/** Optional data from route loader when available. LoadPlan results include `__meta`. */
|
|
156
193
|
data?: unknown
|
|
157
194
|
}
|
|
158
195
|
|
|
@@ -160,10 +197,14 @@ export interface Navigation {
|
|
|
160
197
|
type: 'link' | 'goto' | 'popstate' | 'leave'
|
|
161
198
|
from: NavigationTarget | null
|
|
162
199
|
to: NavigationTarget | null
|
|
200
|
+
/** HTTP-like status for the completed target. Unmatched routes are `404`; `data.__error.status` wins when present. */
|
|
201
|
+
status: number
|
|
163
202
|
will_unload: boolean
|
|
164
203
|
cancelled: boolean
|
|
165
204
|
/** The original browser event that initiated navigation, when available. */
|
|
166
205
|
event?: Event
|
|
206
|
+
/** Optional SSR metadata resolved from the matched leaf route's `ssr` export. */
|
|
207
|
+
ssr?: SsrOptions
|
|
167
208
|
cancel(): void
|
|
168
209
|
}
|
|
169
210
|
|
|
@@ -194,6 +235,8 @@ export interface NavgoHistoryMeta {
|
|
|
194
235
|
export interface Options {
|
|
195
236
|
/** App base path. Default '/' */
|
|
196
237
|
base?: string
|
|
238
|
+
/** Optional bidirectional URL rewrite hooks. */
|
|
239
|
+
rewrite?: Rewrite
|
|
197
240
|
/** Delay before hover preloading in milliseconds. Default 20. */
|
|
198
241
|
preload_delay?: number
|
|
199
242
|
/** Disable hover/touch preloading when `false`. Default true. */
|
|
@@ -203,7 +246,7 @@ export interface Options {
|
|
|
203
246
|
search?: SearchOptions
|
|
204
247
|
/** Global hook fired after per-route `before_route_leave`, before loader/history change. Can cancel. */
|
|
205
248
|
before_navigate?(nav: Navigation): void
|
|
206
|
-
/** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
|
|
249
|
+
/** Global hook fired after routing completes (data loaded, URL updated, handlers run). `goto()` resolves after this hook. */
|
|
207
250
|
after_navigate?(nav: Navigation, on_revalidate?: (cb: () => void) => void): void | Promise<void>
|
|
208
251
|
/** Optional hook awaited after `after_navigate` and before scroll handling.
|
|
209
252
|
* Useful for UI frameworks (e.g., Svelte) to flush DOM updates so anchor/top
|
|
@@ -221,25 +264,39 @@ export interface Options {
|
|
|
221
264
|
/** Navgo default export: class-based router. */
|
|
222
265
|
export default class Navgo<T = unknown> {
|
|
223
266
|
constructor(routes?: Array<RouteEntry<T>>, opts?: Options)
|
|
224
|
-
/** Format
|
|
267
|
+
/** Format a public URL into the router's internal canonical path. */
|
|
225
268
|
format(url: string): string | false
|
|
269
|
+
/** Build a public in-app href from an internal or literal input target. */
|
|
270
|
+
href(
|
|
271
|
+
url: string | URL,
|
|
272
|
+
opts?: { absolute?: boolean; literal?: boolean; context?: unknown },
|
|
273
|
+
): string | false
|
|
226
274
|
/** SvelteKit-like navigation that runs `loader` before updating the URL. */
|
|
227
|
-
goto(
|
|
275
|
+
goto(
|
|
276
|
+
url: string | URL,
|
|
277
|
+
opts?: { replace?: boolean; literal?: boolean; context?: unknown },
|
|
278
|
+
): Promise<void>
|
|
228
279
|
/** Shallow push — updates URL/state without triggering handlers. */
|
|
229
280
|
push_state(url?: string | URL, state?: any): void
|
|
230
281
|
/** Shallow replace — updates URL/state without triggering handlers. */
|
|
231
282
|
replace_state(url?: string | URL, state?: any): void
|
|
232
283
|
/** Manually preload `loader` for a URL (deduped). */
|
|
233
|
-
preload(
|
|
284
|
+
preload(
|
|
285
|
+
url: string | URL,
|
|
286
|
+
opts?: { literal?: boolean; context?: unknown },
|
|
287
|
+
): Promise<unknown | void>
|
|
234
288
|
/** Try to match `url`; returns route tuple and params or `null`. Supports async `validate`. */
|
|
235
289
|
match(url: string): Promise<MatchResult<T> | null>
|
|
236
290
|
/** Attach history + click listeners and immediately process current location. */
|
|
237
291
|
init(): Promise<void>
|
|
238
292
|
/** Remove listeners installed by `init()`. */
|
|
239
293
|
destroy(): void
|
|
240
|
-
/** Writable store with current { url, route, params, matches, layouts, search_params }. */
|
|
294
|
+
/** Writable store with current { url, internal_url, path, context, route, params, matches, layouts, search_params }. */
|
|
241
295
|
readonly route: import('svelte/store').Writable<{
|
|
242
296
|
url: URL
|
|
297
|
+
internal_url: URL
|
|
298
|
+
path: string
|
|
299
|
+
context?: unknown
|
|
243
300
|
route: RouteTuple<T> | null
|
|
244
301
|
params: Params
|
|
245
302
|
matches: Match<T>[]
|
package/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export default class Navgo {
|
|
|
23
23
|
/** @type {Options} */
|
|
24
24
|
#opts = {
|
|
25
25
|
base: '/',
|
|
26
|
+
rewrite: undefined,
|
|
26
27
|
preload_delay: 20,
|
|
27
28
|
preload_on_hover: true,
|
|
28
29
|
before_navigate: undefined,
|
|
@@ -51,9 +52,12 @@ export default class Navgo {
|
|
|
51
52
|
#base_rgx = /^\/+/
|
|
52
53
|
/** @type {Map<string, { promise?: Promise<PreloadBundle>, data?: PreloadBundle }>} */
|
|
53
54
|
#preloads = new Map()
|
|
54
|
-
/** @type {{ url: URL|null, route: RouteTuple|null, params: Params, matches: Match[], layouts: Record<string, Match>, search_params: Record<string, unknown> }} */
|
|
55
|
+
/** @type {{ url: URL|null, internal_url: URL|null, path: string, context: any, route: RouteTuple|null, params: Params, matches: Match[], layouts: Record<string, Match>, search_params: Record<string, unknown> }} */
|
|
55
56
|
#current = {
|
|
56
57
|
url: null,
|
|
58
|
+
internal_url: null,
|
|
59
|
+
path: '',
|
|
60
|
+
context: undefined,
|
|
57
61
|
route: null,
|
|
58
62
|
params: {},
|
|
59
63
|
matches: [],
|
|
@@ -86,6 +90,9 @@ export default class Navgo {
|
|
|
86
90
|
#search_writer = null
|
|
87
91
|
route = writable({
|
|
88
92
|
url: new URL(location.href),
|
|
93
|
+
internal_url: new URL(location.href),
|
|
94
|
+
path: normalize_path(new URL(location.href).pathname),
|
|
95
|
+
context: undefined,
|
|
89
96
|
route: null,
|
|
90
97
|
params: {},
|
|
91
98
|
matches: [],
|
|
@@ -108,7 +115,7 @@ export default class Navgo {
|
|
|
108
115
|
const url = new URL(info.href, location.href)
|
|
109
116
|
|
|
110
117
|
// Hash-only navigation on same path: let browser handle, but track index
|
|
111
|
-
if (url.hash && url
|
|
118
|
+
if (url.hash && this.#same_public_url(url, this.#current.url)) {
|
|
112
119
|
const cur_hash = location.href.split('#')[1]
|
|
113
120
|
const next_hash = url.href.split('#')[1] ?? ''
|
|
114
121
|
if (cur_hash === next_hash) {
|
|
@@ -143,7 +150,7 @@ export default class Navgo {
|
|
|
143
150
|
})
|
|
144
151
|
|
|
145
152
|
ℹ('[🧭 link]', 'intercept', { href: info.href })
|
|
146
|
-
this.goto(info.href, { replace: false }, 'link', e)
|
|
153
|
+
this.goto(info.href, { replace: false, literal: true }, 'link', e)
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
#on_popstate = ev => {
|
|
@@ -154,10 +161,9 @@ export default class Navgo {
|
|
|
154
161
|
ℹ('[🧭 event:popstate]', st)
|
|
155
162
|
// Hash-only or state-only change: pathname+search unchanged -> skip loader
|
|
156
163
|
const cur = this.#current.url
|
|
157
|
-
const target =
|
|
158
|
-
if (cur && target
|
|
159
|
-
const next_current =
|
|
160
|
-
this.#current = next_current
|
|
164
|
+
const target = this.#resolve_url_and_path(location.href, { literal: true })
|
|
165
|
+
if (cur && target && this.#same_public_url(target.url, cur)) {
|
|
166
|
+
const next_current = this.#update_current_info(target)
|
|
161
167
|
ℹ(' - [🧭 event:popstate]', 'same path+search; skip loader')
|
|
162
168
|
this.#apply_scroll(ev)
|
|
163
169
|
this.route.set(next_current)
|
|
@@ -170,10 +176,9 @@ export default class Navgo {
|
|
|
170
176
|
// or after navigating to a different route and coming back via Back/Forward.
|
|
171
177
|
if (st?.shallow) {
|
|
172
178
|
const from = typeof st.from === 'string' ? st.from : null
|
|
173
|
-
if (!from || (cur && from === cur.pathname)) {
|
|
174
|
-
const next_current =
|
|
175
|
-
this.#
|
|
176
|
-
this.#sync_search_from_url(target)
|
|
179
|
+
if (!from || (cur && normalize_path(from) === normalize_path(cur.pathname))) {
|
|
180
|
+
const next_current = this.#update_current_info(target)
|
|
181
|
+
this.#sync_search_from_url(next_current.url)
|
|
177
182
|
ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loader')
|
|
178
183
|
this.#apply_scroll(ev)
|
|
179
184
|
this.route.set(next_current)
|
|
@@ -183,7 +188,7 @@ export default class Navgo {
|
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
ℹ(' - [🧭 event:popstate]', { idx: st?.idx })
|
|
186
|
-
this.goto(location.href, { replace: true }, 'popstate', ev)
|
|
191
|
+
this.goto(location.href, { replace: true, literal: true }, 'popstate', ev)
|
|
187
192
|
}
|
|
188
193
|
#on_hashchange = () => {
|
|
189
194
|
// if hashchange originated from a click we tracked, bump our index and persist it
|
|
@@ -221,8 +226,10 @@ export default class Navgo {
|
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
228
|
// update current URL snapshot and notify
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
const next_current = this.#update_current_info(
|
|
230
|
+
this.#resolve_url_and_path(location.href, { literal: true }),
|
|
231
|
+
)
|
|
232
|
+
this.route.set(next_current)
|
|
226
233
|
this.#update_active_links()
|
|
227
234
|
}
|
|
228
235
|
|
|
@@ -232,7 +239,7 @@ export default class Navgo {
|
|
|
232
239
|
const info = this.#link_from_event(ev, ev.type === 'mousedown')
|
|
233
240
|
if (info) {
|
|
234
241
|
ℹ('[🧭 preload]', 'link hover/tap', { href: info.href })
|
|
235
|
-
this.preload(info.href)
|
|
242
|
+
this.preload(info.href, { literal: true })
|
|
236
243
|
}
|
|
237
244
|
}
|
|
238
245
|
#mouse_move = ev => {
|
|
@@ -287,23 +294,180 @@ export default class Navgo {
|
|
|
287
294
|
//
|
|
288
295
|
// Helpers
|
|
289
296
|
//
|
|
297
|
+
#info(state) {
|
|
298
|
+
if (!state?.url) return null
|
|
299
|
+
const internal_url = state.internal_url || state.url
|
|
300
|
+
return {
|
|
301
|
+
url: state.url,
|
|
302
|
+
internal_url,
|
|
303
|
+
path: state.path || internal_url.pathname || '',
|
|
304
|
+
context: state.context,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#target(state, extra = undefined) {
|
|
309
|
+
const info = this.#info(state)
|
|
310
|
+
if (!info) return null
|
|
311
|
+
const target = {
|
|
312
|
+
...info,
|
|
313
|
+
params: state?.params || {},
|
|
314
|
+
route: state?.route || null,
|
|
315
|
+
matches: state?.matches || [],
|
|
316
|
+
layouts: state?.layouts || Object.create(null),
|
|
317
|
+
}
|
|
318
|
+
return extra ? { ...target, ...extra } : target
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#update_current_info(info, fallback = location.href) {
|
|
322
|
+
this.#current = info
|
|
323
|
+
? { ...this.#current, ...this.#info(info) }
|
|
324
|
+
: { ...this.#current, url: new URL(fallback, location.href) }
|
|
325
|
+
return this.#current
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#coerce_url(url_raw, base = location.href) {
|
|
329
|
+
if (url_raw == null) return new URL(location.href)
|
|
330
|
+
let raw = url_raw instanceof URL ? url_raw.href : String(url_raw)
|
|
331
|
+
const has_scheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)
|
|
332
|
+
const has_relative_prefix = /^(?:\/|\?|#|\.\/?|\.\.\/?)/.test(raw)
|
|
333
|
+
if (!has_scheme && !has_relative_prefix) raw = '/' + raw
|
|
334
|
+
return new URL(raw, base)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#public_key(url) {
|
|
338
|
+
return normalize_path(url.pathname) + (url.search || '')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#same_public_url(a, b) {
|
|
342
|
+
return !!a && !!b && this.#public_key(a) === this.#public_key(b)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#strip_base_path(pathname) {
|
|
346
|
+
const value = normalize_path(pathname)
|
|
347
|
+
const out = this.#base_rgx.test(value) && value.replace(this.#base_rgx, '/')
|
|
348
|
+
return out || false
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#apply_base_path(pathname) {
|
|
352
|
+
const path = String(pathname || '/')
|
|
353
|
+
const out = path[0] === '/' ? path : '/' + path
|
|
354
|
+
if (this.#base == '/') return out
|
|
355
|
+
if (out === '/') return this.#base + '/'
|
|
356
|
+
return this.#base + out
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#current_context(context = undefined) {
|
|
360
|
+
return context !== undefined
|
|
361
|
+
? context
|
|
362
|
+
: this.#current.context !== undefined
|
|
363
|
+
? this.#current.context
|
|
364
|
+
: this.#resolve_public_url(location.href)?.context
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#resolved_info(url, internal_url = url, context = undefined) {
|
|
368
|
+
internal_url.pathname = normalize_path(internal_url.pathname)
|
|
369
|
+
return {
|
|
370
|
+
url,
|
|
371
|
+
internal_url,
|
|
372
|
+
path: internal_url.pathname,
|
|
373
|
+
load_key: this.#public_key(url),
|
|
374
|
+
context,
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#apply_rewrite(kind, url, context = undefined) {
|
|
379
|
+
const fn = this.#opts.rewrite?.[kind]
|
|
380
|
+
if (typeof fn !== 'function') return { url, context }
|
|
381
|
+
try {
|
|
382
|
+
const out = fn({
|
|
383
|
+
url: new URL(url.href),
|
|
384
|
+
current: this.#target(this.#current),
|
|
385
|
+
context,
|
|
386
|
+
})
|
|
387
|
+
if (!out) return { url, context }
|
|
388
|
+
if (typeof out === 'string' || out instanceof URL) {
|
|
389
|
+
return { url: new URL(out, url), context }
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
url: new URL(out.url ?? url, url),
|
|
393
|
+
context: out.context !== undefined ? out.context : context,
|
|
394
|
+
}
|
|
395
|
+
} catch (e) {
|
|
396
|
+
ℹ('[🧭 rewrite]', kind, 'error', { err: e })
|
|
397
|
+
return { url, context }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#resolve_public_url(url_raw) {
|
|
402
|
+
const url = this.#coerce_url(url_raw, location.href)
|
|
403
|
+
if (url.origin !== location.origin) return null
|
|
404
|
+
const stripped = this.#strip_base_path(url.pathname)
|
|
405
|
+
if (!stripped) return null
|
|
406
|
+
const internal_url = new URL(url.href)
|
|
407
|
+
internal_url.pathname = stripped
|
|
408
|
+
const rewritten = this.#apply_rewrite('input', internal_url)
|
|
409
|
+
return this.#resolved_info(url, rewritten.url, rewritten.context)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#resolve_internal_url(url_raw, context = undefined) {
|
|
413
|
+
const base =
|
|
414
|
+
this.#current.internal_url?.href ||
|
|
415
|
+
this.#resolve_public_url(location.href)?.internal_url?.href ||
|
|
416
|
+
location.href
|
|
417
|
+
const internal_url = this.#coerce_url(url_raw, base)
|
|
418
|
+
if (internal_url.origin !== location.origin) return null
|
|
419
|
+
internal_url.pathname = normalize_path(internal_url.pathname)
|
|
420
|
+
const rewritten = this.#apply_rewrite(
|
|
421
|
+
'output',
|
|
422
|
+
internal_url,
|
|
423
|
+
this.#current_context(context),
|
|
424
|
+
)
|
|
425
|
+
const url = rewritten.url
|
|
426
|
+
if (url.origin !== location.origin) return null
|
|
427
|
+
url.pathname = this.#apply_base_path(url.pathname)
|
|
428
|
+
return this.#resolved_info(url, internal_url, rewritten.context)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#resolve_url_and_path(url_raw, opts = {}) {
|
|
432
|
+
if (url_raw == null) return null
|
|
433
|
+
const raw = url_raw instanceof URL ? url_raw.href : String(url_raw)
|
|
434
|
+
const has_scheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)
|
|
435
|
+
const same_origin =
|
|
436
|
+
!has_scheme || this.#coerce_url(url_raw, location.href).origin === location.origin
|
|
437
|
+
const public_info = same_origin ? this.#resolve_public_url(url_raw) : null
|
|
438
|
+
if (opts.literal) return public_info
|
|
439
|
+
if (!same_origin) return null
|
|
440
|
+
const use_public =
|
|
441
|
+
!!public_info && public_info.path !== normalize_path(public_info.url.pathname)
|
|
442
|
+
const out = use_public ? public_info : this.#resolve_internal_url(url_raw, opts.context)
|
|
443
|
+
ℹ('[🧭 resolve]', {
|
|
444
|
+
url_in: url_raw,
|
|
445
|
+
mode: use_public ? 'public' : 'internal',
|
|
446
|
+
url: out?.url?.href,
|
|
447
|
+
internal_url: out?.internal_url?.href,
|
|
448
|
+
path: out?.path,
|
|
449
|
+
context: out?.context,
|
|
450
|
+
})
|
|
451
|
+
return out || public_info
|
|
452
|
+
}
|
|
453
|
+
|
|
290
454
|
/** @param {string} url @returns {string|false} */
|
|
291
455
|
format(url) {
|
|
292
456
|
if (!url) return url
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
457
|
+
const out = this.#resolve_public_url(url)
|
|
458
|
+
const value = out
|
|
459
|
+
? out.internal_url.pathname +
|
|
460
|
+
(out.internal_url.search || '') +
|
|
461
|
+
(out.internal_url.hash || '')
|
|
462
|
+
: false
|
|
463
|
+
ℹ('[🧭 format]', { in: url, out: value || false })
|
|
464
|
+
return value || false
|
|
297
465
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
const path = this.format(url.pathname).match?.(/[^?#]*/)?.[0]
|
|
304
|
-
const load_key = path && path + url.search
|
|
305
|
-
ℹ('[🧭 resolve]', { url_in: url_raw, url: url.href, path })
|
|
306
|
-
return path ? { url, path, load_key } : null
|
|
466
|
+
|
|
467
|
+
href(url_raw = location.href, opts = {}) {
|
|
468
|
+
const info = this.#resolve_url_and_path(url_raw, opts)
|
|
469
|
+
if (!info) return false
|
|
470
|
+
return opts.absolute ? info.url.href : info.url.pathname + info.url.search + info.url.hash
|
|
307
471
|
}
|
|
308
472
|
|
|
309
473
|
#link_from_event(e, check_button = false) {
|
|
@@ -316,20 +480,21 @@ export default class Navgo {
|
|
|
316
480
|
!a.target &&
|
|
317
481
|
!a.download &&
|
|
318
482
|
a.host === location.host &&
|
|
319
|
-
this.#
|
|
483
|
+
this.#resolve_url_and_path(href, { literal: true })
|
|
320
484
|
? { a, href }
|
|
321
485
|
: null
|
|
322
486
|
}
|
|
323
487
|
|
|
324
488
|
#update_active_links() {
|
|
325
489
|
if (!this.#opts.aria_current) return
|
|
326
|
-
const cur = this.
|
|
490
|
+
const cur = this.#current.url && normalize_path(this.#current.url.pathname)
|
|
327
491
|
if (!cur) return
|
|
328
492
|
for (const a of document.querySelectorAll('a[href]')) {
|
|
329
493
|
const href = a.getAttribute('href')
|
|
330
494
|
if (href[0] === '#') continue
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
495
|
+
const link_url = href && this.#resolve_url_and_path(href, { literal: true })?.url
|
|
496
|
+
if (link_url && normalize_path(link_url.pathname) === cur)
|
|
497
|
+
a.setAttribute('aria-current', 'page')
|
|
333
498
|
else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
|
|
334
499
|
}
|
|
335
500
|
}
|
|
@@ -376,9 +541,26 @@ export default class Navgo {
|
|
|
376
541
|
} catch {}
|
|
377
542
|
}
|
|
378
543
|
|
|
544
|
+
#make_loader_ctx(route, info, params, search_params, controller) {
|
|
545
|
+
const { url, internal_url = url, path = internal_url?.pathname || '', context } = info || {}
|
|
546
|
+
return {
|
|
547
|
+
route_entry: route,
|
|
548
|
+
url,
|
|
549
|
+
internal_url,
|
|
550
|
+
path,
|
|
551
|
+
context,
|
|
552
|
+
params,
|
|
553
|
+
search_params,
|
|
554
|
+
signal: controller.signal,
|
|
555
|
+
fetch: (input, init) => fetch(input, { ...init, signal: controller.signal }),
|
|
556
|
+
invalidate: x => this.invalidate(x),
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
379
560
|
/* Resolve search schema + options for the current match. */
|
|
380
|
-
#resolve_search(matches, route,
|
|
381
|
-
const
|
|
561
|
+
#resolve_search(matches, route, info, params) {
|
|
562
|
+
const { url, internal_url = url, path = internal_url?.pathname || '', context } = info || {}
|
|
563
|
+
const ctx = { route_entry: route, url, internal_url, path, context, params }
|
|
382
564
|
let schema = null
|
|
383
565
|
let opts = merge_search_opts(this.#opts.search || {})
|
|
384
566
|
|
|
@@ -406,10 +588,13 @@ export default class Navgo {
|
|
|
406
588
|
#set_search_store(values) {
|
|
407
589
|
const next = values || {}
|
|
408
590
|
this.#search_syncing = true
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
591
|
+
try {
|
|
592
|
+
this.#current.search_params = next
|
|
593
|
+
if (this.#current.url) this.route.set(this.#current)
|
|
594
|
+
this.search_params.set(next)
|
|
595
|
+
} finally {
|
|
596
|
+
this.#search_syncing = false
|
|
597
|
+
}
|
|
413
598
|
}
|
|
414
599
|
|
|
415
600
|
/* Apply resolved search config to current route. */
|
|
@@ -512,11 +697,14 @@ export default class Navgo {
|
|
|
512
697
|
} catch {}
|
|
513
698
|
const out = {}
|
|
514
699
|
const sources = {}
|
|
700
|
+
const preloads = new Set()
|
|
515
701
|
const defaults = this.#opts.load_plan_defaults || {}
|
|
516
702
|
await Promise.all(
|
|
517
703
|
Object.entries(plan || {}).map(async ([as, raw]) => {
|
|
518
704
|
const spec = typeof raw === 'string' ? { request: raw } : raw || {}
|
|
519
705
|
const req = this.#to_get_request(spec.request, spec.init)
|
|
706
|
+
const url = new URL(req.url)
|
|
707
|
+
if (url.origin === location.origin) preloads.add(url.pathname + url.search)
|
|
520
708
|
const parse = spec.parse || defaults.parse || 'json'
|
|
521
709
|
const cache_hints = { ...(defaults.cache || {}), ...(spec.cache || {}) }
|
|
522
710
|
const strategy = cache_hints.strategy || 'swr'
|
|
@@ -579,7 +767,10 @@ export default class Navgo {
|
|
|
579
767
|
sources[as] = source
|
|
580
768
|
}),
|
|
581
769
|
)
|
|
582
|
-
return {
|
|
770
|
+
return {
|
|
771
|
+
...out,
|
|
772
|
+
__meta: { source: sources, at: Date.now(), preloads: [...preloads] },
|
|
773
|
+
}
|
|
583
774
|
}
|
|
584
775
|
|
|
585
776
|
#emit_revalidate(id, as, value) {
|
|
@@ -596,6 +787,7 @@ export default class Navgo {
|
|
|
596
787
|
try {
|
|
597
788
|
data[as] = value
|
|
598
789
|
if (data.__meta?.source) data.__meta.source[as] = 'revalidated'
|
|
790
|
+
r.nav.status = this.#nav_status(r.nav.to)
|
|
599
791
|
r.updated = true
|
|
600
792
|
this.route.set(this.#current)
|
|
601
793
|
for (const fn of r.cbs || [])
|
|
@@ -605,38 +797,9 @@ export default class Navgo {
|
|
|
605
797
|
} catch {}
|
|
606
798
|
}
|
|
607
799
|
|
|
608
|
-
async #
|
|
609
|
-
const loader = this.#get_hooks(route)?.loader
|
|
800
|
+
async #run_loader_fn(loader, route, info, params, search_params, controller, nav_id) {
|
|
610
801
|
if (!loader) return undefined
|
|
611
|
-
const
|
|
612
|
-
route_entry: route,
|
|
613
|
-
url,
|
|
614
|
-
params,
|
|
615
|
-
search_params,
|
|
616
|
-
signal: controller.signal,
|
|
617
|
-
fetch: (i, init) => fetch(i, { ...init, signal: controller.signal }),
|
|
618
|
-
invalidate: x => this.invalidate(x),
|
|
619
|
-
}
|
|
620
|
-
const ret = loader(ctx)
|
|
621
|
-
if (isPromise(ret)) return ret
|
|
622
|
-
if (ret && typeof ret === 'object' && !Array.isArray(ret))
|
|
623
|
-
return this.#run_plan(ret, controller, nav_id)
|
|
624
|
-
return ret
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
async #run_group_loader(group, route, url, params, search_params, controller, nav_id) {
|
|
628
|
-
const loader = group?.loader
|
|
629
|
-
if (!loader) return undefined
|
|
630
|
-
const ctx = {
|
|
631
|
-
route_entry: route,
|
|
632
|
-
url,
|
|
633
|
-
params,
|
|
634
|
-
search_params,
|
|
635
|
-
signal: controller.signal,
|
|
636
|
-
fetch: (i, init) => fetch(i, { ...init, signal: controller.signal }),
|
|
637
|
-
invalidate: x => this.invalidate(x),
|
|
638
|
-
}
|
|
639
|
-
const ret = loader(ctx)
|
|
802
|
+
const ret = loader(this.#make_loader_ctx(route, info, params, search_params, controller))
|
|
640
803
|
if (isPromise(ret)) return ret
|
|
641
804
|
if (ret && typeof ret === 'object' && !Array.isArray(ret))
|
|
642
805
|
return this.#run_plan(ret, controller, nav_id)
|
|
@@ -663,6 +826,17 @@ export default class Navgo {
|
|
|
663
826
|
return out
|
|
664
827
|
}
|
|
665
828
|
|
|
829
|
+
#nav_status(target) {
|
|
830
|
+
const err = target?.data?.__error
|
|
831
|
+
return typeof err?.status === 'number'
|
|
832
|
+
? err.status
|
|
833
|
+
: err
|
|
834
|
+
? 500
|
|
835
|
+
: target?.route === null
|
|
836
|
+
? 404
|
|
837
|
+
: 200
|
|
838
|
+
}
|
|
839
|
+
|
|
666
840
|
#build_matches(route, stack) {
|
|
667
841
|
const out = []
|
|
668
842
|
for (const g of stack || []) {
|
|
@@ -675,9 +849,10 @@ export default class Navgo {
|
|
|
675
849
|
return out
|
|
676
850
|
}
|
|
677
851
|
|
|
678
|
-
async #load_hit(hit,
|
|
852
|
+
async #load_hit(hit, info, controller, nav_id) {
|
|
679
853
|
const matches = hit.matches || this.#build_matches(hit.route, hit.stack)
|
|
680
|
-
const {
|
|
854
|
+
const { url } = info || {}
|
|
855
|
+
const { schema, opts } = this.#resolve_search(matches, hit.route, info, hit.params)
|
|
681
856
|
|
|
682
857
|
const defaults = schema ? v.getDefaults(schema) || {} : {}
|
|
683
858
|
const search_params = schema
|
|
@@ -685,19 +860,19 @@ export default class Navgo {
|
|
|
685
860
|
: {}
|
|
686
861
|
|
|
687
862
|
const ps = matches.map(m => {
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
863
|
+
const entry = m.type === 'route' ? this.#get_hooks(m.route) : m.__entry
|
|
864
|
+
const route = m.type === 'route' ? m.route : hit.route
|
|
865
|
+
return Promise.resolve(
|
|
866
|
+
this.#run_loader_fn(
|
|
867
|
+
entry?.loader,
|
|
868
|
+
route,
|
|
869
|
+
info,
|
|
870
|
+
hit.params,
|
|
871
|
+
search_params,
|
|
872
|
+
controller,
|
|
873
|
+
nav_id,
|
|
874
|
+
),
|
|
875
|
+
).catch(e => ({ __error: e }))
|
|
701
876
|
})
|
|
702
877
|
const datas = await Promise.all(ps)
|
|
703
878
|
for (let i = 0; i < matches.length; i++) matches[i].data = datas[i]
|
|
@@ -713,22 +888,22 @@ export default class Navgo {
|
|
|
713
888
|
* @returns {Navigation}
|
|
714
889
|
*/
|
|
715
890
|
#make_nav({ type, from = undefined, to = undefined, will_unload = false, event = undefined }) {
|
|
716
|
-
const from_obj =
|
|
717
|
-
|
|
718
|
-
? from
|
|
719
|
-
: this.#current.url
|
|
720
|
-
? {
|
|
721
|
-
url: this.#current.url,
|
|
722
|
-
params: this.#current.params || {},
|
|
723
|
-
route: this.#current.route,
|
|
724
|
-
matches: this.#current.matches || [],
|
|
725
|
-
layouts: this.#current.layouts || Object.create(null),
|
|
726
|
-
}
|
|
727
|
-
: null
|
|
891
|
+
const from_obj = from !== undefined ? from : this.#target(this.#current)
|
|
892
|
+
const ssr = to?.route && this.#get_hooks(to.route)?.ssr
|
|
728
893
|
return {
|
|
729
894
|
type, // 'link' | 'goto' | 'popstate' | 'leave'
|
|
730
895
|
from: from_obj,
|
|
731
896
|
to,
|
|
897
|
+
status: 200,
|
|
898
|
+
ssr:
|
|
899
|
+
ssr && typeof ssr === 'object'
|
|
900
|
+
? {
|
|
901
|
+
serve_shell: ssr.serve_shell === true,
|
|
902
|
+
refresh_every: Number.isFinite(ssr.refresh_every)
|
|
903
|
+
? Math.max(0, Math.floor(ssr.refresh_every))
|
|
904
|
+
: 0,
|
|
905
|
+
}
|
|
906
|
+
: undefined,
|
|
732
907
|
will_unload,
|
|
733
908
|
cancelled: false,
|
|
734
909
|
event,
|
|
@@ -762,7 +937,7 @@ export default class Navgo {
|
|
|
762
937
|
}
|
|
763
938
|
|
|
764
939
|
try {
|
|
765
|
-
const info = this.#resolve_url_and_path(url_raw)
|
|
940
|
+
const info = this.#resolve_url_and_path(url_raw, opts)
|
|
766
941
|
if (!info) return void ℹ('[🧭 goto]', 'invalid url', { url: url_raw })
|
|
767
942
|
this.is_navigating.set(true)
|
|
768
943
|
|
|
@@ -780,7 +955,7 @@ export default class Navgo {
|
|
|
780
955
|
|
|
781
956
|
let nav = this.#make_nav({
|
|
782
957
|
type: nav_type,
|
|
783
|
-
to:
|
|
958
|
+
to: this.#target(info),
|
|
784
959
|
event: ev_param,
|
|
785
960
|
})
|
|
786
961
|
ℹ('[🧭 goto]', 'start', {
|
|
@@ -808,16 +983,19 @@ export default class Navgo {
|
|
|
808
983
|
ℹ('[🧭 match]', 'error', { err: e })
|
|
809
984
|
}
|
|
810
985
|
if (nav_id !== this.#nav_active) return
|
|
811
|
-
nav.to =
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
986
|
+
nav.to = this.#target(
|
|
987
|
+
hit
|
|
988
|
+
? {
|
|
989
|
+
...info,
|
|
990
|
+
params: hit.params || {},
|
|
991
|
+
route: hit.route || null,
|
|
992
|
+
matches: hit.matches || [],
|
|
993
|
+
layouts: hit.layouts || this.#build_layouts(hit.matches || []),
|
|
994
|
+
}
|
|
995
|
+
: info,
|
|
996
|
+
)
|
|
820
997
|
if (match_error) nav.to.data = { __error: match_error }
|
|
998
|
+
nav.status = this.#nav_status(nav.to)
|
|
821
999
|
|
|
822
1000
|
// before_navigate (skip initial)
|
|
823
1001
|
if (nav.from) {
|
|
@@ -844,7 +1022,7 @@ export default class Navgo {
|
|
|
844
1022
|
const pre = this.#preloads.get(load_key)
|
|
845
1023
|
bundle =
|
|
846
1024
|
pre?.data ??
|
|
847
|
-
(await (pre?.promise || this.#load_hit(hit,
|
|
1025
|
+
(await (pre?.promise || this.#load_hit(hit, info, controller, nav_id)).catch(
|
|
848
1026
|
e => ({
|
|
849
1027
|
matches: [],
|
|
850
1028
|
data: { __error: e },
|
|
@@ -881,20 +1059,23 @@ export default class Navgo {
|
|
|
881
1059
|
|
|
882
1060
|
const prev = this.#current
|
|
883
1061
|
const matches = hit && !match_error ? bundle?.matches || [] : []
|
|
1062
|
+
const layouts =
|
|
1063
|
+
hit && !match_error
|
|
1064
|
+
? bundle?.layouts || this.#build_layouts(matches)
|
|
1065
|
+
: Object.create(null)
|
|
884
1066
|
const data = match_error
|
|
885
1067
|
? { __error: match_error }
|
|
886
1068
|
: hit
|
|
887
1069
|
? bundle?.data
|
|
888
1070
|
: { __error: { status: 404 } }
|
|
889
1071
|
this.#current = {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
: Object.create(null),
|
|
1072
|
+
...this.#target({
|
|
1073
|
+
...info,
|
|
1074
|
+
route: match_error ? null : hit?.route || null,
|
|
1075
|
+
params: match_error ? {} : hit?.params || {},
|
|
1076
|
+
matches,
|
|
1077
|
+
layouts,
|
|
1078
|
+
}),
|
|
898
1079
|
search_params: {},
|
|
899
1080
|
}
|
|
900
1081
|
|
|
@@ -903,25 +1084,11 @@ export default class Navgo {
|
|
|
903
1084
|
// Build a completion nav using the previous route as `from`
|
|
904
1085
|
nav = this.#make_nav({
|
|
905
1086
|
type: nav_type,
|
|
906
|
-
from: prev
|
|
907
|
-
|
|
908
|
-
url: prev.url,
|
|
909
|
-
params: prev.params || {},
|
|
910
|
-
route: prev.route,
|
|
911
|
-
matches: prev.matches || [],
|
|
912
|
-
layouts: prev.layouts || Object.create(null),
|
|
913
|
-
}
|
|
914
|
-
: null,
|
|
915
|
-
to: {
|
|
916
|
-
url,
|
|
917
|
-
params: match_error ? {} : hit?.params || {},
|
|
918
|
-
route: match_error ? null : hit?.route || null,
|
|
919
|
-
matches,
|
|
920
|
-
layouts: this.#current.layouts || Object.create(null),
|
|
921
|
-
data,
|
|
922
|
-
},
|
|
1087
|
+
from: this.#target(prev),
|
|
1088
|
+
to: this.#target(this.#current, { data }),
|
|
923
1089
|
event: ev_param,
|
|
924
1090
|
})
|
|
1091
|
+
nav.status = this.#nav_status(nav.to)
|
|
925
1092
|
this.nav = nav
|
|
926
1093
|
|
|
927
1094
|
// Wire up revalidation tracking early (revalidate fetches can resolve before after_navigate runs).
|
|
@@ -938,6 +1105,7 @@ export default class Navgo {
|
|
|
938
1105
|
}
|
|
939
1106
|
}
|
|
940
1107
|
reval.pending.clear()
|
|
1108
|
+
nav.status = this.#nav_status(nav.to)
|
|
941
1109
|
}
|
|
942
1110
|
}
|
|
943
1111
|
|
|
@@ -1004,7 +1172,7 @@ export default class Navgo {
|
|
|
1004
1172
|
)
|
|
1005
1173
|
} catch {}
|
|
1006
1174
|
const idx = prev_idx + (replace ? 0 : 1)
|
|
1007
|
-
const from = this.#current.url?.pathname || location.pathname
|
|
1175
|
+
const from = normalize_path(this.#current.url?.pathname || location.pathname)
|
|
1008
1176
|
const st = { ...state, __navgo: { shallow: true, idx, from } }
|
|
1009
1177
|
history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
|
|
1010
1178
|
ℹ('[🧭 history]', replace ? 'replace_state(shallow)' : 'push_state(shallow)', {
|
|
@@ -1020,9 +1188,12 @@ export default class Navgo {
|
|
|
1020
1188
|
m.set('window', { x: scrollX || 0, y: scrollY || 0 })
|
|
1021
1189
|
this.#areas_pos.set(idx, m)
|
|
1022
1190
|
// update current URL snapshot and notify
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1191
|
+
const next_current = this.#update_current_info(
|
|
1192
|
+
this.#resolve_url_and_path(u.href, { literal: true }),
|
|
1193
|
+
u,
|
|
1194
|
+
)
|
|
1195
|
+
this.#sync_search_from_url(next_current.url)
|
|
1196
|
+
this.route.set(next_current)
|
|
1026
1197
|
this.#update_active_links()
|
|
1027
1198
|
}
|
|
1028
1199
|
|
|
@@ -1081,14 +1252,12 @@ export default class Navgo {
|
|
|
1081
1252
|
* Dedupes concurrent preloads for the same path.
|
|
1082
1253
|
*/
|
|
1083
1254
|
/** @param {string} url_raw @returns {Promise<unknown|void>} */
|
|
1084
|
-
async preload(url_raw) {
|
|
1255
|
+
async preload(url_raw, opts = {}) {
|
|
1085
1256
|
try {
|
|
1086
|
-
const
|
|
1257
|
+
const info = this.#resolve_url_and_path(url_raw, opts)
|
|
1258
|
+
const { path, load_key } = info || {}
|
|
1087
1259
|
if (!path) return void ℹ('[🧭 preload]', 'invalid url', { url: url_raw })
|
|
1088
|
-
if (
|
|
1089
|
-
this.format(this.#current.url?.pathname) + (this.#current.url?.search || '') ===
|
|
1090
|
-
load_key
|
|
1091
|
-
)
|
|
1260
|
+
if (this.#current.url && this.#public_key(this.#current.url) === load_key)
|
|
1092
1261
|
return void ℹ('[🧭 preload]', 'skip current path', { path })
|
|
1093
1262
|
const hit = await this.match(path).catch(() => null)
|
|
1094
1263
|
if (!hit) return void ℹ('[🧭 preload]', 'no route', { path })
|
|
@@ -1101,7 +1270,7 @@ export default class Navgo {
|
|
|
1101
1270
|
|
|
1102
1271
|
const entry = {}
|
|
1103
1272
|
const controller = new AbortController()
|
|
1104
|
-
entry.promise = this.#load_hit(hit,
|
|
1273
|
+
entry.promise = this.#load_hit(hit, info, controller, 0).then(
|
|
1105
1274
|
bundle => {
|
|
1106
1275
|
entry.data = bundle
|
|
1107
1276
|
delete entry.promise
|
|
@@ -1209,6 +1378,9 @@ export default class Navgo {
|
|
|
1209
1378
|
this.#base_rgx =
|
|
1210
1379
|
this.#base == '/' ? /^\/+/ : new RegExp('^\\' + this.#base + '(?=\\/|$)\\/?', 'i')
|
|
1211
1380
|
|
|
1381
|
+
const initial = this.#resolve_public_url(location.href)
|
|
1382
|
+
if (initial) this.route.set({ ...this.#target(initial), search_params: {} })
|
|
1383
|
+
|
|
1212
1384
|
const group_ids = new Map()
|
|
1213
1385
|
|
|
1214
1386
|
function compile_routes(entries, stack = []) {
|
package/llms.txt
CHANGED
|
@@ -4,15 +4,19 @@
|
|
|
4
4
|
- route hooks (`loader`, `param_rules`, `search_schema`, `search_options`, `before_route_leave`) live in the route file’s `<script module>`
|
|
5
5
|
- navigation: match -> leave-guard (`before_route_leave`, can `nav.cancel()`) -> loaders -> update stores -> `after_navigate(nav)`
|
|
6
6
|
- `goto()` and `preload()` are safe to call without `await` from events (no unhandled rejections); errors surface via `nav.to.data.__error` (or preload bundle `data.__error`)
|
|
7
|
+
- `nav.status` is the official HTTP-like status for the completed route; unmatched routes are `404`, and `data.__error.status` wins when present
|
|
8
|
+
- `rewrite.input` maps a public browser URL into the canonical internal path before matching; `rewrite.output` maps an internal target back into a public URL for history/links
|
|
9
|
+
- `router.href('/internal')` builds a public in-app URL from a canonical internal path; pass `{ literal: true }` only when the input is already public
|
|
7
10
|
- `router.init()` attaches the router instance to `window.navgo` by default
|
|
8
11
|
- stores:
|
|
9
|
-
- `window.navgo.route`: `{ url, route, params, matches, layouts, search_params }`
|
|
12
|
+
- `window.navgo.route`: `{ url, internal_url, path, context, route, params, matches, layouts, search_params }`
|
|
10
13
|
- `window.navgo.is_navigating`: boolean
|
|
11
14
|
|
|
12
15
|
## Setup (App Wiring)
|
|
13
16
|
|
|
14
17
|
- `const router = new Navgo(routes, { after_navigate })`; `await router.init()` once at startup
|
|
15
|
-
- `after_navigate(nav)` sets app props: `route_data = nav.to?.data ?? null`, `is_404 = nav.
|
|
18
|
+
- `after_navigate(nav)` sets app props: `route_data = nav.to?.data ?? null`, `is_404 = nav.status === 404`, `Component = leaf route module default`
|
|
19
|
+
- `nav.ssr` is resolved from the matched leaf route's `ssr` export before `after_navigate(nav)` runs
|
|
16
20
|
- render `Component` keyed by `$route.url.pathname`, pass `data={route_data}`
|
|
17
21
|
|
|
18
22
|
## Patterns / Usage
|
|
@@ -22,12 +26,13 @@
|
|
|
22
26
|
- update via `window.navgo.search_params.update(o => ({ ...o, q: 'x', page: 1 }))` (shallow URL update; loaders are not re-run)
|
|
23
27
|
- common pattern: loader uses `search_params` for initial data, then a `$effect` refetches when `$search_params` changes
|
|
24
28
|
- Loaders:
|
|
29
|
+
- loader/search hook ctx includes both public `url` and canonical `internal_url`, plus canonical `path` and arbitrary rewrite `context`
|
|
25
30
|
- returning a non-Promise object means a LoadPlan (cached fetch plan), not plain data; return plain object data via `async loader()`
|
|
26
31
|
- layout/group loaders run outer → inner and their results are on `nav.to.matches[*].data` (leaf convenience stays on `nav.to.data`)
|
|
27
32
|
- SWR revalidation can update `nav.to.data` after navigation; subscribe via `after_navigate(nav, on_revalidate)`
|
|
28
33
|
- Avoid these common mistakes:
|
|
29
34
|
- Re-fetching the same data in the component `<script>` that the loader already fetched (double network + out-of-sync state). Prefer using the `data` prop.
|
|
30
|
-
- Overcomplicating the loader: keep
|
|
35
|
+
- Overcomplicating the loader: do not keep an `async loader()` just to do `filter`/`map`/`sort`-style normalization. Usually keep the loader as a LoadPlan (just URLs/specs) and normalize in the component. Use `async loader()` when later fetches depend on earlier results.
|
|
31
36
|
- Duplicating imports across `<script>` and `<script module>`: in Svelte they share imports; duplicating can cause compile errors. Put imports in one place.
|
|
32
37
|
- Shallow history:
|
|
33
38
|
- `push_state`/`replace_state` updates URL/state without re-running loaders
|
|
@@ -40,6 +45,7 @@
|
|
|
40
45
|
- `goto` usage:
|
|
41
46
|
- prefer plain `<a href="/path">`
|
|
42
47
|
- use `window.navgo.goto('/path')` for buttons/menus/command palette
|
|
48
|
+
- with rewrites, keep route patterns canonical (example: `'/about'`) and switch public variants through `context`/`href()` instead of duplicating route entries
|
|
43
49
|
|
|
44
50
|
## Recipes / Common Scenarios
|
|
45
51
|
|
|
@@ -50,9 +56,12 @@
|
|
|
50
56
|
- SWR revalidate refresh:
|
|
51
57
|
- `after_navigate(nav, on_revalidate) { render(nav.to?.data); on_revalidate?.(() => render(nav.to?.data)) }`
|
|
52
58
|
- Handle 404 / loader errors:
|
|
53
|
-
- `
|
|
59
|
+
- `if (nav.status === 404) show_404 = true`
|
|
54
60
|
- Shared layout data:
|
|
55
61
|
- use `nav.to.layouts?.id?.data` or `$route.layouts?.id?.data` for direct access to matched group data
|
|
56
62
|
- `nav.to.matches` remains the ordered outer → inner structure; layouts are `m.type === 'layout'`, route leaf is `m.type === 'route'`
|
|
63
|
+
- Localized/public URL variants:
|
|
64
|
+
- locale prefixes, vanity slugs, and similar browser-only transforms belong in `rewrite`, not in duplicated route patterns
|
|
65
|
+
- `nav.to.url` is the public browser URL; `nav.to.internal_url` / `nav.to.path` are the canonical values your routes and loaders should reason about
|
|
57
66
|
- Stable scroll panes:
|
|
58
67
|
- set `id="pane"` or `data-scroll-id="pane"` on scroll containers to get popstate restoration
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -37,7 +37,7 @@ const props = $state({
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
function after_navigate(nav, on_revalidate) {
|
|
40
|
-
props.is_404 = nav.
|
|
40
|
+
props.is_404 = nav.status === 404
|
|
41
41
|
props.route_data = nav.to?.data ?? null
|
|
42
42
|
props.Component = nav.to?.route?.[1]?.default ?? null
|
|
43
43
|
on_revalidate?.(() => {
|
|
@@ -145,11 +145,14 @@ Notes:
|
|
|
145
145
|
|
|
146
146
|
- `base`: `string` (default `'/'`)
|
|
147
147
|
- App base pathname. With or without leading/trailing slashes is accepted.
|
|
148
|
+
- `rewrite`: `{ input?: (ctx) => string | URL | { url?: string | URL; context?: unknown } | void; output?: (ctx) => string | URL | { url?: string | URL; context?: unknown } | void }`
|
|
149
|
+
- Optional bidirectional URL rewrite hooks. `input` maps a public URL into Navgo's canonical internal path before matching, while `output` maps an internal target back into a public URL for history, links, and preloading. Useful for locale prefixes like `/en/...` without duplicating routes.
|
|
148
150
|
- `before_navigate`: `(nav: Navigation) => void`
|
|
149
151
|
- App-level hook called once per navigation attempt after the per-route guard and before loader/URL update. May call `nav.cancel()` synchronously to prevent navigation.
|
|
150
152
|
- `after_navigate`: `(nav: Navigation, on_revalidate?: (cb: () => void) => void) => void | Promise<void>`
|
|
151
|
-
- App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data.
|
|
153
|
+
- App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data, and `nav.status` is the HTTP-like status for the completed route.
|
|
152
154
|
- If the active route uses SWR and a stale entry is revalidated in the background, register a callback via `on_revalidate(cb)` to refresh UI.
|
|
155
|
+
- `nav.ssr` is resolved from the matched leaf route's `ssr` export before this hook runs.
|
|
153
156
|
- `tick`: `() => void | Promise<void>`
|
|
154
157
|
- Awaited after `after_navigate` and before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
|
|
155
158
|
- `scroll_to_top`: `boolean` (default `true`)
|
|
@@ -173,7 +176,7 @@ Important: Navgo only processes routes that match your `base` path.
|
|
|
173
176
|
|
|
174
177
|
### Instance stores
|
|
175
178
|
|
|
176
|
-
- `router.route` -- `Writable<{ url: URL; route: RouteTuple|null; params: Params; matches: Match[]; layouts: Record<string, Match>; search_params: SearchParams }>`
|
|
179
|
+
- `router.route` -- `Writable<{ url: URL; internal_url: URL; path: string; context?: unknown; route: RouteTuple|null; params: Params; matches: Match[]; layouts: Record<string, Match>; search_params: SearchParams }>`
|
|
177
180
|
- Readonly property that holds the current snapshot.
|
|
178
181
|
- Subscribe to react to changes; Navgo updates it on every URL change.
|
|
179
182
|
- `router.is_navigating` -- `Writable<boolean>`
|
|
@@ -188,6 +191,7 @@ Example:
|
|
|
188
191
|
|
|
189
192
|
```svelte
|
|
190
193
|
Current path: {$route.path}
|
|
194
|
+
Current public URL: {$route.url.pathname}
|
|
191
195
|
<div class="request-indicator" class:active={$is_navigating}></div>
|
|
192
196
|
|
|
193
197
|
<script>
|
|
@@ -224,9 +228,9 @@ import { v } from 'navgo'
|
|
|
224
228
|
export const search_schema = v.object({
|
|
225
229
|
q: v.optional(v.fallback(v.string(), ''), ''),
|
|
226
230
|
page: v.optional(v.fallback(v.number(), 1), 1),
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
// arrays are supported
|
|
232
|
+
tag: v.optional(v.fallback(v.array(v.string()), []), []),
|
|
233
|
+
cat: v.optional(v.fallback(v.array(v.string()), []), []),
|
|
230
234
|
})
|
|
231
235
|
|
|
232
236
|
export const search_options = {
|
|
@@ -234,8 +238,8 @@ export const search_options = {
|
|
|
234
238
|
push_history: true,
|
|
235
239
|
show_defaults: false,
|
|
236
240
|
sort: true,
|
|
237
|
-
|
|
238
|
-
|
|
241
|
+
// arrays default to 'repeat' (?tag=a&tag=b). When using a map, `default` is the fallback for keys you don't list.
|
|
242
|
+
array_style: { default: 'repeat', cat: 'csv' },
|
|
239
243
|
}
|
|
240
244
|
```
|
|
241
245
|
|
|
@@ -266,22 +270,25 @@ Load plans let you define one or more fetches that Navgo can cache via the Cache
|
|
|
266
270
|
|
|
267
271
|
```js
|
|
268
272
|
// sync => treated as a LoadPlan
|
|
269
|
-
function loader({params}) {
|
|
273
|
+
function loader({ params }) {
|
|
270
274
|
return {
|
|
271
275
|
product: `https://dummyjson.com/products/${params.id}`,
|
|
272
276
|
reviews: {
|
|
273
277
|
request: `https://example.com/reviews/${params.id}`,
|
|
274
|
-
cache: {strategy: 'cache-first', ttl: 60_000, tags: ['reviews']},
|
|
278
|
+
cache: { strategy: 'cache-first', ttl: 60_000, tags: ['reviews'] },
|
|
275
279
|
},
|
|
276
280
|
}
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
// async => treated as plain data
|
|
280
284
|
async function loader(ctx) {
|
|
281
|
-
return {session: await ctx.fetch('/api/session').then(r => r.json())}
|
|
285
|
+
return { session: await ctx.fetch('/api/session').then(r => r.json()) }
|
|
282
286
|
}
|
|
283
287
|
```
|
|
284
288
|
|
|
289
|
+
`ctx.fetch(...)` is just `fetch(...)` with the current navigation's abort `signal` already attached.
|
|
290
|
+
Use plain `fetch` if you do not need navigation-scoped cancellation.
|
|
291
|
+
|
|
285
292
|
Global defaults for LoadPlans can be set in `options`:
|
|
286
293
|
|
|
287
294
|
```js
|
|
@@ -295,6 +302,10 @@ const router = new Navgo(routes, {
|
|
|
295
302
|
|
|
296
303
|
See `examples.md` for more setups.
|
|
297
304
|
|
|
305
|
+
Executed LoadPlans also expose the fetched same-origin request URLs on `data.__meta.preloads` as
|
|
306
|
+
relative `pathname + search` strings. This is useful for SSR services that want to turn LoadPlan
|
|
307
|
+
requests into `Link: rel=preload` headers. Async loaders that return plain data do not produce
|
|
308
|
+
`__meta.preloads`.
|
|
298
309
|
|
|
299
310
|
- param_rules?: `Record<string, ParamRule>`
|
|
300
311
|
- Each rule is either a Valibot schema or `{ schema, coercer }`.
|
|
@@ -304,6 +315,8 @@ See `examples.md` for more setups.
|
|
|
304
315
|
- If you return a **non-Promise object**, it is treated as a `LoadPlan` and executed (each entry can be cached).
|
|
305
316
|
- If you return a **Promise**, it is awaited and the resolved value becomes `nav.to.data`.
|
|
306
317
|
- To return a plain object as data, make the loader `async`.
|
|
318
|
+
- ssr?: `{ serve_shell?: boolean; refresh_every?: number }`
|
|
319
|
+
- Optional SSR metadata for the leaf route. Navgo exposes it on completed navigations as `nav.ssr`.
|
|
307
320
|
- validate?(params): `boolean | Promise<boolean>`
|
|
308
321
|
- Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
|
|
309
322
|
- before_route_leave?(nav): `(nav: Navigation) => void`
|
|
@@ -362,7 +375,7 @@ const routes = [
|
|
|
362
375
|
param_rules: {
|
|
363
376
|
account_id: v.pipe(v.string(), v.toNumber(), v.minValue(1)),
|
|
364
377
|
},
|
|
365
|
-
loader: ({params}) => fetch(`/api/account/${params.account_id}`).then(r => r.json()),
|
|
378
|
+
loader: ({ params }) => fetch(`/api/account/${params.account_id}`).then(r => r.json()),
|
|
366
379
|
before_route_leave(nav) {
|
|
367
380
|
if (nav.type === 'link' || nav.type === 'goto') {
|
|
368
381
|
if (!confirm('Leave account settings?')) nav.cancel()
|
|
@@ -383,9 +396,9 @@ router.init()
|
|
|
383
396
|
|
|
384
397
|
Returns: `String` or `false`
|
|
385
398
|
|
|
386
|
-
|
|
399
|
+
Parses a public URL relative to the configured [`base`](#base) path and optional `rewrite.input`, then returns the canonical internal pathname.
|
|
387
400
|
|
|
388
|
-
If the `uri` **does not**
|
|
401
|
+
If the `uri` **does not** belong to the app's `base`, then `false` will be returned instead.<br>
|
|
389
402
|
Otherwise, the return value will always lead with a slash (`/`).
|
|
390
403
|
|
|
391
404
|
> **Note:** This is called automatically within the [`init()`](#init) method.
|
|
@@ -398,6 +411,22 @@ The path to format.
|
|
|
398
411
|
|
|
399
412
|
> **Note:** Much like [`base`](#base), paths with or without leading and trailing slashes are handled identically.
|
|
400
413
|
|
|
414
|
+
### href(uri, options?)
|
|
415
|
+
|
|
416
|
+
Returns: `String` or `false`
|
|
417
|
+
|
|
418
|
+
Builds a public in-app URL from an internal target. This is the forward counterpart to [`format()`](#formaturi) and is especially useful when combined with `rewrite.output` (for example, locale prefixes).
|
|
419
|
+
|
|
420
|
+
#### options
|
|
421
|
+
|
|
422
|
+
Type: `Object`
|
|
423
|
+
|
|
424
|
+
- absolute: `Boolean` (default `false`)
|
|
425
|
+
- literal: `Boolean` (default `false`)
|
|
426
|
+
- context: `Any`
|
|
427
|
+
|
|
428
|
+
When `literal` is `true`, the `uri` is treated as an already-public URL and is only validated/normalized. Otherwise Navgo treats ambiguous inputs like `/about` and same-origin absolute `URL` values as canonical internal targets and applies `base` + `rewrite.output` when building the final public URL.
|
|
429
|
+
|
|
401
430
|
### goto(uri, options?)
|
|
402
431
|
|
|
403
432
|
Returns: `Promise<void>`
|
|
@@ -406,16 +435,19 @@ Runs any matching route `loader` before updating the URL and then updates histor
|
|
|
406
435
|
|
|
407
436
|
#### uri
|
|
408
437
|
|
|
409
|
-
Type: `String`
|
|
438
|
+
Type: `String | URL`
|
|
410
439
|
|
|
411
|
-
The desired path to navigate.
|
|
440
|
+
The desired path to navigate. When `literal` is `false`, ambiguous paths like `/about` and same-origin absolute `URL` values are treated as canonical internal targets and are passed through `base` + `rewrite.output`.
|
|
412
441
|
|
|
413
442
|
#### options
|
|
414
443
|
|
|
415
444
|
Type: `Object`
|
|
416
445
|
|
|
417
446
|
- replace: `Boolean` (default `false`)
|
|
418
|
-
-
|
|
447
|
+
- literal: `Boolean` (default `false`)
|
|
448
|
+
- context: `Any`
|
|
449
|
+
- When `true`, `replace` uses `history.replaceState`; otherwise `history.pushState`.
|
|
450
|
+
- When `literal` is `true`, the `uri` is interpreted as the already-public browser URL. Otherwise ambiguous inputs like `/about` and same-origin absolute `URL` values are treated as canonical internal targets and pass through `rewrite.output`.
|
|
419
451
|
|
|
420
452
|
### init()
|
|
421
453
|
|
|
@@ -464,11 +496,11 @@ Or with a custom id:
|
|
|
464
496
|
<div data-scroll-id="pane">...</div>
|
|
465
497
|
```
|
|
466
498
|
|
|
467
|
-
### preload(uri)
|
|
499
|
+
### preload(uri, options?)
|
|
468
500
|
|
|
469
501
|
Returns: `Promise<unknown | void>`
|
|
470
502
|
|
|
471
|
-
Preload a route's `loader` data for a given `uri` without navigating. Concurrent calls for the same
|
|
503
|
+
Preload a route's `loader` data for a given `uri` without navigating. Concurrent calls for the same public URL are deduped. Accepts the same `literal` / `context` options as [`goto()`](#gotouri-options).
|
|
472
504
|
Note: Resolves to `undefined` when the matched route has no `loader`.
|
|
473
505
|
|
|
474
506
|
### push_state(url?, state?)
|
|
@@ -566,12 +598,13 @@ scroll flow
|
|
|
566
598
|
|
|
567
599
|
### Method-by-Method Semantics
|
|
568
600
|
|
|
569
|
-
- `format(uri)` --
|
|
601
|
+
- `format(uri)` -- parses a public URL relative to `base` and `rewrite.input`, returning the canonical internal path. Returns `false` when `uri` is outside of `base`.
|
|
570
602
|
- `match(uri)` -- returns a Promise of `{ route, params, matches, layouts } | null` using string/RegExp patterns and `param_rules` (Valibot schemas). Awaits an async `validate(params)` if provided.
|
|
571
|
-
- `
|
|
603
|
+
- `href(uri, options?)` -- builds a public in-app URL from a canonical internal target (or validates a literal public URL when `literal: true`).
|
|
604
|
+
- `goto(uri, { replace?, literal?, context? })` -- fires route-level `before_route_leave('goto')`, calls global `before_navigate`, saves scroll, runs loader, pushes/replaces, and completes via `after_navigate`.
|
|
572
605
|
- `init()` -- wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
|
|
573
606
|
- `destroy()` -- removes listeners added by `init()`.
|
|
574
|
-
- `preload(uri)` -- pre-executes a route's `loader` for a path and caches the result; concurrent calls are deduped.
|
|
607
|
+
- `preload(uri, { literal?, context? })` -- pre-executes a route's `loader` for a path and caches the result; concurrent calls are deduped by public URL.
|
|
575
608
|
- `push_state(url?, state?)` -- shallow push that updates the URL and `history.state` without route processing.
|
|
576
609
|
- `replace_state(url?, state?)` -- shallow replace that updates the URL and `history.state` without route processing.
|
|
577
610
|
|