@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.
- package/README.md +326 -0
- 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.
|
|
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",
|