navgo 6.0.2 → 6.0.4

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
@@ -3,6 +3,12 @@
3
3
  ## v6
4
4
 
5
5
  - add route groups for nested layouts/shared loaders; expose ordered `matches` (layouts → route) on `nav.to.matches` and `router.route`
6
+ - add optional route group `id`s plus keyed `layouts` lookups on navigation targets, `match()`, and `router.route` for direct access to shared layout/group data
7
+ - migration:
8
+ - before: `const app_data = nav.to?.matches?.find(m => m.type === 'layout')?.data`
9
+ - after: `const app_data = nav.to?.layouts?.app?.data`
10
+ - docs/typed surfaces now describe `match()` as returning `{ route, params, matches, layouts }` and `window.navgo.route` as including `layouts`
11
+ - covered on direct `match()`, completed navigations, `popstate`, and preload-reused navigations
6
12
  - run group loaders (and group `before_route_leave`) for matched child routes; preload caches the full loader chain and `goto` reuses it
7
13
  - use `param_rules` for per-param validation + coercion (superseding `param_validators`, which has been removed)
8
14
  - example:
package/index.d.ts CHANGED
@@ -7,6 +7,10 @@ export * as v from 'valibot'
7
7
  */
8
8
  export type RawParam = string | null | undefined
9
9
  export type Params = Record<string, any>
10
+ export type SearchParams = Record<string, unknown>
11
+ export type SearchParamsStore = import('svelte/store').Writable<SearchParams> & {
12
+ toString(): string
13
+ }
10
14
 
11
15
  export type ParamSchema = import('valibot').BaseSchema<unknown, unknown, any>
12
16
  export type ParamRule = ParamSchema | { schema?: ParamSchema; coercer?: (value: RawParam) => any }
@@ -83,6 +87,8 @@ export interface LoaderContext {
83
87
  export interface Match<T = unknown> {
84
88
  /** Matched layout/group wrapper or the final route tuple. */
85
89
  type: 'layout' | 'route'
90
+ /** Present when `type === 'layout'` and the route group declared an `id`. */
91
+ id?: string
86
92
  /** Present when `type === 'layout'`. */
87
93
  layout?: any
88
94
  /** Present when `type === 'route'`. */
@@ -91,7 +97,17 @@ export interface Match<T = unknown> {
91
97
  data?: unknown
92
98
  }
93
99
 
100
+ export interface LayoutMatch<T = unknown> extends Match<T> {
101
+ type: 'layout'
102
+ id: string
103
+ }
104
+
105
+ /** Keyed lookup into matched layout/group wrappers. Values are the same objects as in `matches`. */
106
+ export type LayoutsMap<T = unknown> = Record<string, LayoutMatch<T>>
107
+
94
108
  export interface RouteGroup<T = unknown> {
109
+ /** Optional stable key for direct access via `layouts[id]`. Must be unique across groups. */
110
+ id?: string
95
111
  /** Optional layout component/module (router does not render; it just forwards this). */
96
112
  layout?: any
97
113
  /** Load data for this layout group. Return a LoadPlan (object) or a Promise for arbitrary data. */
@@ -109,6 +125,7 @@ export type RouteEntry<T = unknown> = RouteTuple<T> | RouteGroup<T>
109
125
 
110
126
  export interface PreloadBundle<T = unknown> {
111
127
  matches: Match<T>[]
128
+ layouts?: LayoutsMap<T>
112
129
  data?: unknown
113
130
  }
114
131
 
@@ -133,6 +150,8 @@ export interface NavigationTarget<T = unknown> {
133
150
  route: RouteTuple<T> | null
134
151
  /** Ordered matches for nested layouts and the final route (outer → inner). */
135
152
  matches?: Match<T>[]
153
+ /** Keyed lookup into matched layout/group wrappers by `RouteGroup.id`. */
154
+ layouts?: LayoutsMap<T>
136
155
  /** Optional data from route loader when available. */
137
156
  data?: unknown
138
157
  }
@@ -156,6 +175,7 @@ export interface MatchResult<T = unknown> {
156
175
  route: RouteTuple<T>
157
176
  params: Params
158
177
  matches: Match<T>[]
178
+ layouts: LayoutsMap<T>
159
179
  }
160
180
 
161
181
  // For convenience in docs/types, alias the class instance type
@@ -217,20 +237,21 @@ export default class Navgo<T = unknown> {
217
237
  init(): Promise<void>
218
238
  /** Remove listeners installed by `init()`. */
219
239
  destroy(): void
220
- /** Writable store with current { url, route, params, matches, search_params }. */
240
+ /** Writable store with current { url, route, params, matches, layouts, search_params }. */
221
241
  readonly route: import('svelte/store').Writable<{
222
242
  url: URL
223
243
  route: RouteTuple<T> | null
224
244
  params: Params
225
245
  matches: Match<T>[]
226
- search_params: Record<string, unknown>
246
+ layouts: LayoutsMap<T>
247
+ search_params: SearchParams
227
248
  }>
228
249
  /** Last completed navigation object. */
229
250
  nav: Navigation | null
230
251
  /** Writable store indicating active navigation. */
231
252
  readonly is_navigating: import('svelte/store').Writable<boolean>
232
253
  /** Writable store of validated search params for the current route. */
233
- readonly search_params: import('svelte/store').Writable<Record<string, unknown>>
254
+ readonly search_params: SearchParamsStore
234
255
  /** Invalidate cache entries by canonical keys (URLs) or tags. */
235
256
  invalidate(keys_or_tags: string | string[]): Promise<void>
236
257
  }
package/index.js CHANGED
@@ -3,7 +3,9 @@ import { debounce } from 'es-toolkit/function'
3
3
  import { isPromise } from 'es-toolkit/predicate'
4
4
  import * as v from 'valibot'
5
5
  import {
6
+ build_search_string,
6
7
  build_search_url,
8
+ create_search_store,
7
9
  merge_search_opts,
8
10
  normalize_path,
9
11
  read_search,
@@ -49,8 +51,15 @@ export default class Navgo {
49
51
  #base_rgx = /^\/+/
50
52
  /** @type {Map<string, { promise?: Promise<PreloadBundle>, data?: PreloadBundle }>} */
51
53
  #preloads = new Map()
52
- /** @type {{ url: URL|null, route: RouteTuple|null, params: Params, matches: Match[], search_params: Record<string, unknown> }} */
53
- #current = { url: null, route: null, params: {}, matches: [], search_params: {} }
54
+ /** @type {{ url: URL|null, route: RouteTuple|null, params: Params, matches: Match[], layouts: Record<string, Match>, search_params: Record<string, unknown> }} */
55
+ #current = {
56
+ url: null,
57
+ route: null,
58
+ params: {},
59
+ matches: [],
60
+ layouts: Object.create(null),
61
+ search_params: {},
62
+ }
54
63
  /** @type {number} */
55
64
  #route_idx = 0
56
65
  /** @type {boolean} */
@@ -81,12 +90,13 @@ export default class Navgo {
81
90
  route: null,
82
91
  params: {},
83
92
  matches: [],
93
+ layouts: Object.create(null),
84
94
  search_params: {},
85
95
  })
86
96
  /** @type {Navigation|null} */
87
97
  nav = null
88
98
  is_navigating = writable(false)
89
- search_params = writable({})
99
+ search_params = create_search_store()
90
100
 
91
101
  //
92
102
  // Event listeners
@@ -414,9 +424,23 @@ export default class Navgo {
414
424
  this.#search_opts.debounce > 0
415
425
  ? debounce(v => this.#commit_search(v), this.#search_opts.debounce)
416
426
  : null
427
+ this.search_params.set_stringifier(v => this.#stringify_search(v))
417
428
  this.#set_search_store(search?.search_params ?? {})
418
429
  }
419
430
 
431
+ #stringify_search(values) {
432
+ if (!this.#search_schema) return ''
433
+ const cur = this.#current.url || new URL(location.href)
434
+ return build_search_string(
435
+ cur,
436
+ values,
437
+ this.#search_keys,
438
+ this.#search_defaults,
439
+ this.#search_opts,
440
+ isEqual,
441
+ )
442
+ }
443
+
420
444
  /* Read + validate search params from URL. */
421
445
  #sync_search_from_url(url) {
422
446
  if (!this.#search_schema) return this.#set_search_store({})
@@ -632,10 +656,17 @@ export default class Navgo {
632
656
  }
633
657
  }
634
658
 
659
+ #build_layouts(matches) {
660
+ const out = Object.create(null)
661
+ for (const m of matches || []) if (m.type === 'layout' && m.id) out[m.id] = m
662
+ return out
663
+ }
664
+
635
665
  #build_matches(route, stack) {
636
666
  const out = []
637
667
  for (const g of stack || []) {
638
668
  const obj = { type: 'layout', layout: g.layout }
669
+ if (g.id) obj.id = g.id
639
670
  Object.defineProperty(obj, '__entry', { value: g })
640
671
  out.push(obj)
641
672
  }
@@ -671,6 +702,7 @@ export default class Navgo {
671
702
  for (let i = 0; i < matches.length; i++) matches[i].data = datas[i]
672
703
  return {
673
704
  matches,
705
+ layouts: this.#build_layouts(matches),
674
706
  data: datas[datas.length - 1],
675
707
  search: { schema, opts, defaults, search_params },
676
708
  }
@@ -689,6 +721,7 @@ export default class Navgo {
689
721
  params: this.#current.params || {},
690
722
  route: this.#current.route,
691
723
  matches: this.#current.matches || [],
724
+ layouts: this.#current.layouts || Object.create(null),
692
725
  }
693
726
  : null
694
727
  return {
@@ -746,7 +779,7 @@ export default class Navgo {
746
779
 
747
780
  let nav = this.#make_nav({
748
781
  type: nav_type,
749
- to: { url, params: {}, route: null, matches: [] },
782
+ to: { url, params: {}, route: null, matches: [], layouts: Object.create(null) },
750
783
  event: ev_param,
751
784
  })
752
785
  ℹ('[🧭 goto]', 'start', {
@@ -780,8 +813,9 @@ export default class Navgo {
780
813
  params: hit.params || {},
781
814
  route: hit.route || null,
782
815
  matches: hit.matches || [],
816
+ layouts: hit.layouts || this.#build_layouts(hit.matches || []),
783
817
  }
784
- : { url, params: {}, route: null, matches: [] }
818
+ : { url, params: {}, route: null, matches: [], layouts: Object.create(null) }
785
819
  if (match_error) nav.to.data = { __error: match_error }
786
820
 
787
821
  // before_navigate (skip initial)
@@ -856,6 +890,10 @@ export default class Navgo {
856
890
  route: match_error ? null : hit?.route || null,
857
891
  params: match_error ? {} : hit?.params || {},
858
892
  matches,
893
+ layouts:
894
+ hit && !match_error
895
+ ? bundle?.layouts || this.#build_layouts(matches)
896
+ : Object.create(null),
859
897
  search_params: {},
860
898
  }
861
899
 
@@ -870,6 +908,7 @@ export default class Navgo {
870
908
  params: prev.params || {},
871
909
  route: prev.route,
872
910
  matches: prev.matches || [],
911
+ layouts: prev.layouts || Object.create(null),
873
912
  }
874
913
  : null,
875
914
  to: {
@@ -877,6 +916,7 @@ export default class Navgo {
877
916
  params: match_error ? {} : hit?.params || {},
878
917
  route: match_error ? null : hit?.route || null,
879
918
  matches,
919
+ layouts: this.#current.layouts || Object.create(null),
880
920
  data,
881
921
  },
882
922
  event: ev_param,
@@ -1147,10 +1187,12 @@ export default class Navgo {
1147
1187
  }
1148
1188
 
1149
1189
  ℹ('[🧭 match]', 'hit', { pattern: obj.data?.[0], params })
1190
+ const matches = this.#build_matches(obj.data, obj.stack)
1150
1191
  return {
1151
1192
  route: obj.data || null,
1152
1193
  params,
1153
- matches: this.#build_matches(obj.data, obj.stack),
1194
+ matches,
1195
+ layouts: this.#build_layouts(matches),
1154
1196
  }
1155
1197
  }
1156
1198
  ℹ('[🧭 match]', 'miss', { url: url_raw })
@@ -1166,6 +1208,8 @@ export default class Navgo {
1166
1208
  this.#base_rgx =
1167
1209
  this.#base == '/' ? /^\/+/ : new RegExp('^\\' + this.#base + '(?=\\/|$)\\/?', 'i')
1168
1210
 
1211
+ const group_ids = new Map()
1212
+
1169
1213
  function compile_routes(entries, stack = []) {
1170
1214
  const out = []
1171
1215
  for (const e of entries || []) {
@@ -1181,6 +1225,14 @@ export default class Navgo {
1181
1225
  continue
1182
1226
  }
1183
1227
  if (e && typeof e === 'object' && Array.isArray(e.routes)) {
1228
+ if (e.id != null) {
1229
+ if (typeof e.id !== 'string' || !e.id) {
1230
+ throw new Error('Route group id must be a non-empty string')
1231
+ }
1232
+ if (group_ids.has(e.id))
1233
+ throw new Error(`Duplicate route group id "${e.id}"`)
1234
+ group_ids.set(e.id, e)
1235
+ }
1184
1236
  out.push(...compile_routes(e.routes, stack.concat(e)))
1185
1237
  continue
1186
1238
  }
package/llms.txt CHANGED
@@ -6,7 +6,7 @@
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
7
  - `router.init()` attaches the router instance to `window.navgo` by default
8
8
  - stores:
9
- - `window.navgo.route`: `{ url, route, params, matches, search_params }`
9
+ - `window.navgo.route`: `{ url, route, params, matches, layouts, search_params }`
10
10
  - `window.navgo.is_navigating`: boolean
11
11
 
12
12
  ## Setup (App Wiring)
@@ -52,6 +52,7 @@
52
52
  - Handle 404 / loader errors:
53
53
  - `const err = nav.to?.data?.__error; if (err?.status === 404) show_404 = true`
54
54
  - Shared layout data:
55
- - read `nav.to.matches` outer → inner; layouts are `m.type === 'layout'`, route leaf is `m.type === 'route'`
55
+ - use `nav.to.layouts?.id?.data` or `$route.layouts?.id?.data` for direct access to matched group data
56
+ - `nav.to.matches` remains the ordered outer → inner structure; layouts are `m.type === 'layout'`, route leaf is `m.type === 'route'`
56
57
  - Stable scroll panes:
57
58
  - 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.2",
3
+ "version": "6.0.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mustafa0x/navgo.git"
package/readme.md CHANGED
@@ -114,6 +114,7 @@ Each route group is an object:
114
114
 
115
115
  ```js
116
116
  {
117
+ id?: string,
117
118
  layout?: any,
118
119
  loader?: (ctx) => LoadPlan | Promise<unknown>,
119
120
  before_route_leave?: (nav) => void,
@@ -121,6 +122,7 @@ Each route group is an object:
121
122
  }
122
123
  ```
123
124
 
125
+ - `id` enables direct keyed access via `nav.to.layouts[id]` / `$route.layouts[id]`. IDs must be unique across route groups.
124
126
  - `layout` is forwarded into `nav.to.matches` (the router does not render anything).
125
127
  - `loader` runs for every matched child route in the group.
126
128
  - `before_route_leave` runs when leaving a matched route within the group.
@@ -171,15 +173,16 @@ Important: Navgo only processes routes that match your `base` path.
171
173
 
172
174
  ### Instance stores
173
175
 
174
- - `router.route` -- `Writable<{ url: URL; route: RouteTuple|null; params: Params; matches: Match[]; search_params: Record<string, unknown> }>`
176
+ - `router.route` -- `Writable<{ url: URL; route: RouteTuple|null; params: Params; matches: Match[]; layouts: Record<string, Match>; search_params: SearchParams }>`
175
177
  - Readonly property that holds the current snapshot.
176
178
  - Subscribe to react to changes; Navgo updates it on every URL change.
177
179
  - `router.is_navigating` -- `Writable<boolean>`
178
180
  - `true` while a navigation is in flight (between start and completion/cancel).
179
- - `router.search_params` -- `Writable<Record<string, unknown>>`
181
+ - `router.search_params` -- `Writable<SearchParams> & { toString(): string }`
180
182
  - Writable store of validated search params for the **current** route.
181
183
  - If the current route defines a `search_schema`, this store is kept in sync with the URL.
182
184
  - Writing to it updates the URL search string (optionally debounced).
185
+ - The store object has a custom `toString()` that returns the canonical query string (without the leading `?`) for the current route, using `URLSearchParams` encoding.
183
186
 
184
187
  Example:
185
188
 
@@ -255,6 +258,7 @@ Notes:
255
258
 
256
259
  - Writes are **shallow** (URL changes via `replace_state` / `push_state`), so loaders are not re-run automatically.
257
260
  - If you want a full navigation, call `router.goto(...)` with a new URL.
261
+ - You can serialize the current managed query with `router.search_params.toString()`.
258
262
 
259
263
  ### Route Hooks
260
264
 
@@ -315,8 +319,8 @@ The `Navigation` object contains:
315
319
  ```ts
316
320
  {
317
321
  type: 'link' | 'goto' | 'popstate' | 'leave',
318
- from: { url, params, route, matches } | null,
319
- to: { url, params, route, matches, data } | null,
322
+ from: { url, params, route, matches, layouts } | null,
323
+ to: { url, params, route, matches, layouts, data } | null,
320
324
  will_unload: boolean,
321
325
  cancelled: boolean,
322
326
  event?: Event,
@@ -324,7 +328,7 @@ The `Navigation` object contains:
324
328
  }
325
329
  ```
326
330
 
327
- `nav.to.matches` is ordered **outer → inner** and contains both layouts and the final route:
331
+ `nav.to.matches` is ordered **outer → inner** and remains the canonical structure:
328
332
 
329
333
  ```js
330
334
  for (const m of nav.to?.matches || []) {
@@ -333,6 +337,13 @@ for (const m of nav.to?.matches || []) {
333
337
  }
334
338
  ```
335
339
 
340
+ If a matched route group declares an `id`, Navgo also exposes a keyed lookup that points at the same match object:
341
+
342
+ ```js
343
+ const session = nav.to?.layouts?.app?.data
344
+ const admin_layout = $route.layouts?.admin
345
+ ```
346
+
336
347
  #### Order & cancellation:
337
348
 
338
349
  - Router calls `before_route_leave` on the current route (leave).
@@ -556,7 +567,7 @@ scroll flow
556
567
  ### Method-by-Method Semantics
557
568
 
558
569
  - `format(uri)` -- normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
559
- - `match(uri)` -- returns a Promise of `{ route, params } | null` using string/RegExp patterns and `param_rules` (Valibot schemas). Awaits an async `validate(params)` if provided.
570
+ - `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.
560
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`.
561
572
  - `init()` -- wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
562
573
  - `destroy()` -- removes listeners added by `init()`.
package/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { writable } from 'svelte/store'
1
2
  import * as v from 'valibot'
2
3
 
3
4
  export function normalize_path(value) {
@@ -125,7 +126,7 @@ function safe_json(value) {
125
126
  }
126
127
  }
127
128
 
128
- export function build_search_url(cur, values, keys, defaults, opts, same_value) {
129
+ export function build_search_string(cur, values, keys, defaults, opts, same_value) {
129
130
  let sp = new URLSearchParams(cur.search)
130
131
  const as = opts?.array_style
131
132
  for (const k of keys) {
@@ -159,8 +160,34 @@ export function build_search_url(cur, values, keys, defaults, opts, same_value)
159
160
  if (opts?.sort) {
160
161
  sp = new URLSearchParams([...sp.entries()].sort(([a], [b]) => a.localeCompare(b)))
161
162
  }
163
+ return sp.toString()
164
+ }
165
+
166
+ export function build_search_url(cur, values, keys, defaults, opts, same_value) {
162
167
  const next = new URL(cur.href)
163
- const s = sp.toString()
168
+ const s = build_search_string(cur, values, keys, defaults, opts, same_value)
164
169
  next.search = s ? `?${s}` : ''
165
170
  return next.href === cur.href ? null : next
166
171
  }
172
+
173
+ export function create_search_store(stringify = () => '') {
174
+ let current = {}
175
+ const store = writable(current)
176
+ const api = {
177
+ subscribe: store.subscribe,
178
+ set(value) {
179
+ current = value && typeof value === 'object' ? value : {}
180
+ store.set(current)
181
+ },
182
+ update(fn) {
183
+ api.set(fn(current))
184
+ },
185
+ toString() {
186
+ return stringify(current)
187
+ },
188
+ set_stringifier(fn) {
189
+ stringify = fn || (() => '')
190
+ },
191
+ }
192
+ return api
193
+ }