@marianmeres/simple-router 3.0.1 → 3.1.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/AGENTS.md ADDED
@@ -0,0 +1,358 @@
1
+ # @marianmeres/simple-router - AI Agent Reference
2
+
3
+ Machine-readable documentation for AI coding assistants.
4
+
5
+ ## Package Metadata
6
+
7
+ ```yaml
8
+ name: "@marianmeres/simple-router"
9
+ version: "2.2.3"
10
+ runtime: ["deno", "node"]
11
+ module_system: "ESM"
12
+ entry_point: "src/mod.ts"
13
+ npm_dist: ".npm-dist/"
14
+ license: "MIT"
15
+ ```
16
+
17
+ ## Core Concepts
18
+
19
+ ```yaml
20
+ purpose: "Framework-agnostic string pattern matcher and router"
21
+ use_cases:
22
+ - "SPA client-side routing"
23
+ - "File path matching"
24
+ - "Command pattern matching"
25
+ - "Any string identifier matching"
26
+ key_features:
27
+ - "Dynamic route parameters with optional regex constraints"
28
+ - "Wildcard and catch-all routes"
29
+ - "Spread parameters for multi-segment matching"
30
+ - "Query string parsing (toggleable per route)"
31
+ - "Reactive subscriptions (Svelte store compatible)"
32
+ - "First-match-wins routing"
33
+ ```
34
+
35
+ ## File Structure
36
+
37
+ ```yaml
38
+ source_files:
39
+ - path: "src/mod.ts"
40
+ purpose: "Main entry point, re-exports public API"
41
+ exports: ["SimpleRoute", "SimpleRouter", "RouteConfig", "RouteParams", "Logger", "RouteCallback", "RouterConfig", "RouterCurrent", "RouterOnOptions", "RouterOptions", "RouterSubscriber", "RouterUnsubscribe"]
42
+
43
+ - path: "src/route.ts"
44
+ purpose: "Low-level pattern parser"
45
+ exports: ["SimpleRoute", "RouteConfig", "RouteParams"]
46
+ class: "SimpleRoute"
47
+
48
+ - path: "src/router.ts"
49
+ purpose: "High-level router with pub/sub"
50
+ exports: ["SimpleRouter", "RouteParams", "Logger", "RouteCallback", "RouterConfig", "RouterCurrent", "RouterOnOptions", "RouterOptions", "RouterSubscriber", "RouterUnsubscribe"]
51
+ class: "SimpleRouter"
52
+
53
+ test_files:
54
+ - path: "tests/route.test.ts"
55
+ test_count: 63
56
+ coverage: "Pattern matching, query params, errors"
57
+
58
+ - path: "tests/router.test.ts"
59
+ test_count: 24
60
+ coverage: "Router lifecycle, subscriptions, logger, edge cases"
61
+
62
+ build_files:
63
+ - path: "scripts/build-npm.ts"
64
+ purpose: "Deno-to-npm build script"
65
+ ```
66
+
67
+ ## Dependencies
68
+
69
+ ```yaml
70
+ runtime:
71
+ - name: "@marianmeres/pubsub"
72
+ version: "^2.4.1"
73
+ registry: "jsr"
74
+ purpose: "Reactive subscriptions"
75
+
76
+ dev_only:
77
+ - "@std/assert"
78
+ - "@std/fs"
79
+ - "@std/path"
80
+ ```
81
+
82
+ ## API Summary
83
+
84
+ ### SimpleRouter
85
+
86
+ ```yaml
87
+ class: "SimpleRouter<T = unknown>"
88
+ purpose: "Main router for pattern matching and state management"
89
+ generic_param:
90
+ name: "T"
91
+ default: "unknown"
92
+ description: "Return type of route callbacks and exec() method"
93
+
94
+ static_properties:
95
+ - name: "debug"
96
+ type: "boolean"
97
+ default: false
98
+ description: "Enable debug logging (uses logger if provided, else console.log)"
99
+
100
+ constructor:
101
+ params:
102
+ - name: "config"
103
+ type: "RouterConfig<T> | RouterOptions<T> | null"
104
+ optional: true
105
+ description: "Route config or options object with routes and logger"
106
+
107
+ methods:
108
+ - name: "on"
109
+ signature: "(routes: string | string[], cb: RouteCallback<T>, options?: RouterOnOptions) => void"
110
+ description: "Register route(s) with callback"
111
+ important: "Routes matched in registration order - first match wins"
112
+
113
+ - name: "exec"
114
+ signature: "(url: string, fallbackFn?: RouteCallback<T>) => T | false"
115
+ description: "Execute pattern matching"
116
+ returns: "Callback result (type T), or false if no match"
117
+
118
+ - name: "subscribe"
119
+ signature: "(subscription: RouterSubscriber) => RouterUnsubscribe"
120
+ description: "Subscribe to state changes (Svelte store compatible)"
121
+ behavior: "Called immediately with current state"
122
+ returns: "Unsubscribe function directly (not wrapped object)"
123
+
124
+ - name: "reset"
125
+ signature: "() => this"
126
+ description: "Clear all routes (except catch-all)"
127
+
128
+ - name: "info"
129
+ signature: "() => Record<string, string>"
130
+ description: "Get route-to-label mapping"
131
+
132
+ properties:
133
+ - name: "current"
134
+ type: "RouterCurrent"
135
+ readonly: true
136
+ description: "Current router state"
137
+ ```
138
+
139
+ ### SimpleRoute
140
+
141
+ ```yaml
142
+ class: SimpleRoute
143
+ purpose: "Low-level pattern parser"
144
+
145
+ static_properties:
146
+ - name: "SPLITTER"
147
+ type: "string"
148
+ default: "/"
149
+ - name: "WILDCARD"
150
+ type: "string"
151
+ default: "*"
152
+
153
+ constructor:
154
+ params:
155
+ - name: "route"
156
+ type: "string"
157
+ description: "Pattern string"
158
+ throws: "Error if pattern invalid"
159
+
160
+ static_methods:
161
+ - name: "parseQueryString"
162
+ signature: "(str: string) => RouteParams"
163
+ description: "Parse query string to object"
164
+
165
+ methods:
166
+ - name: "parse"
167
+ signature: "(url: string, allowQueryParams?: boolean) => RouteParams | null"
168
+ description: "Match string against pattern"
169
+ returns: "Params object or null"
170
+ ```
171
+
172
+ ## Pattern Syntax Reference
173
+
174
+ ```yaml
175
+ patterns:
176
+ exact:
177
+ syntax: "literal"
178
+ example: "foo"
179
+ matches: "foo"
180
+
181
+ named_param:
182
+ syntax: "[name]"
183
+ example: "[id]"
184
+ captures: "{ id: 'value' }"
185
+
186
+ regex_param:
187
+ syntax: "[name(regex)]"
188
+ example: "[id([0-9]+)]"
189
+ captures: "{ id: '123' }"
190
+ constraint: "Must match regex"
191
+
192
+ optional:
193
+ syntax: "[name]?"
194
+ example: "[id]?"
195
+ captures: "{} or { id: 'value' }"
196
+
197
+ spread:
198
+ syntax: "[...name]"
199
+ example: "[...path]"
200
+ captures: "{ path: 'a/b/c' }"
201
+ constraint: "Only one per route, cannot be optional"
202
+
203
+ wildcard:
204
+ syntax: "*"
205
+ example: "/foo/*"
206
+ constraint: "Must be last segment"
207
+ captures: "{}"
208
+ ```
209
+
210
+ ## Type Definitions
211
+
212
+ ```typescript
213
+ type RouteParams = Record<string, any>;
214
+
215
+ // Generic callback type - T is the return type
216
+ type RouteCallback<T = unknown> = (params: RouteParams | null, route: string) => T;
217
+
218
+ // Generic config type - T is the return type of all callbacks
219
+ type RouterConfig<T = unknown> = Record<string, RouteCallback<T>>;
220
+
221
+ interface Logger {
222
+ debug: (...args: unknown[]) => unknown;
223
+ log: (...args: unknown[]) => unknown;
224
+ warn: (...args: unknown[]) => unknown;
225
+ error: (...args: unknown[]) => unknown;
226
+ }
227
+
228
+ // Generic options type - T is the return type of route callbacks
229
+ interface RouterOptions<T = unknown> {
230
+ routes?: RouterConfig<T> | null;
231
+ logger?: Logger | null;
232
+ }
233
+
234
+ interface RouterCurrent {
235
+ route: string | null;
236
+ params: RouteParams | null;
237
+ label: string | null;
238
+ }
239
+
240
+ interface RouterOnOptions {
241
+ label?: string | null;
242
+ allowQueryParams?: boolean;
243
+ }
244
+
245
+ type RouterSubscriber = (current: RouterCurrent) => void;
246
+
247
+ type RouterUnsubscribe = () => void;
248
+
249
+ interface RouteConfig {
250
+ segment: string;
251
+ name: string | null;
252
+ test: RegExp;
253
+ isOptional: boolean;
254
+ isSpread: boolean;
255
+ }
256
+ ```
257
+
258
+ ## Common Usage Patterns
259
+
260
+ ### SPA Hash Routing (Typed)
261
+
262
+ ```typescript
263
+ // Type-safe router - exec() returns Component | false
264
+ const router = new SimpleRouter<Component>({
265
+ "/": () => HomePage,
266
+ "/user/[id]": (params) => UserPage(params?.id),
267
+ "*": () => NotFoundPage
268
+ });
269
+
270
+ window.addEventListener("hashchange", () => {
271
+ const path = location.hash.slice(1) || "/";
272
+ const result = router.exec(path); // Component | false
273
+ if (result !== false) render(result);
274
+ });
275
+ ```
276
+
277
+ ### File Path Matching
278
+
279
+ ```typescript
280
+ const router = new SimpleRouter({
281
+ "src/[module]/[file].ts": ({ module, file }) => process(module, file),
282
+ "assets/[...path]": ({ path }) => loadAsset(path)
283
+ });
284
+ ```
285
+
286
+ ### Command Routing
287
+
288
+ ```typescript
289
+ const router = new SimpleRouter({
290
+ "user:create": () => createUser(),
291
+ "user:delete:[id]": ({ id }) => deleteUser(id)
292
+ });
293
+ ```
294
+
295
+ ## Important Behaviors
296
+
297
+ ```yaml
298
+ behaviors:
299
+ - name: "First Match Wins"
300
+ description: "Routes matched in registration order"
301
+ implication: "Register specific routes before generic ones"
302
+
303
+ - name: "Separator Normalization"
304
+ description: "Multiple separators normalized, trimmed from ends"
305
+ example: "//foo//bar// matches foo/bar"
306
+
307
+ - name: "Query String Priority"
308
+ description: "Path params override same-name query params"
309
+
310
+ - name: "Catch-all Stored Separately"
311
+ description: "'*' route stored in #catchAll, not in #routes array"
312
+
313
+ - name: "Immediate Subscription Call"
314
+ description: "subscribe() calls callback immediately with current state"
315
+ ```
316
+
317
+ ## Error Conditions
318
+
319
+ ```yaml
320
+ errors:
321
+ - condition: "Multiple spread segments"
322
+ message: "Multiple spread segments are invalid"
323
+
324
+ - condition: "Optional spread"
325
+ message: "Spread segment must not be marked as optional"
326
+
327
+ - condition: "Wildcard not last"
328
+ message: "Wildcard '*' can be used only as a last segment"
329
+
330
+ - condition: "Invalid regex in pattern"
331
+ message: "Invalid regex in route pattern '{segment}': {error}"
332
+
333
+ - condition: "Non-function subscriber"
334
+ throws: "TypeError: Subscription is not a function"
335
+ ```
336
+
337
+ ## Development Commands
338
+
339
+ ```yaml
340
+ commands:
341
+ test: "deno task test"
342
+ build_npm: "deno task npm:build"
343
+ publish_npm: "deno task npm:publish"
344
+ ```
345
+
346
+ ## Code Modification Guidelines
347
+
348
+ ```yaml
349
+ guidelines:
350
+ - "Maintain Svelte store contract compatibility for subscribe()"
351
+ - "subscribe() returns unsubscribe function directly (not wrapped object)"
352
+ - "First-match-wins is core behavior - do not change"
353
+ - "Query param parsing must remain toggleable per-route"
354
+ - "Keep catch-all ('*') handling separate from regular routes"
355
+ - "URL encoding/decoding must be handled for params"
356
+ - "Logger interface must remain compatible with @marianmeres/clog"
357
+ - "Tests cover 87 cases - run before any changes"
358
+ ```
package/API.md ADDED
@@ -0,0 +1,570 @@
1
+ # API Reference
2
+
3
+ Full API documentation for `@marianmeres/simple-router`.
4
+
5
+ ## Table of Contents
6
+
7
+ - [SimpleRouter](#simplerouter)
8
+ - [Constructor](#constructor)
9
+ - [Static Properties](#static-properties)
10
+ - [Instance Properties](#instance-properties)
11
+ - [Methods](#methods)
12
+ - [SimpleRoute](#simpleroute)
13
+ - [Constructor](#simpleroute-constructor)
14
+ - [Static Properties](#simpleroute-static-properties)
15
+ - [Static Methods](#static-methods)
16
+ - [Instance Methods](#instance-methods)
17
+ - [Types](#types)
18
+ - [Pattern Syntax](#pattern-syntax)
19
+
20
+ ---
21
+
22
+ ## SimpleRouter
23
+
24
+ The main router class for registering routes, executing pattern matching, and subscribing to state changes.
25
+
26
+ `SimpleRouter` is generic over `T`, the return type of route callbacks. This allows for type-safe `exec()` return values.
27
+
28
+ ### Constructor
29
+
30
+ ```ts
31
+ new SimpleRouter<T = unknown>(config?: RouterConfig<T> | RouterOptions<T> | null)
32
+ ```
33
+
34
+ Creates a new router instance.
35
+
36
+ **Type Parameters:**
37
+
38
+ | Name | Default | Description |
39
+ |------|---------|-------------|
40
+ | `T` | `unknown` | The return type of route callbacks |
41
+
42
+ **Parameters:**
43
+
44
+ | Name | Type | Description |
45
+ |------|------|-------------|
46
+ | `config` | `RouterConfig<T> \| RouterOptions<T> \| null` | Optional route configuration or options object |
47
+
48
+ **Example:**
49
+
50
+ ```ts
51
+ // Empty router (T defaults to unknown)
52
+ const router = new SimpleRouter();
53
+
54
+ // Typed router - all callbacks must return Component
55
+ const router = new SimpleRouter<Component>({
56
+ "/": () => HomePage,
57
+ "/about": () => AboutPage,
58
+ "*": () => NotFoundPage
59
+ });
60
+
61
+ // Simple config without explicit type (T inferred from callbacks)
62
+ const router = new SimpleRouter({
63
+ "/": () => HomePage,
64
+ "/about": () => AboutPage,
65
+ "*": () => NotFoundPage
66
+ });
67
+
68
+ // With options object (for logger support)
69
+ const router = new SimpleRouter<Component>({
70
+ routes: {
71
+ "/": () => HomePage,
72
+ "/about": () => AboutPage
73
+ },
74
+ logger: myLogger // optional, compatible with @marianmeres/clog
75
+ });
76
+ ```
77
+
78
+ ### Static Properties
79
+
80
+ #### `debug`
81
+
82
+ ```ts
83
+ static debug: boolean = false
84
+ ```
85
+
86
+ Enable/disable console debug logging for route matching. When enabled, logs detailed matching information using the logger instance (if provided) or falls back to `console.log`.
87
+
88
+ **Example:**
89
+
90
+ ```ts
91
+ SimpleRouter.debug = true;
92
+ router.exec("/test"); // Logs: [SimpleRouter] '/test' -> matches '/' with {}
93
+ ```
94
+
95
+ ### Instance Properties
96
+
97
+ #### `current`
98
+
99
+ ```ts
100
+ get current(): RouterCurrent
101
+ ```
102
+
103
+ Returns the current router state containing the last matched route, extracted parameters, and label.
104
+
105
+ **Returns:** `RouterCurrent` - Current state object
106
+
107
+ **Example:**
108
+
109
+ ```ts
110
+ router.exec("/user/123");
111
+ console.log(router.current);
112
+ // { route: "/user/[id]", params: { id: "123" }, label: null }
113
+ ```
114
+
115
+ ### Methods
116
+
117
+ #### `on()`
118
+
119
+ ```ts
120
+ on(routes: string | string[], cb: RouteCallback<T>, options?: RouterOnOptions): void
121
+ ```
122
+
123
+ Registers one or more route patterns with a callback function.
124
+
125
+ **Parameters:**
126
+
127
+ | Name | Type | Description |
128
+ |------|------|-------------|
129
+ | `routes` | `string \| string[]` | Single route pattern or array of patterns |
130
+ | `cb` | `RouteCallback<T>` | Callback function executed when route matches (must return `T`) |
131
+ | `options` | `RouterOnOptions` | Optional configuration object |
132
+
133
+ **Options:**
134
+
135
+ | Name | Type | Default | Description |
136
+ |------|------|---------|-------------|
137
+ | `label` | `string \| null` | `null` | Label for debugging/identification |
138
+ | `allowQueryParams` | `boolean` | `true` | Whether to parse query parameters |
139
+
140
+ **Example:**
141
+
142
+ ```ts
143
+ // Single route
144
+ router.on("/users", () => UsersPage);
145
+
146
+ // Multiple routes to same handler
147
+ router.on(["/", "/home", "/index.html"], () => HomePage);
148
+
149
+ // With dynamic parameters
150
+ router.on("/user/[id]", (params) => UserPage(params?.id));
151
+
152
+ // With regex constraint
153
+ router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));
154
+
155
+ // With label for debugging
156
+ router.on("/admin", () => AdminPage, { label: "admin-dashboard" });
157
+
158
+ // Disable query parameter parsing
159
+ router.on("/raw/[path]", (params) => RawPage(params), { allowQueryParams: false });
160
+
161
+ // Catch-all route (must be registered last)
162
+ router.on("*", () => NotFoundPage);
163
+ ```
164
+
165
+ **Important:** Routes are matched in registration order. First match wins!
166
+
167
+ ---
168
+
169
+ #### `exec()`
170
+
171
+ ```ts
172
+ exec(url: string, fallbackFn?: RouteCallback<T>): T | false
173
+ ```
174
+
175
+ Executes pattern matching against the provided string.
176
+
177
+ **Parameters:**
178
+
179
+ | Name | Type | Description |
180
+ |------|------|-------------|
181
+ | `url` | `string` | String to match against registered patterns |
182
+ | `fallbackFn` | `RouteCallback<T>` | Optional fallback function if no route matches |
183
+
184
+ **Returns:** `T | false` - The value returned by the matched callback (type `T`), or `false` if no match and no catch-all/fallback.
185
+
186
+ **Match Priority:**
187
+ 1. First registered matching route
188
+ 2. Fallback function (if provided)
189
+ 3. Catch-all `"*"` route (if registered)
190
+ 4. Returns `false`
191
+
192
+ **Example:**
193
+
194
+ ```ts
195
+ // Basic usage
196
+ const result = router.exec("/users");
197
+
198
+ // With query parameters
199
+ router.exec("/search?q=hello&page=1");
200
+
201
+ // With fallback function
202
+ router.exec("/unknown", (params, route) => {
203
+ console.log("No match found");
204
+ return NotFoundPage;
205
+ });
206
+
207
+ // Return values from callbacks
208
+ const component = router.exec("/home");
209
+ render(component);
210
+ ```
211
+
212
+ ---
213
+
214
+ #### `subscribe()`
215
+
216
+ ```ts
217
+ subscribe(subscription: RouterSubscriber): RouterUnsubscribe
218
+ ```
219
+
220
+ Subscribes to router state changes. Follows the [Svelte store contract](https://svelte.dev/docs#Store_contract).
221
+
222
+ **Parameters:**
223
+
224
+ | Name | Type | Description |
225
+ |------|------|-------------|
226
+ | `subscription` | `RouterSubscriber` | Callback function receiving state changes |
227
+
228
+ **Returns:** `RouterUnsubscribe` - Unsubscribe function
229
+
230
+ **Behavior:**
231
+ - Subscriber is called immediately with current state
232
+ - Subsequently called on every `exec()` call
233
+
234
+ **Example:**
235
+
236
+ ```ts
237
+ const unsubscribe = router.subscribe((state) => {
238
+ console.log("Route:", state.route);
239
+ console.log("Params:", state.params);
240
+ console.log("Label:", state.label);
241
+ });
242
+
243
+ // In Svelte
244
+ $: currentRoute = $router;
245
+
246
+ // Cleanup
247
+ unsubscribe();
248
+ ```
249
+
250
+ ---
251
+
252
+ #### `reset()`
253
+
254
+ ```ts
255
+ reset(): this
256
+ ```
257
+
258
+ Clears all registered routes except the catch-all (`*`).
259
+
260
+ **Returns:** The router instance for method chaining
261
+
262
+ **Example:**
263
+
264
+ ```ts
265
+ router.reset().on("/new-route", () => NewPage);
266
+ ```
267
+
268
+ ---
269
+
270
+ #### `info()`
271
+
272
+ ```ts
273
+ info(): Record<string, string>
274
+ ```
275
+
276
+ Returns a map of registered route patterns to their labels.
277
+
278
+ **Returns:** Object mapping route patterns to labels (empty string if no label)
279
+
280
+ **Example:**
281
+
282
+ ```ts
283
+ router.on("/users", () => {}, { label: "users-list" });
284
+ router.on("/posts", () => {}, { label: "posts-list" });
285
+
286
+ console.log(router.info());
287
+ // { "/users": "users-list", "/posts": "posts-list" }
288
+ ```
289
+
290
+ ---
291
+
292
+ ## SimpleRoute
293
+
294
+ Low-level route pattern parser. Use this directly only when you need pattern matching without the full router functionality.
295
+
296
+ <a id="simpleroute-constructor"></a>
297
+ ### Constructor
298
+
299
+ ```ts
300
+ new SimpleRoute(pattern: string)
301
+ ```
302
+
303
+ Creates a new route pattern parser.
304
+
305
+ **Parameters:**
306
+
307
+ | Name | Type | Description |
308
+ |------|------|-------------|
309
+ | `pattern` | `string` | Route pattern to match against |
310
+
311
+ **Throws:** `Error` if pattern is invalid (e.g., multiple spread segments, invalid regex)
312
+
313
+ **Example:**
314
+
315
+ ```ts
316
+ const route = new SimpleRoute("/user/[id([0-9]+)]");
317
+ const route2 = new SimpleRoute("/files/[...path]");
318
+ ```
319
+
320
+ <a id="simpleroute-static-properties"></a>
321
+ ### Static Properties
322
+
323
+ #### `SPLITTER`
324
+
325
+ ```ts
326
+ static SPLITTER: string = "/"
327
+ ```
328
+
329
+ Separator used to split paths into segments.
330
+
331
+ #### `WILDCARD`
332
+
333
+ ```ts
334
+ static WILDCARD: string = "*"
335
+ ```
336
+
337
+ Wildcard symbol for catch-all routes. Must be used as the last segment.
338
+
339
+ ### Static Methods
340
+
341
+ #### `parseQueryString()`
342
+
343
+ ```ts
344
+ static parseQueryString(str: string): RouteParams
345
+ ```
346
+
347
+ Parses a query string into an object of key-value pairs.
348
+
349
+ **Parameters:**
350
+
351
+ | Name | Type | Description |
352
+ |------|------|-------------|
353
+ | `str` | `string` | Query string (without leading "?") |
354
+
355
+ **Returns:** `RouteParams` - Object with parsed query parameters
356
+
357
+ **Example:**
358
+
359
+ ```ts
360
+ SimpleRoute.parseQueryString("foo=bar&baz=123");
361
+ // { foo: "bar", baz: "123" }
362
+
363
+ SimpleRoute.parseQueryString("name=John%20Doe&city=New%20York");
364
+ // { name: "John Doe", city: "New York" }
365
+
366
+ SimpleRoute.parseQueryString("empty=&flag");
367
+ // { empty: "", flag: "" }
368
+ ```
369
+
370
+ ### Instance Methods
371
+
372
+ #### `parse()`
373
+
374
+ ```ts
375
+ parse(url: string, allowQueryParams?: boolean): RouteParams | null
376
+ ```
377
+
378
+ Parses a string against this route pattern.
379
+
380
+ **Parameters:**
381
+
382
+ | Name | Type | Default | Description |
383
+ |------|------|---------|-------------|
384
+ | `url` | `string` | - | String to test against the pattern |
385
+ | `allowQueryParams` | `boolean` | `true` | Whether to parse query string parameters |
386
+
387
+ **Returns:** `RouteParams` object with extracted parameters, or `null` if no match
388
+
389
+ **Example:**
390
+
391
+ ```ts
392
+ const route = new SimpleRoute("/user/[id]");
393
+
394
+ route.parse("/user/123");
395
+ // { id: "123" }
396
+
397
+ route.parse("/user/123?tab=profile");
398
+ // { id: "123", tab: "profile" }
399
+
400
+ route.parse("/user/123?tab=profile", false);
401
+ // { id: "123?tab=profile" }
402
+
403
+ route.parse("/post/456");
404
+ // null
405
+ ```
406
+
407
+ ---
408
+
409
+ ## Types
410
+
411
+ ### RouteParams
412
+
413
+ ```ts
414
+ type RouteParams = Record<string, any>;
415
+ ```
416
+
417
+ Object containing extracted route parameters. Keys are parameter names, values are the extracted string values.
418
+
419
+ ### RouteCallback\<T\>
420
+
421
+ ```ts
422
+ type RouteCallback<T = unknown> = (params: RouteParams | null, route: string) => T;
423
+ ```
424
+
425
+ Callback function executed when a route matches.
426
+
427
+ **Type Parameters:**
428
+ - `T` - The return type of the callback (default: `unknown`)
429
+
430
+ **Parameters:**
431
+ - `params` - Extracted route parameters (or `null` if no params)
432
+ - `route` - The matched route pattern string
433
+
434
+ **Returns:** `T` - The value to be returned by `exec()`
435
+
436
+ ### RouterConfig\<T\>
437
+
438
+ ```ts
439
+ type RouterConfig<T = unknown> = Record<string, RouteCallback<T>>;
440
+ ```
441
+
442
+ Configuration object mapping route patterns to callbacks.
443
+
444
+ **Type Parameters:**
445
+ - `T` - The return type of all route callbacks (default: `unknown`)
446
+
447
+ ### RouterCurrent
448
+
449
+ ```ts
450
+ interface RouterCurrent {
451
+ route: string | null; // Matched pattern, "*" for catch-all, null if no match
452
+ params: RouteParams | null;
453
+ label: string | null;
454
+ }
455
+ ```
456
+
457
+ Current router state object.
458
+
459
+ ### RouterOnOptions
460
+
461
+ ```ts
462
+ interface RouterOnOptions {
463
+ label?: string | null; // Optional label for debugging
464
+ allowQueryParams?: boolean; // Whether to parse query params (default: true)
465
+ }
466
+ ```
467
+
468
+ Options for the `on()` method.
469
+
470
+ ### Logger
471
+
472
+ ```ts
473
+ interface Logger {
474
+ debug: (...args: unknown[]) => unknown;
475
+ log: (...args: unknown[]) => unknown;
476
+ warn: (...args: unknown[]) => unknown;
477
+ error: (...args: unknown[]) => unknown;
478
+ }
479
+ ```
480
+
481
+ Logger interface compatible with `@marianmeres/clog`. Provides console-compatible logging methods.
482
+
483
+ ### RouterOptions\<T\>
484
+
485
+ ```ts
486
+ interface RouterOptions<T = unknown> {
487
+ routes?: RouterConfig<T> | null; // Route configuration
488
+ logger?: Logger | null; // Optional logger instance
489
+ }
490
+ ```
491
+
492
+ Options object for the constructor (alternative to plain `RouterConfig`).
493
+
494
+ **Type Parameters:**
495
+ - `T` - The return type of route callbacks (default: `unknown`)
496
+
497
+ ### RouterSubscriber
498
+
499
+ ```ts
500
+ type RouterSubscriber = (current: RouterCurrent) => void;
501
+ ```
502
+
503
+ Callback function for route subscriptions.
504
+
505
+ ### RouterUnsubscribe
506
+
507
+ ```ts
508
+ type RouterUnsubscribe = () => void;
509
+ ```
510
+
511
+ Unsubscribe function returned by `subscribe()`.
512
+
513
+ ### RouteConfig (Internal)
514
+
515
+ ```ts
516
+ interface RouteConfig {
517
+ segment: string;
518
+ name: string | null;
519
+ test: RegExp;
520
+ isOptional: boolean;
521
+ isSpread: boolean;
522
+ }
523
+ ```
524
+
525
+ Internal configuration for a parsed route segment. Exported for advanced use cases.
526
+
527
+ ---
528
+
529
+ ## Pattern Syntax
530
+
531
+ ### Segment Types
532
+
533
+ | Pattern | Description | Example |
534
+ |---------|-------------|---------|
535
+ | `exact` | Literal match | `foo` matches "foo" |
536
+ | `[name]` | Named parameter | `[id]` captures any segment |
537
+ | `[name(regex)]` | Regex constrained | `[id([0-9]+)]` captures digits only |
538
+ | `[name]?` | Optional segment | `[id]?` matches with or without |
539
+ | `[...name]` | Spread parameter | `[...path]` captures multiple segments |
540
+ | `*` | Wildcard catch-all | Matches any remaining path |
541
+
542
+ ### Key Behaviors
543
+
544
+ 1. **Separator Normalization:** Multiple separators normalized to single; trimmed from ends
545
+ 2. **First Match Wins:** Routes matched in registration order
546
+ 3. **Query String Parsing:** Enabled by default, can disable per-route
547
+ 4. **Path Params Priority:** Path params override query params with same name
548
+ 5. **URL Decoding:** Both param names and values are URL-decoded
549
+
550
+ ### Pattern Constraints
551
+
552
+ - Spread segments cannot be optional (`[...path]?` throws error)
553
+ - Only one spread segment allowed per route
554
+ - Wildcard `*` must be the last segment
555
+ - Regex in `[name(regex)]` must be valid JavaScript regex
556
+
557
+ ### Examples
558
+
559
+ | Pattern | Input | Result |
560
+ |---------|-------|--------|
561
+ | `/foo` | `/bar` | `null` |
562
+ | `/foo` | `/foo` | `{}` |
563
+ | `/[foo]` | `/bar` | `{ foo: "bar" }` |
564
+ | `/[id([0-9]+)]` | `/123` | `{ id: "123" }` |
565
+ | `/[id([0-9]+)]` | `/abc` | `null` |
566
+ | `/foo/[bar]?` | `/foo` | `{}` |
567
+ | `/foo/[bar]?` | `/foo/baz` | `{ bar: "baz" }` |
568
+ | `/[...path]/[file]` | `/a/b/c.js` | `{ path: "a/b", file: "c.js" }` |
569
+ | `/*` | `/any/path` | `{}` |
570
+ | `/[foo]/*` | `/bar/baz` | `{ foo: "bar" }` |
package/README.md CHANGED
@@ -255,17 +255,43 @@ SimpleRoute.parseQueryString("foo=bar&baz=123");
255
255
 
256
256
  ## TypeScript
257
257
 
258
- Full TypeScript support with exported types:
258
+ Full TypeScript support with generic types for type-safe route callbacks and `exec()` return values.
259
+
260
+ ### Generic Router
261
+
262
+ `SimpleRouter<T>` is generic over `T`, the return type of route callbacks:
263
+
264
+ ```ts
265
+ // Typed router - all callbacks must return Component, exec() returns Component | false
266
+ const router = new SimpleRouter<Component>({
267
+ "/": () => HomePage,
268
+ "/about": () => AboutPage,
269
+ "*": () => NotFoundPage,
270
+ });
271
+
272
+ const result = router.exec("/about"); // Component | false
273
+ if (result !== false) {
274
+ render(result); // result is Component
275
+ }
276
+
277
+ // Without explicit type - T is inferred from callbacks (or defaults to unknown)
278
+ const router2 = new SimpleRouter({
279
+ "/": () => "home",
280
+ "/about": () => "about",
281
+ });
282
+ ```
283
+
284
+ ### Exported Types
259
285
 
260
286
  ```ts
261
287
  import type {
262
288
  Logger,
263
289
  RouteParams,
264
- RouteCallback,
265
- RouterConfig,
290
+ RouteCallback, // RouteCallback<T = unknown>
291
+ RouterConfig, // RouterConfig<T = unknown>
266
292
  RouterCurrent,
267
293
  RouterOnOptions,
268
- RouterOptions,
294
+ RouterOptions, // RouterOptions<T = unknown>
269
295
  RouterSubscriber,
270
296
  RouterUnsubscribe,
271
297
  } from "@marianmeres/simple-router";
@@ -276,21 +302,24 @@ import type {
276
302
  ### SPA with Hash Routing
277
303
 
278
304
  ```ts
279
- const router = new SimpleRouter({
305
+ // Type-safe router with Component return type
306
+ const router = new SimpleRouter<Component>({
280
307
  "/": () => HomePage,
281
308
  "/about": () => AboutPage,
282
309
  "/user/[id]": (params) => UserPage(params?.id),
283
310
  "*": () => NotFoundPage,
284
311
  });
285
312
 
286
- function render(component) {
287
- document.getElementById("app").innerHTML = component;
313
+ function render(component: Component) {
314
+ document.getElementById("app").innerHTML = component.render();
288
315
  }
289
316
 
290
317
  window.addEventListener("hashchange", () => {
291
318
  const path = location.hash.slice(1) || "/";
292
- const component = router.exec(path);
293
- render(component);
319
+ const component = router.exec(path); // Component | false
320
+ if (component !== false) {
321
+ render(component);
322
+ }
294
323
  });
295
324
 
296
325
  // Trigger initial render
package/dist/router.d.ts CHANGED
@@ -12,19 +12,20 @@ export type RouteParams = Record<string, any>;
12
12
  /**
13
13
  * Callback function executed when a route matches.
14
14
  *
15
+ * @typeParam T - The return type of the callback
15
16
  * @param params - Extracted route parameters (null if no match)
16
17
  * @param route - The matched route pattern string
17
18
  * @returns Any value - useful for returning components, data, or control flow values
18
19
  *
19
20
  * @example
20
21
  * ```ts
21
- * const callback: RouteCallback = (params, route) => {
22
+ * const callback: RouteCallback<MyComponent> = (params, route) => {
22
23
  * console.log(`Matched ${route} with params:`, params);
23
24
  * return MyComponent;
24
25
  * };
25
26
  * ```
26
27
  */
27
- export type RouteCallback = (params: RouteParams | null, route: string) => any;
28
+ export type RouteCallback<T = unknown> = (params: RouteParams | null, route: string) => T;
28
29
  /**
29
30
  * Represents the current router state including the matched route,
30
31
  * extracted parameters, and optional label.
@@ -50,16 +51,18 @@ export interface RouterOnOptions {
50
51
  * Configuration object for initializing the router with routes.
51
52
  * Maps route patterns to their callback functions.
52
53
  *
54
+ * @typeParam T - The return type of route callbacks
55
+ *
53
56
  * @example
54
57
  * ```ts
55
- * const config: RouterConfig = {
58
+ * const config: RouterConfig<Component> = {
56
59
  * "/": () => HomePage,
57
60
  * "/user/[id]": (params) => UserPage(params?.id),
58
61
  * "*": () => NotFoundPage
59
62
  * };
60
63
  * ```
61
64
  */
62
- export type RouterConfig = Record<string, RouteCallback>;
65
+ export type RouterConfig<T = unknown> = Record<string, RouteCallback<T>>;
63
66
  /**
64
67
  * Logger interface compatible with `@marianmeres/clog`.
65
68
  * Provides console-compatible logging methods.
@@ -76,10 +79,12 @@ export interface Logger {
76
79
  }
77
80
  /**
78
81
  * Options for the SimpleRouter factory/constructor.
82
+ *
83
+ * @typeParam T - The return type of route callbacks
79
84
  */
80
- export interface RouterOptions {
85
+ export interface RouterOptions<T = unknown> {
81
86
  /** Route configuration mapping patterns to callbacks */
82
- routes?: RouterConfig | null;
87
+ routes?: RouterConfig<T> | null;
83
88
  /** Optional logger instance for debug output (compatible with @marianmeres/clog) */
84
89
  logger?: Logger | null;
85
90
  }
@@ -123,8 +128,10 @@ export type RouterSubscriber = (current: RouterCurrent) => void;
123
128
  *
124
129
  * cmdRouter.exec("user:delete:123");
125
130
  * ```
131
+ *
132
+ * @typeParam T - The return type of route callbacks (default: unknown)
126
133
  */
127
- export declare class SimpleRouter {
134
+ export declare class SimpleRouter<T = unknown> {
128
135
  #private;
129
136
  /**
130
137
  * Enable/disable console debug logging for route matching.
@@ -140,14 +147,14 @@ export declare class SimpleRouter {
140
147
  *
141
148
  * @example
142
149
  * ```ts
143
- * // Simple config (backwards compatible)
144
- * const router = new SimpleRouter({
150
+ * // Typed router - exec() returns Component | false
151
+ * const router = new SimpleRouter<Component>({
145
152
  * "/": () => HomePage,
146
153
  * "/about": () => AboutPage
147
154
  * });
148
155
  *
149
156
  * // With options object
150
- * const router = new SimpleRouter({
157
+ * const router = new SimpleRouter<Component>({
151
158
  * routes: {
152
159
  * "/": () => HomePage,
153
160
  * "/about": () => AboutPage
@@ -156,7 +163,7 @@ export declare class SimpleRouter {
156
163
  * });
157
164
  * ```
158
165
  */
159
- constructor(config?: RouterConfig | RouterOptions | null);
166
+ constructor(config?: RouterConfig<T> | RouterOptions<T> | null);
160
167
  /**
161
168
  * Returns a map of registered route patterns to their labels.
162
169
  * Useful for debugging and introspection.
@@ -224,38 +231,36 @@ export declare class SimpleRouter {
224
231
  * router.on("*", () => NotFoundPage);
225
232
  * ```
226
233
  */
227
- on(routes: string | string[], cb: RouteCallback, options?: RouterOnOptions): void;
234
+ on(routes: string | string[], cb: RouteCallback<T>, options?: RouterOnOptions): void;
228
235
  /**
229
236
  * Executes pattern matching against the provided string.
230
- * Returns the value returned by the matched route's callback.
237
+ * Returns the value returned by the matched route's callback (type `T`).
231
238
  * Routes are tested in registration order - first match wins.
232
239
  *
233
240
  * @param url - String to match against registered patterns (can be a URL, file path, command, etc.)
234
241
  * @param fallbackFn - Optional fallback function if no route matches
235
- * @returns The value returned by the matched callback, or false if no match
242
+ * @returns `T | false` - The value returned by the matched callback, or `false` if no match
236
243
  *
237
244
  * @example
238
245
  * ```ts
239
- * // URL matching
240
- * router.exec("/users"); // Returns result of callback
246
+ * // Type-safe routing
247
+ * const router = new SimpleRouter<Component>({ "/home": () => HomePage });
248
+ * const result = router.exec("/home"); // Component | false
241
249
  *
242
250
  * // With query parameters
243
251
  * router.exec("/users?sort=name");
244
252
  *
245
- * // File path matching
246
- * router.exec("src/components/Button.ts");
247
- *
248
- * // Command matching
249
- * router.exec("user:delete:123");
250
- *
251
253
  * // With fallback
252
- * router.exec("/unknown", () => console.log("Not found"));
254
+ * router.exec("/unknown", () => NotFoundPage);
253
255
  *
254
- * // Can return components, values, etc.
255
- * const component = router.exec("/home");
256
+ * // Check for no match
257
+ * const component = router.exec("/path");
258
+ * if (component !== false) {
259
+ * render(component);
260
+ * }
256
261
  * ```
257
262
  */
258
- exec(url: string, fallbackFn?: RouteCallback): unknown;
263
+ exec(url: string, fallbackFn?: RouteCallback<T>): T | false;
259
264
  /**
260
265
  * Subscribes to router state changes.
261
266
  * Follows the Svelte store contract - subscriber is called immediately with current state.
package/dist/router.js CHANGED
@@ -30,6 +30,8 @@ const PUBSUB_TOPIC = "current";
30
30
  *
31
31
  * cmdRouter.exec("user:delete:123");
32
32
  * ```
33
+ *
34
+ * @typeParam T - The return type of route callbacks (default: unknown)
33
35
  */
34
36
  export class SimpleRouter {
35
37
  /**
@@ -55,14 +57,14 @@ export class SimpleRouter {
55
57
  *
56
58
  * @example
57
59
  * ```ts
58
- * // Simple config (backwards compatible)
59
- * const router = new SimpleRouter({
60
+ * // Typed router - exec() returns Component | false
61
+ * const router = new SimpleRouter<Component>({
60
62
  * "/": () => HomePage,
61
63
  * "/about": () => AboutPage
62
64
  * });
63
65
  *
64
66
  * // With options object
65
- * const router = new SimpleRouter({
67
+ * const router = new SimpleRouter<Component>({
66
68
  * routes: {
67
69
  * "/": () => HomePage,
68
70
  * "/about": () => AboutPage
@@ -196,32 +198,30 @@ export class SimpleRouter {
196
198
  }
197
199
  /**
198
200
  * Executes pattern matching against the provided string.
199
- * Returns the value returned by the matched route's callback.
201
+ * Returns the value returned by the matched route's callback (type `T`).
200
202
  * Routes are tested in registration order - first match wins.
201
203
  *
202
204
  * @param url - String to match against registered patterns (can be a URL, file path, command, etc.)
203
205
  * @param fallbackFn - Optional fallback function if no route matches
204
- * @returns The value returned by the matched callback, or false if no match
206
+ * @returns `T | false` - The value returned by the matched callback, or `false` if no match
205
207
  *
206
208
  * @example
207
209
  * ```ts
208
- * // URL matching
209
- * router.exec("/users"); // Returns result of callback
210
+ * // Type-safe routing
211
+ * const router = new SimpleRouter<Component>({ "/home": () => HomePage });
212
+ * const result = router.exec("/home"); // Component | false
210
213
  *
211
214
  * // With query parameters
212
215
  * router.exec("/users?sort=name");
213
216
  *
214
- * // File path matching
215
- * router.exec("src/components/Button.ts");
216
- *
217
- * // Command matching
218
- * router.exec("user:delete:123");
219
- *
220
217
  * // With fallback
221
- * router.exec("/unknown", () => console.log("Not found"));
218
+ * router.exec("/unknown", () => NotFoundPage);
222
219
  *
223
- * // Can return components, values, etc.
224
- * const component = router.exec("/home");
220
+ * // Check for no match
221
+ * const component = router.exec("/path");
222
+ * if (component !== false) {
223
+ * render(component);
224
+ * }
225
225
  * ```
226
226
  */
227
227
  exec(url, fallbackFn) {
@@ -233,7 +233,7 @@ export class SimpleRouter {
233
233
  if (params !== null) {
234
234
  this.#publishCurrent(route.route, params, label);
235
235
  this.#dbg(`${dbgPrefix}matches '${route.route}' with`, params);
236
- return typeof cb === "function" ? cb(params, route.route) : true;
236
+ return cb(params, route.route);
237
237
  }
238
238
  }
239
239
  if (typeof fallbackFn === "function") {
package/package.json CHANGED
@@ -1,20 +1,25 @@
1
1
  {
2
2
  "name": "@marianmeres/simple-router",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/mod.d.ts",
10
+ "import": "./dist/mod.js"
11
+ }
12
+ },
7
13
  "author": "Marian Meres",
8
14
  "license": "MIT",
15
+ "dependencies": {
16
+ "@marianmeres/pubsub": "^2.4.4"
17
+ },
9
18
  "repository": {
10
19
  "type": "git",
11
20
  "url": "git+https://github.com/marianmeres/simple-router.git"
12
21
  },
13
22
  "bugs": {
14
23
  "url": "https://github.com/marianmeres/simple-router/issues"
15
- },
16
- "dependencies": {
17
- "@marianmeres/pubsub": "^2.2.0",
18
- "@marianmeres/clog": "^2.3.3"
19
24
  }
20
- }
25
+ }