@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orkestrel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @orkestrel/router
2
+
3
+ A typed request router for the `@orkestrel` line — the first `@orkestrel`
4
+ package to ship both server and browser environments alongside its shared
5
+ core. Built to sit beside `@orkestrel/contract` (validation) and
6
+ `@orkestrel/emitter` (observable lifecycle), reusing both as it takes shape.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install @orkestrel/router
12
+ ```
13
+
14
+ ## Requirements
15
+
16
+ - Node.js >= 24
17
+ - ESM-only (no CommonJS build)
18
+ - Server and browser environments both supported
19
+
20
+ ## Status
21
+
22
+ The public API is under design and not yet implemented — this package
23
+ currently ships no runtime code. This README will gain an install snippet,
24
+ usage examples, and a guide link once the design lands.
25
+
26
+ ## Package
27
+
28
+ Published as three environment-scoped entry points per the `exports` field in
29
+ `package.json`: a shared core, `./browser`, and `./server`.
30
+
31
+ ## License
32
+
33
+ MIT © [Orkestrel](https://github.com/orkestrel) — see [LICENSE](./LICENSE).
@@ -0,0 +1,62 @@
1
+ import type { NavigatorEventMap, NavigatorInterface, NavigatorOptions } from './types.js';
2
+ import type { EmitterInterface } from '@orkestrel/emitter';
3
+ import type { RouteEntry, RouterInterface, RouterMatch } from '@src/core';
4
+ /**
5
+ * The headless History/hash navigation entity — composes one core
6
+ * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and
7
+ * every subsequent navigation event, tracking `active`, and emitting
8
+ * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /
9
+ * `outlet` — the consumer owns rendering.
10
+ *
11
+ * @typeParam Meta - The opaque per-route payload a match carries back
12
+ *
13
+ * @remarks
14
+ * - **One shared engine.** Each `route.path` is registered on the SAME
15
+ * `Router` machine the core `Dispatcher` composes, keyed for dedup by its
16
+ * {@link canonicalizePath} (last write wins, replace-in-place) — literal-
17
+ * over-param precedence, trailing-slash insensitivity, and
18
+ * `:param`/`*wildcard` extraction all come from that one engine (AGENTS
19
+ * §21).
20
+ * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
21
+ * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
22
+ * `fallback` through the SAME engine → a fallback that ALSO matches nothing
23
+ * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
24
+ * newer navigation) and leaves `active` `undefined`, emitting nothing
25
+ * (§21-honest: no phantom match is fabricated) → the optional `guard` may
26
+ * veto → on a verdict, `active` is set and `navigate` emitted.
27
+ * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
28
+ * handle, aborting the PREVIOUS navigation's handle first; a guard verdict
29
+ * that resolves after its navigation was superseded (`signal.aborted`) is
30
+ * discarded, same as a `false`/rejected verdict. A guard throw routes to
31
+ * the `error` handler and vetoes. `stop()`/`destroy()` also abort the
32
+ * pending handle.
33
+ * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds
34
+ * `hashchange`; history mode (`history: true`) binds `popstate` and, when
35
+ * `intercept` is set, same-origin `<a>` click interception (a plain
36
+ * left-click with no modifier keys, `target`, or `download` attribute).
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const navigator = new Navigator<{ readonly title: string }>({
41
+ * routes: [
42
+ * { path: '/users/:id', meta: { title: 'User' } },
43
+ * { path: '/tokens', meta: { title: 'Tokens' } },
44
+ * ],
45
+ * })
46
+ * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))
47
+ * navigator.start() // resolves the current hash now, and on every hashchange
48
+ * navigator.navigate('/tokens')
49
+ * ```
50
+ */
51
+ export declare class Navigator<Meta> implements NavigatorInterface<Meta> {
52
+ #private;
53
+ constructor(options: NavigatorOptions<Meta>);
54
+ get router(): RouterInterface<RouteEntry<Meta>>;
55
+ get emitter(): EmitterInterface<NavigatorEventMap<Meta>>;
56
+ get active(): RouterMatch<Meta> | undefined;
57
+ start(): void;
58
+ stop(): void;
59
+ navigate(path: string): void;
60
+ match(path: string): RouterMatch<Meta> | undefined;
61
+ destroy(): void;
62
+ }
@@ -0,0 +1,33 @@
1
+ import type { NavigatorInterface, NavigatorOptions } from './types.js';
2
+ /**
3
+ * Create a {@link NavigatorInterface} — the headless History/hash navigation
4
+ * entity composing one core `Router<RouteEntry<Meta>>`.
5
+ *
6
+ * @remarks
7
+ * Prefer this over `new Navigator(...)` at call sites that only need the
8
+ * interface.
9
+ *
10
+ * @typeParam Meta - The opaque per-route payload a match carries back
11
+ * @param options - The `routes` to register, the `history` toggle (default
12
+ * `false`, hash mode), an optional `base` (history mode), an optional
13
+ * `fallback` path, an optional `guard` hook, opt-in link `intercept`
14
+ * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter
15
+ * `on`/`error` wiring
16
+ * @returns A live {@link NavigatorInterface} handle — call `start()` to begin
17
+ * dispatching
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { createNavigator } from '@src/browser'
22
+ *
23
+ * const navigator = createNavigator({
24
+ * routes: [
25
+ * { path: '/users/:id', meta: { title: 'User' } },
26
+ * { path: '/tokens', meta: { title: 'Tokens' } },
27
+ * ],
28
+ * on: { navigate: (match) => (document.title = match.meta.title) },
29
+ * })
30
+ * navigator.start()
31
+ * ```
32
+ */
33
+ export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
3
+ * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
4
+ *
5
+ * @remarks
6
+ * The grammar this package matches everywhere is `/`-prefixed (§4 path
7
+ * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`
8
+ * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
9
+ * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
10
+ * back). Total — never throws.
11
+ *
12
+ * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)
13
+ * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * extractHashPath('#/users/7?x') // '/users/7'
18
+ * extractHashPath('#/tokens') // '/tokens'
19
+ * extractHashPath('') // '' — the Navigator falls back
20
+ * extractHashPath('#other') // '' — not a `#/` route hash
21
+ * ```
22
+ */
23
+ export declare function extractHashPath(hash: string): string;
24
+ /**
25
+ * Resolve the `/`-prefixed pathname to match for the CURRENT location, in
26
+ * either navigation mode — the one seam `extractHashPath` (hash mode) and
27
+ * history-mode base-stripping share.
28
+ *
29
+ * @remarks
30
+ * Hash mode (`history: false`) reads `location.hash` through
31
+ * {@link extractHashPath}. History mode (`history: true`) reads
32
+ * `location.pathname` and strips a leading `base` prefix when one is
33
+ * configured: `base` itself maps to the root `'/'`; a pathname that is not
34
+ * under `base` is returned unchanged (a base mismatch is not this helper's
35
+ * concern — the `Navigator`'s match then simply misses). Total — never throws.
36
+ *
37
+ * @param location - The `hash` + `pathname` pair to resolve from (accepts a
38
+ * real `Location` or any object shaped the same, for pure unit testing)
39
+ * @param history - The navigation substrate: `false` for hash mode, `true`
40
+ * for history mode
41
+ * @param base - The history-mode path prefix to strip (ignored in hash mode;
42
+ * omit for no prefix)
43
+ * @returns The `/`-prefixed pathname to match
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'
48
+ * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'
49
+ * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'
50
+ * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'
51
+ * ```
52
+ */
53
+ export declare function resolveLocationPath(location: Pick<Location, 'hash' | 'pathname'>, history: boolean, base?: string): string;
54
+ /**
55
+ * Find the nearest enclosing `<a>` element a DOM event originated from, by
56
+ * walking its composed path — the pure lookup behind history-mode link
57
+ * interception.
58
+ *
59
+ * @remarks
60
+ * Uses `event.composedPath()` (not `event.target`) so a click on a styled
61
+ * child INSIDE an anchor (an icon, a span) still resolves to the anchor.
62
+ * Total — never throws; returns `undefined` when no anchor is found on the
63
+ * path.
64
+ *
65
+ * @param event - The DOM event to search (typically a `click`)
66
+ * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * document.addEventListener('click', (event) => {
71
+ * const anchor = findAnchor(event)
72
+ * if (anchor !== undefined) console.log(anchor.href)
73
+ * })
74
+ * ```
75
+ */
76
+ export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
@@ -0,0 +1,4 @@
1
+ export type * from './types.js';
2
+ export * from './helpers.js';
3
+ export * from './Navigator.js';
4
+ export * from './factories.js';