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 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
- '/dashboard',
56
- {
57
- async loader({ fetch }) {
58
- const [user, posts] = await Promise.all([
59
- fetch('/api/user').then(r => r.json()),
60
- fetch('/api/posts?limit=10').then(r => r.json()),
61
- ])
62
- return { user, posts }
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
- '/dashboard',
76
- {
77
- loader() {
78
- return {
79
- user: '/api/user',
80
- posts: { request: '/api/posts?limit=10', cache: { strategy: 'swr', ttl: 5_000 } },
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
- layout: { default: 'AccountLayout' },
105
- loader: async ctx => ctx.fetch('/api/session').then(r => r.json()),
106
- routes: [['/account/:id', { loader: async ctx => ctx.fetch('/api/account').then(r => r.json()) }]],
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 `url` relative to the configured base. */
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(url: string, opts?: { replace?: boolean }): Promise<void>
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(url: string): Promise<unknown | void>
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.pathname === this.#current.url.pathname) {
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 = new URL(location.href)
158
- if (cur && target.pathname === cur.pathname && target.search === cur.search) {
159
- const next_current = { ...this.#current, url: target }
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 = { ...this.#current, url: target }
175
- this.#current = next_current
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
- this.#current.url = new URL(location.href)
225
- this.route.set(this.#current)
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
- url = normalize_path(url)
294
- const out = this.#base_rgx.test(url) && url.replace(this.#base_rgx, '/')
295
- ℹ('[🧭 format]', { in: url, out })
296
- return out
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
- #resolve_url_and_path(url_raw) {
299
- if (url_raw == null) return null
300
- if (typeof url_raw !== 'string') url_raw = String(url_raw)
301
- if (url_raw[0] == '/' && !this.#base_rgx.test(url_raw)) url_raw = this.#base + url_raw
302
- const url = new URL(url_raw, location.href)
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.#base_rgx.test(a.pathname)
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.format(this.#current.url?.pathname)
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 link_path = href && this.#resolve_url_and_path(href)?.path
332
- if (link_path === cur) a.setAttribute('aria-current', 'page')
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, url, params) {
381
- const ctx = { route_entry: route, url, params }
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
- this.search_params.set(next)
410
- this.#search_syncing = false
411
- this.#current.search_params = next
412
- if (this.#current.url) this.route.set(this.#current)
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 { ...out, __meta: { source: sources, at: Date.now() } }
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 #run_loader(route, url, params, search_params, controller, nav_id) {
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 ctx = {
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, url, controller, nav_id) {
852
+ async #load_hit(hit, info, controller, nav_id) {
679
853
  const matches = hit.matches || this.#build_matches(hit.route, hit.stack)
680
- const { schema, opts } = this.#resolve_search(matches, hit.route, url, hit.params)
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 p =
689
- m.type === 'route'
690
- ? this.#run_loader(m.route, url, hit.params, search_params, controller, nav_id)
691
- : this.#run_group_loader(
692
- m.__entry,
693
- hit.route,
694
- url,
695
- hit.params,
696
- search_params,
697
- controller,
698
- nav_id,
699
- )
700
- return Promise.resolve(p).catch(e => ({ __error: e }))
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
- from !== undefined
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: { url, params: {}, route: null, matches: [], layouts: Object.create(null) },
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 = hit
812
- ? {
813
- url,
814
- params: hit.params || {},
815
- route: hit.route || null,
816
- matches: hit.matches || [],
817
- layouts: hit.layouts || this.#build_layouts(hit.matches || []),
818
- }
819
- : { url, params: {}, route: null, matches: [], layouts: Object.create(null) }
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, url, controller, nav_id)).catch(
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
- url,
891
- route: match_error ? null : hit?.route || null,
892
- params: match_error ? {} : hit?.params || {},
893
- matches,
894
- layouts:
895
- hit && !match_error
896
- ? bundle?.layouts || this.#build_layouts(matches)
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?.url
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
- this.#current.url = u
1024
- this.#sync_search_from_url(u)
1025
- this.route.set(this.#current)
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 { path, url, load_key } = this.#resolve_url_and_path(url_raw) || {}
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, url, controller, 0).then(
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.to?.data?.__error?.status === 404`, `Component = leaf route module default`
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 it as a LoadPlan (just URLs/specs). Do client-side orchestration in the component only if it truly depends on interactive state.
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
- - `const err = nav.to?.data?.__error; if (err?.status === 404) show_404 = true`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "navgo",
3
- "version": "6.0.5",
3
+ "version": "6.0.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mustafa0x/navgo.git"
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.to?.data?.__error?.status === 404
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
- // arrays are supported
228
- tag: v.optional(v.fallback(v.array(v.string()), []), []),
229
- cat: v.optional(v.fallback(v.array(v.string()), []), []),
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
- // arrays default to 'repeat' (?tag=a&tag=b). When using a map, `default` is the fallback for keys you don't list.
238
- array_style: { default: 'repeat', cat: 'csv' },
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
- Formats and returns a pathname relative to the [`base`](#base) path.
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** begin with the `base`, then `false` will be returned instead.<br>
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. If it begins with `/` and does not match the configured [`base`](#base), it will be prefixed automatically.
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
- - When `true`, uses `history.replaceState`; otherwise `history.pushState`.
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 path are deduped.
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)` -- normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
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
- - `goto(uri, { replace? })` -- fires route-level `before_route_leave('goto')`, calls global `before_navigate`, saves scroll, runs loader, pushes/replaces, and completes via `after_navigate`.
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