@marianmeres/simple-router 3.1.0 → 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 CHANGED
@@ -6,7 +6,7 @@ Machine-readable documentation for AI coding assistants.
6
6
 
7
7
  ```yaml
8
8
  name: "@marianmeres/simple-router"
9
- version: "2.2.3"
9
+ version: "3.2.0"
10
10
  runtime: ["deno", "node"]
11
11
  module_system: "ESM"
12
12
  entry_point: "src/mod.ts"
@@ -38,11 +38,11 @@ key_features:
38
38
  source_files:
39
39
  - path: "src/mod.ts"
40
40
  purpose: "Main entry point, re-exports public API"
41
- exports: ["SimpleRoute", "SimpleRouter", "RouteConfig", "RouteParams", "Logger", "RouteCallback", "RouterConfig", "RouterCurrent", "RouterOnOptions", "RouterOptions", "RouterSubscriber", "RouterUnsubscribe"]
41
+ exports: ["SimpleRoute", "SimpleRouter", "RouteConfig", "RouteParams", "SimpleRouteOptions", "Logger", "RouteCallback", "RouterConfig", "RouterCurrent", "RouterOnOptions", "RouterOptions", "RouterSubscriber", "RouterUnsubscribe"]
42
42
 
43
43
  - path: "src/route.ts"
44
44
  purpose: "Low-level pattern parser"
45
- exports: ["SimpleRoute", "RouteConfig", "RouteParams"]
45
+ exports: ["SimpleRoute", "RouteConfig", "RouteParams", "SimpleRouteOptions"]
46
46
  class: "SimpleRoute"
47
47
 
48
48
  - path: "src/router.ts"
@@ -52,12 +52,10 @@ source_files:
52
52
 
53
53
  test_files:
54
54
  - path: "tests/route.test.ts"
55
- test_count: 63
56
- coverage: "Pattern matching, query params, errors"
55
+ coverage: "Pattern matching, query params, regex-with-parens constraints, strict mode, optional-position rules"
57
56
 
58
57
  - path: "tests/router.test.ts"
59
- test_count: 24
60
- coverage: "Router lifecycle, subscriptions, logger, edge cases"
58
+ coverage: "Router lifecycle, subscriptions, logger, strict propagation, specificity-shadowing warning"
61
59
 
62
60
  build_files:
63
61
  - path: "scripts/build-npm.ts"
@@ -102,7 +100,7 @@ constructor:
102
100
  - name: "config"
103
101
  type: "RouterConfig<T> | RouterOptions<T> | null"
104
102
  optional: true
105
- description: "Route config or options object with routes and logger"
103
+ description: "Route config or options object with routes, logger, and strict"
106
104
 
107
105
  methods:
108
106
  - name: "on"
@@ -155,7 +153,11 @@ constructor:
155
153
  - name: "route"
156
154
  type: "string"
157
155
  description: "Pattern string"
158
- throws: "Error if pattern invalid"
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)"
159
161
 
160
162
  static_methods:
161
163
  - name: "parseQueryString"
@@ -187,12 +189,13 @@ patterns:
187
189
  syntax: "[name(regex)]"
188
190
  example: "[id([0-9]+)]"
189
191
  captures: "{ id: '123' }"
190
- constraint: "Must match regex"
192
+ constraint: "name must match /^\\w+$/. Regex may contain parentheses (capturing/non-capturing groups, alternation) — balanced-paren splitting is used."
191
193
 
192
194
  optional:
193
195
  syntax: "[name]?"
194
196
  example: "[id]?"
195
197
  captures: "{} or { id: 'value' }"
198
+ constraint: "Only allowed in trailing position (or directly before wildcard). Optional-followed-by-required throws at construction."
196
199
 
197
200
  spread:
198
201
  syntax: "[...name]"
@@ -229,6 +232,7 @@ interface Logger {
229
232
  interface RouterOptions<T = unknown> {
230
233
  routes?: RouterConfig<T> | null;
231
234
  logger?: Logger | null;
235
+ strict?: boolean; // propagates to every registered route
232
236
  }
233
237
 
234
238
  interface RouterCurrent {
@@ -240,6 +244,11 @@ interface RouterCurrent {
240
244
  interface RouterOnOptions {
241
245
  label?: string | null;
242
246
  allowQueryParams?: boolean;
247
+ strict?: boolean; // overrides router-level default
248
+ }
249
+
250
+ interface SimpleRouteOptions {
251
+ strict?: boolean; // disables empty-segment collapsing
243
252
  }
244
253
 
245
254
  type RouterSubscriber = (current: RouterCurrent) => void;
@@ -298,11 +307,14 @@ const router = new SimpleRouter({
298
307
  behaviors:
299
308
  - name: "First Match Wins"
300
309
  description: "Routes matched in registration order"
301
- implication: "Register specific routes before generic ones"
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)."
302
311
 
303
312
  - name: "Separator Normalization"
304
- description: "Multiple separators normalized, trimmed from ends"
305
- example: "//foo//bar// matches foo/bar"
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."
306
318
 
307
319
  - name: "Query String Priority"
308
320
  description: "Path params override same-name query params"
@@ -312,6 +324,9 @@ behaviors:
312
324
 
313
325
  - name: "Immediate Subscription Call"
314
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."
315
330
  ```
316
331
 
317
332
  ## Error Conditions
@@ -330,6 +345,12 @@ errors:
330
345
  - condition: "Invalid regex in pattern"
331
346
  message: "Invalid regex in route pattern '{segment}': {error}"
332
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
+
333
354
  - condition: "Non-function subscriber"
334
355
  throws: "TypeError: Subscription is not a function"
335
356
  ```
@@ -352,7 +373,27 @@ guidelines:
352
373
  - "First-match-wins is core behavior - do not change"
353
374
  - "Query param parsing must remain toggleable per-route"
354
375
  - "Keep catch-all ('*') handling separate from regular routes"
355
- - "URL encoding/decoding must be handled for params"
376
+ - "URL encoding/decoding: param VALUES are decoded; param NAMES are not"
356
377
  - "Logger interface must remain compatible with @marianmeres/clog"
357
- - "Tests cover 87 cases - run before any changes"
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."
358
399
  ```
package/API.md CHANGED
@@ -136,6 +136,7 @@ Registers one or more route patterns with a callback function.
136
136
  |------|------|---------|-------------|
137
137
  | `label` | `string \| null` | `null` | Label for debugging/identification |
138
138
  | `allowQueryParams` | `boolean` | `true` | Whether to parse query parameters |
139
+ | `strict` | `boolean` | _inherits from router_ | Strict segment matching (see `RouterOptions.strict`) |
139
140
 
140
141
  **Example:**
141
142
 
@@ -164,6 +165,8 @@ router.on("*", () => NotFoundPage);
164
165
 
165
166
  **Important:** Routes are matched in registration order. First match wins!
166
167
 
168
+ **Shadowing warning:** When `SimpleRouter.debug` is enabled, registering a route that is strictly more specific than an already-registered route emits a `warn`-level log message (via the provided `Logger` if any, otherwise `console.warn`). The check is skipped when `debug` is off, so there is no runtime cost in production.
169
+
167
170
  ---
168
171
 
169
172
  #### `exec()`
@@ -297,7 +300,7 @@ Low-level route pattern parser. Use this directly only when you need pattern mat
297
300
  ### Constructor
298
301
 
299
302
  ```ts
300
- new SimpleRoute(pattern: string)
303
+ new SimpleRoute(pattern: string, options?: SimpleRouteOptions)
301
304
  ```
302
305
 
303
306
  Creates a new route pattern parser.
@@ -307,14 +310,18 @@ Creates a new route pattern parser.
307
310
  | Name | Type | Description |
308
311
  |------|------|-------------|
309
312
  | `pattern` | `string` | Route pattern to match against |
313
+ | `options` | `SimpleRouteOptions` | Optional configuration (currently `{ strict?: boolean }`) |
310
314
 
311
- **Throws:** `Error` if pattern is invalid (e.g., multiple spread segments, invalid regex)
315
+ **Throws:** `Error` if pattern is invalid — multiple spread segments, invalid regex, invalid parameter name in a named constraint, or an optional segment followed by a required one.
312
316
 
313
317
  **Example:**
314
318
 
315
319
  ```ts
316
320
  const route = new SimpleRoute("/user/[id([0-9]+)]");
317
321
  const route2 = new SimpleRoute("/files/[...path]");
322
+
323
+ // Strict mode — '/a//b' will NOT match '/a/b'
324
+ const strict = new SimpleRoute("/a/b", { strict: true });
318
325
  ```
319
326
 
320
327
  <a id="simpleroute-static-properties"></a>
@@ -462,11 +469,22 @@ Current router state object.
462
469
  interface RouterOnOptions {
463
470
  label?: string | null; // Optional label for debugging
464
471
  allowQueryParams?: boolean; // Whether to parse query params (default: true)
472
+ strict?: boolean; // Per-route strict mode (overrides router default)
465
473
  }
466
474
  ```
467
475
 
468
476
  Options for the `on()` method.
469
477
 
478
+ ### SimpleRouteOptions
479
+
480
+ ```ts
481
+ interface SimpleRouteOptions {
482
+ strict?: boolean; // Disable empty-segment collapsing (default: false)
483
+ }
484
+ ```
485
+
486
+ Options for the `SimpleRoute` constructor.
487
+
470
488
  ### Logger
471
489
 
472
490
  ```ts
@@ -486,6 +504,7 @@ Logger interface compatible with `@marianmeres/clog`. Provides console-compatibl
486
504
  interface RouterOptions<T = unknown> {
487
505
  routes?: RouterConfig<T> | null; // Route configuration
488
506
  logger?: Logger | null; // Optional logger instance
507
+ strict?: boolean; // Strict mode; propagates to every route
489
508
  }
490
509
  ```
491
510
 
@@ -494,6 +513,8 @@ Options object for the constructor (alternative to plain `RouterConfig`).
494
513
  **Type Parameters:**
495
514
  - `T` - The return type of route callbacks (default: `unknown`)
496
515
 
516
+ **`strict` mode:** when `true`, empty segments in both patterns and inputs (e.g. `/a//b`) are preserved rather than collapsed. This means `/a//b` will NOT match the canonical pattern `/a/b` — useful for URL canonicalization and preventing accidental shadow-matching in environments where concatenation bugs can produce doubled separators. Per-route `RouterOnOptions.strict` overrides this default.
517
+
497
518
  ### RouterSubscriber
498
519
 
499
520
  ```ts
@@ -541,11 +562,11 @@ Internal configuration for a parsed route segment. Exported for advanced use cas
541
562
 
542
563
  ### Key Behaviors
543
564
 
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
565
+ 1. **Separator Normalization:** Multiple separators are collapsed in non-strict mode (default); trimmed from ends. Enable `strict: true` to preserve them.
566
+ 2. **First Match Wins:** Routes matched in registration order. Enable `SimpleRouter.debug` for shadowing warnings at registration time.
567
+ 3. **Query String Parsing:** Enabled by default, can disable per-route.
568
+ 4. **Path Params Priority:** Path params override query params with the same name.
569
+ 5. **URL Decoding:** Parameter _values_ are URL-decoded. Parameter _names_ are taken literally from the pattern (not decoded).
549
570
 
550
571
  ### Pattern Constraints
551
572
 
@@ -553,6 +574,12 @@ Internal configuration for a parsed route segment. Exported for advanced use cas
553
574
  - Only one spread segment allowed per route
554
575
  - Wildcard `*` must be the last segment
555
576
  - Regex in `[name(regex)]` must be valid JavaScript regex
577
+ - In a named constraint `[name(regex)]`, `name` must match `/^\w+$/`
578
+ - An optional segment (`[x]?`) may not be followed by a required segment — optional segments are only allowed in trailing position (or directly before a wildcard `*`)
579
+
580
+ ### Security (regex constraints)
581
+
582
+ Regex constraints are compiled as ordinary JavaScript regexes. Some patterns (notably nested quantifiers like `(a+)+`) can exhibit catastrophic backtracking on crafted inputs. Route patterns are author-controlled — **never** accept them from untrusted input. Prefer simple character classes (`[0-9]+`, `[a-z]+`) over nested quantifiers.
556
583
 
557
584
  ### Examples
558
585
 
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ See [AGENTS.md](./AGENTS.md) for complete project documentation and AI agent instructions.
package/README.md CHANGED
@@ -77,7 +77,35 @@ window.onhashchange = () => {
77
77
 
78
78
  ### Separators
79
79
 
80
- The default separator is `/`. Multiple separators are normalized to single, and separators are trimmed from both ends before matching.
80
+ The default separator is `/`. By default (non-strict mode), multiple consecutive separators are collapsed into a single one, and separators are trimmed from both ends before matching. So `/foo//bar` and `/foo/bar/` both resolve to `/foo/bar`.
81
+
82
+ If you need to treat consecutive separators as significant (for example, to canonicalize URLs and prevent accidental shadow-matching), enable **strict mode**:
83
+
84
+ ```ts
85
+ // At router level (propagates to every route):
86
+ const router = new SimpleRouter({ strict: true, routes: { "/a/b": handler } });
87
+ router.exec("/a/b"); // matches
88
+ router.exec("/a//b"); // false (no match)
89
+
90
+ // Per-route override:
91
+ router.on("/legacy", handler, { strict: true });
92
+
93
+ // Or directly on SimpleRoute:
94
+ new SimpleRoute("/a/b", { strict: true });
95
+ ```
96
+
97
+ ### Optional segments
98
+
99
+ Optional segments (`[name]?`) are only allowed in **trailing position** (or directly before a trailing wildcard `*`). Optional-followed-by-required patterns are rejected at construction:
100
+
101
+ ```ts
102
+ new SimpleRoute("/foo/[bar]?/baz");
103
+ // Throws: optional segment '[bar]?' must not be followed by a required segment
104
+
105
+ // Instead, register two routes:
106
+ router.on("/foo/[bar]/baz", handler);
107
+ router.on("/foo/baz", handler);
108
+ ```
81
109
 
82
110
  ## Pattern Examples
83
111
 
@@ -96,7 +124,6 @@ The default separator is `/`. Multiple separators are normalized to single, and
96
124
  | `/foo/[bar]/[id([0-9]+)]` | `/foo/baz/123` | `{ bar: "baz", id: "123" }` |
97
125
  | `/foo/[bar]?` | `/foo` | `{}` |
98
126
  | `/foo/[bar]?` | `/foo/bar` | `{ bar: "bar" }` |
99
- | `/foo/[bar]?/baz` | `/foo/bar/baz` | `{ bar: "bar" }` |
100
127
  | `/[...path]/[file]` | `/foo/bar/baz.js` | `{ path: "foo/bar", file: "baz.js" }` |
101
128
  | `/foo/*` | `/foo/bar/baz.js` | `{}` |
102
129
  | `/[foo]/*` | `/foo/bar` | `{ foo: "foo" }` |
@@ -126,7 +153,7 @@ const router = new SimpleRouter({
126
153
  });
127
154
  ```
128
155
 
129
- - `config` - Either a `RouterConfig` object mapping route patterns to callbacks, or a `RouterOptions` object with `routes` and `logger` properties
156
+ - `config` - Either a `RouterConfig` object mapping route patterns to callbacks, or a `RouterOptions` object with `routes`, `logger`, and `strict` properties
130
157
 
131
158
  #### Methods
132
159
 
@@ -156,8 +183,9 @@ router.on("/raw", (params) => RawPage, { allowQueryParams: false });
156
183
  **Options:**
157
184
  - `label` - Optional label for debugging (visible via `info()`)
158
185
  - `allowQueryParams` - Whether to parse query parameters (default: `true`)
186
+ - `strict` - Per-route strict matching (overrides router default)
159
187
 
160
- **Important:** Routes are matched in registration order. First match wins!
188
+ **Important:** Routes are matched in registration order. First match wins! When `SimpleRouter.debug` is enabled, a warning is logged at registration time if a new route would be shadowed by an already-registered one.
161
189
 
162
190
  ##### `exec(url, fallbackFn?)`
163
191
 
@@ -287,13 +315,14 @@ const router2 = new SimpleRouter({
287
315
  import type {
288
316
  Logger,
289
317
  RouteParams,
290
- RouteCallback, // RouteCallback<T = unknown>
291
- RouterConfig, // RouterConfig<T = unknown>
318
+ RouteCallback, // RouteCallback<T = unknown>
319
+ RouterConfig, // RouterConfig<T = unknown>
292
320
  RouterCurrent,
293
321
  RouterOnOptions,
294
- RouterOptions, // RouterOptions<T = unknown>
322
+ RouterOptions, // RouterOptions<T = unknown>
295
323
  RouterSubscriber,
296
324
  RouterUnsubscribe,
325
+ SimpleRouteOptions, // { strict?: boolean }
297
326
  } from "@marianmeres/simple-router";
298
327
  ```
299
328
 
@@ -394,5 +423,19 @@ dotRouter.exec("app.settings.profile");
394
423
  // Returns: "Settings: profile"
395
424
  ```
396
425
 
426
+ ## Security notes
427
+
428
+ Regex constraints inside a route pattern (e.g. `[id([0-9]+)]`) are compiled with `RegExp`, so any valid JavaScript regex is accepted. A handful of patterns — typically nested quantifiers such as `(a+)+` — can exhibit catastrophic backtracking (ReDoS) on crafted inputs.
429
+
430
+ - Route patterns are author-controlled: **never** accept them from untrusted input.
431
+ - Prefer simple character classes (`[0-9]+`, `[a-z]+`) over nested quantifiers.
432
+ - If you need a complex constraint, test it against adversarial inputs first.
433
+
434
+ ## Breaking changes
435
+
436
+ ### 3.2.0
397
437
 
438
+ - **Optional-followed-by-required is now rejected at construction.** Patterns like `/foo/[bar]?/baz` previously parsed but silently treated the optional marker as required — `/foo/baz` never matched. Such patterns now throw with a clear error. Register two separate routes instead (`/foo/[bar]/baz` and `/foo/baz`).
439
+ - **Parameter names are no longer URI-decoded.** A pattern like `/foo/[id%20x]` used to produce the key `id x` in the params object; it now produces `id%20x`. Param values are still decoded. Parameter names in a named constraint (`[name(regex)]`) must now match `\w+`.
440
+ - **Regex constraints with parentheses now work correctly.** Patterns like `/[id((?:a|b)+)]` previously produced garbage regexes or threw obscure "Invalid regular expression" errors; they now parse and match as expected.
398
441
 
package/dist/mod.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { SimpleRoute } from "./route.js";
2
2
  export { SimpleRouter } from "./router.js";
3
- export type { RouteConfig, RouteParams } from "./route.js";
3
+ export type { RouteConfig, RouteParams, SimpleRouteOptions } from "./route.js";
4
4
  export type { Logger, RouteCallback, RouterConfig, RouterCurrent, RouterOnOptions, RouterOptions, RouterSubscriber, RouterUnsubscribe, } from "./router.js";
package/dist/route.d.ts CHANGED
@@ -13,6 +13,17 @@ export interface RouteConfig {
13
13
  * Route parameters extracted from URL segments and query strings.
14
14
  */
15
15
  export type RouteParams = Record<string, any>;
16
+ /**
17
+ * Options for the `SimpleRoute` constructor.
18
+ */
19
+ export interface SimpleRouteOptions {
20
+ /**
21
+ * When true, empty segments (caused by consecutive separators) are preserved
22
+ * rather than silently collapsed. In strict mode, inputs like `/a//b` will
23
+ * NOT match a pattern `/a/b`. Default: false.
24
+ */
25
+ strict?: boolean;
26
+ }
16
27
  export declare class SimpleRoute {
17
28
  #private;
18
29
  readonly route: string;
@@ -30,7 +41,9 @@ export declare class SimpleRoute {
30
41
  * Creates a new SimpleRoute instance.
31
42
  *
32
43
  * @param route - Pattern string to match against (URL, file path, command, or any string identifier)
33
- * @throws {Error} If route pattern is invalid (e.g., multiple spread segments)
44
+ * @param options - Optional configuration (e.g. `{ strict: true }` to disable empty-segment collapsing)
45
+ * @throws {Error} If route pattern is invalid (multiple spread segments, optional
46
+ * segment followed by a required one, invalid regex constraint, etc.)
34
47
  *
35
48
  * @example
36
49
  * ```ts
@@ -44,9 +57,12 @@ export declare class SimpleRoute {
44
57
  *
45
58
  * // Command patterns
46
59
  * new SimpleRoute("user:delete:[id]");
60
+ *
61
+ * // Strict mode: '/a//b' will NOT match '/a/b'
62
+ * new SimpleRoute("/a/b", { strict: true });
47
63
  * ```
48
64
  */
49
- constructor(route: string);
65
+ constructor(route: string, options?: SimpleRouteOptions);
50
66
  /**
51
67
  * Parses a query string into an object of key-value pairs.
52
68
  * Handles URL decoding of both keys and values.
package/dist/route.js CHANGED
@@ -11,11 +11,14 @@ export class SimpleRoute {
11
11
  */
12
12
  static WILDCARD = "*";
13
13
  #parsed;
14
+ #strict;
14
15
  /**
15
16
  * Creates a new SimpleRoute instance.
16
17
  *
17
18
  * @param route - Pattern string to match against (URL, file path, command, or any string identifier)
18
- * @throws {Error} If route pattern is invalid (e.g., multiple spread segments)
19
+ * @param options - Optional configuration (e.g. `{ strict: true }` to disable empty-segment collapsing)
20
+ * @throws {Error} If route pattern is invalid (multiple spread segments, optional
21
+ * segment followed by a required one, invalid regex constraint, etc.)
19
22
  *
20
23
  * @example
21
24
  * ```ts
@@ -29,31 +32,54 @@ export class SimpleRoute {
29
32
  *
30
33
  * // Command patterns
31
34
  * new SimpleRoute("user:delete:[id]");
35
+ *
36
+ * // Strict mode: '/a//b' will NOT match '/a/b'
37
+ * new SimpleRoute("/a/b", { strict: true });
32
38
  * ```
33
39
  */
34
- constructor(route) {
40
+ constructor(route, options = {}) {
35
41
  this.route = route;
36
- this.#parsed = SimpleRoute.#parse(route);
42
+ this.#strict = options.strict === true;
43
+ this.#parsed = SimpleRoute.#parse(route, this.#strict);
37
44
  }
38
45
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
39
46
  static #escapeRegExp(str) {
40
47
  return str.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
41
48
  }
42
- static #sanitizeAndSplit(str) {
49
+ static #sanitizeAndSplit(str, strict) {
43
50
  const s = SimpleRoute.#escapeRegExp(SimpleRoute.SPLITTER);
44
- return (`${str}`
51
+ let out = `${str}`
45
52
  .trim()
46
53
  // Trim splitters from left and right
47
54
  .replace(new RegExp(`^(${s})+`), "")
48
55
  .replace(new RegExp(`(${s})+$`), "")
49
- .split(SimpleRoute.SPLITTER)
50
- // Remove empty segments (normalizes multiple splitters into one)
51
- .filter(Boolean));
56
+ .split(SimpleRoute.SPLITTER);
57
+ // In non-strict mode, empty (internal) segments are silently collapsed.
58
+ // In strict mode, they are preserved and treated as literal empty segments
59
+ // (which generally will not match any parameterized pattern position).
60
+ if (!strict)
61
+ out = out.filter(Boolean);
62
+ return out;
63
+ }
64
+ /**
65
+ * Splits a bracketed definition like "name(regex)" into its two parts.
66
+ * Uses balanced-paren tracking so nested groups in the regex are preserved.
67
+ * Returns null if the input is not in the name(...) shape.
68
+ */
69
+ static #splitNamedConstraint(inner) {
70
+ const open = inner.indexOf("(");
71
+ if (open <= 0)
72
+ return null;
73
+ if (!inner.endsWith(")"))
74
+ return null;
75
+ const name = inner.slice(0, open);
76
+ const regex = inner.slice(open + 1, -1);
77
+ return { name, regex };
52
78
  }
53
- static #parse(route) {
79
+ static #parse(route, strict) {
54
80
  let wasSpread = false;
55
81
  let wasWildcard = false;
56
- return SimpleRoute.#sanitizeAndSplit(route).reduce((memo, segment, idx, all) => {
82
+ const parsed = SimpleRoute.#sanitizeAndSplit(route, strict).reduce((memo, segment, idx, all) => {
57
83
  let name = null;
58
84
  // If optional, remove trailing '?' marker
59
85
  let isOptional = segment.endsWith("?");
@@ -84,12 +110,16 @@ export class SimpleRoute {
84
110
  if (m) {
85
111
  name = m[1];
86
112
  test = /.+/;
87
- // id([0-9]+)
88
- const m2 = m[1].match(/^(\w.*)\((.+)\)$/);
89
- if (m2) {
90
- name = m2[1];
113
+ // Named constraint: [id([0-9]+)], [s((?:a|b)+)], ...
114
+ // Split on first '(' (balanced-paren-safe).
115
+ const split = SimpleRoute.#splitNamedConstraint(m[1]);
116
+ if (split) {
117
+ if (!/^\w+$/.test(split.name)) {
118
+ throw new Error(`Invalid parameter name '${split.name}' in route pattern '${segment}' (must match /^\\w+$/)`);
119
+ }
120
+ name = split.name;
91
121
  try {
92
- test = new RegExp("^" + m2[2] + "$");
122
+ test = new RegExp("^" + split.regex + "$");
93
123
  }
94
124
  catch (e) {
95
125
  throw new Error(`Invalid regex in route pattern '${segment}': ${e.message}`);
@@ -104,6 +134,24 @@ export class SimpleRoute {
104
134
  memo.push({ segment, name, test, isOptional, isSpread });
105
135
  return memo;
106
136
  }, []);
137
+ // An optional segment may only appear in trailing position (or directly
138
+ // before a wildcard, which itself is optional). Otherwise the 'optional'
139
+ // marker is misleading — it can never actually be skipped.
140
+ for (let i = 0; i < parsed.length; i++) {
141
+ const p = parsed[i];
142
+ if (!p.isOptional)
143
+ continue;
144
+ // segment is optional — all following segments must also be optional
145
+ for (let j = i + 1; j < parsed.length; j++) {
146
+ if (!parsed[j].isOptional) {
147
+ throw new Error(`Invalid route '${route}': optional segment '${p.segment}?' must not be followed by a required segment ('${parsed[j].segment}'). ` +
148
+ `Optional segments are only allowed in trailing position. ` +
149
+ `Register separate routes instead.`);
150
+ }
151
+ }
152
+ break;
153
+ }
154
+ return parsed;
107
155
  }
108
156
  /**
109
157
  * Parses a query string into an object of key-value pairs.
@@ -186,7 +234,7 @@ export class SimpleRoute {
186
234
  url = _backup.slice(0, qPos);
187
235
  matched = SimpleRoute.parseQueryString(_backup.slice(qPos + 1));
188
236
  }
189
- let segments = SimpleRoute.#sanitizeAndSplit(url);
237
+ let segments = SimpleRoute.#sanitizeAndSplit(url, this.#strict);
190
238
  // If there are spread definitions, we need to adjust input by grouping
191
239
  // (joining) segments that were initially split
192
240
  const hasSpread = this.#parsed.some((v) => v.isSpread);
@@ -238,7 +286,7 @@ export class SimpleRoute {
238
286
  }
239
287
  if (p.name) {
240
288
  try {
241
- matched[decodeURIComponent(p.name)] = decodeURIComponent(s);
289
+ matched[p.name] = decodeURIComponent(s);
242
290
  }
243
291
  catch {
244
292
  // Fallback for malformed URI components
package/dist/router.d.ts CHANGED
@@ -46,6 +46,11 @@ export interface RouterOnOptions {
46
46
  label?: string | null;
47
47
  /** Whether to parse query parameters (default: true) */
48
48
  allowQueryParams?: boolean;
49
+ /**
50
+ * Strict matching mode for this route (overrides router-level default).
51
+ * See `RouterOptions.strict`.
52
+ */
53
+ strict?: boolean;
49
54
  }
50
55
  /**
51
56
  * Configuration object for initializing the router with routes.
@@ -87,6 +92,13 @@ export interface RouterOptions<T = unknown> {
87
92
  routes?: RouterConfig<T> | null;
88
93
  /** Optional logger instance for debug output (compatible with @marianmeres/clog) */
89
94
  logger?: Logger | null;
95
+ /**
96
+ * Strict matching mode. When true, empty segments in inputs (caused by
97
+ * consecutive separators like `/a//b`) are NOT silently collapsed — such
98
+ * inputs will not match the canonical pattern `/a/b`. Propagates to every
99
+ * route registered via this router. Default: false.
100
+ */
101
+ strict?: boolean;
90
102
  }
91
103
  /**
92
104
  * Unsubscribe function returned by the `subscribe()` method.
package/dist/router.js CHANGED
@@ -42,6 +42,7 @@ export class SimpleRouter {
42
42
  static debug = false;
43
43
  #routes = [];
44
44
  #catchAll = null;
45
+ #strict = false;
45
46
  #current = {
46
47
  route: null,
47
48
  params: null,
@@ -76,11 +77,12 @@ export class SimpleRouter {
76
77
  constructor(config = null) {
77
78
  let routes = null;
78
79
  if (config) {
79
- // Check if config is RouterOptions (has 'routes' or 'logger' key)
80
- if ("routes" in config || "logger" in config) {
80
+ // Check if config is RouterOptions (has 'routes', 'logger' or 'strict' key)
81
+ if ("routes" in config || "logger" in config || "strict" in config) {
81
82
  const options = config;
82
83
  routes = options.routes ?? null;
83
84
  this.#logger = options.logger ?? null;
85
+ this.#strict = options.strict === true;
84
86
  }
85
87
  else {
86
88
  // Backwards compatible: treat as RouterConfig
@@ -180,6 +182,7 @@ export class SimpleRouter {
180
182
  */
181
183
  on(routes, cb, options = {}) {
182
184
  const { label = null, allowQueryParams = true } = options;
185
+ const strict = options.strict ?? this.#strict;
183
186
  if (!Array.isArray(routes))
184
187
  routes = [routes];
185
188
  routes.forEach((route) => {
@@ -187,15 +190,43 @@ export class SimpleRouter {
187
190
  this.#catchAll = cb;
188
191
  }
189
192
  else {
190
- this.#routes.push([
191
- new SimpleRoute(route),
192
- cb,
193
- allowQueryParams,
194
- label,
195
- ]);
193
+ const sr = new SimpleRoute(route, { strict });
194
+ // Dev-only shadowing warning: if an earlier route would consume the
195
+ // literal form of this one, the new route is unreachable. Only runs
196
+ // when debug logging is enabled to avoid per-registration overhead
197
+ // in production.
198
+ if (SimpleRouter.debug)
199
+ this.#warnIfShadowed(route);
200
+ this.#routes.push([sr, cb, allowQueryParams, label]);
196
201
  }
197
202
  });
198
203
  }
204
+ #warnIfShadowed(route) {
205
+ for (const [existing] of this.#routes) {
206
+ if (existing.route === route)
207
+ continue;
208
+ // If the existing pattern matches the literal text of the new route,
209
+ // any real input intended for the new route will also match the
210
+ // existing one first (first-match-wins).
211
+ let matched = null;
212
+ try {
213
+ matched = existing.parse(route);
214
+ }
215
+ catch {
216
+ // ignore — some patterns may not be parseable as inputs
217
+ }
218
+ if (matched !== null) {
219
+ const msg = `[SimpleRouter] route '${route}' is shadowed by earlier ` +
220
+ `route '${existing.route}' (first-match-wins). ` +
221
+ `Register the more specific route first.`;
222
+ if (this.#logger)
223
+ this.#logger.warn(msg);
224
+ else
225
+ console.warn(msg);
226
+ break;
227
+ }
228
+ }
229
+ }
199
230
  /**
200
231
  * Executes pattern matching against the provided string.
201
232
  * Returns the value returned by the matched route's callback (type `T`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/simple-router",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -10,10 +10,18 @@
10
10
  "import": "./dist/mod.js"
11
11
  }
12
12
  },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md",
17
+ "API.md",
18
+ "AGENTS.md",
19
+ "CLAUDE.md"
20
+ ],
13
21
  "author": "Marian Meres",
14
22
  "license": "MIT",
15
23
  "dependencies": {
16
- "@marianmeres/pubsub": "^2.4.4"
24
+ "@marianmeres/pubsub": "^3.0.0"
17
25
  },
18
26
  "repository": {
19
27
  "type": "git",