@rhi-zone/rainbow-router 0.0.1 → 0.2.0-alpha.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/README.md +73 -0
- package/dist/matcher.d.ts +12 -8
- package/dist/mountable.d.ts +35 -0
- package/dist/router.d.ts +36 -0
- package/dist/scroll.d.ts +14 -0
- package/dist/types.d.ts +49 -5
- package/package.json +10 -10
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @rhi-zone/rainbow-router
|
|
2
|
+
|
|
3
|
+
A trie-based SPA router for TypeScript. Signal-native, no codegen, no file-system conventions.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
rainbow-router treats the URL as state and routing as a lens. There is no magic file discovery, no compiler plugin, no generated types. You define a plain route tree object, hand it to `createRouter`, and get back a `Router` with a `current` signal that updates on every navigation.
|
|
8
|
+
|
|
9
|
+
Key properties:
|
|
10
|
+
|
|
11
|
+
- **Trie matching** — `O(depth)` per navigation, exact segments take priority over dynamic ones.
|
|
12
|
+
- **Dynamic segments** — keys starting with `_` (e.g. `_id`) capture URL segments; validated by `ParamParser` functions.
|
|
13
|
+
- **Loaders** — async data fetching per route, with automatic `AbortSignal` cancellation on navigation.
|
|
14
|
+
- **Scroll handlers** — composable, swappable: `scrollRestore`, `scrollTop`, `scrollNone`, `scrollToHash`.
|
|
15
|
+
- **Mountable subtrees** — type-branded subtrees for composable sub-routers.
|
|
16
|
+
- **No framework dependency** — works with any UI library or vanilla JS.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install @rhi-zone/rainbow-router
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick example
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createRouter } from '@rhi-zone/rainbow-router'
|
|
28
|
+
import { scrollRestore } from '@rhi-zone/rainbow-router/scroll'
|
|
29
|
+
|
|
30
|
+
const router = createRouter(
|
|
31
|
+
{
|
|
32
|
+
'': { component: HomePage },
|
|
33
|
+
about: { component: AboutPage },
|
|
34
|
+
posts: {
|
|
35
|
+
'': { component: PostListPage },
|
|
36
|
+
_id: {
|
|
37
|
+
'': {
|
|
38
|
+
component: PostPage,
|
|
39
|
+
loader: async ({ params, signal }) => {
|
|
40
|
+
const res = await fetch(`/api/posts/${params.id}`, { signal })
|
|
41
|
+
return res.json()
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{ scroll: scrollRestore },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// Navigate programmatically
|
|
51
|
+
router.navigate('/posts/42')
|
|
52
|
+
|
|
53
|
+
// Subscribe to route changes
|
|
54
|
+
router.current.subscribe((route) => {
|
|
55
|
+
if (route) console.log('matched', route.leaf.component, route.params)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Clean up
|
|
59
|
+
router.destroy()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Entry points
|
|
63
|
+
|
|
64
|
+
| Import | Contents |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `@rhi-zone/rainbow-router` | `createRouter`, types, `match`, `defineMountable` |
|
|
67
|
+
| `@rhi-zone/rainbow-router/scroll` | `scrollRestore`, `scrollTop`, `scrollNone`, `scrollToHash` |
|
|
68
|
+
| `@rhi-zone/rainbow-router/defaults` | `createRouter` pre-wired with `scrollRestore` |
|
|
69
|
+
| `@rhi-zone/rainbow-router/adapters/standard-schema` | `fromSchema` — adapt Standard Schema validators to `ParamParser` |
|
|
70
|
+
|
|
71
|
+
## Docs
|
|
72
|
+
|
|
73
|
+
Full guide and API reference are at the [Rainbow VitePress site](https://rhi.zone/rainbow/).
|
package/dist/matcher.d.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { RouteTree, MatchedRoute } from './types.ts';
|
|
2
2
|
/**
|
|
3
|
-
* Match a pathname against a route tree
|
|
3
|
+
* Match a pathname against a route tree, returning a `MatchedRoute` or `null`.
|
|
4
4
|
*
|
|
5
|
-
* Algorithm
|
|
6
|
-
* 1. Split pathname into segments
|
|
7
|
-
* 2. Walk the tree segment-by-segment
|
|
8
|
-
* 3. At each node: try exact key first, then `_name` dynamic key
|
|
9
|
-
* 4. Run ParamParser for dynamic
|
|
10
|
-
* 5. Collect `''` handlers at intermediate nodes as layout layers
|
|
11
|
-
* 6. At final segment: `''` key is the leaf handler
|
|
5
|
+
* Algorithm:
|
|
6
|
+
* 1. Split pathname into segments.
|
|
7
|
+
* 2. Walk the tree segment-by-segment.
|
|
8
|
+
* 3. At each node: try the exact key first, then any `_name` dynamic key.
|
|
9
|
+
* 4. Run the `ParamParser` for dynamic segments — a `null` return means no match (→ 404).
|
|
10
|
+
* 5. Collect `''` handlers at intermediate nodes as layout layers.
|
|
11
|
+
* 6. At the final segment: the `''` key is the leaf handler.
|
|
12
|
+
*
|
|
13
|
+
* @param tree - The route tree to match against.
|
|
14
|
+
* @param pathname - The URL pathname to match (e.g. `/posts/42`).
|
|
15
|
+
* @returns The matched route with layouts, leaf config, and parsed params; or `null` if unmatched.
|
|
12
16
|
*/
|
|
13
17
|
export declare function match(tree: RouteTree, pathname: string): MatchedRoute | null;
|
package/dist/mountable.d.ts
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import type { RouteTree } from './types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A `RouteTree` branded with a `Context` type that describes the params
|
|
4
|
+
* guaranteed to be present in any route matched within this subtree.
|
|
5
|
+
*
|
|
6
|
+
* Used to build composable sub-routers that can be mounted at a dynamic
|
|
7
|
+
* path segment. The `~context` property is a phantom type marker; it is
|
|
8
|
+
* never present at runtime.
|
|
9
|
+
*/
|
|
2
10
|
export type Mountable<Context extends Record<string, string>> = RouteTree & {
|
|
3
11
|
readonly '~context': Context;
|
|
4
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* A factory function that stamps a `RouteTree` as `Mountable<Context>`.
|
|
15
|
+
* Created by `defineMountable<Context>()`.
|
|
16
|
+
*/
|
|
5
17
|
export type MountableFactory<Context extends Record<string, string>> = <Tree extends RouteTree>(tree: Tree) => Tree & Mountable<Context>;
|
|
18
|
+
/**
|
|
19
|
+
* Define a reusable subtree factory for routes that require a specific param context.
|
|
20
|
+
*
|
|
21
|
+
* The type parameter `Context` declares which params will be available to all
|
|
22
|
+
* routes mounted in this subtree (e.g. `{ teamId: string }`). The factory is a
|
|
23
|
+
* no-op at runtime — it only applies the type brand.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* const teamMountable = defineMountable<{ teamId: string }>()
|
|
28
|
+
*
|
|
29
|
+
* const teamRoutes = teamMountable({
|
|
30
|
+
* '': { component: TeamPage },
|
|
31
|
+
* settings: { component: TeamSettingsPage },
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* const routes: RouteTree = {
|
|
35
|
+
* teams: {
|
|
36
|
+
* _teamId: teamRoutes,
|
|
37
|
+
* },
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
6
41
|
export declare const defineMountable: <Context extends Record<string, string>>() => MountableFactory<Context>;
|
package/dist/router.d.ts
CHANGED
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
import type { ReadonlySignal, AsyncData } from '@rhi-zone/rainbow';
|
|
2
2
|
import type { RouteTree, MatchedRoute, ScrollHandler } from './types.ts';
|
|
3
|
+
/** Options passed to `createRouter`. */
|
|
3
4
|
export type RouterOptions = {
|
|
5
|
+
/** Scroll behavior handler; defaults to `scrollRestore` when using the `defaults` entry point. */
|
|
4
6
|
scroll?: ScrollHandler;
|
|
5
7
|
};
|
|
8
|
+
/** A running router instance. */
|
|
6
9
|
export type Router = {
|
|
10
|
+
/** Read-only signal of the currently matched route, or `null` if no route matched. */
|
|
7
11
|
readonly current: ReadonlySignal<MatchedRoute | null>;
|
|
12
|
+
/** Read-only signal of the active loader's `AsyncData` state. */
|
|
8
13
|
readonly loaderState: ReadonlySignal<AsyncData<unknown>>;
|
|
14
|
+
/** Push a new history entry and navigate to `pathname`. */
|
|
9
15
|
navigate(pathname: string): void;
|
|
16
|
+
/** Replace the current history entry and navigate to `pathname`. */
|
|
10
17
|
replace(pathname: string): void;
|
|
18
|
+
/** Go back one entry in the browser history. */
|
|
11
19
|
back(): void;
|
|
20
|
+
/** Go forward one entry in the browser history. */
|
|
12
21
|
forward(): void;
|
|
22
|
+
/** Remove event listeners and abort any in-flight loader. Call when unmounting. */
|
|
13
23
|
destroy(): void;
|
|
14
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Create a router that binds to the browser's `window.location` and History API.
|
|
27
|
+
*
|
|
28
|
+
* The `current` signal updates synchronously on every navigation. If the matched
|
|
29
|
+
* route has a `loader`, the router automatically manages the `loaderState` signal
|
|
30
|
+
* through `notAsked → loading → success | failure`, aborting in-flight requests on
|
|
31
|
+
* subsequent navigations.
|
|
32
|
+
*
|
|
33
|
+
* @param tree - The route tree to match against.
|
|
34
|
+
* @param options - Optional scroll handler and other settings.
|
|
35
|
+
* @returns A `Router` instance.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const router = createRouter({
|
|
40
|
+
* '': { component: HomePage },
|
|
41
|
+
* about: { component: AboutPage },
|
|
42
|
+
* posts: {
|
|
43
|
+
* _id: { '': { component: PostPage } },
|
|
44
|
+
* },
|
|
45
|
+
* })
|
|
46
|
+
*
|
|
47
|
+
* router.navigate('/posts/42')
|
|
48
|
+
* router.current.get() // { leaf: { component: PostPage }, params: { id: '42' }, ... }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
15
51
|
export declare function createRouter(tree: RouteTree, options?: RouterOptions): Router;
|
package/dist/scroll.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { ScrollHandler } from './types.ts';
|
|
2
|
+
/** Scroll to the top of the page on every navigation. */
|
|
2
3
|
export declare const scrollTop: ScrollHandler;
|
|
4
|
+
/** No-op scroll handler — do not scroll on navigation. */
|
|
3
5
|
export declare const scrollNone: ScrollHandler;
|
|
6
|
+
/**
|
|
7
|
+
* Scroll to the element matching the URL hash on navigation.
|
|
8
|
+
* If the hash is absent or no element is found, no scroll occurs.
|
|
9
|
+
*/
|
|
4
10
|
export declare const scrollToHash: ScrollHandler;
|
|
11
|
+
/**
|
|
12
|
+
* Save the current scroll position before navigating away and restore it on
|
|
13
|
+
* browser back/forward (pop). On push/replace, scrolls to the hash anchor if
|
|
14
|
+
* present, otherwise to the top of the page.
|
|
15
|
+
*
|
|
16
|
+
* Scroll coordinates are stored in `history.state` so they survive page refreshes.
|
|
17
|
+
* This is the default scroll handler used by the `defaults` entry point.
|
|
18
|
+
*/
|
|
5
19
|
export declare const scrollRestore: ScrollHandler;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,36 +1,79 @@
|
|
|
1
1
|
import type { AsyncData } from '@rhi-zone/rainbow';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The router's only contract for param validation.
|
|
4
|
+
* Receive the raw URL segment string; return the parsed value or `null` to signal no match (→ 404).
|
|
5
|
+
*/
|
|
3
6
|
export type ParamParser<T> = (raw: string) => T | null;
|
|
7
|
+
/** Navigation metadata passed to a `ScrollHandler`. */
|
|
4
8
|
export type ScrollNav = {
|
|
9
|
+
/** Whether this navigation was a history push, pop (back/forward), or replace. */
|
|
5
10
|
readonly type: 'push' | 'pop' | 'replace';
|
|
11
|
+
/** The hash fragment of the destination URL without the `#`, or `null` if absent. */
|
|
6
12
|
readonly hash: string | null;
|
|
13
|
+
/** The pathname navigated from. */
|
|
7
14
|
readonly from: string;
|
|
15
|
+
/** The pathname navigated to. */
|
|
8
16
|
readonly to: string;
|
|
9
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* A function called after every navigation to control scroll behavior.
|
|
20
|
+
* @see scrollTop, scrollNone, scrollToHash, scrollRestore
|
|
21
|
+
*/
|
|
10
22
|
export type ScrollHandler = (nav: ScrollNav) => void;
|
|
23
|
+
/** Context passed to a route loader function. */
|
|
11
24
|
export type LoaderCtx<P extends Record<string, unknown> = Record<string, string>> = {
|
|
25
|
+
/** Validated and parsed route params for this route. */
|
|
12
26
|
readonly params: P;
|
|
27
|
+
/** AbortSignal that is aborted if the user navigates away before the loader resolves. */
|
|
13
28
|
readonly signal: AbortSignal;
|
|
14
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* An async function that fetches data for a route.
|
|
32
|
+
* Receives params and an AbortSignal; resolves to the loaded data.
|
|
33
|
+
*/
|
|
15
34
|
export type LoaderFn<P extends Record<string, unknown> = Record<string, string>, T = unknown> = (ctx: LoaderCtx<P>) => Promise<T>;
|
|
16
35
|
/**
|
|
17
|
-
*
|
|
36
|
+
* Metadata attached to a route at a given path depth.
|
|
18
37
|
* Component type is `unknown` to stay framework-agnostic;
|
|
19
|
-
*
|
|
38
|
+
* framework adapters provide typed wrappers.
|
|
20
39
|
*/
|
|
21
40
|
export type RouteConfig = {
|
|
41
|
+
/** The component or view to render at this route. */
|
|
22
42
|
readonly component?: unknown;
|
|
43
|
+
/** Optional data loader; called when the route is matched. */
|
|
23
44
|
readonly loader?: LoaderFn;
|
|
45
|
+
/** ParamParser map for dynamic segments at this node. */
|
|
24
46
|
readonly params?: Record<string, ParamParser<unknown>>;
|
|
47
|
+
/** Scroll behavior override for this route. */
|
|
25
48
|
readonly scroll?: ScrollHandler;
|
|
26
49
|
};
|
|
27
50
|
/**
|
|
28
|
-
* A
|
|
29
|
-
*
|
|
51
|
+
* A trie node in the route tree.
|
|
52
|
+
*
|
|
53
|
+
* - String keys are static path segments (e.g. `admin`, `settings`).
|
|
54
|
+
* - Keys starting with `_` are dynamic segments (e.g. `_id`, `_slug`).
|
|
55
|
+
* - The `''` key is the handler at the exact depth of that node.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const routes: RouteTree = {
|
|
60
|
+
* '': { component: HomePage },
|
|
61
|
+
* admin: {
|
|
62
|
+
* '': { component: AdminLayout },
|
|
63
|
+
* users: { component: UsersPage },
|
|
64
|
+
* },
|
|
65
|
+
* posts: {
|
|
66
|
+
* _id: {
|
|
67
|
+
* '': { component: PostPage, params: { id: Number } },
|
|
68
|
+
* },
|
|
69
|
+
* },
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
30
72
|
*/
|
|
31
73
|
export type RouteTree = {
|
|
32
74
|
readonly [segment: string]: RouteTree | RouteConfig | unknown;
|
|
33
75
|
};
|
|
76
|
+
/** The result of a successful route match. */
|
|
34
77
|
export type MatchedRoute = {
|
|
35
78
|
/** Layout stack from outermost to innermost, excluding leaf. */
|
|
36
79
|
readonly layouts: RouteConfig[];
|
|
@@ -41,4 +84,5 @@ export type MatchedRoute = {
|
|
|
41
84
|
/** The full matched pathname. */
|
|
42
85
|
readonly pathname: string;
|
|
43
86
|
};
|
|
87
|
+
/** The reactive state of the active route's loader. */
|
|
44
88
|
export type LoaderState<T = unknown> = AsyncData<T, unknown>;
|
package/package.json
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhi-zone/rainbow-router",
|
|
3
|
-
"version": "0.0.1",
|
|
3
|
+
"version": "0.2.0-alpha.1",
|
|
4
4
|
"description": "Trie-based SPA router for rainbow",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
9
|
"import": "./dist/index.js",
|
|
10
|
-
"types":
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
11
|
},
|
|
12
12
|
"./scroll": {
|
|
13
13
|
"import": "./dist/scroll.js",
|
|
14
|
-
"types":
|
|
14
|
+
"types": "./dist/scroll.d.ts"
|
|
15
15
|
},
|
|
16
16
|
"./defaults": {
|
|
17
17
|
"import": "./dist/defaults.js",
|
|
18
|
-
"types":
|
|
18
|
+
"types": "./dist/defaults.d.ts"
|
|
19
19
|
},
|
|
20
20
|
"./adapters/standard-schema": {
|
|
21
21
|
"import": "./dist/adapters/standard-schema.js",
|
|
22
|
-
"types":
|
|
22
|
+
"types": "./dist/adapters/standard-schema.d.ts"
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"files": ["dist"],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"dev":
|
|
28
|
-
"build":
|
|
27
|
+
"dev": "vite build --watch",
|
|
28
|
+
"build": "vite build && tsgo --emitDeclarationOnly",
|
|
29
29
|
"typecheck": "tsgo --noEmit",
|
|
30
|
-
"test":
|
|
30
|
+
"test": "vitest run"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@rhi-zone/rainbow": "0.
|
|
33
|
+
"@rhi-zone/rainbow": "0.2.0-alpha.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@standard-schema/spec": "^1.0.0",
|
|
37
|
-
"vite":
|
|
37
|
+
"vite": "^6.0.0",
|
|
38
38
|
"vitest": "^2.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|