@orkestrel/router 0.0.1

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.
@@ -0,0 +1,643 @@
1
+ import { canonicalizePath, createRouter, joinPaths } from "../core/index.cjs";
2
+ //#region src/browser/helpers.ts
3
+ /**
4
+ * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
5
+ * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
6
+ *
7
+ * @remarks
8
+ * The grammar this package matches everywhere is `/`-prefixed (§4 path
9
+ * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`
10
+ * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
11
+ * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
12
+ * back). Total — never throws.
13
+ *
14
+ * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)
15
+ * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * extractHashPath('#/users/7?x') // '/users/7'
20
+ * extractHashPath('#/tokens') // '/tokens'
21
+ * extractHashPath('') // '' — the Navigator falls back
22
+ * extractHashPath('#other') // '' — not a `#/` route hash
23
+ * ```
24
+ */
25
+ function extractHashPath(hash) {
26
+ if (!hash.startsWith("#/")) return "";
27
+ const withoutHash = hash.slice(1);
28
+ const queryIndex = withoutHash.indexOf("?");
29
+ return queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
30
+ }
31
+ /**
32
+ * Resolve the `/`-prefixed pathname to match for the CURRENT location, in
33
+ * either navigation mode — the one seam `extractHashPath` (hash mode) and
34
+ * history-mode base-stripping share.
35
+ *
36
+ * @remarks
37
+ * Hash mode (`history: false`) reads `location.hash` through
38
+ * {@link extractHashPath}. History mode (`history: true`) reads
39
+ * `location.pathname` and strips a leading `base` prefix when one is
40
+ * configured: `base` itself maps to the root `'/'`; a pathname that is not
41
+ * under `base` is returned unchanged (a base mismatch is not this helper's
42
+ * concern — the `Navigator`'s match then simply misses). Total — never throws.
43
+ *
44
+ * @param location - The `hash` + `pathname` pair to resolve from (accepts a
45
+ * real `Location` or any object shaped the same, for pure unit testing)
46
+ * @param history - The navigation substrate: `false` for hash mode, `true`
47
+ * for history mode
48
+ * @param base - The history-mode path prefix to strip (ignored in hash mode;
49
+ * omit for no prefix)
50
+ * @returns The `/`-prefixed pathname to match
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'
55
+ * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'
56
+ * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'
57
+ * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'
58
+ * ```
59
+ */
60
+ function resolveLocationPath(location, history, base) {
61
+ if (!history) return extractHashPath(location.hash);
62
+ const pathname = location.pathname;
63
+ if (base === void 0 || base === "") return pathname;
64
+ const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
65
+ if (pathname === normalizedBase) return "/";
66
+ if (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length);
67
+ return pathname;
68
+ }
69
+ /**
70
+ * Find the nearest enclosing `<a>` element a DOM event originated from, by
71
+ * walking its composed path — the pure lookup behind history-mode link
72
+ * interception.
73
+ *
74
+ * @remarks
75
+ * Uses `event.composedPath()` (not `event.target`) so a click on a styled
76
+ * child INSIDE an anchor (an icon, a span) still resolves to the anchor.
77
+ * Total — never throws; returns `undefined` when no anchor is found on the
78
+ * path.
79
+ *
80
+ * @param event - The DOM event to search (typically a `click`)
81
+ * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * document.addEventListener('click', (event) => {
86
+ * const anchor = findAnchor(event)
87
+ * if (anchor !== undefined) console.log(anchor.href)
88
+ * })
89
+ * ```
90
+ */
91
+ function findAnchor(event) {
92
+ for (const node of event.composedPath()) if (node instanceof HTMLAnchorElement) return node;
93
+ }
94
+ //#endregion
95
+ //#region node_modules/@orkestrel/abort/dist/src/core/index.js
96
+ /**
97
+ * Link an own `AbortSignal` to an optional parent signal.
98
+ *
99
+ * @remarks
100
+ * When `parent` is `undefined`, the own signal is returned unchanged. When a
101
+ * parent is given, the result is `AbortSignal.any([own, parent])`, which fires
102
+ * on EITHER the own signal aborting or the parent aborting — without
103
+ * re-implementing listener wiring. A parent that has ALREADY aborted makes the
104
+ * combined signal born aborted (carrying the parent's reason).
105
+ *
106
+ * @param own - The instance's own signal.
107
+ * @param parent - An optional parent signal to link against.
108
+ * @returns `own` unchanged when `parent` is `undefined`, otherwise
109
+ * `AbortSignal.any([own, parent])`.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * import { linkSignal } from '@src/core'
114
+ *
115
+ * const controller = new AbortController()
116
+ * const linked = linkSignal(controller.signal, undefined) // controller.signal
117
+ * ```
118
+ */
119
+ function linkSignal(own, parent) {
120
+ return parent === void 0 ? own : AbortSignal.any([own, parent]);
121
+ }
122
+ Object.freeze([
123
+ "null",
124
+ "boolean",
125
+ "object",
126
+ "array",
127
+ "number",
128
+ "integer",
129
+ "string"
130
+ ]);
131
+ /** Determine whether a value is a string. */
132
+ function isString$1(value) {
133
+ return typeof value === "string";
134
+ }
135
+ /**
136
+ * A cancellation handle — a thin, traceable wrapper over a native
137
+ * `AbortController` whose exposed `signal` can be linked to a parent signal.
138
+ *
139
+ * @remarks
140
+ * - **Own controller.** The instance owns a private `AbortController`; `abort`
141
+ * aborts it, and `aborted` reads the exposed signal. `abort(reason)` keeps any
142
+ * DEFINED reason verbatim (including a falsy `null` / `0` / `''` / `false`);
143
+ * `abort()` / `abort(undefined)` defaults `signal.reason` to an `AbortError`
144
+ * `DOMException`. Aborting is idempotent — the first reason sticks.
145
+ * - **Parent linking.** When `options.signal` is given, the exposed `signal` is
146
+ * `AbortSignal.any([own, parent])`, so it fires on EITHER the own `abort()` or
147
+ * the parent aborting — without re-implementing listener wiring. A parent that
148
+ * has ALREADY aborted makes the handle born aborted (carrying the parent's reason).
149
+ * - **Traceable.** Each handle carries an `id` (caller-supplied or a random UUID)
150
+ * for correlating cancellations across the system.
151
+ * - **Event-free.** A pure functional primitive — no Emitter, no events.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * const abort = new Abort()
156
+ * abort.signal.addEventListener('abort', () => stop(), { once: true })
157
+ * abort.abort('cancelled') // flips `aborted`, fires `signal` with the reason
158
+ * ```
159
+ */
160
+ var Abort = class {
161
+ #controller = new AbortController();
162
+ id;
163
+ signal;
164
+ constructor(options) {
165
+ this.id = isString$1(options?.id) ? options.id : crypto.randomUUID();
166
+ this.signal = linkSignal(this.#controller.signal, options?.signal);
167
+ }
168
+ get aborted() {
169
+ return this.signal.aborted;
170
+ }
171
+ abort(reason) {
172
+ this.#controller.abort(reason);
173
+ }
174
+ };
175
+ /**
176
+ * Create a cancellation handle — a thin, traceable wrapper over a native
177
+ * `AbortController` whose `signal` can be linked to a parent signal.
178
+ *
179
+ * @remarks
180
+ * The created handle's `signal` fires when its own `abort()` is called; when
181
+ * `options.signal` is given, it ALSO fires when that parent signal aborts (linked
182
+ * via `AbortSignal.any`). Pass `options.id` to label the handle for tracing, or
183
+ * let it default to a random UUID.
184
+ *
185
+ * @param options - Optional `id` (a trace label; defaults to a random UUID) and
186
+ * `signal` (a parent signal whose abort also fires the created handle's signal)
187
+ * @returns A working {@link AbortInterface}
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * import { createAbort } from '@src/core'
192
+ *
193
+ * const abort = createAbort()
194
+ * const work = fetch(url, { signal: abort.signal })
195
+ * abort.abort() // cancels the fetch via the linked native signal
196
+ * ```
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * // Link to a parent so a parent cancellation also aborts the child.
201
+ * const parent = createAbort()
202
+ * const child = createAbort({ signal: parent.signal })
203
+ * parent.abort() // child.aborted is now true
204
+ * ```
205
+ */
206
+ function createAbort(options) {
207
+ return new Abort(options);
208
+ }
209
+ //#endregion
210
+ //#region node_modules/@orkestrel/emitter/dist/src/core/index.js
211
+ /**
212
+ * Extract the own enumerable keys of a mapped object, typed as its key union.
213
+ *
214
+ * @remarks
215
+ * `Object.keys` widens its result to `string[]`, which breaks the key↔value
216
+ * correlation a mapped type (like `EmitterHooks<TMap>`) otherwise guarantees.
217
+ * A `for…in` push into a `keyof`-typed array narrows the result back,
218
+ * type-safely and with no assertion.
219
+ *
220
+ * @typeParam T - The object shape whose keys are extracted.
221
+ * @param object - The object to read keys from.
222
+ * @returns The object's own enumerable keys, typed as `(keyof T)[]`.
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * import { extractKeys } from '@src/core'
227
+ *
228
+ * const hooks = { tick: () => {}, done: () => {} }
229
+ * extractKeys(hooks) // ['tick', 'done']
230
+ * extractKeys({}) // []
231
+ * ```
232
+ */
233
+ function extractKeys(object) {
234
+ const collected = [];
235
+ for (const key in object) collected.push(key);
236
+ return collected;
237
+ }
238
+ Object.freeze([
239
+ "null",
240
+ "boolean",
241
+ "object",
242
+ "array",
243
+ "number",
244
+ "integer",
245
+ "string"
246
+ ]);
247
+ /** Determine whether a value is callable. */
248
+ function isFunction$1(value) {
249
+ return typeof value === "function";
250
+ }
251
+ /**
252
+ * A typed synchronous event emitter — the foundational observable primitive of
253
+ * the codebase (AGENTS §13). Stateful entities OWN one as a `#emitter` field and
254
+ * expose it through `readonly emitter`; they never inherit from it.
255
+ *
256
+ * @typeParam TMap - The event map: each event name to the argument tuple its
257
+ * listeners receive.
258
+ *
259
+ * @remarks
260
+ * - **Synchronous.** `emit` invokes listeners in registration order, in the
261
+ * current tick.
262
+ * - **Listener isolation.** A throwing listener never stops its siblings: every
263
+ * listener runs, and a throw is routed to the `error` handler
264
+ * ({@link EmitterOptions.error}) — never rethrown. Every throwing listener
265
+ * surfaces (not just the first), and with no `error` handler a throw is swallowed
266
+ * silently. The `error` handler runs inside its own try/catch, so a throwing
267
+ * error-handler is swallowed too (anti-recursion — it cannot escape or re-enter).
268
+ * - **Per-event storage.** Listeners live in a per-event `Set`, so every public
269
+ * method is precisely typed with no assertions.
270
+ * - **Destroyed → no-op.** After `destroy()`, `on` / `once` / `emit` do nothing
271
+ * and `destroyed` is `true`.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * type CounterEventMap = {
276
+ * tick: readonly [count: number]
277
+ * done: readonly []
278
+ * }
279
+ *
280
+ * const emitter = new Emitter<CounterEventMap>({
281
+ * on: { done: () => stop() },
282
+ * error: (error, event) => log(`listener for ${event} threw`, error),
283
+ * })
284
+ * emitter.on('tick', (count) => render(count))
285
+ * emitter.emit('tick', 1)
286
+ * ```
287
+ */
288
+ var Emitter = class {
289
+ #destroyed = false;
290
+ #listeners = {};
291
+ #wrappers = {};
292
+ #error;
293
+ constructor(options) {
294
+ const error = options?.error;
295
+ this.#error = isFunction$1(error) ? error : void 0;
296
+ const hooks = options?.on;
297
+ if (hooks !== void 0) this.#wire(hooks);
298
+ }
299
+ get destroyed() {
300
+ return this.#destroyed;
301
+ }
302
+ on(event, handler) {
303
+ if (this.#destroyed) return;
304
+ (this.#listeners[event] ??= /* @__PURE__ */ new Set()).add(handler);
305
+ }
306
+ once(event, handler) {
307
+ if (this.#destroyed) return;
308
+ const pending = this.#wrappers[event] ??= /* @__PURE__ */ new Map();
309
+ const wrapper = (...args) => {
310
+ this.#listeners[event]?.delete(wrapper);
311
+ const wrappers = pending.get(handler);
312
+ wrappers?.delete(wrapper);
313
+ if (wrappers !== void 0 && wrappers.size === 0) pending.delete(handler);
314
+ handler(...args);
315
+ };
316
+ const wrappers = pending.get(handler) ?? /* @__PURE__ */ new Set();
317
+ wrappers.add(wrapper);
318
+ pending.set(handler, wrappers);
319
+ this.on(event, wrapper);
320
+ }
321
+ off(event, handler) {
322
+ const listeners = this.#listeners[event];
323
+ const wrappers = this.#wrappers[event];
324
+ const pending = wrappers?.get(handler);
325
+ if (pending !== void 0) {
326
+ for (const wrapper of pending) listeners?.delete(wrapper);
327
+ wrappers?.delete(handler);
328
+ }
329
+ listeners?.delete(handler);
330
+ }
331
+ emit(event, ...args) {
332
+ if (this.#destroyed) return;
333
+ const listeners = this.#listeners[event];
334
+ if (listeners === void 0) return;
335
+ for (const handler of [...listeners]) try {
336
+ handler(...args);
337
+ } catch (error) {
338
+ this.#surface(error, event);
339
+ }
340
+ }
341
+ count(event) {
342
+ if (event !== void 0) return this.#listeners[event]?.size ?? 0;
343
+ let total = 0;
344
+ for (const set of Object.values(this.#listeners)) total += set?.size ?? 0;
345
+ return total;
346
+ }
347
+ clear(event) {
348
+ if (event !== void 0) {
349
+ delete this.#listeners[event];
350
+ delete this.#wrappers[event];
351
+ return;
352
+ }
353
+ this.#listeners = {};
354
+ this.#wrappers = {};
355
+ }
356
+ destroy() {
357
+ this.#listeners = {};
358
+ this.#wrappers = {};
359
+ this.#error = void 0;
360
+ this.#destroyed = true;
361
+ }
362
+ #surface(error, event) {
363
+ const handler = this.#error;
364
+ if (handler === void 0) return;
365
+ try {
366
+ handler(error, String(event));
367
+ } catch {}
368
+ }
369
+ #wire(hooks) {
370
+ for (const event of extractKeys(hooks)) {
371
+ const handler = hooks[event];
372
+ if (isFunction$1(handler)) this.on(event, handler);
373
+ }
374
+ }
375
+ };
376
+ Object.freeze([
377
+ "null",
378
+ "boolean",
379
+ "object",
380
+ "array",
381
+ "number",
382
+ "integer",
383
+ "string"
384
+ ]);
385
+ /** Determine whether a value is a string. */
386
+ function isString(value) {
387
+ return typeof value === "string";
388
+ }
389
+ /** Determine whether a value is callable. */
390
+ function isFunction(value) {
391
+ return typeof value === "function";
392
+ }
393
+ //#endregion
394
+ //#region src/browser/Navigator.ts
395
+ /**
396
+ * The headless History/hash navigation entity — composes one core
397
+ * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and
398
+ * every subsequent navigation event, tracking `active`, and emitting
399
+ * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /
400
+ * `outlet` — the consumer owns rendering.
401
+ *
402
+ * @typeParam Meta - The opaque per-route payload a match carries back
403
+ *
404
+ * @remarks
405
+ * - **One shared engine.** Each `route.path` is registered on the SAME
406
+ * `Router` machine the core `Dispatcher` composes, keyed for dedup by its
407
+ * {@link canonicalizePath} (last write wins, replace-in-place) — literal-
408
+ * over-param precedence, trailing-slash insensitivity, and
409
+ * `:param`/`*wildcard` extraction all come from that one engine (AGENTS
410
+ * §21).
411
+ * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
412
+ * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
413
+ * `fallback` through the SAME engine → a fallback that ALSO matches nothing
414
+ * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
415
+ * newer navigation) and leaves `active` `undefined`, emitting nothing
416
+ * (§21-honest: no phantom match is fabricated) → the optional `guard` may
417
+ * veto → on a verdict, `active` is set and `navigate` emitted.
418
+ * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
419
+ * handle, aborting the PREVIOUS navigation's handle first; a guard verdict
420
+ * that resolves after its navigation was superseded (`signal.aborted`) is
421
+ * discarded, same as a `false`/rejected verdict. A guard throw routes to
422
+ * the `error` handler and vetoes. `stop()`/`destroy()` also abort the
423
+ * pending handle.
424
+ * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds
425
+ * `hashchange`; history mode (`history: true`) binds `popstate` and, when
426
+ * `intercept` is set, same-origin `<a>` click interception (a plain
427
+ * left-click with no modifier keys, `target`, or `download` attribute).
428
+ *
429
+ * @example
430
+ * ```ts
431
+ * const navigator = new Navigator<{ readonly title: string }>({
432
+ * routes: [
433
+ * { path: '/users/:id', meta: { title: 'User' } },
434
+ * { path: '/tokens', meta: { title: 'Tokens' } },
435
+ * ],
436
+ * })
437
+ * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))
438
+ * navigator.start() // resolves the current hash now, and on every hashchange
439
+ * navigator.navigate('/tokens')
440
+ * ```
441
+ */
442
+ var Navigator = class {
443
+ #router;
444
+ #emitter;
445
+ #history;
446
+ #base;
447
+ #fallback;
448
+ #guard;
449
+ #error;
450
+ #intercept;
451
+ #hashListener;
452
+ #popListener;
453
+ #clickListener;
454
+ #active;
455
+ #started = false;
456
+ #current;
457
+ constructor(options) {
458
+ if (options.guard !== void 0 && !isFunction(options.guard)) throw new TypeError(`a navigator guard must be a function, got ${JSON.stringify(options.guard)}`);
459
+ if (options.fallback !== void 0 && !isString(options.fallback)) throw new TypeError(`a navigator fallback must be a string, got ${JSON.stringify(options.fallback)}`);
460
+ if (options.base !== void 0 && !isString(options.base)) throw new TypeError(`a navigator base must be a string, got ${JSON.stringify(options.base)}`);
461
+ this.#history = options.history ?? false;
462
+ this.#base = options.base;
463
+ this.#intercept = options.intercept ?? false;
464
+ this.#guard = options.guard;
465
+ this.#error = options.error;
466
+ this.#emitter = new Emitter({
467
+ on: options.on,
468
+ error: options.error
469
+ });
470
+ this.#router = createRouter({
471
+ entries: options.routes.map((route) => ({
472
+ path: route.path,
473
+ meta: route,
474
+ name: route.name
475
+ })),
476
+ sensitive: options.sensitive,
477
+ key: (entry) => canonicalizePath(entry.meta.path)
478
+ });
479
+ this.#fallback = options.fallback ?? options.routes[0]?.path;
480
+ this.#hashListener = () => this.#resolve();
481
+ this.#popListener = () => this.#resolve();
482
+ this.#clickListener = (event) => this.#intercepted(event);
483
+ }
484
+ get router() {
485
+ return this.#router;
486
+ }
487
+ get emitter() {
488
+ return this.#emitter;
489
+ }
490
+ get active() {
491
+ return this.#active;
492
+ }
493
+ start() {
494
+ if (this.#started) return;
495
+ this.#started = true;
496
+ if (!this.#history) window.addEventListener("hashchange", this.#hashListener);
497
+ else {
498
+ window.addEventListener("popstate", this.#popListener);
499
+ if (this.#intercept) document.addEventListener("click", this.#clickListener);
500
+ }
501
+ this.#resolve();
502
+ }
503
+ stop() {
504
+ if (!this.#started) return;
505
+ this.#started = false;
506
+ if (!this.#history) window.removeEventListener("hashchange", this.#hashListener);
507
+ else {
508
+ window.removeEventListener("popstate", this.#popListener);
509
+ if (this.#intercept) document.removeEventListener("click", this.#clickListener);
510
+ }
511
+ this.#current?.abort();
512
+ }
513
+ navigate(path) {
514
+ if (!this.#history) {
515
+ const next = `#${path}`;
516
+ if (window.location.hash === next) this.#resolve();
517
+ else window.location.hash = next;
518
+ return;
519
+ }
520
+ const target = this.#base === void 0 ? path : joinPaths(this.#base, path);
521
+ window.history.pushState(null, "", target);
522
+ this.#resolve();
523
+ }
524
+ match(path) {
525
+ const hit = this.#router.match(path);
526
+ if (hit === void 0) return void 0;
527
+ return {
528
+ path: hit.path,
529
+ params: hit.params,
530
+ meta: hit.meta.meta,
531
+ name: hit.meta.name
532
+ };
533
+ }
534
+ destroy() {
535
+ this.stop();
536
+ this.#emitter.destroy();
537
+ }
538
+ #resolve() {
539
+ const pathname = resolveLocationPath({
540
+ hash: window.location.hash,
541
+ pathname: window.location.pathname
542
+ }, this.#history, this.#base);
543
+ const to = this.match(pathname) ?? this.#matchFallback();
544
+ if (to === void 0) {
545
+ this.#current?.abort();
546
+ this.#active = void 0;
547
+ return;
548
+ }
549
+ this.#navigate(to);
550
+ }
551
+ #matchFallback() {
552
+ if (this.#fallback === void 0) return void 0;
553
+ return this.match(this.#fallback);
554
+ }
555
+ #navigate(to) {
556
+ this.#current?.abort();
557
+ const handle = createAbort();
558
+ this.#current = handle;
559
+ const guard = this.#guard;
560
+ if (guard === void 0) {
561
+ this.#commit(to);
562
+ return;
563
+ }
564
+ this.#guarded(guard, to, this.#active, handle);
565
+ }
566
+ #commit(to) {
567
+ this.#active = to;
568
+ this.#emitter.emit("navigate", to);
569
+ }
570
+ async #guarded(guard, to, from, handle) {
571
+ let verdict;
572
+ try {
573
+ verdict = await guard(to, from, handle.signal);
574
+ } catch (error) {
575
+ this.#surface(error);
576
+ return;
577
+ }
578
+ if (handle.signal.aborted || !verdict) return;
579
+ this.#commit(to);
580
+ }
581
+ #surface(error) {
582
+ const handler = this.#error;
583
+ if (handler === void 0) return;
584
+ try {
585
+ handler(error, "navigate");
586
+ } catch {}
587
+ }
588
+ #intercepted(event) {
589
+ if (event.defaultPrevented || event.button !== 0) return;
590
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
591
+ const anchor = findAnchor(event);
592
+ if (anchor === void 0) return;
593
+ if (anchor.target !== "" && anchor.target !== "_self") return;
594
+ if (anchor.hasAttribute("download")) return;
595
+ const url = new URL(anchor.href, window.location.href);
596
+ if (url.origin !== window.location.origin) return;
597
+ event.preventDefault();
598
+ this.navigate(resolveLocationPath({
599
+ hash: url.hash,
600
+ pathname: url.pathname
601
+ }, true, this.#base));
602
+ }
603
+ };
604
+ //#endregion
605
+ //#region src/browser/factories.ts
606
+ /**
607
+ * Create a {@link NavigatorInterface} — the headless History/hash navigation
608
+ * entity composing one core `Router<RouteEntry<Meta>>`.
609
+ *
610
+ * @remarks
611
+ * Prefer this over `new Navigator(...)` at call sites that only need the
612
+ * interface.
613
+ *
614
+ * @typeParam Meta - The opaque per-route payload a match carries back
615
+ * @param options - The `routes` to register, the `history` toggle (default
616
+ * `false`, hash mode), an optional `base` (history mode), an optional
617
+ * `fallback` path, an optional `guard` hook, opt-in link `intercept`
618
+ * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter
619
+ * `on`/`error` wiring
620
+ * @returns A live {@link NavigatorInterface} handle — call `start()` to begin
621
+ * dispatching
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * import { createNavigator } from '@src/browser'
626
+ *
627
+ * const navigator = createNavigator({
628
+ * routes: [
629
+ * { path: '/users/:id', meta: { title: 'User' } },
630
+ * { path: '/tokens', meta: { title: 'Tokens' } },
631
+ * ],
632
+ * on: { navigate: (match) => (document.title = match.meta.title) },
633
+ * })
634
+ * navigator.start()
635
+ * ```
636
+ */
637
+ function createNavigator(options) {
638
+ return new Navigator(options);
639
+ }
640
+ //#endregion
641
+ export { Navigator, createNavigator, extractHashPath, findAnchor, resolveLocationPath };
642
+
643
+ //# sourceMappingURL=index.js.map