@marianmeres/simple-router 2.1.0 → 2.2.3

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/dist/router.js ADDED
@@ -0,0 +1,255 @@
1
+ import { SimpleRoute } from "./route.js";
2
+ import { createPubSub } from "@marianmeres/pubsub";
3
+ const PUBSUB_TOPIC = "current";
4
+ /**
5
+ * A simple, framework-agnostic string pattern matcher and router with support
6
+ * for dynamic parameters, wildcards, query strings, and reactive subscriptions.
7
+ *
8
+ * Can match any string identifiers - URLs, file paths, command names, or custom patterns.
9
+ * Primarily designed for client-side SPA routing, but flexible enough for any pattern
10
+ * matching needs.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { SimpleRouter } from "@marianmeres/simple-router";
15
+ *
16
+ * // URL routing
17
+ * const router = new SimpleRouter({
18
+ * "/": () => console.log("Home"),
19
+ * "/user/[id]": (params) => console.log("User:", params?.id),
20
+ * "*": () => console.log("Not found")
21
+ * });
22
+ *
23
+ * router.exec("/user/123"); // Logs: "User: 123"
24
+ *
25
+ * // Command routing
26
+ * const cmdRouter = new SimpleRouter({
27
+ * "user:create": () => createUser(),
28
+ * "user:delete:[id]": ({ id }) => deleteUser(id),
29
+ * });
30
+ *
31
+ * cmdRouter.exec("user:delete:123");
32
+ * ```
33
+ */
34
+ export class SimpleRouter {
35
+ /** Enable/disable console debug logging for route matching */
36
+ static debug = false;
37
+ #routes = [];
38
+ #catchAll = null;
39
+ #current = {
40
+ route: null,
41
+ params: null,
42
+ label: null,
43
+ };
44
+ #pubsub = createPubSub();
45
+ /**
46
+ * Creates a new SimpleRouter instance.
47
+ *
48
+ * @param config - Optional configuration object mapping route patterns to callbacks
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const router = new SimpleRouter({
53
+ * "/": () => HomePage,
54
+ * "/about": () => AboutPage
55
+ * });
56
+ * ```
57
+ */
58
+ constructor(config = null) {
59
+ Object.entries(config || {}).forEach(([route, cb]) => {
60
+ this.on(route, cb);
61
+ });
62
+ }
63
+ #dbg(...a) {
64
+ SimpleRouter.debug && console.log("[SimpleRouter]", ...a);
65
+ }
66
+ /**
67
+ * Returns a map of registered route patterns to their labels.
68
+ * Useful for debugging and introspection.
69
+ *
70
+ * @returns Object mapping route patterns to labels
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * router.on("/users", () => {}, { label: "users-list" });
75
+ * console.log(router.info()); // { "/users": "users-list" }
76
+ * ```
77
+ */
78
+ info() {
79
+ return this.#routes.reduce((m, r) => {
80
+ m[r[0].route] = r[3] || "";
81
+ return m;
82
+ }, {});
83
+ }
84
+ /**
85
+ * Clears all registered routes (except catch-all).
86
+ * Useful for testing or dynamic route reconfiguration.
87
+ *
88
+ * @returns The router instance for method chaining
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * router.reset().on("/new-route", () => {});
93
+ * ```
94
+ */
95
+ reset() {
96
+ this.#routes = [];
97
+ return this;
98
+ }
99
+ /**
100
+ * Gets the current router state (last matched route and params).
101
+ *
102
+ * @returns Current router state
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * router.exec("/user/123");
107
+ * console.log(router.current); // { route: "/user/[id]", params: { id: "123" }, label: null }
108
+ * ```
109
+ */
110
+ get current() {
111
+ return this.#current;
112
+ }
113
+ /**
114
+ * Registers one or more route patterns with a callback.
115
+ * Routes are matched in the order they are registered (first match wins).
116
+ * Use "*" as a catch-all route.
117
+ *
118
+ * @param routes - Single route pattern or array of patterns
119
+ * @param cb - Callback function to execute when route matches
120
+ * @param options - Optional configuration (label, allowQueryParams)
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * // Single route
125
+ * router.on("/users", () => UsersPage);
126
+ *
127
+ * // Multiple routes to same handler
128
+ * router.on(["/", "/home", "/index.html"], () => HomePage);
129
+ *
130
+ * // With dynamic parameters
131
+ * router.on("/user/[id]", (params) => UserPage(params?.id));
132
+ *
133
+ * // With regex constraint
134
+ * router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));
135
+ *
136
+ * // With label for debugging
137
+ * router.on("/admin", () => AdminPage, { label: "admin-dashboard" });
138
+ *
139
+ * // Catch-all route
140
+ * router.on("*", () => NotFoundPage);
141
+ * ```
142
+ */
143
+ on(routes, cb, { label = null, allowQueryParams = true } = {}) {
144
+ if (!Array.isArray(routes))
145
+ routes = [routes];
146
+ routes.forEach((route) => {
147
+ if (route === "*") {
148
+ this.#catchAll = cb;
149
+ }
150
+ else {
151
+ this.#routes.push([
152
+ new SimpleRoute(route),
153
+ cb,
154
+ allowQueryParams,
155
+ label,
156
+ ]);
157
+ }
158
+ });
159
+ }
160
+ /**
161
+ * Executes pattern matching against the provided string.
162
+ * Returns the value returned by the matched route's callback.
163
+ * Routes are tested in registration order - first match wins.
164
+ *
165
+ * @param url - String to match against registered patterns (can be a URL, file path, command, etc.)
166
+ * @param fallbackFn - Optional fallback function if no route matches
167
+ * @returns The value returned by the matched callback, or false if no match
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * // URL matching
172
+ * router.exec("/users"); // Returns result of callback
173
+ *
174
+ * // With query parameters
175
+ * router.exec("/users?sort=name");
176
+ *
177
+ * // File path matching
178
+ * router.exec("src/components/Button.ts");
179
+ *
180
+ * // Command matching
181
+ * router.exec("user:delete:123");
182
+ *
183
+ * // With fallback
184
+ * router.exec("/unknown", () => console.log("Not found"));
185
+ *
186
+ * // Can return components, values, etc.
187
+ * const component = router.exec("/home");
188
+ * ```
189
+ */
190
+ // deno-lint-ignore no-explicit-any
191
+ exec(url, fallbackFn) {
192
+ const dbgPrefix = `'${url}' -> `;
193
+ for (const [route, cb, allowQueryParams, label] of this.#routes) {
194
+ // First match wins
195
+ // Parse returns null or a params object (which can be empty)
196
+ const params = route.parse(url, allowQueryParams);
197
+ if (params !== null) {
198
+ this.#publishCurrent(route.route, params, label);
199
+ this.#dbg(`${dbgPrefix}matches '${route.route}' with`, params);
200
+ return typeof cb === "function" ? cb(params, route.route) : true;
201
+ }
202
+ }
203
+ if (typeof fallbackFn === "function") {
204
+ this.#publishCurrent(null, null, null);
205
+ this.#dbg(`${dbgPrefix}fallback...`);
206
+ return fallbackFn(null, "");
207
+ }
208
+ if (typeof this.#catchAll === "function") {
209
+ this.#publishCurrent("*", null, null);
210
+ this.#dbg(`${dbgPrefix}catchall...`);
211
+ return this.#catchAll(null, "*");
212
+ }
213
+ this.#publishCurrent(null, null, null);
214
+ this.#dbg(`${dbgPrefix}no match...`);
215
+ return false;
216
+ }
217
+ #publishCurrent(route, params, label) {
218
+ this.#current = { route, params, label };
219
+ this.#pubsub.publish(PUBSUB_TOPIC, this.#current);
220
+ }
221
+ /**
222
+ * Subscribes to router state changes.
223
+ * Follows the Svelte store contract - subscriber is called immediately with current state.
224
+ * Compatible with RxJS Observables (returns object with `unsubscribe` method).
225
+ *
226
+ * @param subscription - Callback function that receives router state changes
227
+ * @returns Subscription object with `unsubscribe()` method
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * const { unsubscribe } = router.subscribe((state) => {
232
+ * console.log("Route changed:", state.route);
233
+ * console.log("Params:", state.params);
234
+ * });
235
+ *
236
+ * // Later, to unsubscribe
237
+ * unsubscribe();
238
+ * ```
239
+ *
240
+ * @see https://svelte.dev/docs#Store_contract
241
+ */
242
+ subscribe(subscription) {
243
+ if (typeof subscription !== "function") {
244
+ throw new TypeError("Subscription is not a function");
245
+ }
246
+ const unsubscribe = this.#pubsub.subscribe(PUBSUB_TOPIC, subscription);
247
+ // immediately call with current value (as per store contract)
248
+ subscription(this.#current);
249
+ // For interoperability with RxJS Observables, the .subscribe method is also
250
+ // allowed to return an object with an .unsubscribe method
251
+ return {
252
+ unsubscribe,
253
+ };
254
+ }
255
+ }
package/package.json CHANGED
@@ -1,51 +1,20 @@
1
1
  {
2
2
  "name": "@marianmeres/simple-router",
3
- "version": "2.1.0",
4
- "description": "Minimalistic route parser for sapper-like regex routes",
3
+ "version": "2.2.3",
5
4
  "type": "module",
6
- "main": "./dist/index.cjs",
7
- "module": "./dist/index.js",
8
- "exports": {
9
- "require": "./dist/index.cjs",
10
- "import": "./dist/index.js"
11
- },
12
- "types": "./dist/index.d.ts",
13
- "files": [
14
- "dist"
15
- ],
16
- "scripts": {
17
- "clean": "rimraf dist/*",
18
- "build": "npm run clean && rollup -c",
19
- "dev": "npm run clean && rollup -c -w",
20
- "prettier": "prettier --write \"{src,tests,examples}/**/*.{js,ts,json}\"",
21
- "release": "release -v minor",
22
- "release:patch": "release -v patch",
23
- "test": "test-runner",
24
- "test:watch": "nodemon -q -w dist -w tests -x test-runner --"
25
- },
5
+ "main": "dist/mod.js",
6
+ "types": "dist/mod.d.ts",
7
+ "author": "Marian Meres",
8
+ "license": "MIT",
26
9
  "repository": {
27
10
  "type": "git",
28
11
  "url": "git+https://github.com/marianmeres/simple-router.git"
29
12
  },
30
- "keywords": [
31
- "router"
32
- ],
33
- "author": "Marian Meres <marian@meres.sk>",
34
- "license": "WTFPL",
35
13
  "bugs": {
36
14
  "url": "https://github.com/marianmeres/simple-router/issues"
37
15
  },
38
- "homepage": "https://github.com/marianmeres/simple-router#readme",
39
- "devDependencies": {
40
- "@marianmeres/release": "^1.1.2",
41
- "@marianmeres/test-runner": "^2.0.8",
42
- "@rollup/plugin-typescript": "^9.0.2",
43
- "@types/node": "^14.18.33",
44
- "lodash": "^4.17.21",
45
- "prettier": "^2.2.1",
46
- "rimraf": "^3.0.2",
47
- "rollup": "^3.3.0",
48
- "tslib": "^2.4.1",
49
- "typescript": "^4.8.4"
16
+ "dependencies": {
17
+ "@marianmeres/pubsub": "^2.2.0",
18
+ "@marianmeres/clog": "^2.3.3"
50
19
  }
51
- }
20
+ }
package/dist/index.cjs DELETED
@@ -1,251 +0,0 @@
1
- 'use strict';
2
-
3
- class SimpleRoute {
4
- route;
5
- static SPLITTER = '/';
6
- static WILDCARD = '*';
7
- _parsed;
8
- constructor(route) {
9
- this.route = route;
10
- this._parsed = SimpleRoute._parse(route);
11
- }
12
- // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
13
- static _escapeRegExp(str) {
14
- return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
15
- }
16
- static _sanitizeAndSplit(str) {
17
- const s = SimpleRoute._escapeRegExp(SimpleRoute.SPLITTER);
18
- return (`${str}`
19
- .trim()
20
- // splitter trim left and right
21
- .replace(new RegExp(`^(${s})+`), '')
22
- .replace(new RegExp(`(${s})+$`), '')
23
- .split(SimpleRoute.SPLITTER)
24
- // remove empty segments... will "normalize" multiple splitters into one
25
- .filter(Boolean));
26
- }
27
- static _parse(route) {
28
- let wasSpread = false;
29
- let wasWildcard = false;
30
- return SimpleRoute._sanitizeAndSplit(route).reduce((memo, segment, idx, all) => {
31
- let name = null;
32
- // if optional, remove trailing '?' marker
33
- let isOptional = segment.endsWith('?');
34
- if (isOptional)
35
- segment = segment.slice(0, -1);
36
- let isSpread = segment.startsWith('[...');
37
- if (isSpread) {
38
- if (isOptional) {
39
- throw new Error('Spread segment must not be marked as optional');
40
- }
41
- if (wasSpread) {
42
- throw new Error('Multiple spread segments are invalid');
43
- }
44
- wasSpread = true;
45
- segment = '[' + segment.slice(4);
46
- }
47
- let test = new RegExp('^' + SimpleRoute._escapeRegExp(segment) + '$');
48
- // catch all wildcard
49
- if (segment === SimpleRoute.WILDCARD) {
50
- if (idx < all.length - 1) {
51
- throw new Error(`Wildcard '${SimpleRoute.WILDCARD}' can be used only as a last segment`);
52
- }
53
- wasWildcard = true;
54
- }
55
- else {
56
- // starting with at least one word char within brackets...
57
- let m = segment.match(/^\[(\w.*)]$/);
58
- if (m) {
59
- name = m[1];
60
- test = /.+/;
61
- // id([0-9]+)
62
- let m2 = m[1].match(/^(\w.*)\((.+)\)$/);
63
- if (m2) {
64
- name = m2[1];
65
- test = new RegExp('^' + m2[2] + '$');
66
- }
67
- }
68
- }
69
- if (wasWildcard) {
70
- isOptional = true;
71
- test = /.*/;
72
- }
73
- memo.push({ segment, name, test, isOptional, isSpread });
74
- return memo;
75
- }, []);
76
- }
77
- static parseQueryString(str) {
78
- return `${str}`
79
- .replace(/^&/, '')
80
- .replace(/&$/, '')
81
- .split('&')
82
- .reduce((memo, kvpair) => {
83
- const [k, v] = kvpair.split('=').map(decodeURIComponent);
84
- if (k.length)
85
- memo[k] = v;
86
- return memo;
87
- }, {});
88
- }
89
- parse(url, allowQueryParams = true) {
90
- let matched = {};
91
- const qPos = url.indexOf('?');
92
- if (allowQueryParams && ~qPos) {
93
- const _backup = url;
94
- url = _backup.slice(0, qPos);
95
- matched = SimpleRoute.parseQueryString(_backup.slice(qPos + 1));
96
- }
97
- let segments = SimpleRoute._sanitizeAndSplit(url);
98
- // SPREAD PARAMS DANCING BLOCK - if there are "spread" definitions we need to adjust input
99
- // that is "group" (join) segments that were initially splitted
100
- const hasSpread = !!this._parsed.filter((v) => v.isSpread).length;
101
- if (hasSpread) {
102
- let newSegments = [];
103
- this._parsed.forEach((p, i) => {
104
- if (p.isSpread) {
105
- // there are defined segments after the "spread" definition
106
- if (this._parsed[i + 1]) {
107
- // SimpleRoute
108
- newSegments.push(segments.slice(0, this._parsed.length - i).join(SimpleRoute.SPLITTER));
109
- segments = segments.slice(this._parsed.length - i);
110
- }
111
- // there are no more defined segments
112
- else {
113
- newSegments.push(segments.join(SimpleRoute.SPLITTER));
114
- }
115
- }
116
- else {
117
- newSegments = newSegments.concat(segments.slice(0, 1));
118
- segments = segments.slice(1);
119
- }
120
- });
121
- segments = newSegments;
122
- }
123
- // return early no-op special case: first segment is a wildcard
124
- // (no need to process further)
125
- if (this._parsed?.[0]?.segment === SimpleRoute.WILDCARD) {
126
- return matched;
127
- }
128
- // minimum required (not optional) segments length
129
- const reqLen = this._parsed.reduce((memo, p, idx, arr) => {
130
- const next = arr[idx + 1];
131
- // if is not optional or has not optional still to come
132
- if (!p.isOptional || (next && !next.isOptional)) {
133
- memo++;
134
- }
135
- return memo;
136
- }, 0);
137
- // quick cheap check: if counts dont match = no match
138
- if (segments.length < reqLen) {
139
- return null;
140
- }
141
- for (const [i, s] of segments.entries()) {
142
- const p = this._parsed[i];
143
- if (!p || !p.test.test(s)) {
144
- return null;
145
- }
146
- if (p.name) {
147
- matched[decodeURIComponent(p.name)] = decodeURIComponent(s);
148
- }
149
- if (p.segment === SimpleRoute.WILDCARD) {
150
- break;
151
- }
152
- }
153
- return matched;
154
- }
155
- }
156
-
157
- class SimpleRouter {
158
- // console log debug on/off switch
159
- static debug = false;
160
- _routes = [];
161
- _catchAll;
162
- // current (last matched) route and params (in the shape { route: "...", params: {} } )
163
- _current = {
164
- route: null,
165
- params: null,
166
- label: null,
167
- };
168
- // https://svelte.dev/docs#Store_contract
169
- _subscriptions = new Set();
170
- constructor(config = null) {
171
- Object.entries(config || {}).forEach(([route, cb]) => {
172
- this.on(route, cb);
173
- });
174
- }
175
- _dbg(...a) {
176
- SimpleRouter.debug && console.log('[SimpleRouter]', ...a);
177
- }
178
- /** Will report map of registered routes and their labels. Intended for debugging. */
179
- info() {
180
- return this._routes.reduce((m, r) => {
181
- m[r[0].route] = r[3];
182
- return m;
183
- }, {});
184
- }
185
- reset() {
186
- this._routes = [];
187
- return this;
188
- }
189
- get current() {
190
- return this._current;
191
- }
192
- on(routes, cb, { label = null, allowQueryParams = true } = {}) {
193
- if (!Array.isArray(routes))
194
- routes = [routes];
195
- routes.forEach((route) => {
196
- if (route === '*') {
197
- this._catchAll = cb;
198
- }
199
- else {
200
- this._routes.push([new SimpleRoute(route), cb, allowQueryParams, label]);
201
- }
202
- });
203
- }
204
- exec(url, fallbackFn) {
205
- const dbgPrefix = `'${url}' -> `;
206
- const isFn = (v) => typeof v === 'function';
207
- for (const [route, cb, allowQueryParams, label] of this._routes) {
208
- // first match wins
209
- // parse returns null or params object (which can be empty)
210
- const params = route.parse(url, allowQueryParams);
211
- if (params) {
212
- this._publishCurrent(route.route, params, label);
213
- this._dbg(`${dbgPrefix}matches '${route.route}' with`, params);
214
- return isFn(cb) ? cb(params, route.route) : true;
215
- }
216
- }
217
- if (isFn(fallbackFn)) {
218
- this._publishCurrent(null, null, null);
219
- this._dbg(`${dbgPrefix}fallback...`);
220
- return fallbackFn();
221
- }
222
- if (isFn(this._catchAll)) {
223
- this._publishCurrent('*', null, null);
224
- this._dbg(`${dbgPrefix}catchall...`);
225
- return this._catchAll(null, '*');
226
- }
227
- this._publishCurrent(null, null, null);
228
- this._dbg(`${dbgPrefix}no match...`);
229
- return false;
230
- }
231
- _publishCurrent(route, params, label) {
232
- this._current = { route, params, label };
233
- this._subscriptions.forEach((cb) => cb(this._current));
234
- }
235
- // https://svelte.dev/docs#Store_contract
236
- subscribe(subscription) {
237
- if (typeof subscription !== 'function') {
238
- throw new TypeError('Subscription is not a function');
239
- }
240
- this._subscriptions.add(subscription);
241
- subscription(this._current);
242
- // For interoperability with RxJS Observables, the .subscribe method is also
243
- // allowed to return an object with an .unsubscribe method
244
- return {
245
- unsubscribe: () => this._subscriptions.delete(subscription),
246
- };
247
- }
248
- }
249
-
250
- exports.SimpleRoute = SimpleRoute;
251
- exports.SimpleRouter = SimpleRouter;
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- export { SimpleRoute } from './route';
2
- export { SimpleRouter } from './router';