navgo 5.0.1 → 6.0.0-beta.0

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.
Files changed (4) hide show
  1. package/index.d.ts +21 -16
  2. package/index.js +76 -29
  3. package/package.json +2 -2
  4. package/readme.md +56 -55
package/index.d.ts CHANGED
@@ -3,23 +3,31 @@
3
3
  * For string patterns, missing optional params are `null`.
4
4
  * For RegExp named groups, missing groups may be `undefined`.
5
5
  */
6
- export type Params = Record<string, string | null | undefined>
6
+ export type RawParam = string | null | undefined
7
+ export type Params = Record<string, any>
7
8
 
8
9
  /** Built-in validator helpers shape. */
9
10
  export interface ValidatorHelpers {
10
- int(opts?: {
11
- min?: number | null
12
- max?: number | null
13
- }): (value: string | null | undefined) => boolean
14
- one_of(values: Iterable<string>): (value: string | null | undefined) => boolean
11
+ int(opts?: { min?: number | null; max?: number | null }): (value: RawParam) => boolean
12
+ one_of(values: Iterable<string>): (value: RawParam) => boolean
13
+ }
14
+
15
+ export type ParamRule =
16
+ | ((value: RawParam) => boolean)
17
+ | { validator?: (value: RawParam) => boolean; coercer?: (value: RawParam) => any }
18
+
19
+ export interface LoaderArgs {
20
+ route_entry: RouteTuple
21
+ url: URL
22
+ params: Params
15
23
  }
16
24
 
17
25
  /** Optional per-route hooks recognized by Navgo. */
18
26
  export interface Hooks {
19
- /** Validate params with custom per-param validators. Return `false` to skip a match. */
20
- param_validators?: Record<string, (value: string | null | undefined) => boolean>
27
+ /** Validate and/or coerce params (validator runs before coercer). */
28
+ param_rules?: Record<string, ParamRule>
21
29
  /** Load data for a route before navigation. May return a Promise or an array of values/promises. */
22
- loader?(params: Params): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
30
+ loader?(args: LoaderArgs): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
23
31
  /** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
24
32
  validate?(params: Params): boolean | Promise<boolean>
25
33
  /** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
@@ -46,8 +54,8 @@ export interface Navigation {
46
54
  cancel(): void
47
55
  }
48
56
 
49
- /** A route tuple: [pattern, data?]. The `data` may include {@link Hooks}. */
50
- export type RouteTuple<T = unknown> = [pattern: string | RegExp, data: T]
57
+ /** A route tuple: [pattern, data?, extra?]. The `data`/`extra` may include {@link Hooks}. */
58
+ export type RouteTuple<T = unknown, U = unknown> = [pattern: string | RegExp, data?: T, extra?: U]
51
59
 
52
60
  /** Result of calling `router.match(url)` */
53
61
  export interface MatchResult<T = unknown> {
@@ -88,11 +96,8 @@ export interface Options {
88
96
  tick?: () => void | Promise<void>
89
97
  /** When `false`, do not scroll to top on non-hash navigations. Default true. */
90
98
  scroll_to_top?: boolean
91
- /** Global hook fired whenever the URL changes.
92
- * Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
93
- * Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
94
- */
95
- url_changed?(payload: any): void
99
+ /** When `true`, sets `aria-current="page"` on active in-app links. Default false. */
100
+ aria_current?: boolean
96
101
  }
97
102
 
98
103
  /** Navgo default export: class-based router. */
package/index.js CHANGED
@@ -13,9 +13,9 @@ export default class Navgo {
13
13
  preload_on_hover: true,
14
14
  before_navigate: undefined,
15
15
  after_navigate: undefined,
16
- url_changed: undefined,
17
16
  tick,
18
17
  scroll_to_top: true,
18
+ aria_current: false,
19
19
  attach_to_window: true,
20
20
  }
21
21
  /** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
@@ -107,6 +107,7 @@ export default class Navgo {
107
107
  ℹ(' - [🧭 event:popstate]', 'same path+search; skip loader')
108
108
  this.#apply_scroll(ev)
109
109
  this.route.set(this.#current)
110
+ this.#update_active_links()
110
111
  return
111
112
  }
112
113
  // Explicit shallow entries (pushState/replaceState) regardless of path
@@ -115,6 +116,7 @@ export default class Navgo {
115
116
  ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loader')
116
117
  this.#apply_scroll(ev)
117
118
  this.route.set(this.#current)
119
+ this.#update_active_links()
118
120
  return
119
121
  }
120
122
 
@@ -150,6 +152,7 @@ export default class Navgo {
150
152
  // update current URL snapshot and notify
151
153
  this.#current.url = new URL(location.href)
152
154
  this.route.set(this.#current)
155
+ this.#update_active_links()
153
156
  }
154
157
 
155
158
  /** @type {any} */
@@ -202,7 +205,7 @@ export default class Navgo {
202
205
  will_unload: true,
203
206
  event: ev,
204
207
  })
205
- this.#current.route?.[1]?.before_route_leave?.(nav)
208
+ this.#get_hooks(this.#current.route)?.before_route_leave?.(nav)
206
209
  if (nav.cancelled) {
207
210
  ℹ('[🧭 navigate]', 'cancelled by before_route_leave during unload')
208
211
  ev.preventDefault()
@@ -247,17 +250,39 @@ export default class Navgo {
247
250
  : null
248
251
  }
249
252
 
250
- #check_param_validators(param_validators, params) {
251
- for (const k in param_validators) {
252
- const fn = param_validators[k]
253
- if (typeof fn !== 'function') continue
254
- if (!fn(params[k])) return false
253
+ #update_active_links() {
254
+ if (!this.#opts.aria_current) return
255
+ const cur = this.format(this.#current.url?.pathname)
256
+ if (!cur) return
257
+ for (const a of document.querySelectorAll('a[href]')) {
258
+ const href = a.getAttribute('href')
259
+ if (href[0] === '#') continue
260
+ const link_path = href && this.#resolve_url_and_path(href)?.path
261
+ if (link_path === cur) a.setAttribute('aria-current', 'page')
262
+ else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
255
263
  }
256
- return true
264
+ }
265
+
266
+ #get_hooks(route) {
267
+ if (!route) return {}
268
+ const a = route[1]
269
+ const b = route[2]
270
+ if (!b) return a || {}
271
+ const hooks = { ...(a || {}), ...b }
272
+ const ar = a?.param_rules
273
+ const br = b?.param_rules
274
+ if (ar || br) {
275
+ const out = {}
276
+ const norm = r => (typeof r === 'function' ? { validator: r } : r || {})
277
+ for (const k in ar || {}) out[k] = norm(ar[k])
278
+ for (const k in br || {}) out[k] = { ...out[k], ...norm(br[k]) }
279
+ hooks.param_rules = out
280
+ }
281
+ return hooks
257
282
  }
258
283
 
259
284
  async #run_loader(route, url, params) {
260
- const ret_val = route[1].loader?.({ route_entry: route, url, params })
285
+ const ret_val = this.#get_hooks(route)?.loader?.({ route_entry: route, url, params })
261
286
  return Array.isArray(ret_val) ? Promise.all(ret_val) : ret_val
262
287
  }
263
288
 
@@ -318,7 +343,7 @@ export default class Navgo {
318
343
  //
319
344
  // before_route_leave
320
345
  //
321
- this.#current.route?.[1]?.before_route_leave?.(nav)
346
+ this.#get_hooks(this.#current.route)?.before_route_leave?.(nav)
322
347
  if (nav.cancelled) {
323
348
  // use history.go to cancel the nav, and jump back to where we are
324
349
  if (is_popstate) {
@@ -355,11 +380,23 @@ export default class Navgo {
355
380
  let data
356
381
  if (hit) {
357
382
  const pre = this.#preloads.get(path)
358
- data =
359
- pre?.data ??
360
- (await (pre?.promise || this.#run_loader(hit.route, url, hit.params)).catch(e => ({
361
- __error: e,
362
- })))
383
+ try {
384
+ data =
385
+ pre?.data ??
386
+ (await (pre?.promise || this.#run_loader(hit.route, url, hit.params)))
387
+ } catch (e) {
388
+ this.#preloads.delete(path)
389
+ ℹ('[🧭 loader]', 'error; abort', { path, error: e })
390
+ if (is_popstate) {
391
+ const new_idx = ev_param?.state?.__navgo?.idx
392
+ if (new_idx != null) {
393
+ const delta = new_idx - this.#route_idx
394
+ if (delta) history.go(-delta)
395
+ }
396
+ }
397
+ if (nav_id === this.#nav_active) this.is_navigating.set(false)
398
+ return
399
+ }
363
400
  this.#preloads.delete(path)
364
401
  ℹ('[🧭 loader]', pre ? 'using preloaded data' : 'loaded', {
365
402
  path,
@@ -427,6 +464,7 @@ export default class Navgo {
427
464
  // allow frameworks to flush DOM before scrolling
428
465
  await this.#opts.tick?.()
429
466
 
467
+ this.#update_active_links()
430
468
  this.#apply_scroll(nav)
431
469
  this.is_navigating.set(false)
432
470
  }
@@ -467,6 +505,7 @@ export default class Navgo {
467
505
  // update current URL snapshot and notify
468
506
  this.#current.url = u
469
507
  this.route.set(this.#current)
508
+ this.#update_active_links()
470
509
  }
471
510
 
472
511
  /** @param {string|URL} [url] @param {any} [state] */
@@ -537,15 +576,28 @@ export default class Navgo {
537
576
  }
538
577
 
539
578
  // per-route validators and optional async validate()
540
- const hooks = obj.data[1]
541
- if (
542
- hooks.param_validators &&
543
- !this.#check_param_validators(hooks.param_validators, params)
544
- ) {
545
- ℹ('[🧭 match]', 'skip: param_validators', {
546
- pattern: obj.data?.[0],
547
- })
548
- continue
579
+ const hooks = this.#get_hooks(obj.data)
580
+ if (hooks.param_rules) {
581
+ let ok = true
582
+ for (const k in hooks.param_rules) {
583
+ const param_rule = hooks.param_rules[k]
584
+ const param_validator =
585
+ typeof param_rule === 'function' ? param_rule : param_rule?.validator
586
+ if (typeof param_validator === 'function' && !param_validator(params[k])) {
587
+ ok = false
588
+ break
589
+ }
590
+ }
591
+ if (!ok) {
592
+ ℹ('[🧭 match]', 'skip: param_rules', { pattern: obj.data?.[0] })
593
+ continue
594
+ }
595
+ for (const k in hooks.param_rules) {
596
+ const param_rule = hooks.param_rules[k]
597
+ const param_coercer =
598
+ typeof param_rule === 'function' ? null : param_rule?.coercer
599
+ if (typeof param_coercer === 'function') params[k] = param_coercer(params[k])
600
+ }
549
601
  }
550
602
  if (hooks.validate && !(await hooks.validate(params))) {
551
603
  ℹ('[🧭 match]', 'skip: validate', { pattern: obj.data?.[0] })
@@ -574,11 +626,6 @@ export default class Navgo {
574
626
  return pat
575
627
  })
576
628
 
577
- // TODO: deprecated, remove later
578
- this.route.subscribe(() => {
579
- this.#opts.url_changed?.(this.#current)
580
- })
581
-
582
629
  ℹ('[🧭 init]', {
583
630
  base: this.#base,
584
631
  routes: this.#routes.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "navgo",
3
- "version": "5.0.1",
3
+ "version": "6.0.0-beta.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mustafa0x/navgo.git"
@@ -18,7 +18,7 @@
18
18
  "build": "perl -0777 -i -pe 's/console.debug\\(...args\\)/{}/g' index.js",
19
19
  "prepublishOnly": "pnpm run build",
20
20
  "test": "vitest run index.test.js",
21
- "test:e2e": "playwright test",
21
+ "test:e2e": "playwright test --project=chromium",
22
22
  "start:testsite": "pnpm vite dev test/site --port 5714",
23
23
  "types": "tsc -p test/types"
24
24
  },
package/readme.md CHANGED
@@ -8,62 +8,64 @@ $ pnpm install --dev navgo
8
8
 
9
9
  ```js
10
10
  import Navgo from 'navgo'
11
+ import {mount} from 'svelte'
12
+
13
+ import App from './App.svelte'
14
+ import * as HomeRoute from './routes/Home.svelte'
15
+ import * as ReaderRoute from './routes/Reader.svelte'
16
+ import * as AccountRoute from './routes/Account.svelte'
17
+ import * as AdminRoute from './routes/Admin.svelte'
18
+ import * as DebugRoute from './routes/Debug.svelte'
11
19
 
12
- // Define routes up front (strings or RegExp)
13
20
  const routes = [
14
- ['/', {}],
15
- ['/users/:username', {}],
16
- ['/books/*', {}],
17
- [/articles\/(?<year>[0-9]{4})/, {}],
18
- [/privacy|privacy-policy/, {}],
21
+ ['/', HomeRoute],
22
+ ['/:book_id', ReaderRoute],
23
+ ['/account', AccountRoute],
19
24
  [
20
- '/admin',
25
+ '/admin/:id',
26
+ AdminRoute,
21
27
  {
22
- // constrain params with built-ins or your own
23
- param_validators: {
24
- /* id: Navgo.validators.int({ min: 1 }) */
28
+ // constrain/coerce params
29
+ param_rules: {
30
+ id: { validator: Navgo.validators.int({ min: 1 }), coercer: Number },
25
31
  },
26
32
  // load data before URL changes; result goes to after_navigate(...)
27
- loader: params => fetch('/api/admin').then(r => r.json()),
33
+ loader: ({ params }) => fetch(`/api/admin/${params.id}`).then(r => r.json()),
28
34
  // per-route guard; cancel synchronously to block nav
29
35
  before_route_leave(nav) {
30
- if ((nav.type === 'link' || nav.type === 'nav') && !confirm('Enter admin?')) {
36
+ if ((nav.type === 'link' || nav.type === 'goto') && !confirm('Enter admin?')) {
31
37
  nav.cancel()
32
38
  }
33
39
  },
34
40
  },
35
41
  ],
36
42
  ]
43
+ if (window.__DEBUG__) routes.push(['/debug', DebugRoute])
44
+
45
+ const props = $state({
46
+ component: null,
47
+ route_data: null,
48
+ is_404: false,
49
+ })
50
+
51
+ function after_navigate(nav) {
52
+ props.is_404 = nav.to?.data?.__error?.status === 404
53
+ props.route_data = nav.to?.data ?? null
54
+ props.component = nav.to?.route?.[1]?.default || null
55
+ }
37
56
 
38
- // Create router with options + callbacks
39
57
  const router = new Navgo(routes, {
40
- base: '/',
41
58
  before_navigate(nav) {
42
59
  // app-level hook before loader/URL update; may cancel
43
60
  console.log('before_navigate', nav.type, '→', nav.to?.url.pathname)
44
61
  },
45
- after_navigate(nav) {
46
- // called after routing completes; nav.to.data holds loader result
47
- if (nav.to?.data?.__error?.status === 404) {
48
- console.log('404 for', nav.to.url.pathname)
49
- return
50
- }
51
-
52
- console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
53
- },
54
- // let your framework flush DOM before scroll
55
- // e.g. in Svelte: `import { tick } from 'svelte'`
56
- tick: tick,
57
- url_changed(cur) {
58
- // fires on shallow/hash/popstate-shallow/404 and full navigations
59
- // `cur` is the router snapshot: { url: URL, route, params }
60
- console.log('url_changed', cur.url.href)
61
- },
62
+ after_navigate,
62
63
  })
63
64
 
64
65
  // Long-lived router: history + <a> bindings
65
66
  // Also immediately processes the current location
66
67
  router.init()
68
+ mount(App, {target: document.body, props})
67
69
  ```
68
70
 
69
71
  ## API
@@ -74,9 +76,9 @@ Returns: `Router`
74
76
 
75
77
  #### `routes`
76
78
 
77
- Type: `Array<[pattern: string | RegExp, data: any]>`
79
+ Type: `Array<[pattern: string | RegExp, data?: any, extra?: any]>`
78
80
 
79
- Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). Pass `{}` when no hooks are needed. Navgo returns this tuple back to you unchanged via `onRoute`.
81
+ Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). The optional third item is extra hooks and is merged with the second item (third wins; `param_rules` are merged by key).
80
82
 
81
83
  Supported pattern types:
82
84
 
@@ -104,10 +106,8 @@ Notes:
104
106
  - Awaited after `after_navigate` and before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
105
107
  - `scroll_to_top`: `boolean` (default `true`)
106
108
  - When `false`, skips the default top scroll for non-hash navigations.
107
- - `url_changed`: `(snapshot: any) => void`
108
- - Fires on every URL change -- shallow `push_state`/`replace_state`, hash changes, `popstate` shallow entries, 404s, and full navigations. (deprecated; subscribe to `.route` instead.)
109
- - Receives the router's current snapshot: an object like `{ url: URL, route: RouteTuple|null, params: Params }`.
110
- - The snapshot type is intentionally `any` and may evolve without a breaking change.
109
+ - `aria_current`: `boolean` (default `false`)
110
+ - When `true`, sets `aria-current="page"` on active in-app links.
111
111
  - `preload_delay`: `number` (default `20`)
112
112
  - Delay in ms before hover preloading triggers.
113
113
  - `preload_on_hover`: `boolean` (default `true`)
@@ -139,10 +139,11 @@ const {route, is_navigating} = router
139
139
 
140
140
  ### Route Hooks
141
141
 
142
- - param_validators?: `Record<string, (value: string|null|undefined) => boolean>`
143
- - Validate params (e.g., `id: Navgo.validators.int({ min: 1 })`). Any `false` result skips the route.
144
- - loader?(params): `unknown | Promise | Array<unknown|Promise>`
145
- - Run before URL changes on `link`/`nav`. Results are cached per formatted path and forwarded to `after_navigate`.
142
+ - param_rules?: `Record<string, ((value: string|null|undefined) => boolean) | { validator?: (value: string|null|undefined) => boolean; coercer?: (value: string|null|undefined) => any }>`
143
+ - Single place for param rules. If the value is a function, it is treated as a validator.
144
+ - Validators run on raw params; coercers run after validators and may transform params before `validate(...)`/`loader`.
145
+ - loader?({ params }): `unknown | Promise | Array<unknown|Promise>`
146
+ - Run before URL changes on `link`/`goto`. Results are cached per formatted path and forwarded to `after_navigate`.
146
147
  - validate?(params): `boolean | Promise<boolean>`
147
148
  - Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
148
149
  - before_route_leave?(nav): `(nav: Navigation) => void`
@@ -152,7 +153,7 @@ The `Navigation` object contains:
152
153
 
153
154
  ```ts
154
155
  {
155
- type: 'link' | 'nav' | 'popstate' | 'leave',
156
+ type: 'link' | 'goto' | 'popstate' | 'leave',
156
157
  from: { url, params, route } | null,
157
158
  to: { url, params, route } | null,
158
159
  will_unload: boolean,
@@ -166,7 +167,7 @@ The `Navigation` object contains:
166
167
 
167
168
  - Router calls `before_navigate` on the current route (leave).
168
169
  - Call `nav.cancel()` synchronously to cancel.
169
- - For `link`/`nav`, it stops before URL change.
170
+ - For `link`/`goto`, it stops before URL change.
170
171
  - For `popstate`, cancellation causes an automatic `history.go(...)` to revert to the previous index.
171
172
  - For `leave`, cancellation triggers the native “Leave site?” dialog (behavior is browser-controlled).
172
173
 
@@ -175,15 +176,15 @@ Example:
175
176
  ```js
176
177
  const routes = [
177
178
  [
178
- '/admin',
179
+ '/account/:account_id',
179
180
  {
180
- param_validators: {
181
- /* ... */
181
+ param_rules: {
182
+ account_id: {validator: Navgo.validators.int({min: 1}), coercer: Number},
182
183
  },
183
- loader: params => fetch('/api/admin/stats').then(r => r.json()),
184
+ loader: ({params}) => fetch(`/api/account/${params.account_id}`).then(r => r.json()),
184
185
  before_route_leave(nav) {
185
- if (nav.type === 'link' || nav.type === 'nav') {
186
- if (!confirm('Enter admin area?')) nav.cancel()
186
+ if (nav.type === 'link' || nav.type === 'goto') {
187
+ if (!confirm('Leave account settings?')) nav.cancel()
187
188
  }
188
189
  },
189
190
  },
@@ -325,7 +326,7 @@ The router passes the type to your route-level `before_route_leave(nav)` hook.
325
326
  - Named params from string patterns populate `params` with `string` values; optional params that do not appear are `null`.
326
327
  - Wildcards use the `'*'` key.
327
328
  - RegExp named groups also populate `params`; omitted groups can be `undefined`.
328
- - If `data.param_validators` is present, each `params[k]` is validated; any `false` result skips that route.
329
+ - If `data.param_rules` is present, each `params[k]` validator runs first, then coercers run to transform params.
329
330
  - If `data.validate(params)` returns or resolves to `false`, the route is also skipped.
330
331
 
331
332
  ### Data Flow
@@ -337,7 +338,7 @@ For `link` and `goto` navigations that match a route:
337
338
  → before_route_leave({ type }) // per-route guard
338
339
  → before_navigate(nav) // app-level start
339
340
  → cancelled? yes → stop
340
- → no → run loader(params) // may be value, Promise, or Promise[]
341
+ → no → run loader({ params }) // may be value, Promise, or Promise[]
341
342
  → cache data by formatted path
342
343
  → history.push/replaceState(new URL)
343
344
  → after_navigate(nav)
@@ -345,8 +346,8 @@ For `link` and `goto` navigations that match a route:
345
346
  → scroll restore/hash/top
346
347
  ```
347
348
 
348
- - If a loader throws/rejects, navigation continues and `after_navigate(..., with nav.to.data = { __error })` is delivered so UI can render an error state.
349
- - For `popstate`, the route's `loader` runs before completion so content matches the target entry; this improves scroll restoration. Errors are delivered via `after_navigate` with `nav.to.data = { __error }`.
349
+ - If a loader throws/rejects, navigation aborts (no URL/history change, no `after_navigate`).
350
+ - For `popstate`, the route's `loader` runs before completion so content matches the target entry; this improves scroll restoration. If the loader throws, the popstate is reverted.
350
351
 
351
352
  ### Shallow Routing
352
353
 
@@ -369,7 +370,7 @@ To enable `popstate` cancellation, Navgo stores a monotonic `idx` in `history.st
369
370
  Navgo manages scroll manually (sets `history.scrollRestoration = 'manual'`) and applies SvelteKit-like behavior:
370
371
 
371
372
  - Saves the current scroll position for the active history index.
372
- - On `link`/`nav` (after route commit):
373
+ - On `link`/`goto` (after route commit):
373
374
  - If the URL has a `#hash`, scroll to the matching element `id` or `[name="..."]`.
374
375
  - Otherwise, scroll to the top `(0, 0)`.
375
376
  - On `popstate`: restore the saved position for the target history index; if not found but there is a `#hash`, scroll to the anchor instead.
@@ -398,7 +399,7 @@ scroll flow
398
399
  - `Navgo.validators.int({ min?, max? })` -- `true` iff the value is an integer within optional bounds.
399
400
  - `Navgo.validators.one_of(iterable)` -- `true` iff the value is in the provided set.
400
401
 
401
- Attach validators via a route tuple's `data.param_validators` to constrain matches.
402
+ Attach validators via a route tuple's `data.param_rules` rules.
402
403
 
403
404
  # Credits
404
405