@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/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
 
@@ -255,19 +283,46 @@ SimpleRoute.parseQueryString("foo=bar&baz=123");
255
283
 
256
284
  ## TypeScript
257
285
 
258
- Full TypeScript support with exported types:
286
+ Full TypeScript support with generic types for type-safe route callbacks and `exec()` return values.
287
+
288
+ ### Generic Router
289
+
290
+ `SimpleRouter<T>` is generic over `T`, the return type of route callbacks:
291
+
292
+ ```ts
293
+ // Typed router - all callbacks must return Component, exec() returns Component | false
294
+ const router = new SimpleRouter<Component>({
295
+ "/": () => HomePage,
296
+ "/about": () => AboutPage,
297
+ "*": () => NotFoundPage,
298
+ });
299
+
300
+ const result = router.exec("/about"); // Component | false
301
+ if (result !== false) {
302
+ render(result); // result is Component
303
+ }
304
+
305
+ // Without explicit type - T is inferred from callbacks (or defaults to unknown)
306
+ const router2 = new SimpleRouter({
307
+ "/": () => "home",
308
+ "/about": () => "about",
309
+ });
310
+ ```
311
+
312
+ ### Exported Types
259
313
 
260
314
  ```ts
261
315
  import type {
262
316
  Logger,
263
317
  RouteParams,
264
- RouteCallback,
265
- RouterConfig,
318
+ RouteCallback, // RouteCallback<T = unknown>
319
+ RouterConfig, // RouterConfig<T = unknown>
266
320
  RouterCurrent,
267
321
  RouterOnOptions,
268
- RouterOptions,
322
+ RouterOptions, // RouterOptions<T = unknown>
269
323
  RouterSubscriber,
270
324
  RouterUnsubscribe,
325
+ SimpleRouteOptions, // { strict?: boolean }
271
326
  } from "@marianmeres/simple-router";
272
327
  ```
273
328
 
@@ -276,21 +331,24 @@ import type {
276
331
  ### SPA with Hash Routing
277
332
 
278
333
  ```ts
279
- const router = new SimpleRouter({
334
+ // Type-safe router with Component return type
335
+ const router = new SimpleRouter<Component>({
280
336
  "/": () => HomePage,
281
337
  "/about": () => AboutPage,
282
338
  "/user/[id]": (params) => UserPage(params?.id),
283
339
  "*": () => NotFoundPage,
284
340
  });
285
341
 
286
- function render(component) {
287
- document.getElementById("app").innerHTML = component;
342
+ function render(component: Component) {
343
+ document.getElementById("app").innerHTML = component.render();
288
344
  }
289
345
 
290
346
  window.addEventListener("hashchange", () => {
291
347
  const path = location.hash.slice(1) || "/";
292
- const component = router.exec(path);
293
- render(component);
348
+ const component = router.exec(path); // Component | false
349
+ if (component !== false) {
350
+ render(component);
351
+ }
294
352
  });
295
353
 
296
354
  // Trigger initial render
@@ -365,5 +423,19 @@ dotRouter.exec("app.settings.profile");
365
423
  // Returns: "Settings: profile"
366
424
  ```
367
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
368
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.
369
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
@@ -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.
@@ -45,21 +46,28 @@ export interface RouterOnOptions {
45
46
  label?: string | null;
46
47
  /** Whether to parse query parameters (default: true) */
47
48
  allowQueryParams?: boolean;
49
+ /**
50
+ * Strict matching mode for this route (overrides router-level default).
51
+ * See `RouterOptions.strict`.
52
+ */
53
+ strict?: boolean;
48
54
  }
49
55
  /**
50
56
  * Configuration object for initializing the router with routes.
51
57
  * Maps route patterns to their callback functions.
52
58
  *
59
+ * @typeParam T - The return type of route callbacks
60
+ *
53
61
  * @example
54
62
  * ```ts
55
- * const config: RouterConfig = {
63
+ * const config: RouterConfig<Component> = {
56
64
  * "/": () => HomePage,
57
65
  * "/user/[id]": (params) => UserPage(params?.id),
58
66
  * "*": () => NotFoundPage
59
67
  * };
60
68
  * ```
61
69
  */
62
- export type RouterConfig = Record<string, RouteCallback>;
70
+ export type RouterConfig<T = unknown> = Record<string, RouteCallback<T>>;
63
71
  /**
64
72
  * Logger interface compatible with `@marianmeres/clog`.
65
73
  * Provides console-compatible logging methods.
@@ -76,12 +84,21 @@ export interface Logger {
76
84
  }
77
85
  /**
78
86
  * Options for the SimpleRouter factory/constructor.
87
+ *
88
+ * @typeParam T - The return type of route callbacks
79
89
  */
80
- export interface RouterOptions {
90
+ export interface RouterOptions<T = unknown> {
81
91
  /** Route configuration mapping patterns to callbacks */
82
- routes?: RouterConfig | null;
92
+ routes?: RouterConfig<T> | null;
83
93
  /** Optional logger instance for debug output (compatible with @marianmeres/clog) */
84
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;
85
102
  }
86
103
  /**
87
104
  * Unsubscribe function returned by the `subscribe()` method.
@@ -123,8 +140,10 @@ export type RouterSubscriber = (current: RouterCurrent) => void;
123
140
  *
124
141
  * cmdRouter.exec("user:delete:123");
125
142
  * ```
143
+ *
144
+ * @typeParam T - The return type of route callbacks (default: unknown)
126
145
  */
127
- export declare class SimpleRouter {
146
+ export declare class SimpleRouter<T = unknown> {
128
147
  #private;
129
148
  /**
130
149
  * Enable/disable console debug logging for route matching.
@@ -140,14 +159,14 @@ export declare class SimpleRouter {
140
159
  *
141
160
  * @example
142
161
  * ```ts
143
- * // Simple config (backwards compatible)
144
- * const router = new SimpleRouter({
162
+ * // Typed router - exec() returns Component | false
163
+ * const router = new SimpleRouter<Component>({
145
164
  * "/": () => HomePage,
146
165
  * "/about": () => AboutPage
147
166
  * });
148
167
  *
149
168
  * // With options object
150
- * const router = new SimpleRouter({
169
+ * const router = new SimpleRouter<Component>({
151
170
  * routes: {
152
171
  * "/": () => HomePage,
153
172
  * "/about": () => AboutPage
@@ -156,7 +175,7 @@ export declare class SimpleRouter {
156
175
  * });
157
176
  * ```
158
177
  */
159
- constructor(config?: RouterConfig | RouterOptions | null);
178
+ constructor(config?: RouterConfig<T> | RouterOptions<T> | null);
160
179
  /**
161
180
  * Returns a map of registered route patterns to their labels.
162
181
  * Useful for debugging and introspection.
@@ -224,38 +243,36 @@ export declare class SimpleRouter {
224
243
  * router.on("*", () => NotFoundPage);
225
244
  * ```
226
245
  */
227
- on(routes: string | string[], cb: RouteCallback, options?: RouterOnOptions): void;
246
+ on(routes: string | string[], cb: RouteCallback<T>, options?: RouterOnOptions): void;
228
247
  /**
229
248
  * Executes pattern matching against the provided string.
230
- * Returns the value returned by the matched route's callback.
249
+ * Returns the value returned by the matched route's callback (type `T`).
231
250
  * Routes are tested in registration order - first match wins.
232
251
  *
233
252
  * @param url - String to match against registered patterns (can be a URL, file path, command, etc.)
234
253
  * @param fallbackFn - Optional fallback function if no route matches
235
- * @returns The value returned by the matched callback, or false if no match
254
+ * @returns `T | false` - The value returned by the matched callback, or `false` if no match
236
255
  *
237
256
  * @example
238
257
  * ```ts
239
- * // URL matching
240
- * router.exec("/users"); // Returns result of callback
258
+ * // Type-safe routing
259
+ * const router = new SimpleRouter<Component>({ "/home": () => HomePage });
260
+ * const result = router.exec("/home"); // Component | false
241
261
  *
242
262
  * // With query parameters
243
263
  * router.exec("/users?sort=name");
244
264
  *
245
- * // File path matching
246
- * router.exec("src/components/Button.ts");
247
- *
248
- * // Command matching
249
- * router.exec("user:delete:123");
250
- *
251
265
  * // With fallback
252
- * router.exec("/unknown", () => console.log("Not found"));
266
+ * router.exec("/unknown", () => NotFoundPage);
253
267
  *
254
- * // Can return components, values, etc.
255
- * const component = router.exec("/home");
268
+ * // Check for no match
269
+ * const component = router.exec("/path");
270
+ * if (component !== false) {
271
+ * render(component);
272
+ * }
256
273
  * ```
257
274
  */
258
- exec(url: string, fallbackFn?: RouteCallback): unknown;
275
+ exec(url: string, fallbackFn?: RouteCallback<T>): T | false;
259
276
  /**
260
277
  * Subscribes to router state changes.
261
278
  * Follows the Svelte store contract - subscriber is called immediately with current state.