@orkestrel/router 0.0.11 → 0.0.13
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/README.md +13 -10
- package/dist/src/browser/index.d.ts +43 -44
- package/dist/src/browser/index.js +60 -52
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +189 -117
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +945 -911
- package/dist/src/core/index.d.ts +945 -911
- package/dist/src/core/index.js +188 -117
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +25 -21
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +21 -21
- package/dist/src/server/index.d.ts +21 -21
- package/dist/src/server/index.js +25 -21
- package/dist/src/server/index.js.map +1 -1
- package/package.json +19 -15
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# @orkestrel/router
|
|
2
2
|
|
|
3
|
-
A typed request router for the `@orkestrel` line
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A typed request router for the `@orkestrel` line. One matching engine compiles
|
|
4
|
+
route patterns, extracts URL-decoded params, and resolves the most specific
|
|
5
|
+
match; a `Dispatcher` layers fetch-standard, method-dimensioned dispatch over
|
|
6
|
+
it; a headless `Navigator` drives History or hash navigation in the browser;
|
|
7
|
+
and a `node:http` adapter converts messages in both directions. Built on
|
|
8
|
+
`@orkestrel/contract` for validation, `@orkestrel/emitter` for the observable
|
|
9
|
+
surface, and `@orkestrel/abort` for cancellation.
|
|
7
10
|
|
|
8
11
|
## Install
|
|
9
12
|
|
|
@@ -13,8 +16,8 @@ npm install @orkestrel/router
|
|
|
13
16
|
|
|
14
17
|
## Requirements
|
|
15
18
|
|
|
16
|
-
- Node.js >=
|
|
17
|
-
- ESM
|
|
19
|
+
- Node.js >= 22.12.0
|
|
20
|
+
- ESM and CommonJS for the core and `./server` entries; the `./browser` entry is ESM only.
|
|
18
21
|
- Server and browser environments both supported
|
|
19
22
|
|
|
20
23
|
## Usage
|
|
@@ -41,8 +44,8 @@ const response = await dispatcher.handle(new Request('http://x/users/7'), { user
|
|
|
41
44
|
`Router` is the shared registry-and-match engine — literal-over-param-over-wildcard
|
|
42
45
|
precedence, trailing-slash folding, and tolerant percent-decoding — that both `Dispatcher`
|
|
43
46
|
(fetch-standard, method-dimensioned) and the browser `Navigator` compose. Path params are
|
|
44
|
-
inferred at the type level from the literal pattern
|
|
45
|
-
`RouteInput`'s path so literal inference survives across call sites. The `./browser` entry
|
|
47
|
+
inferred at the type level from the literal pattern through `PathParams`, and `defineRoute()`
|
|
48
|
+
pins a `RouteInput`'s path so literal inference survives across call sites. The `./browser` entry
|
|
46
49
|
adds `createNavigator` for headless History/hash navigation; the `./server` entry adds
|
|
47
50
|
`buildRequest` / `sendResponse` / `createListener` for `node:http`.
|
|
48
51
|
|
|
@@ -50,11 +53,11 @@ adds `createNavigator` for headless History/hash navigation; the `./server` entr
|
|
|
50
53
|
|
|
51
54
|
For the full surface — the core `Router`, the `Dispatcher`, the browser `Navigator`, and the
|
|
52
55
|
`node:http` server adapter — see
|
|
53
|
-
[`guides/
|
|
56
|
+
[`guides/router.md`](guides/router.md).
|
|
54
57
|
|
|
55
58
|
## Package
|
|
56
59
|
|
|
57
|
-
Published as
|
|
60
|
+
Published as environment-scoped entry points per the `exports` field in
|
|
58
61
|
`package.json`: a shared core, `./browser`, and `./server`.
|
|
59
62
|
|
|
60
63
|
## License
|
|
@@ -6,28 +6,27 @@ import { RouterInterface } from '@orkestrel/router';
|
|
|
6
6
|
import { RouterMatch } from '@orkestrel/router';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Computes the registry key for a browser navigation route.
|
|
10
10
|
*
|
|
11
11
|
* @remarks
|
|
12
|
-
* Projects the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Projects the route's path through the core engine's canonical trailing-slash
|
|
13
|
+
* identity, so `/users` and `/users/` replace one another in the Navigator's
|
|
14
|
+
* shared Router. The entry's `meta` payload is never read, so any payload type
|
|
15
|
+
* is accepted.
|
|
15
16
|
*
|
|
16
|
-
* @param entry - The
|
|
17
|
-
* @returns The
|
|
17
|
+
* @param entry - The Router entry carrying the Navigator route
|
|
18
|
+
* @returns The route's canonical path
|
|
18
19
|
*
|
|
19
20
|
* @example
|
|
20
21
|
* ```ts
|
|
21
|
-
* computeNavigationKey({ path: '/users/', meta: {
|
|
22
|
+
* computeNavigationKey({ path: '/users/', meta: {} }) // '/users'
|
|
22
23
|
* ```
|
|
23
24
|
*/
|
|
24
|
-
export declare function computeNavigationKey(entry: RouteEntry<
|
|
25
|
-
readonly path: string;
|
|
26
|
-
}>): string;
|
|
25
|
+
export declare function computeNavigationKey(entry: RouteEntry<unknown>): string;
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
|
-
*
|
|
30
|
-
* entity composing one core `Router<
|
|
28
|
+
* Creates a {@link NavigatorInterface} — the headless History/hash navigation
|
|
29
|
+
* entity composing one core `Router<Meta>`.
|
|
31
30
|
*
|
|
32
31
|
* @remarks
|
|
33
32
|
* Prefer this over `new Navigator(...)` at call sites that only need the
|
|
@@ -37,7 +36,7 @@ export declare function computeNavigationKey(entry: RouteEntry<{
|
|
|
37
36
|
* @param options - The `routes` to register, the `history` toggle (default
|
|
38
37
|
* `false`, hash mode), an optional `base` (history mode), an optional
|
|
39
38
|
* `fallback` path, an optional `guard` hook, opt-in link `intercept`
|
|
40
|
-
* (history mode), the `sensitive` case toggle, and the
|
|
39
|
+
* (history mode), the `sensitive` case toggle, and the Emitter pattern's
|
|
41
40
|
* `on`/`error` wiring
|
|
42
41
|
* @returns A live {@link NavigatorInterface} handle — call `start()` to begin
|
|
43
42
|
* dispatching
|
|
@@ -59,17 +58,17 @@ export declare function computeNavigationKey(entry: RouteEntry<{
|
|
|
59
58
|
export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta>;
|
|
60
59
|
|
|
61
60
|
/**
|
|
62
|
-
*
|
|
61
|
+
* Extracts the `/`-prefixed pathname from a `location.hash` value — strips the
|
|
63
62
|
* leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
|
|
64
63
|
*
|
|
65
64
|
* @remarks
|
|
66
|
-
* The grammar this package matches everywhere is `/`-prefixed
|
|
67
|
-
*
|
|
65
|
+
* The grammar this package matches everywhere is `/`-prefixed, so a hash-mode
|
|
66
|
+
* location's `'#/users/7?x'` becomes `'/users/7'`
|
|
68
67
|
* — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
|
|
69
68
|
* or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
|
|
70
69
|
* back). Total — never throws.
|
|
71
70
|
*
|
|
72
|
-
* @param hash - The raw `window.location.hash` value (
|
|
71
|
+
* @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)
|
|
73
72
|
* @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
|
|
74
73
|
*
|
|
75
74
|
* @example
|
|
@@ -83,7 +82,7 @@ export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>):
|
|
|
83
82
|
export declare function extractHashPath(hash: string): string;
|
|
84
83
|
|
|
85
84
|
/**
|
|
86
|
-
*
|
|
85
|
+
* Finds the nearest enclosing `<a>` element a DOM event originated from, by
|
|
87
86
|
* walking its composed path — the pure lookup behind history-mode link
|
|
88
87
|
* interception.
|
|
89
88
|
*
|
|
@@ -107,10 +106,10 @@ export declare function extractHashPath(hash: string): string;
|
|
|
107
106
|
export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
|
|
108
107
|
|
|
109
108
|
/**
|
|
110
|
-
*
|
|
111
|
-
* `Router<
|
|
109
|
+
* Represents the headless History/hash navigation entity — composes one core
|
|
110
|
+
* `Router<Meta>`, resolving the current location on `start()` and
|
|
112
111
|
* every subsequent navigation event, tracking `active`, and emitting
|
|
113
|
-
* `navigate` through the core {@link Emitter}
|
|
112
|
+
* `navigate` through the core {@link Emitter}. No `render` /
|
|
114
113
|
* `outlet` — the consumer owns rendering.
|
|
115
114
|
*
|
|
116
115
|
* @typeParam Meta - The opaque per-route payload a match carries back
|
|
@@ -120,14 +119,13 @@ export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
|
|
|
120
119
|
* `Router` machine the core `Dispatcher` composes, keyed for dedup by its
|
|
121
120
|
* {@link canonicalizePath} (last write wins, replace-in-place) — literal-
|
|
122
121
|
* over-param precedence, trailing-slash insensitivity, and
|
|
123
|
-
* `:param`/`*wildcard` extraction all come from that one engine
|
|
124
|
-
* §21).
|
|
122
|
+
* `:param`/`*wildcard` extraction all come from that one shared engine.
|
|
125
123
|
* - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
|
|
126
124
|
* ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
|
|
127
125
|
* `fallback` through the SAME engine → a fallback that ALSO matches nothing
|
|
128
126
|
* aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
|
|
129
|
-
* newer navigation) and leaves `active` `undefined`, emitting nothing
|
|
130
|
-
*
|
|
127
|
+
* newer navigation) and leaves `active` `undefined`, emitting nothing —
|
|
128
|
+
* honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may
|
|
131
129
|
* veto → on a verdict, `active` is set and `navigate` emitted.
|
|
132
130
|
* - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
|
|
133
131
|
* handle, aborting the PREVIOUS navigation's handle first; a guard verdict
|
|
@@ -156,7 +154,7 @@ export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
|
|
|
156
154
|
declare class Navigator_2<Meta> implements NavigatorInterface<Meta> {
|
|
157
155
|
#private;
|
|
158
156
|
constructor(options: NavigatorOptions<Meta>);
|
|
159
|
-
get router(): RouterInterface<
|
|
157
|
+
get router(): RouterInterface<Meta>;
|
|
160
158
|
get emitter(): EmitterInterface<NavigatorEventMap<Meta>>;
|
|
161
159
|
get active(): RouterMatch<Meta> | undefined;
|
|
162
160
|
start(): void;
|
|
@@ -168,7 +166,7 @@ declare class Navigator_2<Meta> implements NavigatorInterface<Meta> {
|
|
|
168
166
|
export { Navigator_2 as Navigator }
|
|
169
167
|
|
|
170
168
|
/**
|
|
171
|
-
*
|
|
169
|
+
* Represents the `Navigator`'s event map — the single `navigate` signal a
|
|
172
170
|
* consumer observes.
|
|
173
171
|
*
|
|
174
172
|
* @typeParam Meta - The opaque per-route payload the resolved match carries
|
|
@@ -177,25 +175,25 @@ export { Navigator_2 as Navigator }
|
|
|
177
175
|
* `navigate` fires once per successful resolution (start, hashchange/popstate,
|
|
178
176
|
* `navigate()`, link interception) — never for a vetoed or superseded navigation
|
|
179
177
|
* ({@link NavigatorOptions.guard}), and never when a miss's fallback also
|
|
180
|
-
* misses
|
|
178
|
+
* misses — honest to the one-shared-engine rule: `active` is left `undefined`, nothing emitted.
|
|
181
179
|
*/
|
|
182
180
|
export declare type NavigatorEventMap<Meta> = {
|
|
183
181
|
readonly navigate: readonly [match: RouterMatch<Meta>];
|
|
184
182
|
};
|
|
185
183
|
|
|
186
184
|
/**
|
|
187
|
-
*
|
|
185
|
+
* Represents the headless History/hash navigation entity contract (the behavioral-
|
|
188
186
|
* interface role for the one-class-per-file `Navigator`). Composes a core
|
|
189
|
-
* `Router<
|
|
187
|
+
* `Router<Meta>`, resolves the current location on `start()` and
|
|
190
188
|
* on every subsequent navigation event, tracks `active`, and emits
|
|
191
|
-
* `navigate` through the
|
|
189
|
+
* `navigate` through the {@link EmitterInterface}.
|
|
192
190
|
*
|
|
193
191
|
* @typeParam Meta - The opaque per-route payload a match carries back
|
|
194
192
|
*
|
|
195
193
|
* @remarks
|
|
196
194
|
* - `router` — the underlying registry, exposed READONLY for introspection
|
|
197
195
|
* (the same object routes were registered on).
|
|
198
|
-
* - `emitter` — the
|
|
196
|
+
* - `emitter` — the observable surface for {@link NavigatorEventMap}.
|
|
199
197
|
* - `active` — the currently-resolved {@link RouterMatch}, or `undefined`
|
|
200
198
|
* before the first resolve (or when a miss's fallback also misses).
|
|
201
199
|
* - `start()` — begin listening (`hashchange` in hash mode; `popstate` +
|
|
@@ -208,10 +206,10 @@ export declare type NavigatorEventMap<Meta> = {
|
|
|
208
206
|
* no `hashchange` would otherwise fire.
|
|
209
207
|
* - `match(path)` — a PURE lookup through the underlying `Router`: no
|
|
210
208
|
* location read, no fallback, no guard, no emit.
|
|
211
|
-
* - `destroy()` — `stop()` plus tear down the `#emitter
|
|
209
|
+
* - `destroy()` — `stop()` plus tear down the `#emitter`.
|
|
212
210
|
*/
|
|
213
211
|
export declare interface NavigatorInterface<Meta> {
|
|
214
|
-
readonly router: RouterInterface<
|
|
212
|
+
readonly router: RouterInterface<Meta>;
|
|
215
213
|
readonly emitter: EmitterInterface<NavigatorEventMap<Meta>>;
|
|
216
214
|
readonly active: RouterMatch<Meta> | undefined;
|
|
217
215
|
start(): void;
|
|
@@ -222,8 +220,8 @@ export declare interface NavigatorInterface<Meta> {
|
|
|
222
220
|
}
|
|
223
221
|
|
|
224
222
|
/**
|
|
225
|
-
*
|
|
226
|
-
* navigation substrate, the optional guard hook, and the
|
|
223
|
+
* Represents the options for `createNavigator` — the `routes` to dispatch between, the
|
|
224
|
+
* navigation substrate, the optional guard hook, and the Emitter pattern's
|
|
227
225
|
* wiring.
|
|
228
226
|
*
|
|
229
227
|
* @typeParam Meta - The opaque payload each route may carry
|
|
@@ -240,20 +238,21 @@ export declare interface NavigatorInterface<Meta> {
|
|
|
240
238
|
* - `fallback` — the route PATTERN to resolve when the current location
|
|
241
239
|
* matches NOTHING. Omitted ⇒ the first route's path. A `fallback` that
|
|
242
240
|
* itself matches no registered route leaves `active` `undefined` and emits
|
|
243
|
-
* nothing
|
|
241
|
+
* nothing — honest to the one-shared-engine rule: no phantom match is fabricated.
|
|
244
242
|
* - `guard` — `(to, from, signal) => boolean | Promise<boolean>`, called
|
|
245
243
|
* before a navigation commits; a `false`/rejected verdict, or one arriving
|
|
246
244
|
* after the navigation was SUPERSEDED (`signal.aborted`), is discarded —
|
|
247
245
|
* `active` stays unchanged and nothing is emitted. `signal` fires when a
|
|
248
246
|
* NEWER navigation starts (or on `stop`/`destroy`), so a slow async guard
|
|
249
247
|
* can cancel its own work off it. A throw routes to the `error` handler
|
|
250
|
-
*
|
|
248
|
+
* described later in this list and vetoes the navigation.
|
|
251
249
|
* - `intercept` — opt-in same-origin `<a>` click interception (history mode
|
|
252
250
|
* only): a plain left-click on a same-origin link with no modifier keys,
|
|
253
251
|
* no `target`, and no `download` attribute is intercepted into `navigate`.
|
|
254
252
|
* - `sensitive` — forwarded to the underlying `Router` (default `true`).
|
|
255
|
-
* - `on` — initial `NavigatorEventMap` listeners (
|
|
256
|
-
*
|
|
253
|
+
* - `on` — initial `NavigatorEventMap` listeners (the Emitter pattern's
|
|
254
|
+
* similar-surface pin).
|
|
255
|
+
* - `error` — the emitter's listener-error handler; ALSO the
|
|
257
256
|
* handler a thrown {@link guard} routes to (the Navigator's own pipeline,
|
|
258
257
|
* not a listener throw, so it is surfaced through the same channel).
|
|
259
258
|
*/
|
|
@@ -270,7 +269,7 @@ export declare interface NavigatorOptions<Meta> {
|
|
|
270
269
|
}
|
|
271
270
|
|
|
272
271
|
/**
|
|
273
|
-
*
|
|
272
|
+
* Resolves the `/`-prefixed pathname to match for the CURRENT location, in
|
|
274
273
|
* either navigation mode — the one seam `extractHashPath` (hash mode) and
|
|
275
274
|
* history-mode base-stripping share.
|
|
276
275
|
*
|
|
@@ -280,12 +279,12 @@ export declare interface NavigatorOptions<Meta> {
|
|
|
280
279
|
* `location.pathname` and strips a leading `base` prefix when one is
|
|
281
280
|
* configured: `base` itself maps to the root `'/'`; a pathname that is not
|
|
282
281
|
* under `base` is returned unchanged (a base mismatch is not this helper's
|
|
283
|
-
* concern — the `Navigator`'s match then
|
|
282
|
+
* concern — the `Navigator`'s match then misses). Total — never throws.
|
|
284
283
|
*
|
|
285
284
|
* @param location - The `hash` + `pathname` pair to resolve from (accepts a
|
|
286
285
|
* real `Location` or any object shaped the same, for pure unit testing)
|
|
287
|
-
* @param history -
|
|
288
|
-
*
|
|
286
|
+
* @param history - If `true`, the pathname is read from `location.pathname`
|
|
287
|
+
* with `base` stripped; if `false`, it is read from `location.hash`
|
|
289
288
|
* @param base - The history-mode path prefix to strip (ignored in hash mode;
|
|
290
289
|
* omit for no prefix)
|
|
291
290
|
* @returns The `/`-prefixed pathname to match
|
|
@@ -1,39 +1,40 @@
|
|
|
1
1
|
import { canonicalizePath, createRouter, joinPaths } from "../core/index.js";
|
|
2
2
|
import { createAbort } from "@orkestrel/abort";
|
|
3
3
|
import { Emitter } from "@orkestrel/emitter";
|
|
4
|
-
import { isFunction, isString } from "@orkestrel/contract";
|
|
4
|
+
import { ContractError, isFunction, isString, preview } from "@orkestrel/contract";
|
|
5
5
|
//#region src/browser/helpers.ts
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Computes the registry key for a browser navigation route.
|
|
8
8
|
*
|
|
9
9
|
* @remarks
|
|
10
|
-
* Projects the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Projects the route's path through the core engine's canonical trailing-slash
|
|
11
|
+
* identity, so `/users` and `/users/` replace one another in the Navigator's
|
|
12
|
+
* shared Router. The entry's `meta` payload is never read, so any payload type
|
|
13
|
+
* is accepted.
|
|
13
14
|
*
|
|
14
|
-
* @param entry - The
|
|
15
|
-
* @returns The
|
|
15
|
+
* @param entry - The Router entry carrying the Navigator route
|
|
16
|
+
* @returns The route's canonical path
|
|
16
17
|
*
|
|
17
18
|
* @example
|
|
18
19
|
* ```ts
|
|
19
|
-
* computeNavigationKey({ path: '/users/', meta: {
|
|
20
|
+
* computeNavigationKey({ path: '/users/', meta: {} }) // '/users'
|
|
20
21
|
* ```
|
|
21
22
|
*/
|
|
22
23
|
function computeNavigationKey(entry) {
|
|
23
|
-
return canonicalizePath(entry.
|
|
24
|
+
return canonicalizePath(entry.path);
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* Extracts the `/`-prefixed pathname from a `location.hash` value — strips the
|
|
27
28
|
* leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
|
|
28
29
|
*
|
|
29
30
|
* @remarks
|
|
30
|
-
* The grammar this package matches everywhere is `/`-prefixed
|
|
31
|
-
*
|
|
31
|
+
* The grammar this package matches everywhere is `/`-prefixed, so a hash-mode
|
|
32
|
+
* location's `'#/users/7?x'` becomes `'/users/7'`
|
|
32
33
|
* — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
|
|
33
34
|
* or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
|
|
34
35
|
* back). Total — never throws.
|
|
35
36
|
*
|
|
36
|
-
* @param hash - The raw `window.location.hash` value (
|
|
37
|
+
* @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)
|
|
37
38
|
* @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
|
|
38
39
|
*
|
|
39
40
|
* @example
|
|
@@ -51,7 +52,7 @@ function extractHashPath(hash) {
|
|
|
51
52
|
return queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
|
-
*
|
|
55
|
+
* Resolves the `/`-prefixed pathname to match for the CURRENT location, in
|
|
55
56
|
* either navigation mode — the one seam `extractHashPath` (hash mode) and
|
|
56
57
|
* history-mode base-stripping share.
|
|
57
58
|
*
|
|
@@ -61,12 +62,12 @@ function extractHashPath(hash) {
|
|
|
61
62
|
* `location.pathname` and strips a leading `base` prefix when one is
|
|
62
63
|
* configured: `base` itself maps to the root `'/'`; a pathname that is not
|
|
63
64
|
* under `base` is returned unchanged (a base mismatch is not this helper's
|
|
64
|
-
* concern — the `Navigator`'s match then
|
|
65
|
+
* concern — the `Navigator`'s match then misses). Total — never throws.
|
|
65
66
|
*
|
|
66
67
|
* @param location - The `hash` + `pathname` pair to resolve from (accepts a
|
|
67
68
|
* real `Location` or any object shaped the same, for pure unit testing)
|
|
68
|
-
* @param history -
|
|
69
|
-
*
|
|
69
|
+
* @param history - If `true`, the pathname is read from `location.pathname`
|
|
70
|
+
* with `base` stripped; if `false`, it is read from `location.hash`
|
|
70
71
|
* @param base - The history-mode path prefix to strip (ignored in hash mode;
|
|
71
72
|
* omit for no prefix)
|
|
72
73
|
* @returns The `/`-prefixed pathname to match
|
|
@@ -89,7 +90,7 @@ function resolveLocationPath(location, history, base) {
|
|
|
89
90
|
return pathname;
|
|
90
91
|
}
|
|
91
92
|
/**
|
|
92
|
-
*
|
|
93
|
+
* Finds the nearest enclosing `<a>` element a DOM event originated from, by
|
|
93
94
|
* walking its composed path — the pure lookup behind history-mode link
|
|
94
95
|
* interception.
|
|
95
96
|
*
|
|
@@ -116,10 +117,10 @@ function findAnchor(event) {
|
|
|
116
117
|
//#endregion
|
|
117
118
|
//#region src/browser/Navigator.ts
|
|
118
119
|
/**
|
|
119
|
-
*
|
|
120
|
-
* `Router<
|
|
120
|
+
* Represents the headless History/hash navigation entity — composes one core
|
|
121
|
+
* `Router<Meta>`, resolving the current location on `start()` and
|
|
121
122
|
* every subsequent navigation event, tracking `active`, and emitting
|
|
122
|
-
* `navigate` through the core {@link Emitter}
|
|
123
|
+
* `navigate` through the core {@link Emitter}. No `render` /
|
|
123
124
|
* `outlet` — the consumer owns rendering.
|
|
124
125
|
*
|
|
125
126
|
* @typeParam Meta - The opaque per-route payload a match carries back
|
|
@@ -129,14 +130,13 @@ function findAnchor(event) {
|
|
|
129
130
|
* `Router` machine the core `Dispatcher` composes, keyed for dedup by its
|
|
130
131
|
* {@link canonicalizePath} (last write wins, replace-in-place) — literal-
|
|
131
132
|
* over-param precedence, trailing-slash insensitivity, and
|
|
132
|
-
* `:param`/`*wildcard` extraction all come from that one engine
|
|
133
|
-
* §21).
|
|
133
|
+
* `:param`/`*wildcard` extraction all come from that one shared engine.
|
|
134
134
|
* - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
|
|
135
135
|
* ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
|
|
136
136
|
* `fallback` through the SAME engine → a fallback that ALSO matches nothing
|
|
137
137
|
* aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
|
|
138
|
-
* newer navigation) and leaves `active` `undefined`, emitting nothing
|
|
139
|
-
*
|
|
138
|
+
* newer navigation) and leaves `active` `undefined`, emitting nothing —
|
|
139
|
+
* honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may
|
|
140
140
|
* veto → on a verdict, `active` is set and `navigate` emitted.
|
|
141
141
|
* - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
|
|
142
142
|
* handle, aborting the PREVIOUS navigation's handle first; a guard verdict
|
|
@@ -171,16 +171,36 @@ var Navigator = class {
|
|
|
171
171
|
#guard;
|
|
172
172
|
#error;
|
|
173
173
|
#intercept;
|
|
174
|
-
#
|
|
175
|
-
#popListener;
|
|
174
|
+
#listener;
|
|
176
175
|
#clickListener;
|
|
177
176
|
#active;
|
|
178
177
|
#started = false;
|
|
179
178
|
#current;
|
|
180
179
|
constructor(options) {
|
|
181
|
-
if (options.guard !== void 0 && !isFunction(options.guard)) throw new
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
if (options.guard !== void 0 && !isFunction(options.guard)) throw new ContractError("a navigator guard must be a function when defined", {
|
|
181
|
+
code: "literal",
|
|
182
|
+
context: {
|
|
183
|
+
path: ["options", "guard"],
|
|
184
|
+
limit: "function or undefined",
|
|
185
|
+
received: preview(options.guard)
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
if (options.fallback !== void 0 && !isString(options.fallback)) throw new ContractError("a navigator fallback must be a string when defined", {
|
|
189
|
+
code: "literal",
|
|
190
|
+
context: {
|
|
191
|
+
path: ["options", "fallback"],
|
|
192
|
+
limit: "string or undefined",
|
|
193
|
+
received: preview(options.fallback)
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
if (options.base !== void 0 && !isString(options.base)) throw new ContractError("a navigator base must be a string when defined", {
|
|
197
|
+
code: "literal",
|
|
198
|
+
context: {
|
|
199
|
+
path: ["options", "base"],
|
|
200
|
+
limit: "string or undefined",
|
|
201
|
+
received: preview(options.base)
|
|
202
|
+
}
|
|
203
|
+
});
|
|
184
204
|
this.#history = options.history ?? false;
|
|
185
205
|
this.#base = options.base;
|
|
186
206
|
this.#intercept = options.intercept ?? false;
|
|
@@ -191,17 +211,12 @@ var Navigator = class {
|
|
|
191
211
|
...options.error === void 0 ? {} : { error: options.error }
|
|
192
212
|
});
|
|
193
213
|
this.#router = createRouter({
|
|
194
|
-
entries: options.routes
|
|
195
|
-
path: route.path,
|
|
196
|
-
meta: route,
|
|
197
|
-
...route.name === void 0 ? {} : { name: route.name }
|
|
198
|
-
})),
|
|
214
|
+
entries: options.routes,
|
|
199
215
|
...options.sensitive === void 0 ? {} : { sensitive: options.sensitive },
|
|
200
216
|
key: computeNavigationKey
|
|
201
217
|
});
|
|
202
218
|
this.#fallback = options.fallback ?? options.routes[0]?.path;
|
|
203
|
-
this.#
|
|
204
|
-
this.#popListener = this.#resolve.bind(this);
|
|
219
|
+
this.#listener = this.#resolve.bind(this);
|
|
205
220
|
this.#clickListener = this.#intercepted.bind(this);
|
|
206
221
|
}
|
|
207
222
|
get router() {
|
|
@@ -216,9 +231,9 @@ var Navigator = class {
|
|
|
216
231
|
start() {
|
|
217
232
|
if (this.#started) return;
|
|
218
233
|
this.#started = true;
|
|
219
|
-
if (!this.#history) window.addEventListener("hashchange", this.#
|
|
234
|
+
if (!this.#history) window.addEventListener("hashchange", this.#listener);
|
|
220
235
|
else {
|
|
221
|
-
window.addEventListener("popstate", this.#
|
|
236
|
+
window.addEventListener("popstate", this.#listener);
|
|
222
237
|
if (this.#intercept) document.addEventListener("click", this.#clickListener);
|
|
223
238
|
}
|
|
224
239
|
this.#resolve();
|
|
@@ -226,9 +241,9 @@ var Navigator = class {
|
|
|
226
241
|
stop() {
|
|
227
242
|
if (!this.#started) return;
|
|
228
243
|
this.#started = false;
|
|
229
|
-
if (!this.#history) window.removeEventListener("hashchange", this.#
|
|
244
|
+
if (!this.#history) window.removeEventListener("hashchange", this.#listener);
|
|
230
245
|
else {
|
|
231
|
-
window.removeEventListener("popstate", this.#
|
|
246
|
+
window.removeEventListener("popstate", this.#listener);
|
|
232
247
|
if (this.#intercept) document.removeEventListener("click", this.#clickListener);
|
|
233
248
|
}
|
|
234
249
|
this.#current?.abort();
|
|
@@ -245,14 +260,7 @@ var Navigator = class {
|
|
|
245
260
|
this.#resolve();
|
|
246
261
|
}
|
|
247
262
|
match(path) {
|
|
248
|
-
|
|
249
|
-
if (hit === void 0) return void 0;
|
|
250
|
-
return {
|
|
251
|
-
path: hit.path,
|
|
252
|
-
params: hit.params,
|
|
253
|
-
meta: hit.meta.meta,
|
|
254
|
-
...hit.meta.name === void 0 ? {} : { name: hit.meta.name }
|
|
255
|
-
};
|
|
263
|
+
return this.#router.match(path);
|
|
256
264
|
}
|
|
257
265
|
destroy() {
|
|
258
266
|
this.stop();
|
|
@@ -327,8 +335,8 @@ var Navigator = class {
|
|
|
327
335
|
//#endregion
|
|
328
336
|
//#region src/browser/factories.ts
|
|
329
337
|
/**
|
|
330
|
-
*
|
|
331
|
-
* entity composing one core `Router<
|
|
338
|
+
* Creates a {@link NavigatorInterface} — the headless History/hash navigation
|
|
339
|
+
* entity composing one core `Router<Meta>`.
|
|
332
340
|
*
|
|
333
341
|
* @remarks
|
|
334
342
|
* Prefer this over `new Navigator(...)` at call sites that only need the
|
|
@@ -338,7 +346,7 @@ var Navigator = class {
|
|
|
338
346
|
* @param options - The `routes` to register, the `history` toggle (default
|
|
339
347
|
* `false`, hash mode), an optional `base` (history mode), an optional
|
|
340
348
|
* `fallback` path, an optional `guard` hook, opt-in link `intercept`
|
|
341
|
-
* (history mode), the `sensitive` case toggle, and the
|
|
349
|
+
* (history mode), the `sensitive` case toggle, and the Emitter pattern's
|
|
342
350
|
* `on`/`error` wiring
|
|
343
351
|
* @returns A live {@link NavigatorInterface} handle — call `start()` to begin
|
|
344
352
|
* dispatching
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["#router","#emitter","#history","#base","#fallback","#guard","#error","#intercept","#hashListener","#popListener","#clickListener","#resolve","#intercepted","#active","#started","#current","#matchFallback","#navigate","#commit","#guarded","#surface"],"sources":["../../../src/browser/helpers.ts","../../../src/browser/Navigator.ts","../../../src/browser/factories.ts"],"sourcesContent":["// The PURE browser-navigation primitives (AGENTS §4.3 multi-word names — module\n// scope, no entity context). Every one is exported (the centralized-file rule,\n// §5): the `Navigator` composes them, and each has its own unit test. NO `node:*`\n// — DOM-typed only (`Location`, `Event`, `HTMLAnchorElement`), valid under the\n// `src:browser` scoped check (AGENTS §17.7).\n\nimport type { RouteEntry } from '@src/core'\nimport { canonicalizePath } from '@src/core'\n\n/**\n * Compute the registry key for a browser navigation route.\n *\n * @remarks\n * Projects the nested route's path through the core engine's canonical\n * trailing-slash identity, so `/users` and `/users/` replace one another in\n * the Navigator's shared Router.\n *\n * @param entry - The outer Router entry carrying the Navigator route\n * @returns The nested route's canonical path\n *\n * @example\n * ```ts\n * computeNavigationKey({ path: '/users/', meta: { path: '/users/' } }) // '/users'\n * ```\n */\nexport function computeNavigationKey(entry: RouteEntry<{ readonly path: string }>): string {\n\treturn canonicalizePath(entry.meta.path)\n}\n\n/**\n * Extract the `/`-prefixed pathname from a `location.hash` value — strip the\n * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.\n *\n * @remarks\n * The grammar this package matches everywhere is `/`-prefixed (§4 path\n * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`\n * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,\n * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls\n * back). Total — never throws.\n *\n * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)\n * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash\n *\n * @example\n * ```ts\n * extractHashPath('#/users/7?x') // '/users/7'\n * extractHashPath('#/tokens') // '/tokens'\n * extractHashPath('') // '' — the Navigator falls back\n * extractHashPath('#other') // '' — not a `#/` route hash\n * ```\n */\nexport function extractHashPath(hash: string): string {\n\tif (!hash.startsWith('#/')) return ''\n\tconst withoutHash = hash.slice(1)\n\tconst queryIndex = withoutHash.indexOf('?')\n\treturn queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex)\n}\n\n/**\n * Resolve the `/`-prefixed pathname to match for the CURRENT location, in\n * either navigation mode — the one seam `extractHashPath` (hash mode) and\n * history-mode base-stripping share.\n *\n * @remarks\n * Hash mode (`history: false`) reads `location.hash` through\n * {@link extractHashPath}. History mode (`history: true`) reads\n * `location.pathname` and strips a leading `base` prefix when one is\n * configured: `base` itself maps to the root `'/'`; a pathname that is not\n * under `base` is returned unchanged (a base mismatch is not this helper's\n * concern — the `Navigator`'s match then simply misses). Total — never throws.\n *\n * @param location - The `hash` + `pathname` pair to resolve from (accepts a\n * real `Location` or any object shaped the same, for pure unit testing)\n * @param history - The navigation substrate: `false` for hash mode, `true`\n * for history mode\n * @param base - The history-mode path prefix to strip (ignored in hash mode;\n * omit for no prefix)\n * @returns The `/`-prefixed pathname to match\n *\n * @example\n * ```ts\n * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'\n * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'\n * ```\n */\nexport function resolveLocationPath(\n\tlocation: Pick<Location, 'hash' | 'pathname'>,\n\thistory: boolean,\n\tbase?: string,\n): string {\n\tif (!history) return extractHashPath(location.hash)\n\tconst pathname = location.pathname\n\tif (base === undefined || base === '') return pathname\n\tconst normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base\n\tif (pathname === normalizedBase) return '/'\n\tif (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length)\n\treturn pathname\n}\n\n/**\n * Find the nearest enclosing `<a>` element a DOM event originated from, by\n * walking its composed path — the pure lookup behind history-mode link\n * interception.\n *\n * @remarks\n * Uses `event.composedPath()` (not `event.target`) so a click on a styled\n * child INSIDE an anchor (an icon, a span) still resolves to the anchor.\n * Total — never throws; returns `undefined` when no anchor is found on the\n * path.\n *\n * @param event - The DOM event to search (typically a `click`)\n * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`\n *\n * @example\n * ```ts\n * document.addEventListener('click', (event) => {\n * \tconst anchor = findAnchor(event)\n * \tif (anchor !== undefined) console.log(anchor.href)\n * })\n * ```\n */\nexport function findAnchor(event: Event): HTMLAnchorElement | undefined {\n\tfor (const node of event.composedPath()) {\n\t\tif (node instanceof HTMLAnchorElement) return node\n\t}\n\treturn undefined\n}\n","import type { NavigatorEventMap, NavigatorInterface, NavigatorOptions } from './types.js'\nimport type { AbortInterface } from '@orkestrel/abort'\nimport type { EmitterErrorHandler, EmitterInterface } from '@orkestrel/emitter'\nimport type { RouteEntry, RouterInterface, RouterMatch } from '@src/core'\nimport { createAbort } from '@orkestrel/abort'\nimport { Emitter } from '@orkestrel/emitter'\nimport { isFunction, isString } from '@orkestrel/contract'\nimport { createRouter, joinPaths } from '@src/core'\nimport { computeNavigationKey, findAnchor, resolveLocationPath } from './helpers.js'\n\n/**\n * The headless History/hash navigation entity — composes one core\n * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and\n * every subsequent navigation event, tracking `active`, and emitting\n * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /\n * `outlet` — the consumer owns rendering.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n *\n * @remarks\n * - **One shared engine.** Each `route.path` is registered on the SAME\n * `Router` machine the core `Dispatcher` composes, keyed for dedup by its\n * {@link canonicalizePath} (last write wins, replace-in-place) — literal-\n * over-param precedence, trailing-slash insensitivity, and\n * `:param`/`*wildcard` extraction all come from that one engine (AGENTS\n * §21).\n * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match\n * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the\n * `fallback` through the SAME engine → a fallback that ALSO matches nothing\n * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a\n * newer navigation) and leaves `active` `undefined`, emitting nothing\n * (§21-honest: no phantom match is fabricated) → the optional `guard` may\n * veto → on a verdict, `active` is set and `navigate` emitted.\n * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`\n * handle, aborting the PREVIOUS navigation's handle first; a guard verdict\n * that resolves after its navigation was superseded (`signal.aborted`) is\n * discarded, same as a `false`/rejected verdict. A guard throw routes to\n * the `error` handler and vetoes. `stop()`/`destroy()` also abort the\n * pending handle.\n * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds\n * `hashchange`; history mode (`history: true`) binds `popstate` and, when\n * `intercept` is set, same-origin `<a>` click interception (a plain\n * left-click with no modifier keys, `target`, or `download` attribute).\n *\n * @example\n * ```ts\n * const navigator = new Navigator<{ readonly title: string }>({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * })\n * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))\n * navigator.start() // resolves the current hash now, and on every hashchange\n * navigator.navigate('/tokens')\n * ```\n */\nexport class Navigator<Meta> implements NavigatorInterface<Meta> {\n\treadonly #router: RouterInterface<RouteEntry<Meta>>\n\treadonly #emitter: Emitter<NavigatorEventMap<Meta>>\n\treadonly #history: boolean\n\treadonly #base: string | undefined\n\treadonly #fallback: string | undefined\n\treadonly #guard: NavigatorOptions<Meta>['guard']\n\treadonly #error: EmitterErrorHandler | undefined\n\treadonly #intercept: boolean\n\treadonly #hashListener: () => void\n\treadonly #popListener: () => void\n\treadonly #clickListener: (event: MouseEvent) => void\n\t#active: RouterMatch<Meta> | undefined\n\t#started = false\n\t#current: AbortInterface | undefined\n\n\tconstructor(options: NavigatorOptions<Meta>) {\n\t\tif (options.guard !== undefined && !isFunction(options.guard))\n\t\t\tthrow new TypeError(\n\t\t\t\t`a navigator guard must be a function, got ${JSON.stringify(options.guard)}`,\n\t\t\t)\n\t\tif (options.fallback !== undefined && !isString(options.fallback))\n\t\t\tthrow new TypeError(\n\t\t\t\t`a navigator fallback must be a string, got ${JSON.stringify(options.fallback)}`,\n\t\t\t)\n\t\tif (options.base !== undefined && !isString(options.base))\n\t\t\tthrow new TypeError(`a navigator base must be a string, got ${JSON.stringify(options.base)}`)\n\t\tthis.#history = options.history ?? false\n\t\tthis.#base = options.base\n\t\tthis.#intercept = options.intercept ?? false\n\t\tthis.#guard = options.guard\n\t\tthis.#error = options.error\n\t\tthis.#emitter = new Emitter<NavigatorEventMap<Meta>>({\n\t\t\t...(options.on === undefined ? {} : { on: options.on }),\n\t\t\t...(options.error === undefined ? {} : { error: options.error }),\n\t\t})\n\t\tthis.#router = createRouter<RouteEntry<Meta>>({\n\t\t\tentries: options.routes.map((route) => ({\n\t\t\t\tpath: route.path,\n\t\t\t\tmeta: route,\n\t\t\t\t...(route.name === undefined ? {} : { name: route.name }),\n\t\t\t})),\n\t\t\t...(options.sensitive === undefined ? {} : { sensitive: options.sensitive }),\n\t\t\tkey: computeNavigationKey,\n\t\t})\n\t\tthis.#fallback = options.fallback ?? options.routes[0]?.path\n\t\tthis.#hashListener = this.#resolve.bind(this)\n\t\tthis.#popListener = this.#resolve.bind(this)\n\t\tthis.#clickListener = this.#intercepted.bind(this)\n\t}\n\n\tget router(): RouterInterface<RouteEntry<Meta>> {\n\t\treturn this.#router\n\t}\n\n\tget emitter(): EmitterInterface<NavigatorEventMap<Meta>> {\n\t\treturn this.#emitter\n\t}\n\n\tget active(): RouterMatch<Meta> | undefined {\n\t\treturn this.#active\n\t}\n\n\tstart(): void {\n\t\tif (this.#started) return\n\t\tthis.#started = true\n\t\tif (!this.#history) {\n\t\t\twindow.addEventListener('hashchange', this.#hashListener)\n\t\t} else {\n\t\t\twindow.addEventListener('popstate', this.#popListener)\n\t\t\tif (this.#intercept) document.addEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#resolve()\n\t}\n\n\tstop(): void {\n\t\tif (!this.#started) return\n\t\tthis.#started = false\n\t\tif (!this.#history) {\n\t\t\twindow.removeEventListener('hashchange', this.#hashListener)\n\t\t} else {\n\t\t\twindow.removeEventListener('popstate', this.#popListener)\n\t\t\tif (this.#intercept) document.removeEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#current?.abort()\n\t}\n\n\tnavigate(path: string): void {\n\t\tif (!this.#history) {\n\t\t\tconst next = `#${path}`\n\t\t\tif (window.location.hash === next) this.#resolve()\n\t\t\telse window.location.hash = next\n\t\t\treturn\n\t\t}\n\t\tconst target = this.#base === undefined ? path : joinPaths(this.#base, path)\n\t\twindow.history.pushState(null, '', target)\n\t\tthis.#resolve()\n\t}\n\n\tmatch(path: string): RouterMatch<Meta> | undefined {\n\t\tconst hit = this.#router.match(path)\n\t\tif (hit === undefined) return undefined\n\t\treturn {\n\t\t\tpath: hit.path,\n\t\t\tparams: hit.params,\n\t\t\tmeta: hit.meta.meta,\n\t\t\t...(hit.meta.name === undefined ? {} : { name: hit.meta.name }),\n\t\t}\n\t}\n\n\tdestroy(): void {\n\t\tthis.stop()\n\t\tthis.#emitter.destroy()\n\t}\n\n\t// === Private\n\n\t// Compute the pathname to match for the CURRENT location, resolve it (falling back to the\n\t// configured fallback through the SAME engine on a miss), and either navigate or — when\n\t// neither the location nor the fallback matches anything — leave `active` `undefined` with\n\t// no emit (§21-honest: no phantom match is fabricated).\n\t#resolve(): void {\n\t\tconst pathname = resolveLocationPath(\n\t\t\t{ hash: window.location.hash, pathname: window.location.pathname },\n\t\t\tthis.#history,\n\t\t\tthis.#base,\n\t\t)\n\t\tconst to = this.match(pathname) ?? this.#matchFallback()\n\t\tif (to === undefined) {\n\t\t\tthis.#current?.abort()\n\t\t\tthis.#active = undefined\n\t\t\treturn\n\t\t}\n\t\tthis.#navigate(to)\n\t}\n\n\t#matchFallback(): RouterMatch<Meta> | undefined {\n\t\tif (this.#fallback === undefined) return undefined\n\t\treturn this.match(this.#fallback)\n\t}\n\n\t// Supersede the previous pending navigation's abort handle, mint a fresh one for this\n\t// navigation, and either commit directly (no guard configured — the synchronous fast path) or\n\t// run the guard pipeline.\n\t#navigate(to: RouterMatch<Meta>): void {\n\t\tthis.#current?.abort()\n\t\tconst handle = createAbort()\n\t\tthis.#current = handle\n\t\tconst guard = this.#guard\n\t\tif (guard === undefined) {\n\t\t\tthis.#commit(to)\n\t\t\treturn\n\t\t}\n\t\tvoid this.#guarded(guard, to, this.#active, handle)\n\t}\n\n\t#commit(to: RouterMatch<Meta>): void {\n\t\tthis.#active = to\n\t\tthis.#emitter.emit('navigate', to)\n\t}\n\n\t// Await the guard's verdict; a throw routes to the `error` handler and vetoes, a discarded\n\t// verdict (superseded via `handle.signal.aborted`, or a plain `false`/rejected verdict) leaves\n\t// `active` unchanged with no emit, and a true verdict commits.\n\tasync #guarded(\n\t\tguard: NonNullable<NavigatorOptions<Meta>['guard']>,\n\t\tto: RouterMatch<Meta>,\n\t\tfrom: RouterMatch<Meta> | undefined,\n\t\thandle: AbortInterface,\n\t): Promise<void> {\n\t\tlet verdict: boolean\n\t\ttry {\n\t\t\tverdict = await guard(to, from, handle.signal)\n\t\t} catch (error) {\n\t\t\tthis.#surface(error)\n\t\t\treturn\n\t\t}\n\t\tif (handle.signal.aborted || !verdict) return\n\t\tthis.#commit(to)\n\t}\n\n\t// Route a guard throw to the `error` handler (AGENTS §13's own channel, not a listener\n\t// throw so it cannot flow through the emitter's `emit`), swallowing a throwing handler itself\n\t// (anti-recursion, mirroring the emitter's own contract).\n\t#surface(error: unknown): void {\n\t\tconst handler = this.#error\n\t\tif (handler === undefined) return\n\t\ttry {\n\t\t\thandler(error, 'navigate')\n\t\t} catch {\n\t\t\t// The error handler itself threw — swallow it (anti-recursion).\n\t\t}\n\t}\n\n\t// Same-origin `<a>` click interception (history mode, opt-in via `intercept`): skip an\n\t// already-handled event, a non-primary button, any modifier key, a targeted or download link,\n\t// or a cross-origin destination — otherwise prevent the native navigation and `navigate` instead.\n\t#intercepted(event: MouseEvent): void {\n\t\tif (event.defaultPrevented || event.button !== 0) return\n\t\tif (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n\t\tconst anchor = findAnchor(event)\n\t\tif (anchor === undefined) return\n\t\tif (anchor.target !== '' && anchor.target !== '_self') return\n\t\tif (anchor.hasAttribute('download')) return\n\t\tconst url = new URL(anchor.href, window.location.href)\n\t\tif (url.origin !== window.location.origin) return\n\t\tevent.preventDefault()\n\t\tthis.navigate(resolveLocationPath({ hash: url.hash, pathname: url.pathname }, true, this.#base))\n\t}\n}\n","import type { NavigatorInterface, NavigatorOptions } from './types.js'\nimport { Navigator } from './Navigator.js'\n\n/**\n * Create a {@link NavigatorInterface} — the headless History/hash navigation\n * entity composing one core `Router<RouteEntry<Meta>>`.\n *\n * @remarks\n * Prefer this over `new Navigator(...)` at call sites that only need the\n * interface.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n * @param options - The `routes` to register, the `history` toggle (default\n * `false`, hash mode), an optional `base` (history mode), an optional\n * `fallback` path, an optional `guard` hook, opt-in link `intercept`\n * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter\n * `on`/`error` wiring\n * @returns A live {@link NavigatorInterface} handle — call `start()` to begin\n * dispatching\n *\n * @example\n * ```ts\n * import { createNavigator } from '@src/browser'\n *\n * const navigator = createNavigator({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * \ton: { navigate: (match) => (document.title = match.meta.title) },\n * })\n * navigator.start()\n * ```\n */\nexport function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta> {\n\treturn new Navigator<Meta>(options)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["#router","#emitter","#history","#base","#fallback","#guard","#error","#intercept","#listener","#clickListener","#resolve","#intercepted","#active","#started","#current","#matchFallback","#navigate","#commit","#guarded","#surface"],"sources":["../../../src/browser/helpers.ts","../../../src/browser/Navigator.ts","../../../src/browser/factories.ts"],"sourcesContent":["// The PURE browser-navigation primitives (self-describing helper naming —\n// module scope, no entity context). Every one is exported (the centralized-file\n// rule): the `Navigator` composes them, and each has its own unit test. NO `node:*`\n// — DOM-typed only (`Location`, `Event`, `HTMLAnchorElement`), valid under the\n// `src:browser` scoped isolation check.\n\nimport type { RouteEntry } from '@src/core'\nimport { canonicalizePath } from '@src/core'\n\n/**\n * Computes the registry key for a browser navigation route.\n *\n * @remarks\n * Projects the route's path through the core engine's canonical trailing-slash\n * identity, so `/users` and `/users/` replace one another in the Navigator's\n * shared Router. The entry's `meta` payload is never read, so any payload type\n * is accepted.\n *\n * @param entry - The Router entry carrying the Navigator route\n * @returns The route's canonical path\n *\n * @example\n * ```ts\n * computeNavigationKey({ path: '/users/', meta: {} }) // '/users'\n * ```\n */\nexport function computeNavigationKey(entry: RouteEntry<unknown>): string {\n\treturn canonicalizePath(entry.path)\n}\n\n/**\n * Extracts the `/`-prefixed pathname from a `location.hash` value — strips the\n * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.\n *\n * @remarks\n * The grammar this package matches everywhere is `/`-prefixed, so a hash-mode\n * location's `'#/users/7?x'` becomes `'/users/7'`\n * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,\n * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls\n * back). Total — never throws.\n *\n * @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)\n * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash\n *\n * @example\n * ```ts\n * extractHashPath('#/users/7?x') // '/users/7'\n * extractHashPath('#/tokens') // '/tokens'\n * extractHashPath('') // '' — the Navigator falls back\n * extractHashPath('#other') // '' — not a `#/` route hash\n * ```\n */\nexport function extractHashPath(hash: string): string {\n\tif (!hash.startsWith('#/')) return ''\n\tconst withoutHash = hash.slice(1)\n\tconst queryIndex = withoutHash.indexOf('?')\n\treturn queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex)\n}\n\n/**\n * Resolves the `/`-prefixed pathname to match for the CURRENT location, in\n * either navigation mode — the one seam `extractHashPath` (hash mode) and\n * history-mode base-stripping share.\n *\n * @remarks\n * Hash mode (`history: false`) reads `location.hash` through\n * {@link extractHashPath}. History mode (`history: true`) reads\n * `location.pathname` and strips a leading `base` prefix when one is\n * configured: `base` itself maps to the root `'/'`; a pathname that is not\n * under `base` is returned unchanged (a base mismatch is not this helper's\n * concern — the `Navigator`'s match then misses). Total — never throws.\n *\n * @param location - The `hash` + `pathname` pair to resolve from (accepts a\n * real `Location` or any object shaped the same, for pure unit testing)\n * @param history - If `true`, the pathname is read from `location.pathname`\n * with `base` stripped; if `false`, it is read from `location.hash`\n * @param base - The history-mode path prefix to strip (ignored in hash mode;\n * omit for no prefix)\n * @returns The `/`-prefixed pathname to match\n *\n * @example\n * ```ts\n * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'\n * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'\n * ```\n */\nexport function resolveLocationPath(\n\tlocation: Pick<Location, 'hash' | 'pathname'>,\n\thistory: boolean,\n\tbase?: string,\n): string {\n\tif (!history) return extractHashPath(location.hash)\n\tconst pathname = location.pathname\n\tif (base === undefined || base === '') return pathname\n\tconst normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base\n\tif (pathname === normalizedBase) return '/'\n\tif (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length)\n\treturn pathname\n}\n\n/**\n * Finds the nearest enclosing `<a>` element a DOM event originated from, by\n * walking its composed path — the pure lookup behind history-mode link\n * interception.\n *\n * @remarks\n * Uses `event.composedPath()` (not `event.target`) so a click on a styled\n * child INSIDE an anchor (an icon, a span) still resolves to the anchor.\n * Total — never throws; returns `undefined` when no anchor is found on the\n * path.\n *\n * @param event - The DOM event to search (typically a `click`)\n * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`\n *\n * @example\n * ```ts\n * document.addEventListener('click', (event) => {\n * \tconst anchor = findAnchor(event)\n * \tif (anchor !== undefined) console.log(anchor.href)\n * })\n * ```\n */\nexport function findAnchor(event: Event): HTMLAnchorElement | undefined {\n\tfor (const node of event.composedPath()) {\n\t\tif (node instanceof HTMLAnchorElement) return node\n\t}\n\treturn undefined\n}\n","import type { NavigatorEventMap, NavigatorInterface, NavigatorOptions } from './types.js'\nimport type { AbortInterface } from '@orkestrel/abort'\nimport type { EmitterErrorHandler, EmitterInterface } from '@orkestrel/emitter'\nimport type { RouterInterface, RouterMatch } from '@src/core'\nimport { createAbort } from '@orkestrel/abort'\nimport { Emitter } from '@orkestrel/emitter'\nimport { ContractError, isFunction, isString, preview } from '@orkestrel/contract'\nimport { createRouter, joinPaths } from '@src/core'\nimport { computeNavigationKey, findAnchor, resolveLocationPath } from './helpers.js'\n\n/**\n * Represents the headless History/hash navigation entity — composes one core\n * `Router<Meta>`, resolving the current location on `start()` and\n * every subsequent navigation event, tracking `active`, and emitting\n * `navigate` through the core {@link Emitter}. No `render` /\n * `outlet` — the consumer owns rendering.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n *\n * @remarks\n * - **One shared engine.** Each `route.path` is registered on the SAME\n * `Router` machine the core `Dispatcher` composes, keyed for dedup by its\n * {@link canonicalizePath} (last write wins, replace-in-place) — literal-\n * over-param precedence, trailing-slash insensitivity, and\n * `:param`/`*wildcard` extraction all come from that one shared engine.\n * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match\n * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the\n * `fallback` through the SAME engine → a fallback that ALSO matches nothing\n * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a\n * newer navigation) and leaves `active` `undefined`, emitting nothing —\n * honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may\n * veto → on a verdict, `active` is set and `navigate` emitted.\n * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`\n * handle, aborting the PREVIOUS navigation's handle first; a guard verdict\n * that resolves after its navigation was superseded (`signal.aborted`) is\n * discarded, same as a `false`/rejected verdict. A guard throw routes to\n * the `error` handler and vetoes. `stop()`/`destroy()` also abort the\n * pending handle.\n * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds\n * `hashchange`; history mode (`history: true`) binds `popstate` and, when\n * `intercept` is set, same-origin `<a>` click interception (a plain\n * left-click with no modifier keys, `target`, or `download` attribute).\n *\n * @example\n * ```ts\n * const navigator = new Navigator<{ readonly title: string }>({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * })\n * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))\n * navigator.start() // resolves the current hash now, and on every hashchange\n * navigator.navigate('/tokens')\n * ```\n */\nexport class Navigator<Meta> implements NavigatorInterface<Meta> {\n\treadonly #router: RouterInterface<Meta>\n\treadonly #emitter: Emitter<NavigatorEventMap<Meta>>\n\treadonly #history: boolean\n\treadonly #base: string | undefined\n\treadonly #fallback: string | undefined\n\treadonly #guard: NavigatorOptions<Meta>['guard']\n\treadonly #error: EmitterErrorHandler | undefined\n\treadonly #intercept: boolean\n\treadonly #listener: () => void\n\treadonly #clickListener: (event: MouseEvent) => void\n\t#active: RouterMatch<Meta> | undefined\n\t#started = false\n\t#current: AbortInterface | undefined\n\n\tconstructor(options: NavigatorOptions<Meta>) {\n\t\tif (options.guard !== undefined && !isFunction(options.guard))\n\t\t\tthrow new ContractError('a navigator guard must be a function when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'guard'],\n\t\t\t\t\tlimit: 'function or undefined',\n\t\t\t\t\treceived: preview(options.guard),\n\t\t\t\t},\n\t\t\t})\n\t\tif (options.fallback !== undefined && !isString(options.fallback))\n\t\t\tthrow new ContractError('a navigator fallback must be a string when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'fallback'],\n\t\t\t\t\tlimit: 'string or undefined',\n\t\t\t\t\treceived: preview(options.fallback),\n\t\t\t\t},\n\t\t\t})\n\t\tif (options.base !== undefined && !isString(options.base))\n\t\t\tthrow new ContractError('a navigator base must be a string when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'base'],\n\t\t\t\t\tlimit: 'string or undefined',\n\t\t\t\t\treceived: preview(options.base),\n\t\t\t\t},\n\t\t\t})\n\t\tthis.#history = options.history ?? false\n\t\tthis.#base = options.base\n\t\tthis.#intercept = options.intercept ?? false\n\t\tthis.#guard = options.guard\n\t\tthis.#error = options.error\n\t\tthis.#emitter = new Emitter<NavigatorEventMap<Meta>>({\n\t\t\t...(options.on === undefined ? {} : { on: options.on }),\n\t\t\t...(options.error === undefined ? {} : { error: options.error }),\n\t\t})\n\t\tthis.#router = createRouter<Meta>({\n\t\t\tentries: options.routes,\n\t\t\t...(options.sensitive === undefined ? {} : { sensitive: options.sensitive }),\n\t\t\tkey: computeNavigationKey,\n\t\t})\n\t\tthis.#fallback = options.fallback ?? options.routes[0]?.path\n\t\tthis.#listener = this.#resolve.bind(this)\n\t\tthis.#clickListener = this.#intercepted.bind(this)\n\t}\n\n\tget router(): RouterInterface<Meta> {\n\t\treturn this.#router\n\t}\n\n\tget emitter(): EmitterInterface<NavigatorEventMap<Meta>> {\n\t\treturn this.#emitter\n\t}\n\n\tget active(): RouterMatch<Meta> | undefined {\n\t\treturn this.#active\n\t}\n\n\tstart(): void {\n\t\tif (this.#started) return\n\t\tthis.#started = true\n\t\tif (!this.#history) {\n\t\t\twindow.addEventListener('hashchange', this.#listener)\n\t\t} else {\n\t\t\twindow.addEventListener('popstate', this.#listener)\n\t\t\tif (this.#intercept) document.addEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#resolve()\n\t}\n\n\tstop(): void {\n\t\tif (!this.#started) return\n\t\tthis.#started = false\n\t\tif (!this.#history) {\n\t\t\twindow.removeEventListener('hashchange', this.#listener)\n\t\t} else {\n\t\t\twindow.removeEventListener('popstate', this.#listener)\n\t\t\tif (this.#intercept) document.removeEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#current?.abort()\n\t}\n\n\tnavigate(path: string): void {\n\t\tif (!this.#history) {\n\t\t\tconst next = `#${path}`\n\t\t\tif (window.location.hash === next) this.#resolve()\n\t\t\telse window.location.hash = next\n\t\t\treturn\n\t\t}\n\t\tconst target = this.#base === undefined ? path : joinPaths(this.#base, path)\n\t\twindow.history.pushState(null, '', target)\n\t\tthis.#resolve()\n\t}\n\n\tmatch(path: string): RouterMatch<Meta> | undefined {\n\t\treturn this.#router.match(path)\n\t}\n\n\tdestroy(): void {\n\t\tthis.stop()\n\t\tthis.#emitter.destroy()\n\t}\n\n\t// === Private\n\n\t// Compute the pathname to match for the CURRENT location, resolve it (falling back to the\n\t// configured fallback through the SAME engine on a miss), and either navigate or — when\n\t// neither the location nor the fallback matches anything — leave `active` `undefined` with\n\t// no emit, honest to the one-shared-engine rule: no phantom match is fabricated.\n\t#resolve(): void {\n\t\tconst pathname = resolveLocationPath(\n\t\t\t{ hash: window.location.hash, pathname: window.location.pathname },\n\t\t\tthis.#history,\n\t\t\tthis.#base,\n\t\t)\n\t\tconst to = this.match(pathname) ?? this.#matchFallback()\n\t\tif (to === undefined) {\n\t\t\tthis.#current?.abort()\n\t\t\tthis.#active = undefined\n\t\t\treturn\n\t\t}\n\t\tthis.#navigate(to)\n\t}\n\n\t#matchFallback(): RouterMatch<Meta> | undefined {\n\t\tif (this.#fallback === undefined) return undefined\n\t\treturn this.match(this.#fallback)\n\t}\n\n\t// Supersede the previous pending navigation's abort handle, mint a fresh one for this\n\t// navigation, and either commit directly (no guard configured — the synchronous fast path) or\n\t// run the guard pipeline.\n\t#navigate(to: RouterMatch<Meta>): void {\n\t\tthis.#current?.abort()\n\t\tconst handle = createAbort()\n\t\tthis.#current = handle\n\t\tconst guard = this.#guard\n\t\tif (guard === undefined) {\n\t\t\tthis.#commit(to)\n\t\t\treturn\n\t\t}\n\t\tvoid this.#guarded(guard, to, this.#active, handle)\n\t}\n\n\t#commit(to: RouterMatch<Meta>): void {\n\t\tthis.#active = to\n\t\tthis.#emitter.emit('navigate', to)\n\t}\n\n\t// Await the guard's verdict; a throw routes to the `error` handler and vetoes, a discarded\n\t// verdict (superseded through `handle.signal.aborted`, or a plain `false`/rejected verdict) leaves\n\t// `active` unchanged with no emit, and a true verdict commits.\n\tasync #guarded(\n\t\tguard: NonNullable<NavigatorOptions<Meta>['guard']>,\n\t\tto: RouterMatch<Meta>,\n\t\tfrom: RouterMatch<Meta> | undefined,\n\t\thandle: AbortInterface,\n\t): Promise<void> {\n\t\tlet verdict: boolean\n\t\ttry {\n\t\t\tverdict = await guard(to, from, handle.signal)\n\t\t} catch (error) {\n\t\t\tthis.#surface(error)\n\t\t\treturn\n\t\t}\n\t\tif (handle.signal.aborted || !verdict) return\n\t\tthis.#commit(to)\n\t}\n\n\t// Route a guard throw to the `error` handler (the Emitter pattern's own channel, not a listener\n\t// throw so it cannot flow through the emitter's `emit`), swallowing a throwing handler itself\n\t// (anti-recursion, mirroring the emitter's own contract).\n\t#surface(error: unknown): void {\n\t\tconst handler = this.#error\n\t\tif (handler === undefined) return\n\t\ttry {\n\t\t\thandler(error, 'navigate')\n\t\t} catch {\n\t\t\t// The error handler itself threw — swallow it (anti-recursion).\n\t\t}\n\t}\n\n\t// Same-origin `<a>` click interception (history mode, opt-in through `intercept`): skip an\n\t// already-handled event, a non-primary button, any modifier key, a targeted or download link,\n\t// or a cross-origin destination — otherwise prevent the native navigation and `navigate` instead.\n\t#intercepted(event: MouseEvent): void {\n\t\tif (event.defaultPrevented || event.button !== 0) return\n\t\tif (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n\t\tconst anchor = findAnchor(event)\n\t\tif (anchor === undefined) return\n\t\tif (anchor.target !== '' && anchor.target !== '_self') return\n\t\tif (anchor.hasAttribute('download')) return\n\t\tconst url = new URL(anchor.href, window.location.href)\n\t\tif (url.origin !== window.location.origin) return\n\t\tevent.preventDefault()\n\t\tthis.navigate(resolveLocationPath({ hash: url.hash, pathname: url.pathname }, true, this.#base))\n\t}\n}\n","import type { NavigatorInterface, NavigatorOptions } from './types.js'\nimport { Navigator } from './Navigator.js'\n\n/**\n * Creates a {@link NavigatorInterface} — the headless History/hash navigation\n * entity composing one core `Router<Meta>`.\n *\n * @remarks\n * Prefer this over `new Navigator(...)` at call sites that only need the\n * interface.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n * @param options - The `routes` to register, the `history` toggle (default\n * `false`, hash mode), an optional `base` (history mode), an optional\n * `fallback` path, an optional `guard` hook, opt-in link `intercept`\n * (history mode), the `sensitive` case toggle, and the Emitter pattern's\n * `on`/`error` wiring\n * @returns A live {@link NavigatorInterface} handle — call `start()` to begin\n * dispatching\n *\n * @example\n * ```ts\n * import { createNavigator } from '@src/browser'\n *\n * const navigator = createNavigator({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * \ton: { navigate: (match) => (document.title = match.meta.title) },\n * })\n * navigator.start()\n * ```\n */\nexport function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta> {\n\treturn new Navigator<Meta>(options)\n}\n"],"mappings":"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"}
|