@kcrwfrd/trouter 0.1.0 → 0.1.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.
Files changed (2) hide show
  1. package/README.md +326 -0
  2. package/package.json +13 -2
package/README.md ADDED
@@ -0,0 +1,326 @@
1
+ # @kcrwfrd/trouter 🐟
2
+
3
+ A client-side hash fragment URL router with a tree of async resolvers and controllers, built for single-page applications.
4
+
5
+ ## Key Features
6
+
7
+ - **Framework-agnostic** — bind plain functions or classes as route handlers
8
+ - **Hierarchical route tree with async data resolution** — parent resolvers run before child controllers
9
+ - **Zero dependencies**
10
+ - **Path params** (`:id`), **query params** (`?filter`), and nested URL composition
11
+ - **Controller exit hooks** — async, can block navigation
12
+ - **Transition lifecycle hooks** — `onStart`, `onSuccess`, `onError`
13
+
14
+ ## Install
15
+
16
+ ```
17
+ npm install @kcrwfrd/trouter
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```js
23
+ import Router from '@kcrwfrd/trouter'
24
+
25
+ const router = new Router()
26
+
27
+ router
28
+ .route('home', {
29
+ url: '/',
30
+ controller: (params) => {
31
+ document.body.textContent = 'Home'
32
+ }
33
+ })
34
+ .route('users', {
35
+ url: '/users',
36
+ resolve: () => fetch('/api/users').then(r => r.json()),
37
+ controller: (params, users) => {
38
+ document.body.textContent = `${users.length} users`
39
+ }
40
+ })
41
+
42
+ router.listen()
43
+ ```
44
+
45
+ Navigate by updating the hash (`#!/users`) or programmatically:
46
+
47
+ ```js
48
+ router.go('users')
49
+ ```
50
+
51
+ ## Core Concepts
52
+
53
+ ### Route Tree & Async Resolution
54
+
55
+ Routes form a tree. Each route can define a `resolve` function that fetches data **before** its controller runs. Parent resolvers execute before child controllers, so data flows down the tree.
56
+
57
+ ```js
58
+ router
59
+ .route('users', {
60
+ url: '/users/:userId',
61
+ resolve: (params) => fetch(`/api/users/${params.userId}`).then(r => r.json()),
62
+ controller: (params, user) => {
63
+ // `user` is the resolved data from this route's resolve
64
+ }
65
+ })
66
+ .route('users.posts', {
67
+ url: '/posts',
68
+ controller: (params) => {
69
+ // By the time this runs, the parent `users` route has already
70
+ // resolved and its controller has executed.
71
+ }
72
+ })
73
+ .route('users.posts.create', {
74
+ url: '/create',
75
+ controller: (params) => {
76
+ // Parent chain: users → users.posts → users.posts.create
77
+ // Each resolve/controller pair runs in sequence down the tree.
78
+ }
79
+ })
80
+ ```
81
+
82
+ Navigating to `#!/users/123/posts/create` will:
83
+
84
+ 1. **Resolve** `users` (fetch user 123)
85
+ 2. **Enter** `users` controller
86
+ 3. **Enter** `users.posts` controller
87
+ 4. **Enter** `users.posts.create` controller
88
+
89
+ ### Defining Routes
90
+
91
+ Register routes with `router.route(name, definition)`. Routes return the router for chaining.
92
+
93
+ ```js
94
+ router
95
+ .route('admin', {
96
+ url: '/admin',
97
+ abstract: true // cannot be navigated to directly; serves as a parent
98
+ })
99
+ .route('admin.users', {
100
+ url: '/users', // full URL becomes /admin/users
101
+ controller: AdminUsersController
102
+ })
103
+ .route('admin.settings', {
104
+ url: '/settings', // full URL becomes /admin/settings
105
+ controller: AdminSettingsController
106
+ })
107
+ ```
108
+
109
+ **Hierarchy** is established in three ways:
110
+
111
+ 1. **Dot notation** — `'admin.users'` automatically parents under `'admin'`
112
+ 2. **`parent` property** — `{ parent: 'admin', ... }`
113
+ 3. **Route instance** — `{ parent: adminRoute, ... }`
114
+
115
+ Child URLs are appended to parent URLs. A parent with `/users/:userId` and a child with `/posts/:postId` produces `/users/:userId/posts/:postId`.
116
+
117
+ ### Controllers
118
+
119
+ Controllers are plain functions or classes. They receive `(params, resolvedData)` when the route is entered.
120
+
121
+ ```js
122
+ // Function controller
123
+ router.route('home', {
124
+ url: '/',
125
+ controller: (params, data) => {
126
+ document.querySelector('#app').innerHTML = renderHome(data)
127
+ }
128
+ })
129
+
130
+ // Class controller with onExit hook
131
+ class UserController {
132
+ constructor(params, user) {
133
+ this.render(user)
134
+ }
135
+
136
+ onExit() {
137
+ // Called when leaving this route.
138
+ // Return a promise to delay the transition (e.g. confirm unsaved changes).
139
+ return cleanup()
140
+ }
141
+ }
142
+
143
+ router.route('user', {
144
+ url: '/users/:userId',
145
+ resolve: (params) => fetchUser(params.userId),
146
+ controller: UserController
147
+ })
148
+ ```
149
+
150
+ A controller class can also define a static `resolve`:
151
+
152
+ ```js
153
+ class UserController {
154
+ static resolve(params) {
155
+ return fetchUser(params.userId)
156
+ }
157
+
158
+ constructor(params, user) {
159
+ this.render(user)
160
+ }
161
+ }
162
+
163
+ router.route('user', {
164
+ url: '/users/:userId',
165
+ controller: UserController
166
+ // no separate `resolve` needed — uses UserController.resolve
167
+ })
168
+ ```
169
+
170
+ ### Resolvers
171
+
172
+ The `resolve` property supports several formats:
173
+
174
+ ```js
175
+ // Function — receives params, returns a value or promise
176
+ resolve: (params) => fetch(`/api/items/${params.id}`)
177
+
178
+ // Object — named resolves run in parallel, controller receives the keyed results
179
+ resolve: {
180
+ users: () => fetch('/api/users').then(r => r.json()),
181
+ posts: () => fetch('/api/posts').then(r => r.json())
182
+ }
183
+ // controller receives { users: [...], posts: [...] }
184
+
185
+ // Array — parallel resolution, controller receives array
186
+ resolve: [
187
+ () => fetch('/api/users').then(r => r.json()),
188
+ () => fetch('/api/posts').then(r => r.json())
189
+ ]
190
+ // controller receives [usersData, postsData]
191
+
192
+ // Promise
193
+ resolve: Promise.resolve({ cached: true })
194
+ ```
195
+
196
+ If a resolver rejects, the transition is cancelled and the router state is restored.
197
+
198
+ ### URL Patterns
199
+
200
+ **Path params** are prefixed with `:` and are required:
201
+
202
+ ```
203
+ /users/:userId → #!/users/42
204
+ /users/:userId/posts/:postId → #!/users/42/posts/7
205
+ ```
206
+
207
+ **Query params** are listed after `?` and are optional:
208
+
209
+ ```
210
+ /search?query&page → #!/search?query=hello&page=2
211
+ → #!/search?query=hello (page omitted)
212
+ → #!/search (both omitted)
213
+ ```
214
+
215
+ Query params that are `null` or `undefined` are omitted from the URL. Falsy values like `0`, `false`, and `''` are included.
216
+
217
+ ## API Reference
218
+
219
+ ### `new Router({ prefix })`
220
+
221
+ Create a router instance.
222
+
223
+ | Option | Type | Default | Description |
224
+ |--------|------|---------|-------------|
225
+ | `prefix` | `String` | `'#!'` | URL prefix. Use `'#!'` for hash routing or `''` for History API routing. |
226
+
227
+ ### `router.route(name, definition)` → `Router`
228
+
229
+ Register a route. Returns the router for chaining.
230
+
231
+ ### `router.listen()`
232
+
233
+ Start listening for URL changes. Immediately processes the current URL. Uses `popstate` if the History API is available, otherwise `hashchange`.
234
+
235
+ ### `router.go(name, params)` → `Promise`
236
+
237
+ Navigate to a named route. Updates the browser URL and returns a promise that resolves with the new `router.current` state. Throws if the route name is not found.
238
+
239
+ ### `router.href(name, params)` → `String`
240
+
241
+ Generate a URL string for a route. Inherits current params by default, merged with any provided params.
242
+
243
+ ```js
244
+ router.href('users.posts', { userId: 42, postId: 7 })
245
+ // → '#!/users/42/posts/7'
246
+ ```
247
+
248
+ ### `router.reload(params, hardRefresh)` → `Promise`
249
+
250
+ Reload the current route. If `hardRefresh` is true, performs a full browser reload. Otherwise re-runs the transition.
251
+
252
+ ### `router.transitionTo(route, params, options)` → `Promise`
253
+
254
+ Low-level transition method. `options.location` controls whether the browser URL is updated.
255
+
256
+ ### `router.pushState(state, title, url)`
257
+
258
+ Wrapper around `window.history.pushState` with hash fallback.
259
+
260
+ ### Transition Hooks
261
+
262
+ ```js
263
+ router.transitions.onStart((route) => {
264
+ // Called before each transition. Return a promise to delay it.
265
+ showSpinner()
266
+ })
267
+
268
+ router.transitions.onSuccess((current) => {
269
+ // Called after a successful transition.
270
+ // current = { route, params }
271
+ document.title = current.route.title
272
+ hideSpinner()
273
+ })
274
+
275
+ router.transitions.onError((error) => {
276
+ // Called when a transition fails (resolve rejected, onExit rejected, etc.)
277
+ hideSpinner()
278
+ showError(error)
279
+ })
280
+ ```
281
+
282
+ ### Router State
283
+
284
+ | Property | Description |
285
+ |----------|-------------|
286
+ | `router.current` | Current state: `{ route, params }`. Also exposes `.url()` and `.path()`. |
287
+ | `router.previous` | Previous state: `{ route, params }` |
288
+
289
+ ## Route Definition Properties
290
+
291
+ | Property | Type | Description |
292
+ |----------|------|-------------|
293
+ | `name` | `String` | Route identifier. Dot notation (`'parent.child'`) establishes hierarchy. |
294
+ | `url` | `String` | URL pattern. Path params with `:param`, query params after `?`. |
295
+ | `controller` | `Function\|Class` | Called with `(params, resolvedData)` when route is entered. |
296
+ | `resolve` | `Function\|Object\|Array\|Promise` | Data to resolve before the controller runs. |
297
+ | `parent` | `String\|Route` | Parent route name or instance. Inferred from dot notation if omitted. |
298
+ | `abstract` | `Boolean` | If `true`, route cannot be navigated to directly (useful for layout routes). |
299
+ | `title` | `String` | Page title. Defaults to the route name. |
300
+
301
+ ## Smart Transitions
302
+
303
+ When navigating between routes, Trouter calculates the minimal set of routes to exit and enter based on the nearest common ancestor.
304
+
305
+ **Parent controllers are NOT re-invoked** when navigating between siblings with unchanged parent params:
306
+
307
+ ```
308
+ Navigate: users.detail → users.edit (userId stays 42)
309
+ Exit: users.detail
310
+ Enter: users.edit
311
+ (users controller is NOT re-entered)
312
+ ```
313
+
314
+ **Parent controllers ARE re-invoked** when their params change:
315
+
316
+ ```
317
+ Navigate: users.detail(userId=42) → users.detail(userId=99)
318
+ Exit: users.detail, users
319
+ Enter: users, users.detail
320
+ (users controller IS re-entered because userId changed)
321
+ ```
322
+
323
+ ## License
324
+
325
+ MIT
326
+
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@kcrwfrd/trouter",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Client-side router with a tree of handlers.",
6
+ "author": {
7
+ "name": "Kevin Crawford",
8
+ "url": "https://kcrwfrd.dev"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/kcrwfrd/trouter.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/kcrwfrd/trouter/issues"
16
+ },
17
+ "homepage": "https://github.com/kcrwfrd/trouter",
6
18
  "main": "dist/trouter.umd.js",
7
19
  "module": "dist/trouter.js",
8
20
  "exports": {
@@ -23,7 +35,6 @@
23
35
  "publishConfig": {
24
36
  "access": "public"
25
37
  },
26
- "author": "Kevin Crawford",
27
38
  "license": "MIT",
28
39
  "devDependencies": {
29
40
  "jsdom": "^28.0.0",