@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.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/src/browser/Navigator.d.ts +62 -0
- package/dist/src/browser/factories.d.ts +33 -0
- package/dist/src/browser/helpers.d.ts +76 -0
- package/dist/src/browser/index.d.ts +4 -0
- package/dist/src/browser/index.js +643 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/browser/types.d.ts +101 -0
- package/dist/src/core/DispatchGroup.d.ts +32 -0
- package/dist/src/core/Dispatcher.d.ts +51 -0
- package/dist/src/core/Group.d.ts +30 -0
- package/dist/src/core/Router.d.ts +42 -0
- package/dist/src/core/constants.d.ts +60 -0
- package/dist/src/core/factories.d.ts +53 -0
- package/dist/src/core/helpers.d.ts +274 -0
- package/dist/src/core/index.d.ts +8 -0
- package/dist/src/core/index.js +1014 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/core/types.d.ts +445 -0
- package/dist/src/server/helpers.d.ts +127 -0
- package/dist/src/server/index.cjs +417 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.ts +2 -0
- package/dist/src/server/types.d.ts +31 -0
- package/package.json +93 -0
|
@@ -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
|