|
| 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