@marianmeres/simple-router 3.0.1 → 3.3.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 +399 -0
- package/API.md +597 -0
- package/CLAUDE.md +3 -0
- package/README.md +85 -13
- package/dist/mod.d.ts +1 -1
- package/dist/route.d.ts +18 -2
- package/dist/route.js +65 -17
- package/dist/router.d.ts +43 -26
- package/dist/router.js +56 -25
- package/package.json +19 -6
package/AGENTS.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
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: "3.2.0"
|
|
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", "SimpleRouteOptions", "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", "SimpleRouteOptions"]
|
|
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
|
+
coverage: "Pattern matching, query params, regex-with-parens constraints, strict mode, optional-position rules"
|
|
56
|
+
|
|
57
|
+
- path: "tests/router.test.ts"
|
|
58
|
+
coverage: "Router lifecycle, subscriptions, logger, strict propagation, specificity-shadowing warning"
|
|
59
|
+
|
|
60
|
+
build_files:
|
|
61
|
+
- path: "scripts/build-npm.ts"
|
|
62
|
+
purpose: "Deno-to-npm build script"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Dependencies
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
runtime:
|
|
69
|
+
- name: "@marianmeres/pubsub"
|
|
70
|
+
version: "^2.4.1"
|
|
71
|
+
registry: "jsr"
|
|
72
|
+
purpose: "Reactive subscriptions"
|
|
73
|
+
|
|
74
|
+
dev_only:
|
|
75
|
+
- "@std/assert"
|
|
76
|
+
- "@std/fs"
|
|
77
|
+
- "@std/path"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API Summary
|
|
81
|
+
|
|
82
|
+
### SimpleRouter
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
class: "SimpleRouter<T = unknown>"
|
|
86
|
+
purpose: "Main router for pattern matching and state management"
|
|
87
|
+
generic_param:
|
|
88
|
+
name: "T"
|
|
89
|
+
default: "unknown"
|
|
90
|
+
description: "Return type of route callbacks and exec() method"
|
|
91
|
+
|
|
92
|
+
static_properties:
|
|
93
|
+
- name: "debug"
|
|
94
|
+
type: "boolean"
|
|
95
|
+
default: false
|
|
96
|
+
description: "Enable debug logging (uses logger if provided, else console.log)"
|
|
97
|
+
|
|
98
|
+
constructor:
|
|
99
|
+
params:
|
|
100
|
+
- name: "config"
|
|
101
|
+
type: "RouterConfig<T> | RouterOptions<T> | null"
|
|
102
|
+
optional: true
|
|
103
|
+
description: "Route config or options object with routes, logger, and strict"
|
|
104
|
+
|
|
105
|
+
methods:
|
|
106
|
+
- name: "on"
|
|
107
|
+
signature: "(routes: string | string[], cb: RouteCallback<T>, options?: RouterOnOptions) => void"
|
|
108
|
+
description: "Register route(s) with callback"
|
|
109
|
+
important: "Routes matched in registration order - first match wins"
|
|
110
|
+
|
|
111
|
+
- name: "exec"
|
|
112
|
+
signature: "(url: string, fallbackFn?: RouteCallback<T>) => T | false"
|
|
113
|
+
description: "Execute pattern matching"
|
|
114
|
+
returns: "Callback result (type T), or false if no match"
|
|
115
|
+
|
|
116
|
+
- name: "subscribe"
|
|
117
|
+
signature: "(subscription: RouterSubscriber) => RouterUnsubscribe"
|
|
118
|
+
description: "Subscribe to state changes (Svelte store compatible)"
|
|
119
|
+
behavior: "Called immediately with current state"
|
|
120
|
+
returns: "Unsubscribe function directly (not wrapped object)"
|
|
121
|
+
|
|
122
|
+
- name: "reset"
|
|
123
|
+
signature: "() => this"
|
|
124
|
+
description: "Clear all routes (except catch-all)"
|
|
125
|
+
|
|
126
|
+
- name: "info"
|
|
127
|
+
signature: "() => Record<string, string>"
|
|
128
|
+
description: "Get route-to-label mapping"
|
|
129
|
+
|
|
130
|
+
properties:
|
|
131
|
+
- name: "current"
|
|
132
|
+
type: "RouterCurrent"
|
|
133
|
+
readonly: true
|
|
134
|
+
description: "Current router state"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### SimpleRoute
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
class: SimpleRoute
|
|
141
|
+
purpose: "Low-level pattern parser"
|
|
142
|
+
|
|
143
|
+
static_properties:
|
|
144
|
+
- name: "SPLITTER"
|
|
145
|
+
type: "string"
|
|
146
|
+
default: "/"
|
|
147
|
+
- name: "WILDCARD"
|
|
148
|
+
type: "string"
|
|
149
|
+
default: "*"
|
|
150
|
+
|
|
151
|
+
constructor:
|
|
152
|
+
params:
|
|
153
|
+
- name: "route"
|
|
154
|
+
type: "string"
|
|
155
|
+
description: "Pattern string"
|
|
156
|
+
- name: "options"
|
|
157
|
+
type: "SimpleRouteOptions"
|
|
158
|
+
optional: true
|
|
159
|
+
description: "e.g. { strict: true } to disable empty-segment collapsing"
|
|
160
|
+
throws: "Error if pattern invalid (multiple spread segments, invalid regex, invalid param name, optional-followed-by-required)"
|
|
161
|
+
|
|
162
|
+
static_methods:
|
|
163
|
+
- name: "parseQueryString"
|
|
164
|
+
signature: "(str: string) => RouteParams"
|
|
165
|
+
description: "Parse query string to object"
|
|
166
|
+
|
|
167
|
+
methods:
|
|
168
|
+
- name: "parse"
|
|
169
|
+
signature: "(url: string, allowQueryParams?: boolean) => RouteParams | null"
|
|
170
|
+
description: "Match string against pattern"
|
|
171
|
+
returns: "Params object or null"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Pattern Syntax Reference
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
patterns:
|
|
178
|
+
exact:
|
|
179
|
+
syntax: "literal"
|
|
180
|
+
example: "foo"
|
|
181
|
+
matches: "foo"
|
|
182
|
+
|
|
183
|
+
named_param:
|
|
184
|
+
syntax: "[name]"
|
|
185
|
+
example: "[id]"
|
|
186
|
+
captures: "{ id: 'value' }"
|
|
187
|
+
|
|
188
|
+
regex_param:
|
|
189
|
+
syntax: "[name(regex)]"
|
|
190
|
+
example: "[id([0-9]+)]"
|
|
191
|
+
captures: "{ id: '123' }"
|
|
192
|
+
constraint: "name must match /^\\w+$/. Regex may contain parentheses (capturing/non-capturing groups, alternation) — balanced-paren splitting is used."
|
|
193
|
+
|
|
194
|
+
optional:
|
|
195
|
+
syntax: "[name]?"
|
|
196
|
+
example: "[id]?"
|
|
197
|
+
captures: "{} or { id: 'value' }"
|
|
198
|
+
constraint: "Only allowed in trailing position (or directly before wildcard). Optional-followed-by-required throws at construction."
|
|
199
|
+
|
|
200
|
+
spread:
|
|
201
|
+
syntax: "[...name]"
|
|
202
|
+
example: "[...path]"
|
|
203
|
+
captures: "{ path: 'a/b/c' }"
|
|
204
|
+
constraint: "Only one per route, cannot be optional"
|
|
205
|
+
|
|
206
|
+
wildcard:
|
|
207
|
+
syntax: "*"
|
|
208
|
+
example: "/foo/*"
|
|
209
|
+
constraint: "Must be last segment"
|
|
210
|
+
captures: "{}"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Type Definitions
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
type RouteParams = Record<string, any>;
|
|
217
|
+
|
|
218
|
+
// Generic callback type - T is the return type
|
|
219
|
+
type RouteCallback<T = unknown> = (params: RouteParams | null, route: string) => T;
|
|
220
|
+
|
|
221
|
+
// Generic config type - T is the return type of all callbacks
|
|
222
|
+
type RouterConfig<T = unknown> = Record<string, RouteCallback<T>>;
|
|
223
|
+
|
|
224
|
+
interface Logger {
|
|
225
|
+
debug: (...args: unknown[]) => unknown;
|
|
226
|
+
log: (...args: unknown[]) => unknown;
|
|
227
|
+
warn: (...args: unknown[]) => unknown;
|
|
228
|
+
error: (...args: unknown[]) => unknown;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Generic options type - T is the return type of route callbacks
|
|
232
|
+
interface RouterOptions<T = unknown> {
|
|
233
|
+
routes?: RouterConfig<T> | null;
|
|
234
|
+
logger?: Logger | null;
|
|
235
|
+
strict?: boolean; // propagates to every registered route
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface RouterCurrent {
|
|
239
|
+
route: string | null;
|
|
240
|
+
params: RouteParams | null;
|
|
241
|
+
label: string | null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface RouterOnOptions {
|
|
245
|
+
label?: string | null;
|
|
246
|
+
allowQueryParams?: boolean;
|
|
247
|
+
strict?: boolean; // overrides router-level default
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface SimpleRouteOptions {
|
|
251
|
+
strict?: boolean; // disables empty-segment collapsing
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type RouterSubscriber = (current: RouterCurrent) => void;
|
|
255
|
+
|
|
256
|
+
type RouterUnsubscribe = () => void;
|
|
257
|
+
|
|
258
|
+
interface RouteConfig {
|
|
259
|
+
segment: string;
|
|
260
|
+
name: string | null;
|
|
261
|
+
test: RegExp;
|
|
262
|
+
isOptional: boolean;
|
|
263
|
+
isSpread: boolean;
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Common Usage Patterns
|
|
268
|
+
|
|
269
|
+
### SPA Hash Routing (Typed)
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// Type-safe router - exec() returns Component | false
|
|
273
|
+
const router = new SimpleRouter<Component>({
|
|
274
|
+
"/": () => HomePage,
|
|
275
|
+
"/user/[id]": (params) => UserPage(params?.id),
|
|
276
|
+
"*": () => NotFoundPage
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
window.addEventListener("hashchange", () => {
|
|
280
|
+
const path = location.hash.slice(1) || "/";
|
|
281
|
+
const result = router.exec(path); // Component | false
|
|
282
|
+
if (result !== false) render(result);
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### File Path Matching
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
const router = new SimpleRouter({
|
|
290
|
+
"src/[module]/[file].ts": ({ module, file }) => process(module, file),
|
|
291
|
+
"assets/[...path]": ({ path }) => loadAsset(path)
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Command Routing
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
const router = new SimpleRouter({
|
|
299
|
+
"user:create": () => createUser(),
|
|
300
|
+
"user:delete:[id]": ({ id }) => deleteUser(id)
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Important Behaviors
|
|
305
|
+
|
|
306
|
+
```yaml
|
|
307
|
+
behaviors:
|
|
308
|
+
- name: "First Match Wins"
|
|
309
|
+
description: "Routes matched in registration order"
|
|
310
|
+
implication: "Register specific routes before generic ones. When SimpleRouter.debug is true, shadowing is detected at registration and warned via the logger (or console.warn)."
|
|
311
|
+
|
|
312
|
+
- name: "Separator Normalization"
|
|
313
|
+
description: "Non-strict (default): multiple separators are collapsed and trimmed from ends. Strict (opt-in): internal empty segments are preserved."
|
|
314
|
+
example: "non-strict: '//foo//bar//' matches 'foo/bar'. strict: '/a//b' does NOT match '/a/b'."
|
|
315
|
+
|
|
316
|
+
- name: "Optional Segment Position"
|
|
317
|
+
description: "Optional segments ('[x]?') may only appear trailing (or immediately before wildcard '*'). Optional-followed-by-required throws at construction — register separate routes instead."
|
|
318
|
+
|
|
319
|
+
- name: "Query String Priority"
|
|
320
|
+
description: "Path params override same-name query params"
|
|
321
|
+
|
|
322
|
+
- name: "Catch-all Stored Separately"
|
|
323
|
+
description: "'*' route stored in #catchAll, not in #routes array"
|
|
324
|
+
|
|
325
|
+
- name: "Immediate Subscription Call"
|
|
326
|
+
description: "subscribe() calls callback immediately with current state"
|
|
327
|
+
|
|
328
|
+
- name: "Parameter Names Are Literal"
|
|
329
|
+
description: "Parameter names in the pattern are NOT URI-decoded. The pattern '[id%20x]' produces the key 'id%20x'. Parameter values ARE decoded."
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Error Conditions
|
|
333
|
+
|
|
334
|
+
```yaml
|
|
335
|
+
errors:
|
|
336
|
+
- condition: "Multiple spread segments"
|
|
337
|
+
message: "Multiple spread segments are invalid"
|
|
338
|
+
|
|
339
|
+
- condition: "Optional spread"
|
|
340
|
+
message: "Spread segment must not be marked as optional"
|
|
341
|
+
|
|
342
|
+
- condition: "Wildcard not last"
|
|
343
|
+
message: "Wildcard '*' can be used only as a last segment"
|
|
344
|
+
|
|
345
|
+
- condition: "Invalid regex in pattern"
|
|
346
|
+
message: "Invalid regex in route pattern '{segment}': {error}"
|
|
347
|
+
|
|
348
|
+
- condition: "Invalid parameter name in named constraint"
|
|
349
|
+
message: "Invalid parameter name '{name}' in route pattern '{segment}' (must match /^\\w+$/)"
|
|
350
|
+
|
|
351
|
+
- condition: "Optional segment followed by a required segment"
|
|
352
|
+
message: "Invalid route '{route}': optional segment '{segment}?' must not be followed by a required segment ..."
|
|
353
|
+
|
|
354
|
+
- condition: "Non-function subscriber"
|
|
355
|
+
throws: "TypeError: Subscription is not a function"
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Development Commands
|
|
359
|
+
|
|
360
|
+
```yaml
|
|
361
|
+
commands:
|
|
362
|
+
test: "deno task test"
|
|
363
|
+
build_npm: "deno task npm:build"
|
|
364
|
+
publish_npm: "deno task npm:publish"
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Code Modification Guidelines
|
|
368
|
+
|
|
369
|
+
```yaml
|
|
370
|
+
guidelines:
|
|
371
|
+
- "Maintain Svelte store contract compatibility for subscribe()"
|
|
372
|
+
- "subscribe() returns unsubscribe function directly (not wrapped object)"
|
|
373
|
+
- "First-match-wins is core behavior - do not change"
|
|
374
|
+
- "Query param parsing must remain toggleable per-route"
|
|
375
|
+
- "Keep catch-all ('*') handling separate from regular routes"
|
|
376
|
+
- "URL encoding/decoding: param VALUES are decoded; param NAMES are not"
|
|
377
|
+
- "Logger interface must remain compatible with @marianmeres/clog"
|
|
378
|
+
- "Strict mode is opt-in and propagates router → route; per-route can override"
|
|
379
|
+
- "Shadowing warning runs only when SimpleRouter.debug is true (no perf cost otherwise)"
|
|
380
|
+
- "Run `deno task test` before any changes"
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## Breaking Changes (3.2.0)
|
|
384
|
+
|
|
385
|
+
```yaml
|
|
386
|
+
breaking_changes:
|
|
387
|
+
- id: "optional-middle-rejected"
|
|
388
|
+
description: "Patterns like '/foo/[bar]?/baz' now throw at construction. Previously they parsed but silently treated the optional marker as required. Register two separate routes instead."
|
|
389
|
+
|
|
390
|
+
- id: "param-names-not-decoded"
|
|
391
|
+
description: "Parameter names are taken literally from the pattern. '/foo/[id%20x]' used to produce the key 'id x'; now produces 'id%20x'. Parameter values are still URI-decoded."
|
|
392
|
+
|
|
393
|
+
- id: "named-constraint-name-validated"
|
|
394
|
+
description: "In a named regex constraint '[name(regex)]', 'name' must match /^\\w+$/. Previously this was not validated."
|
|
395
|
+
|
|
396
|
+
bug_fixes:
|
|
397
|
+
- id: "regex-constraint-with-parens"
|
|
398
|
+
description: "Constraints containing parentheses (e.g. '[s((?:a|b)+)]', '[x((\\d+)-(\\d+))]') used to produce garbage regexes or throw obscure errors due to greedy regex-based splitting. Now uses balanced-paren splitting and works correctly."
|
|
399
|
+
```
|