@marianmeres/simple-router 2.0.1 → 2.2.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Marian Meres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,79 +1,360 @@
1
1
  # @marianmeres/simple-router
2
2
 
3
- Small path-like string parser. Originally inspired by
4
- [sapper-like regex routes](https://sapper.svelte.dev/docs#Regexes_in_routes).
5
- Intended primarily for - but not limited to - client side SPA routing.
3
+ A lightweight, framework-agnostic string pattern matcher and router with support for dynamic parameters, wildcards, query strings, and reactive subscriptions.
4
+
5
+ Can match any string identifiers - URLs, file paths, command names, or custom patterns. Originally inspired by [Sapper-like regex routes](https://sapper.svelte.dev/docs#Regexes_in_routes). Primarily designed for client-side SPA routing, but flexible enough for any pattern matching needs.
6
+
7
+ ## Features
8
+
9
+ - âœĻ Dynamic route parameters with optional regex constraints
10
+ - ðŸŽŊ Wildcard and catch-all routes
11
+ - ðŸ“Ķ Spread parameters for multi-segment matching
12
+ - 🔍 Query string parsing (with option to disable per route)
13
+ - 🔄 Reactive subscriptions (Svelte store contract compatible)
14
+ - ðŸŠķ Zero dependencies (except `@marianmeres/pubsub` for subscriptions)
15
+ - 📘 Full TypeScript support
16
+ - ðŸŽĻ Framework-agnostic
6
17
 
7
18
  ## Installation
8
19
 
9
20
  ```shell
10
- npm i @marianmeres/simple-router
21
+ deno add "jsr:@marianmeres/simple-router"
22
+ ```
23
+
24
+ ```shell
25
+ npm install @marianmeres/simple-router
26
+ ```
27
+
28
+ ```ts
29
+ import { SimpleRouter } from "@marianmeres/simple-router";
30
+ ```
31
+
32
+ ## Quick Example
33
+
34
+ ```ts
35
+ import { SimpleRouter } from "@marianmeres/simple-router";
36
+
37
+ // Routes can be defined via constructor config
38
+ const router = new SimpleRouter({
39
+ "/": () => HomePage,
40
+ "/about": () => AboutPage,
41
+ "*": () => NotFoundPage, // catch-all fallback
42
+ });
43
+
44
+ // Or via the "on" API
45
+ router.on("/user/[id([0-9]+)]", (params) => {
46
+ console.log("User ID:", params?.id);
47
+ return UserPage(params?.id);
48
+ });
49
+
50
+ router.on("/article/[id]/[slug]", ({ id, slug }) => {
51
+ return ArticlePage(id, slug);
52
+ });
53
+
54
+ // Execute route matching
55
+ const component = router.exec("/user/123");
56
+
57
+ // Use with hash routing
58
+ window.onhashchange = () => {
59
+ const component = router.exec(location.hash.slice(1));
60
+ render(component);
61
+ };
62
+ ```
63
+
64
+ ## Route Patterns
65
+
66
+ ### Basic Segments
67
+
68
+ - `exact` - Matches exactly "exact"
69
+ - `[name]` - Matches any segment, captured as `{ name: "value" }`
70
+ - `[name(regex)]` - Matches if regex test passes
71
+ - `[name]?` - Optional segment
72
+ - `[...name]` - Spread params (matches multiple segments)
73
+ - `*` - Wildcard (matches zero or more segments)
74
+
75
+ ### Separators
76
+
77
+ The default separator is `/`. Multiple separators are normalized to single, and separators are trimmed from both ends before matching.
78
+
79
+ ## Pattern Examples
80
+
81
+ | Route Pattern | URL Input | Params Result |
82
+ | ----------------------------- | ------------------ | -------------------------------- |
83
+ | `/foo` | `/bar` | `null` (no match) |
84
+ | `/foo` | (empty) | `null` |
85
+ | `/foo/[bar]` | `/foo` | `null` |
86
+ | `/` | (empty) | `{}` |
87
+ | `foo` | `foo` | `{}` |
88
+ | `//foo///bar.baz/` | `foo/bar.baz` | `{}` |
89
+ | `/[foo]` | `/bar` | `{ foo: "bar" }` |
90
+ | `#/[foo]/[bar]` | `#/baz/bat` | `{ foo: "baz", bar: "bat" }` |
91
+ | `/[id([0-9]+)]` | `/123` | `{ id: "123" }` |
92
+ | `/[id([0-9]+)]` | `/foo` | `null` (regex fails) |
93
+ | `/foo/[bar]/[id([0-9]+)]` | `/foo/baz/123` | `{ bar: "baz", id: "123" }` |
94
+ | `/foo/[bar]?` | `/foo` | `{}` |
95
+ | `/foo/[bar]?` | `/foo/bar` | `{ bar: "bar" }` |
96
+ | `/foo/[bar]?/baz` | `/foo/bar/baz` | `{ bar: "bar" }` |
97
+ | `/[...path]/[file]` | `/foo/bar/baz.js` | `{ path: "foo/bar", file: "baz.js" }` |
98
+ | `/foo/*` | `/foo/bar/baz.js` | `{}` |
99
+ | `/[foo]/*` | `/foo/bar` | `{ foo: "foo" }` |
100
+
101
+ ## API Reference
102
+
103
+ ### SimpleRouter
104
+
105
+ #### Constructor
106
+
107
+ ```ts
108
+ const router = new SimpleRouter(config?);
109
+ ```
110
+
111
+ - `config` - Optional object mapping route patterns to callbacks
112
+
113
+ #### Methods
114
+
115
+ ##### `on(routes, callback, options?)`
116
+
117
+ Register one or more route patterns with a callback.
118
+
119
+ ```ts
120
+ router.on("/users", () => UsersPage);
121
+
122
+ // Multiple routes to same handler
123
+ router.on(["/", "/home", "/index.html"], () => HomePage);
124
+
125
+ // With dynamic params
126
+ router.on("/user/[id]", (params) => UserPage(params?.id));
127
+
128
+ // With regex constraint
129
+ router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));
130
+
131
+ // With label for debugging
132
+ router.on("/admin", () => AdminPage, { label: "admin-dashboard" });
133
+
134
+ // Disable query param parsing for this route
135
+ router.on("/raw", (params) => RawPage, { allowQueryParams: false });
136
+ ```
137
+
138
+ **Options:**
139
+ - `label` - Optional label for debugging (visible via `info()`)
140
+ - `allowQueryParams` - Whether to parse query parameters (default: `true`)
141
+
142
+ **Important:** Routes are matched in registration order. First match wins!
143
+
144
+ ##### `exec(url, fallbackFn?)`
145
+
146
+ Execute route matching against a URL.
147
+
148
+ ```ts
149
+ const result = router.exec("/users");
150
+
151
+ // With fallback
152
+ router.exec("/unknown", () => console.log("Not found"));
153
+
154
+ // With query params
155
+ router.exec("/search?q=hello");
156
+ ```
157
+
158
+ Returns the value returned by the matched callback, or `false` if no match.
159
+
160
+ ##### `subscribe(callback)`
161
+
162
+ Subscribe to router state changes. Follows the [Svelte store contract](https://svelte.dev/docs#Store_contract).
163
+
164
+ ```ts
165
+ const { unsubscribe } = router.subscribe((state) => {
166
+ console.log("Route:", state.route);
167
+ console.log("Params:", state.params);
168
+ console.log("Label:", state.label);
169
+ });
170
+
171
+ // Later
172
+ unsubscribe();
173
+ ```
174
+
175
+ The callback is called immediately with the current state, then on every route change.
176
+
177
+ ##### `reset()`
178
+
179
+ Clears all registered routes (except catch-all).
180
+
181
+ ```ts
182
+ router.reset().on("/new-route", () => NewPage);
183
+ ```
184
+
185
+ ##### `info()`
186
+
187
+ Returns a map of registered routes to their labels (for debugging).
188
+
189
+ ```ts
190
+ router.on("/users", () => {}, { label: "users-list" });
191
+ console.log(router.info()); // { "/users": "users-list" }
192
+ ```
193
+
194
+ #### Properties
195
+
196
+ ##### `current`
197
+
198
+ Gets the current router state (readonly).
199
+
200
+ ```ts
201
+ router.exec("/user/123");
202
+ console.log(router.current);
203
+ // { route: "/user/[id]", params: { id: "123" }, label: null }
204
+ ```
205
+
206
+ ##### `static debug`
207
+
208
+ Enable/disable debug logging.
209
+
210
+ ```ts
211
+ SimpleRouter.debug = true;
212
+ router.exec("/test"); // Logs matching details to console
11
213
  ```
12
214
 
13
- ## Quick example
215
+ ### SimpleRoute
216
+
217
+ Low-level route parser. Usually you don't need to use this directly.
218
+
219
+ ```ts
220
+ import { SimpleRoute } from "@marianmeres/simple-router";
14
221
 
15
- ```js
16
- // routes definitions can be added via ctor config object
222
+ const route = new SimpleRoute("/user/[id([0-9]+)]");
223
+ const params = route.parse("/user/123");
224
+ console.log(params); // { id: "123" }
225
+ ```
226
+
227
+ #### Static Methods
228
+
229
+ ##### `parseQueryString(str)`
230
+
231
+ Parse a query string into an object.
232
+
233
+ ```ts
234
+ SimpleRoute.parseQueryString("foo=bar&baz=123");
235
+ // Returns: { foo: "bar", baz: "123" }
236
+ ```
237
+
238
+ ## TypeScript
239
+
240
+ Full TypeScript support with exported types:
241
+
242
+ ```ts
243
+ import type {
244
+ RouteParams,
245
+ RouteCallback,
246
+ RouterConfig,
247
+ RouterCurrent,
248
+ RouterOnOptions,
249
+ RouterSubscriber,
250
+ RouterSubscription,
251
+ } from "@marianmeres/simple-router";
252
+ ```
253
+
254
+ ## Advanced Examples
255
+
256
+ ### SPA with Hash Routing
257
+
258
+ ```ts
17
259
  const router = new SimpleRouter({
18
- '/': () => pageIndex(),
19
- '*': () => page404(), // catch all (last resort fallback)
260
+ "/": () => HomePage,
261
+ "/about": () => AboutPage,
262
+ "/user/[id]": (params) => UserPage(params?.id),
263
+ "*": () => NotFoundPage,
264
+ });
265
+
266
+ function render(component) {
267
+ document.getElementById("app").innerHTML = component;
268
+ }
269
+
270
+ window.addEventListener("hashchange", () => {
271
+ const path = location.hash.slice(1) || "/";
272
+ const component = router.exec(path);
273
+ render(component);
274
+ });
275
+
276
+ // Trigger initial render
277
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
278
+ ```
279
+
280
+ ### Route Priority
281
+
282
+ Routes are matched in registration order (first match wins):
283
+
284
+ ```ts
285
+ const router = new SimpleRouter();
286
+
287
+ // Register generic route first
288
+ router.on("/user/[id]", (params) => {
289
+ console.log("Generic:", params?.id); // This will match
20
290
  });
21
291
 
22
- // or via "on" api
23
- router.on(
24
- '/article/[id([0-9]+)]/[slug]',
25
- ({ id, slug }) => pageArticle(id, slug)
26
- );
27
-
28
- // finally, perform route match (execute router)
29
- // (example here returns "component" which is then "rendered")
30
- window.onhashchange = () => render(router.exec(location.hash));
31
- ```
32
-
33
- See [tests](tests) or [examples](examples) for more.
34
-
35
- ## Route matching
36
-
37
- Route segments:
38
- - `exact` matches `exact`
39
- - `[name]` matches `any` and is resolved as `{ name: 'any' }` param
40
- - `[name(regex)]` matches if `regex.test(segment)` is truthy
41
- - `exact?` or `[name]?` marks segment as optional
42
- - `[...name]` matches "rest segments"
43
- - `*` matches zero or more following segments
44
-
45
- Few notes on segments separators (which is slash `/` by default):
46
- - multiple ones are always normalized to single,
47
- - separator is always trimmed (both left and right) before matching
48
-
49
- | Example route definition | Example input | Result |
50
- | ------------------------- | ------------------ | --------------------------- |
51
- | `/foo` | `/bar` | `null` |
52
- | `/foo` | (empty string) | `null` |
53
- | `/foo/[bar]` | `/foo` | `null` |
54
- | `/[foo]/bar` | `/foo` | `null` |
55
- | `/[foo]/[bar]` | `/foo` | `null` |
56
- | `/` | (empty string) | `{}` |
57
- | (empty string) | `///` | `{}` |
58
- | `foo` | `foo` | `{}` |
59
- | `//foo///bar.baz/` | `foo/bar.baz` | `{}` |
60
- | `foo/bar/baz` | `//foo//bar/baz//` | `{}` |
61
- | `/[foo]` | `/bar` | `{"foo":"bar"}` |
62
- | `#/foo/[bar]` | `#/foo/bat` | `{"bar":"bat"}` |
63
- | `#/[foo]/[bar]` | `#/baz/bat` | `{"foo":"baz","bar":"bat"}` |
64
- | `#/[foo]/bar` | `#/baz/bar` | `{"foo":"baz"}` |
65
- | `/[id([0-9]+)]` | `/123` | `{"id":"123"}` |
66
- | `/[id(\d-\d)]` | `/1-2` | `{"id":"1-2"}` |
67
- | `/[id([0-9]+)]` | `/foo` | `null` |
68
- | `/foo/[bar]/[id([0-9]+)]` | `/foo/baz/123` | `{"bar":"baz","id":"123"}` |
69
- | `/foo/[id([0-9]+)]/[bar]` | `/foo/bar/baz` | `null` |
70
- | `/foo/[([0-9]+)]` | `/foo/123` | `null` (missing name before regex) |
71
- | `/foo/[bar]?` | `/foo` | `{}` |
72
- | `/foo/[bar]?` | `/foo/bar` | `{ bar: "bar" }` |
73
- | `/foo/[bar]?/baz` | `/foo/bar/baz` | `{ bar: "bar" }` |
74
- | `/[...path]/[file]` | `/foo/bar/baz.js` | `{ path: "foo/bar", file: "baz.js" }`|
75
- | `/foo/*` | `/foo/bar/baz.js` | `{}` |
76
- | `/[foo]/*` | `/foo/bar` | `{ foo: "foo" }` |
77
- | `/*` | `/foo/bar` | `{}` |
78
- See [tests](tests) or [examples](examples) for more.
292
+ // Specific route registered second (won't match "/user/admin")
293
+ router.on("/user/admin", () => {
294
+ console.log("Admin"); // This won't be reached
295
+ });
296
+
297
+ router.exec("/user/admin"); // Logs: "Generic: admin"
298
+ ```
299
+
300
+ To fix, register more specific routes first:
301
+
302
+ ```ts
303
+ router.on("/user/admin", () => console.log("Admin"));
304
+ router.on("/user/[id]", (params) => console.log("User:", params?.id));
305
+ ```
306
+
307
+ ### Beyond URLs: General Pattern Matching
308
+
309
+ The router can match any string patterns, not just URLs:
310
+
311
+ ```ts
312
+ // File path routing
313
+ const fileRouter = new SimpleRouter({
314
+ "src/[module]/[file].ts": ({ module, file }) =>
315
+ console.log(`Module: ${module}, File: ${file}`),
316
+ "assets/images/[...path]": ({ path }) =>
317
+ console.log(`Image path: ${path}`),
318
+ });
319
+
320
+ fileRouter.exec("src/components/Button.ts");
321
+ // Logs: "Module: components, File: Button"
322
+
323
+ fileRouter.exec("assets/images/icons/user.png");
324
+ // Logs: "Image path: icons/user.png"
325
+
326
+ // Command routing
327
+ const cmdRouter = new SimpleRouter({
328
+ "user:create": () => createUser(),
329
+ "user:delete:[id([0-9]+)]": ({ id }) => deleteUser(id),
330
+ "cache:clear:[type(redis|memcached)]?": ({ type = "all" }) =>
331
+ clearCache(type),
332
+ });
333
+
334
+ cmdRouter.exec("user:delete:123");
335
+ cmdRouter.exec("cache:clear:redis");
336
+ cmdRouter.exec("cache:clear"); // type defaults to "all"
337
+
338
+ // Custom separator (anything that's not a special regex char works)
339
+ const dotRouter = new SimpleRouter({
340
+ "app.settings.theme": () => "Theme settings",
341
+ "app.settings.[section]": ({ section }) => `Settings: ${section}`,
342
+ });
343
+
344
+ dotRouter.exec("app.settings.profile");
345
+ // Returns: "Settings: profile"
346
+ ```
347
+
348
+
349
+ ## Links
350
+
351
+ - [Tests](tests/) - Comprehensive test suite with 82+ tests
352
+ - [GitHub Repository](https://github.com/marianmeres/simple-router)
353
+ - [JSR Package](https://jsr.io/@marianmeres/simple-router)
354
+
355
+ ## Package Identity
79
356
 
357
+ - **Name:** @marianmeres/simple-router
358
+ - **Author:** Marian Meres
359
+ - **Repository:** https://github.com/marianmeres/simple-router
360
+ - **License:** MIT
package/dist/mod.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { SimpleRoute } from "./route.js";
2
+ export { SimpleRouter } from "./router.js";
3
+ export type { RouteConfig, RouteParams } from "./route.js";
4
+ export type { RouteCallback, RouterConfig, RouterCurrent, RouterOnOptions, RouterSubscriber, RouterSubscription, } from "./router.js";
package/dist/mod.js ADDED
@@ -0,0 +1,3 @@
1
+ // Main classes
2
+ export { SimpleRoute } from "./route.js";
3
+ export { SimpleRouter } from "./router.js";
package/dist/route.d.ts CHANGED
@@ -1,19 +1,98 @@
1
+ /**
2
+ * Configuration for a single route segment.
3
+ * @internal
4
+ */
1
5
  export interface RouteConfig {
2
6
  segment: string;
3
- name: string;
7
+ name: string | null;
4
8
  test: RegExp;
5
9
  isOptional: boolean;
6
10
  isSpread: boolean;
7
11
  }
12
+ /**
13
+ * Route parameters extracted from URL segments and query strings.
14
+ */
15
+ export type RouteParams = Record<string, any>;
8
16
  export declare class SimpleRoute {
9
- readonly route: any;
17
+ #private;
18
+ readonly route: string;
19
+ /** Separator used to split URL paths into segments */
10
20
  static SPLITTER: string;
21
+ /** Wildcard symbol for catch-all routes */
11
22
  static WILDCARD: string;
12
- protected _parsed: RouteConfig[];
13
- constructor(route: any);
14
- protected static _escapeRegExp(str: string): string;
15
- protected static _sanitizeAndSplit(str: any): string[];
16
- protected static _parse(route: any): any[];
17
- static parseQueryString(str: any): {};
18
- parse(url: any, allowQueryParams?: boolean): {};
23
+ /**
24
+ * Creates a new SimpleRoute instance.
25
+ *
26
+ * @param route - Pattern string to match against (URL, file path, command, or any string identifier)
27
+ * @throws {Error} If route pattern is invalid (e.g., multiple spread segments)
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * // URL patterns
32
+ * new SimpleRoute("/user/[id]");
33
+ * new SimpleRoute("/post/[id([0-9]+)]");
34
+ * new SimpleRoute("/api/[...path]");
35
+ *
36
+ * // File path patterns
37
+ * new SimpleRoute("src/[module]/[file].ts");
38
+ *
39
+ * // Command patterns
40
+ * new SimpleRoute("user:delete:[id]");
41
+ * ```
42
+ */
43
+ constructor(route: string);
44
+ /**
45
+ * Parses a query string into an object of key-value pairs.
46
+ * Handles URL decoding of both keys and values.
47
+ *
48
+ * @param str - Query string (without leading "?")
49
+ * @returns Object with parsed query parameters
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * SimpleRoute.parseQueryString("foo=bar&baz=123");
54
+ * // Returns: { foo: "bar", baz: "123" }
55
+ *
56
+ * SimpleRoute.parseQueryString("name=John%20Doe");
57
+ * // Returns: { name: "John Doe" }
58
+ * ```
59
+ */
60
+ static parseQueryString(str: string): RouteParams;
61
+ /**
62
+ * Parses a string against this route pattern.
63
+ * Returns extracted parameters if the string matches, null otherwise.
64
+ *
65
+ * @param url - String to test against the pattern (can be a URL, file path, command, etc.)
66
+ * @param allowQueryParams - Whether to parse query string parameters (default: true)
67
+ * @returns Object with extracted parameters, or null if no match
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * // URL matching
72
+ * const route = new SimpleRoute("/user/[id]");
73
+ *
74
+ * route.parse("/user/123");
75
+ * // Returns: { id: "123" }
76
+ *
77
+ * route.parse("/user/123?tab=profile");
78
+ * // Returns: { id: "123", tab: "profile" }
79
+ *
80
+ * route.parse("/user/123?tab=profile", false);
81
+ * // Returns: { id: "123?tab=profile" } - query string not parsed
82
+ *
83
+ * route.parse("/post/456");
84
+ * // Returns: null - no match
85
+ *
86
+ * // File path matching
87
+ * const fileRoute = new SimpleRoute("src/[module]/[file].ts");
88
+ * fileRoute.parse("src/components/Button.ts");
89
+ * // Returns: { module: "components", file: "Button" }
90
+ *
91
+ * // Command matching
92
+ * const cmdRoute = new SimpleRoute("user:delete:[id]");
93
+ * cmdRoute.parse("user:delete:123");
94
+ * // Returns: { id: "123" }
95
+ * ```
96
+ */
97
+ parse(url: string, allowQueryParams?: boolean): RouteParams | null;
19
98
  }