Skip to content

Commit 808b7c7

Browse files
Lane F: Add router guide, update README (#101)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mahyarmlk <mahyarmlk@users.noreply.github.com>
1 parent ca3a14c commit 808b7c7

2 files changed

Lines changed: 421 additions & 22 deletions

File tree

docs/Router.md

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
# Intent Router Guide
2+
3+
The `@intent-framework/router` package provides typed route definitions and navigation for Intent screens. It maps URL paths to screen definitions and injects type-safe `navigate()` into action handlers.
4+
5+
**The router is transport, not the product model.** Routes describe how users arrive at screens, not what screens are or how they behave. The product model lives in screen definitions, states, resources, and actions — not in URL structure.
6+
7+
---
8+
9+
## Installation
10+
11+
```sh
12+
pnpm add @intent-framework/core @intent-framework/router
13+
```
14+
15+
```sh
16+
npm install @intent-framework/core @intent-framework/router
17+
```
18+
19+
---
20+
21+
## Quick start
22+
23+
```ts
24+
import { createRouter } from "@intent-framework/router"
25+
import { screen } from "@intent-framework/core"
26+
27+
const HomeScreen = screen("Home", $ => {
28+
$.surface("main").contains()
29+
})
30+
31+
const router = createRouter()
32+
.route("home", "/", HomeScreen)
33+
.route("team", "/teams/:teamId", TeamScreen)
34+
.route("invite", "/teams/:teamId/invite", InviteScreen)
35+
36+
// Match a pathname
37+
const match = router.match("/teams/abc123")
38+
if (match.found) {
39+
console.log(match.name) // "team"
40+
console.log(match.params) // { teamId: "abc123" }
41+
}
42+
43+
// Build a typed path
44+
router.path("team", { teamId: "abc123" }) // "/teams/abc123"
45+
46+
// List all routes
47+
router.routes() // [{ name, path, screen }, ...]
48+
```
49+
50+
---
51+
52+
## `createRouter()`
53+
54+
Creates a new, empty router. Accepts an optional generic type parameter for screen services.
55+
56+
```ts
57+
const router = createRouter()
58+
const typedRouter = createRouter<AppServices>()
59+
```
60+
61+
### `.route(name, path, screen)`
62+
63+
Registers a route. Returns the router for chaining.
64+
65+
- **`name`** — unique route identifier (e.g. `"team.details"`)
66+
- **`path`** — URL pattern (e.g. `"/teams/:teamId"`)
67+
- **`screen`** — a `ScreenDefinition` created with `screen()`
68+
69+
```ts
70+
const router = createRouter()
71+
.route("home", "/", HomeScreen)
72+
.route("team.details", "/teams/:teamId", TeamDetailScreen)
73+
```
74+
75+
Paths are normalized: a leading `/` is added if missing, and a trailing `/` is stripped (except for the root path `"/"`).
76+
77+
Duplicate route names or paths throw at registration time.
78+
79+
### `.match(pathname)`
80+
81+
Matches a pathname against registered routes. Returns a discriminated union:
82+
83+
```ts
84+
type RouteMatch =
85+
| { found: true; name: string; path: string; params: Record<string, string>; screen: ScreenDefinition }
86+
| { found: false; pathname: string }
87+
```
88+
89+
Routes are checked in registration order. The first match wins.
90+
91+
```ts
92+
const match = router.match("/teams/abc123")
93+
if (match.found) {
94+
// match.name, match.params, match.screen available
95+
} else {
96+
// match.pathname available
97+
}
98+
```
99+
100+
### `.path(name, params?)`
101+
102+
Generates a path string for a named route. Returns the compiled path.
103+
104+
```ts
105+
router.path("home") // "/"
106+
router.path("team.details", { teamId: "t1" }) // "/teams/t1"
107+
router.path("invite", { teamId: "t1" }) // "/teams/t1/invite"
108+
```
109+
110+
Throws if the route name is unknown or required params are missing.
111+
112+
### `.routes()`
113+
114+
Returns a copy of all registered route definitions.
115+
116+
```ts
117+
const routes = router.routes()
118+
// [{ name: "home", path: "/", screen: HomeScreen }, ...]
119+
```
120+
121+
---
122+
123+
## Route parameters
124+
125+
Dynamic path segments start with `:` and match any non-empty segment.
126+
127+
| Pattern | Example path | Matches |
128+
|---------|-------------|---------|
129+
| `"/"` | `/` | Root |
130+
| `"/about"` | `/about` | Static |
131+
| `"/users/:userId"` | `/users/abc123` | Single param |
132+
| `"/teams/:teamId/members/:memberId"` | `/teams/t1/members/u2` | Multiple params |
133+
134+
Params are extracted as strings.
135+
136+
### Trailing slash behavior
137+
138+
Trailing slashes are normalized. `/about/` and `/about` match the same route. The root path `"/"` always remains `"/"`.
139+
140+
---
141+
142+
## Typed navigation
143+
144+
The router provides compile-time safety for route names and their required params.
145+
146+
### `RouterNavigate<Routes>`
147+
148+
A typed `navigate` function that enforces correct route names and params:
149+
150+
```ts
151+
import type { RouterNavigate, RoutesFromPaths } from "@intent-framework/router"
152+
153+
const appPaths = {
154+
home: "/",
155+
"team.details": "/teams/:teamId",
156+
} as const
157+
158+
type AppRoutes = RoutesFromPaths<typeof appPaths>
159+
type Navigate = RouterNavigate<AppRoutes>
160+
161+
// OK
162+
const n: Navigate = (name, ...args) => {}
163+
n("home")
164+
n("team.details", { teamId: "t1" })
165+
166+
// Type errors:
167+
n("team.details") // missing required params
168+
n("team.details", { wrong: "x" }) // wrong param name
169+
n("nonexistent") // unknown route name
170+
```
171+
172+
### `RouterServices<Routes, ExtraServices>`
173+
174+
Combines `navigate` with extra application services:
175+
176+
```ts
177+
import type { RouterServices, RoutesFromPaths } from "@intent-framework/router"
178+
179+
const appPaths = { home: "/" } as const
180+
type AppRoutes = RoutesFromPaths<typeof appPaths>
181+
182+
type AppServices = RouterServices<AppRoutes, {
183+
analytics: { track(event: string): void }
184+
}>
185+
186+
// Services object includes both navigate and analytics
187+
const svc: AppServices = {
188+
navigate: (name) => {},
189+
analytics: { track: (e) => {} },
190+
}
191+
```
192+
193+
### Typed `navigate` in screen actions
194+
195+
When screens are typed with `RouterServices`, action handlers receive a fully typed `navigate`:
196+
197+
```ts
198+
import type { RouterServices, RoutesFromPaths, RouteContext } from "@intent-framework/router"
199+
200+
const appPaths = {
201+
home: "/",
202+
"team.details": "/teams/:teamId",
203+
} as const
204+
205+
type AppRoutes = RoutesFromPaths<typeof appPaths>
206+
type AppServices = RouterServices<AppRoutes, {
207+
route: RouteContext<AppRoutes>
208+
}>
209+
210+
const TeamScreen = screen<AppServices>("Team Details", $ => {
211+
$.act("Back home")
212+
.does(({ navigate }) => {
213+
navigate("home")
214+
})
215+
216+
$.act("Invite")
217+
.does(({ navigate, route }) => {
218+
navigate("team.invite", { teamId: route.params.teamId })
219+
})
220+
221+
$.surface("main").contains()
222+
})
223+
```
224+
225+
### `RouteContext<Routes>` and `RouteContextFor<Routes, Name>`
226+
227+
Provides the current route's name, path, and typed params. Useful for actions that need to read the current route context:
228+
229+
```ts
230+
import type { RouteContext, RouteContextFor, RoutesFromPaths } from "@intent-framework/router"
231+
232+
type AppRoutes = RoutesFromPaths<typeof appPaths>
233+
234+
// Shaped as a discriminated union keyed by name
235+
const ctx: RouteContext<AppRoutes> = { name: "team.details", path: "/teams/:teamId", params: { teamId: "t1" } }
236+
237+
// Narrow by name to get typed params
238+
if (ctx.name === "team.details") {
239+
ctx.params.teamId // string
240+
}
241+
```
242+
243+
---
244+
245+
## `renderRouter()` (DOM integration)
246+
247+
The `@intent-framework/dom` package provides `renderRouter()` to materialize routed screens into the DOM.
248+
249+
```ts
250+
import { renderRouter } from "@intent-framework/dom"
251+
import { router } from "./router.js"
252+
253+
const handle = renderRouter(router, {
254+
target: document.getElementById("root")!,
255+
notFound: NotFoundScreen, // optional fallback screen
256+
services: { /* custom services */ }, // optional, omits navigate and route
257+
showScreenName: true, // optional, shows screen name heading
258+
showSemanticIds: true, // optional, adds data-intent-* attributes
259+
})
260+
```
261+
262+
### Options
263+
264+
| Option | Type | Description |
265+
|--------|------|-------------|
266+
| `target` | `HTMLElement` | Required. DOM element to render into. |
267+
| `window` | `Window` | Optional. Custom window (for testing, SSR). Defaults to `globalThis.window`. |
268+
| `notFound` | `ScreenDefinition \| ((pathname: string) => ScreenDefinition)` | Optional. Screen to render when no route matches. |
269+
| `services` | `Omit<TServices, "navigate" \| "route">` | Optional. Custom services injected into all screens. |
270+
| `showScreenName` | `boolean` | Optional. Renders a `<h1>` heading with the screen name. |
271+
| `showSemanticIds` | `boolean` | Optional. Adds `data-intent-*` attributes for debugging. |
272+
273+
### Return value
274+
275+
```ts
276+
type RouterDomHandle = {
277+
navigate(name, ...args): void // programmatic navigation
278+
renderPath(pathname: string): void // render a specific path
279+
dispose(): void // cleanup
280+
}
281+
```
282+
283+
---
284+
285+
## Popstate behavior
286+
287+
`renderRouter()` listens for `popstate` events on the window. When the user clicks browser back/forward, the router re-matches the current `location.pathname` and re-renders the corresponding screen.
288+
289+
Calling `navigate()` (either from the returned handle or from action contexts) calls `history.pushState()` and renders the target path, enabling standard browser navigation behavior.
290+
291+
The `dispose()` method removes the `popstate` listener and cleans up the current render.
292+
293+
---
294+
295+
## Complete example
296+
297+
```ts
298+
import { createRouter } from "@intent-framework/router"
299+
import { screen } from "@intent-framework/core"
300+
import { renderRouter } from "@intent-framework/dom"
301+
import type { RouterServices, RoutesFromPaths, RouteContext } from "@intent-framework/router"
302+
303+
// 1. Define paths
304+
const appPaths = {
305+
home: "/",
306+
"team.details": "/teams/:teamId",
307+
} as const
308+
309+
// 2. Derive types
310+
type AppRoutes = RoutesFromPaths<typeof appPaths>
311+
type AppServices = RouterServices<AppRoutes, {
312+
route: RouteContext<AppRoutes>
313+
}>
314+
315+
// 3. Define screens
316+
const HomeScreen = screen<AppServices>("Home", $ => {
317+
$.act("Open team")
318+
.does(({ navigate }) => {
319+
navigate("team.details", { teamId: "team_1" })
320+
})
321+
$.surface("main").contains()
322+
})
323+
324+
const TeamScreen = screen<AppServices>("Team Details", $ => {
325+
$.act("Back")
326+
.does(({ navigate }) => {
327+
navigate("home")
328+
})
329+
$.surface("main").contains()
330+
})
331+
332+
// 4. Create router
333+
const router = createRouter<AppServices>()
334+
.route("home", appPaths.home, HomeScreen)
335+
.route("team.details", appPaths["team.details"], TeamScreen)
336+
337+
// 5. Render
338+
renderRouter(router, {
339+
target: document.getElementById("root")!,
340+
notFound: screen("Not Found", $ => { $.surface("main").contains() }),
341+
showScreenName: true,
342+
})
343+
```
344+
345+
---
346+
347+
## Current non-goals
348+
349+
The following are intentionally not part of the router at this stage:
350+
351+
| Feature | Status |
352+
|---------|--------|
353+
| Nested/layout routes | Not implemented |
354+
| Route guards / middleware | Not implemented |
355+
| Lazy loading / code splitting | Not implemented |
356+
| Query string parsing | Not implemented |
357+
| Hash-based routing | Not implemented |
358+
| SSR / server-side matching | Not implemented |
359+
| Scroll restoration | Not implemented |
360+
| Link components | DOM renderer uses actions; no `<a>` generation |
361+
| Route metadata / titles | Screen names serve this role |
362+
| Redirects | Not implemented |
363+
364+
These may be added in future iterations as the framework matures. The router is designed to remain focused on typed path-to-screen mapping without accumulating app-model responsibilities.
365+
366+
---
367+
368+
## Related
369+
370+
- [@intent-framework/router README](../packages/router/README.md) — package reference
371+
- [DOM guide](Semantic-DOM-Debugging.md) — DOM rendering and debugging
372+
- [Resource guide](Resources.md) — resource lifecycle and runtime scoping
373+
- [Demo guide](Demo.md) — web-basic demo walkthrough

0 commit comments

Comments
 (0)