@miurajs/miura-router 0.0.0

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 ADDED
@@ -0,0 +1,237 @@
1
+ # `@miura/miura-router`
2
+
3
+ Modern, declarative routing for miura applications. Built for Web Components, the router handles hash/history/memory navigation modes, async guards, data loaders, redirects, and DOM rendering hooks.
4
+
5
+ ## ✨ Features
6
+
7
+ - **Multiple navigation modes**: `hash`, `history`, or in-memory for tests.
8
+ - **Guards & loaders**: resolve access/gate data before components render.
9
+ - **Nested routes & redirects**: declarative tree definitions.
10
+ - **Type-safe route params**: `defineRoute<TParams>()` with typed `buildPath()` and `navigate()`.
11
+ - **Runtime param validation**: optional Zod / Valibot / ArkType schema on any route.
12
+ - **Event-driven**: emits lifecycle events through the framework EventBus.
13
+ - **Performance hooks**: timing integration via `PerformanceMonitor`.
14
+
15
+ ## 🚦 Quick Start
16
+
17
+ ```ts
18
+ import { createRouter } from '@miura/miura-router';
19
+
20
+ const router = createRouter({
21
+ mode: 'history',
22
+ base: '/',
23
+ fallback: '/login',
24
+ routes: [
25
+ { path: '/', component: 'app-home' },
26
+ {
27
+ path: '/admin',
28
+ component: 'app-admin',
29
+ renderZone: '#primary',
30
+ guards: [async ({ data }) => (data.user?.isAdmin ? true : '/login')],
31
+ loaders: [async () => ({ stats: await fetchStats() })],
32
+ },
33
+ ],
34
+ render: (context) => mountIntoDom(context),
35
+ eventBus,
36
+ performance,
37
+ });
38
+
39
+ await router.start();
40
+ ```
41
+
42
+ ## 🛡️ Route Guards
43
+
44
+ Guards run before loaders/rendering. They may:
45
+ - return `true`/`void` to continue
46
+ - return `false` to block (`router:blocked` event)
47
+ - return a string path (sync/async) to redirect
48
+
49
+ ```ts
50
+ const routes = [
51
+ {
52
+ path: '/dashboard',
53
+ component: 'app-dashboard',
54
+ guards: [async ({ data }) => (data.session ? true : '/login')],
55
+ },
56
+ ];
57
+ ```
58
+
59
+ ## 📦 Route Loaders
60
+
61
+ Loaders run after guards and before rendering. Each loader can return any object; objects are shallow-merged in order.
62
+
63
+ ```ts
64
+ const routes = [
65
+ {
66
+ path: '/profile/:id',
67
+ component: 'app-profile',
68
+ loaders: [
69
+ ({ params }) => ({ profile: fetchProfile(params.id) }),
70
+ async ({ params }) => ({ permissions: await fetchPermissions(params.id) }),
71
+ ],
72
+ },
73
+ ];
74
+ ```
75
+
76
+ Access loader results inside the render callback (or components via `routeContext`) through `context.data`.
77
+
78
+ ## 🗂️ Nested Routes & Layout Outlets
79
+
80
+ Define a `children` array on any route to create a parent/child hierarchy. The parent route acts as a layout shell; the matched child fills the `<router-outlet>` inside it.
81
+
82
+ ```ts
83
+ const routes = [
84
+ {
85
+ path: '/app',
86
+ component: 'app-layout', // renders the shell + <router-outlet>
87
+ children: [
88
+ { path: 'dashboard', component: 'app-dashboard' },
89
+ { path: 'settings', component: 'app-settings' },
90
+ { path: 'profile/:id', component: 'app-profile' },
91
+ ],
92
+ },
93
+ ];
94
+ ```
95
+
96
+ ```typescript
97
+ // app-layout component
98
+ template() {
99
+ return html`
100
+ <nav>...</nav>
101
+ <main>
102
+ <router-outlet></router-outlet> <!-- child component mounts here -->
103
+ </main>
104
+ `;
105
+ }
106
+ ```
107
+
108
+ `context.matched` contains the full chain from root to leaf, so nested outlets at any depth receive the correct slice of the matched array.
109
+
110
+ ## 🔗 `<router-outlet>`
111
+
112
+ The `<router-outlet>` custom element is a passive mount-point. The router's render callback uses `context.matched` to determine which components to mount at each level.
113
+
114
+ ```typescript
115
+ import { RouterOutlet } from '@miura/miura-router';
116
+ // RouterOutlet registers itself as <router-outlet> when imported
117
+ ```
118
+
119
+ ## � Redirects
120
+
121
+ ```ts
122
+ { path: '/old-path', redirect: '/new-path' }
123
+ { path: '/dynamic', redirect: (ctx) => `/target/${ctx.params.id}` }
124
+ ```
125
+
126
+ ## �📢 Router Events
127
+
128
+ | Event | When |
129
+ |-------|------|
130
+ | `router:setup` | Router initialised |
131
+ | `router:navigating` | Navigation started |
132
+ | `router:navigated` | Navigation committed |
133
+ | `router:blocked` | Guard returned `false` |
134
+ | `router:not-found` | No matching route |
135
+ | `router:error` | Unhandled navigation error |
136
+ | `router:rendered` | Render callback completed |
137
+
138
+ ## 🛠️ Router API
139
+
140
+ | Method | Description |
141
+ |--------|-------------|
142
+ | `router.navigate(path, opts?)` | Push a new entry and navigate |
143
+ | `router.replace(path, opts?)` | Replace current entry and navigate |
144
+ | `router.back()` | Go back in history |
145
+ | `router.forward()` | Go forward in history |
146
+ | `router.current` | Current `RouteContext` |
147
+ | `router.previous` | Previous `RouteContext` |
148
+ | `router.start()` | Start listening to navigation events |
149
+ | `router.stop()` | Stop listening (keeps state) |
150
+ | `router.destroy()` | Full teardown |
151
+
152
+ `navigate()` and `replace()` both return `Promise<NavigationResult>`:
153
+
154
+ ```ts
155
+ const result = await router.navigate('/dashboard');
156
+ if (!result.ok) console.log('blocked:', result.reason);
157
+ ```
158
+
159
+ ## 🔷 Type-Safe Route Params
160
+
161
+ Use `defineRoute<TParams>()` to get typed `buildPath()` and `navigate()` helpers with compile-time safety on route params.
162
+
163
+ ```ts
164
+ import { defineRoute, createRouter } from '@miura/miura-router';
165
+
166
+ // No params
167
+ const homeRoute = defineRoute({ path: '/', component: 'app-home' });
168
+
169
+ // Single param — TypeScript enforces { id: string }
170
+ const userRoute = defineRoute<{ id: string }>({
171
+ path: '/users/:id',
172
+ component: 'user-page',
173
+ });
174
+
175
+ // Multiple params
176
+ const postRoute = defineRoute<{ userId: string; postId: string }>({
177
+ path: '/users/:userId/posts/:postId',
178
+ component: 'post-page',
179
+ });
180
+
181
+ // Pass records to createRouter
182
+ const router = createRouter({
183
+ mode: 'history',
184
+ routes: [homeRoute.record, userRoute.record, postRoute.record],
185
+ render: (ctx) => mountComponent(ctx),
186
+ });
187
+
188
+ // Typed navigation — TS error if a param is missing or wrong type
189
+ await userRoute.navigate(router, { id: '42' });
190
+ await postRoute.navigate(router, { userId: '1', postId: '99' });
191
+
192
+ // Build path without navigating
193
+ userRoute.buildPath({ id: '42' }); // → '/users/42'
194
+ postRoute.buildPath({ userId: '1', postId: '99' }); // → '/users/1/posts/99'
195
+ ```
196
+
197
+ ### Runtime validation with Zod
198
+
199
+ Pass any Zod-compatible schema as a second argument. Params are validated (and coerced) after every match, before guards run.
200
+
201
+ ```ts
202
+ import { z } from 'zod';
203
+
204
+ const UserParams = z.object({ id: z.string().regex(/^\d+$/) });
205
+
206
+ const userRoute = defineRoute(
207
+ { path: '/users/:id', component: 'user-page' },
208
+ UserParams, // ← schema
209
+ );
210
+
211
+ // Navigation to /users/abc → NavigationResult { ok: false, reason: 'error' }
212
+ // Navigation to /users/42 → proceeds normally, ctx.params.id is '42'
213
+ ```
214
+
215
+ The `ParamsSchema` interface is minimal — anything with `safeParse()` works (Zod, Valibot, ArkType, custom).
216
+
217
+ ## 🧪 Testing
218
+
219
+ Use `mode: 'memory'` to avoid touching the real browser location. Provide a spy render callback to inspect contexts:
220
+
221
+ ```ts
222
+ const renders: RouteRenderContext[] = [];
223
+ const router = createRouter({
224
+ mode: 'memory',
225
+ routes,
226
+ render: (ctx) => { renders.push(ctx); },
227
+ });
228
+ await router.start();
229
+ await router.navigate('/dashboard');
230
+ assert.equal(renders.at(-1)?.route.component, 'app-dashboard');
231
+ ```
232
+
233
+ The repository contains `test/router.guards-loaders.test.ts` covering redirects, blocks, and loader merges.
234
+
235
+ ## 📚 Framework Integration
236
+
237
+ `miuraFramework` wires this router automatically. Define a static `router` config in your framework subclass, and the framework handles instantiation, DOM zones, and navigation helpers (`navigate`, `replaceRoute`, `goBack`, `goForward`).
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { MiuraRouter, createRouter } from './src/router.js';
2
+ export { RouterOutlet } from './src/router-outlet.js';
3
+ export { defineRoute, buildPath } from './src/route-builder.js';
4
+ export type { TypedRoute, NavigableRouter } from './src/route-builder.js';
5
+ export * from './src/types.js';
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@miurajs/miura-router",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./index.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc"
16
+ },
17
+ "dependencies": {
18
+ "@miura/miura-debugger": "workspace:*"
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.4.5"
22
+ }
23
+ }
@@ -0,0 +1,114 @@
1
+ import type {
2
+ RouteRecord,
3
+ NavigationOptions,
4
+ NavigationResult,
5
+ ParamsSchema,
6
+ } from './types.js';
7
+
8
+ // ── Typed route builder ────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Minimal router interface required by `TypedRoute.navigate()`.
12
+ * `MiuraRouter` satisfies this; any compatible router will too.
13
+ */
14
+ export interface NavigableRouter {
15
+ navigate(path: string, options?: NavigationOptions): Promise<NavigationResult>;
16
+ }
17
+
18
+ /**
19
+ * A typed route definition. Created by `defineRoute<TParams>()`.
20
+ *
21
+ * Pass `record` to the `routes` array of `createRouter`, then use
22
+ * `buildPath()` / `navigate()` elsewhere for fully-typed navigation.
23
+ */
24
+ export interface TypedRoute<TParams extends Record<string, string> = Record<string, string>> {
25
+ /** Raw RouteRecord — add this to `createRouter({ routes })`. */
26
+ readonly record: RouteRecord<TParams>;
27
+
28
+ /**
29
+ * Substitute `:param` segments in the route path with the supplied values.
30
+ *
31
+ * ```ts
32
+ * userRoute.buildPath({ id: '42' }) // → '/users/42'
33
+ * ```
34
+ * @throws if a required segment is missing from `params`.
35
+ */
36
+ buildPath(params: TParams): string;
37
+
38
+ /**
39
+ * Navigate the router to this route, substituting params into the path.
40
+ *
41
+ * ```ts
42
+ * await userRoute.navigate(router, { id: '42' });
43
+ * ```
44
+ */
45
+ navigate(router: NavigableRouter, params: TParams, options?: NavigationOptions): Promise<NavigationResult>;
46
+ }
47
+
48
+ /**
49
+ * Define a type-safe route.
50
+ *
51
+ * @param config - Standard RouteRecord fields (without `paramsSchema`).
52
+ * @param schema - Optional Zod-compatible schema for runtime param validation.
53
+ *
54
+ * ```ts
55
+ * // No runtime validation
56
+ * export const userRoute = defineRoute<{ id: string }>({
57
+ * path: '/users/:id',
58
+ * component: 'user-page',
59
+ * });
60
+ *
61
+ * // With Zod schema
62
+ * import { z } from 'zod';
63
+ * const UserParams = z.object({ id: z.string().regex(/^\d+$/) });
64
+ * export const userRoute = defineRoute({ path: '/users/:id', component: 'user-page' }, UserParams);
65
+ *
66
+ * // Usage
67
+ * createRouter({ routes: [userRoute.record, ...] });
68
+ * await userRoute.navigate(router, { id: '42' });
69
+ * userRoute.buildPath({ id: '42' }); // → '/users/42'
70
+ * ```
71
+ */
72
+ export function defineRoute<TParams extends Record<string, string> = Record<string, string>>(
73
+ config: Omit<RouteRecord<TParams>, 'paramsSchema'>,
74
+ schema?: ParamsSchema<TParams>,
75
+ ): TypedRoute<TParams> {
76
+ const record: RouteRecord<TParams> = schema
77
+ ? { ...(config as RouteRecord<TParams>), paramsSchema: schema }
78
+ : (config as RouteRecord<TParams>);
79
+
80
+ return {
81
+ record,
82
+
83
+ buildPath(params: TParams): string {
84
+ return _interpolate(record.path, params);
85
+ },
86
+
87
+ navigate(router: NavigableRouter, params: TParams, options?: NavigationOptions): Promise<NavigationResult> {
88
+ return router.navigate(_interpolate(record.path, params), options);
89
+ },
90
+ };
91
+ }
92
+
93
+ // ── Utility — also exported for standalone use ─────────────────────────────────
94
+
95
+ /**
96
+ * Substitute `:param` segments in a path pattern with values from `params`.
97
+ *
98
+ * ```ts
99
+ * buildPath('/users/:id/posts/:postId', { id: '1', postId: '99' })
100
+ * // → '/users/1/posts/99'
101
+ * ```
102
+ */
103
+ export function buildPath(pattern: string, params: Record<string, string>): string {
104
+ return _interpolate(pattern, params);
105
+ }
106
+
107
+ function _interpolate(pattern: string, params: Record<string, string>): string {
108
+ return pattern.replace(/:(\w+)/g, (_, key) => {
109
+ if (!(key in params)) {
110
+ throw new Error(`[miura-router] Missing required param "${key}" for path "${pattern}"`);
111
+ }
112
+ return encodeURIComponent(params[key]);
113
+ });
114
+ }
@@ -0,0 +1,87 @@
1
+ import type { RouteContext, RouteRecord } from './types.js';
2
+
3
+ /**
4
+ * RouterOutlet — `<miura-router-outlet>`
5
+ *
6
+ * Drop this element into any layout component's template to mark where child
7
+ * routes should render. The router identifies outlets by their `name` attribute
8
+ * (default: "default") and injects child route components into them.
9
+ *
10
+ * Usage:
11
+ * ```html
12
+ * <!-- In a layout component template -->
13
+ * <nav>...</nav>
14
+ * <miura-router-outlet></miura-router-outlet>
15
+ * ```
16
+ *
17
+ * ```html
18
+ * <!-- Named outlet -->
19
+ * <miura-router-outlet name="sidebar"></miura-router-outlet>
20
+ * ```
21
+ *
22
+ * Routes define which outlet they target via `renderZone`:
23
+ * ```ts
24
+ * { path: '/users/:id', component: 'user-detail', renderZone: 'sidebar' }
25
+ * ```
26
+ */
27
+ export class RouterOutlet extends HTMLElement {
28
+ private _current: HTMLElement | null = null;
29
+
30
+ static get observedAttributes() {
31
+ return ['name'];
32
+ }
33
+
34
+ get outletName(): string {
35
+ return this.getAttribute('name') || 'default';
36
+ }
37
+
38
+ connectedCallback(): void {
39
+ if (!this.hasAttribute('role')) {
40
+ this.setAttribute('role', 'region');
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Render a route component into this outlet.
46
+ * Called by MiuraRouter when the matched route targets this outlet.
47
+ */
48
+ renderRoute(record: RouteRecord, context: RouteContext): void {
49
+ this._clearCurrent();
50
+
51
+ const element = document.createElement(record.component);
52
+ this._injectContext(element, context);
53
+ this.appendChild(element);
54
+ this._current = element as HTMLElement;
55
+ }
56
+
57
+ /**
58
+ * Clear whatever is currently rendered in this outlet.
59
+ */
60
+ clear(): void {
61
+ this._clearCurrent();
62
+ }
63
+
64
+ private _clearCurrent(): void {
65
+ if (this._current) {
66
+ this._current.remove();
67
+ this._current = null;
68
+ }
69
+ }
70
+
71
+ private _injectContext(element: Element, context: RouteContext): void {
72
+ if ('routeContext' in element) {
73
+ (element as any).routeContext = context;
74
+ return;
75
+ }
76
+ element.setAttribute('data-route', JSON.stringify({
77
+ params: context.params,
78
+ query: Object.fromEntries(context.query.entries()),
79
+ hash: context.hash,
80
+ }));
81
+ }
82
+ }
83
+
84
+ /** Register the element if not already registered */
85
+ if (!customElements.get('miura-router-outlet')) {
86
+ customElements.define('miura-router-outlet', RouterOutlet);
87
+ }