@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/route.js ADDED
@@ -0,0 +1,248 @@
1
+ export class SimpleRoute {
2
+ route;
3
+ /** Separator used to split URL paths into segments */
4
+ static SPLITTER = "/";
5
+ /** Wildcard symbol for catch-all routes */
6
+ static WILDCARD = "*";
7
+ #parsed;
8
+ /**
9
+ * Creates a new SimpleRoute instance.
10
+ *
11
+ * @param route - Pattern string to match against (URL, file path, command, or any string identifier)
12
+ * @throws {Error} If route pattern is invalid (e.g., multiple spread segments)
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * // URL patterns
17
+ * new SimpleRoute("/user/[id]");
18
+ * new SimpleRoute("/post/[id([0-9]+)]");
19
+ * new SimpleRoute("/api/[...path]");
20
+ *
21
+ * // File path patterns
22
+ * new SimpleRoute("src/[module]/[file].ts");
23
+ *
24
+ * // Command patterns
25
+ * new SimpleRoute("user:delete:[id]");
26
+ * ```
27
+ */
28
+ constructor(route) {
29
+ this.route = route;
30
+ this.#parsed = SimpleRoute.#parse(route);
31
+ }
32
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
33
+ static #escapeRegExp(str) {
34
+ return str.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
35
+ }
36
+ static #sanitizeAndSplit(str) {
37
+ const s = SimpleRoute.#escapeRegExp(SimpleRoute.SPLITTER);
38
+ return (`${str}`
39
+ .trim()
40
+ // Trim splitters from left and right
41
+ .replace(new RegExp(`^(${s})+`), "")
42
+ .replace(new RegExp(`(${s})+$`), "")
43
+ .split(SimpleRoute.SPLITTER)
44
+ // Remove empty segments (normalizes multiple splitters into one)
45
+ .filter(Boolean));
46
+ }
47
+ static #parse(route) {
48
+ let wasSpread = false;
49
+ let wasWildcard = false;
50
+ return SimpleRoute.#sanitizeAndSplit(route).reduce((memo, segment, idx, all) => {
51
+ let name = null;
52
+ // If optional, remove trailing '?' marker
53
+ let isOptional = segment.endsWith("?");
54
+ if (isOptional)
55
+ segment = segment.slice(0, -1);
56
+ const isSpread = segment.startsWith("[...");
57
+ if (isSpread) {
58
+ if (isOptional) {
59
+ throw new Error("Spread segment must not be marked as optional");
60
+ }
61
+ if (wasSpread) {
62
+ throw new Error("Multiple spread segments are invalid");
63
+ }
64
+ wasSpread = true;
65
+ segment = "[" + segment.slice(4);
66
+ }
67
+ let test = new RegExp("^" + SimpleRoute.#escapeRegExp(segment) + "$");
68
+ // Catch-all wildcard
69
+ if (segment === SimpleRoute.WILDCARD) {
70
+ if (idx < all.length - 1) {
71
+ throw new Error(`Wildcard '${SimpleRoute.WILDCARD}' can be used only as a last segment`);
72
+ }
73
+ wasWildcard = true;
74
+ }
75
+ else {
76
+ // Starts with at least one word character within brackets
77
+ const m = segment.match(/^\[(\w.*)]$/);
78
+ if (m) {
79
+ name = m[1];
80
+ test = /.+/;
81
+ // id([0-9]+)
82
+ const m2 = m[1].match(/^(\w.*)\((.+)\)$/);
83
+ if (m2) {
84
+ name = m2[1];
85
+ try {
86
+ test = new RegExp("^" + m2[2] + "$");
87
+ }
88
+ catch (e) {
89
+ throw new Error(`Invalid regex in route pattern '${segment}': ${e.message}`);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ if (wasWildcard) {
95
+ isOptional = true;
96
+ test = /.*/;
97
+ }
98
+ memo.push({ segment, name, test, isOptional, isSpread });
99
+ return memo;
100
+ }, []);
101
+ }
102
+ /**
103
+ * Parses a query string into an object of key-value pairs.
104
+ * Handles URL decoding of both keys and values.
105
+ *
106
+ * @param str - Query string (without leading "?")
107
+ * @returns Object with parsed query parameters
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * SimpleRoute.parseQueryString("foo=bar&baz=123");
112
+ * // Returns: { foo: "bar", baz: "123" }
113
+ *
114
+ * SimpleRoute.parseQueryString("name=John%20Doe");
115
+ * // Returns: { name: "John Doe" }
116
+ * ```
117
+ */
118
+ static parseQueryString(str) {
119
+ return `${str}`
120
+ .replace(/^&/, "")
121
+ .replace(/&$/, "")
122
+ .split("&")
123
+ .reduce((memo, kvpair) => {
124
+ const [k, v = ""] = kvpair.split("=");
125
+ if (k.length) {
126
+ try {
127
+ const decodedKey = decodeURIComponent(k);
128
+ const decodedValue = v ? decodeURIComponent(v) : "";
129
+ memo[decodedKey] = decodedValue;
130
+ }
131
+ catch {
132
+ // Fallback for malformed URI components
133
+ memo[k] = v;
134
+ }
135
+ }
136
+ return memo;
137
+ }, {});
138
+ }
139
+ /**
140
+ * Parses a string against this route pattern.
141
+ * Returns extracted parameters if the string matches, null otherwise.
142
+ *
143
+ * @param url - String to test against the pattern (can be a URL, file path, command, etc.)
144
+ * @param allowQueryParams - Whether to parse query string parameters (default: true)
145
+ * @returns Object with extracted parameters, or null if no match
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * // URL matching
150
+ * const route = new SimpleRoute("/user/[id]");
151
+ *
152
+ * route.parse("/user/123");
153
+ * // Returns: { id: "123" }
154
+ *
155
+ * route.parse("/user/123?tab=profile");
156
+ * // Returns: { id: "123", tab: "profile" }
157
+ *
158
+ * route.parse("/user/123?tab=profile", false);
159
+ * // Returns: { id: "123?tab=profile" } - query string not parsed
160
+ *
161
+ * route.parse("/post/456");
162
+ * // Returns: null - no match
163
+ *
164
+ * // File path matching
165
+ * const fileRoute = new SimpleRoute("src/[module]/[file].ts");
166
+ * fileRoute.parse("src/components/Button.ts");
167
+ * // Returns: { module: "components", file: "Button" }
168
+ *
169
+ * // Command matching
170
+ * const cmdRoute = new SimpleRoute("user:delete:[id]");
171
+ * cmdRoute.parse("user:delete:123");
172
+ * // Returns: { id: "123" }
173
+ * ```
174
+ */
175
+ parse(url, allowQueryParams = true) {
176
+ let matched = {};
177
+ const qPos = url.indexOf("?");
178
+ if (allowQueryParams && qPos !== -1) {
179
+ const _backup = url;
180
+ url = _backup.slice(0, qPos);
181
+ matched = SimpleRoute.parseQueryString(_backup.slice(qPos + 1));
182
+ }
183
+ let segments = SimpleRoute.#sanitizeAndSplit(url);
184
+ // If there are spread definitions, we need to adjust input by grouping
185
+ // (joining) segments that were initially split
186
+ const hasSpread = this.#parsed.some((v) => v.isSpread);
187
+ if (hasSpread) {
188
+ let newSegments = [];
189
+ this.#parsed.forEach((p, i) => {
190
+ if (p.isSpread) {
191
+ // There are defined segments after the spread definition
192
+ if (this.#parsed[i + 1]) {
193
+ newSegments.push(segments
194
+ .slice(0, this.#parsed.length - i)
195
+ .join(SimpleRoute.SPLITTER));
196
+ segments = segments.slice(this.#parsed.length - i);
197
+ }
198
+ // There are no more defined segments
199
+ else {
200
+ newSegments.push(segments.join(SimpleRoute.SPLITTER));
201
+ }
202
+ }
203
+ else {
204
+ newSegments = newSegments.concat(segments.slice(0, 1));
205
+ segments = segments.slice(1);
206
+ }
207
+ });
208
+ segments = newSegments;
209
+ }
210
+ // Return early (special case): first segment is a wildcard
211
+ // (no need to process further)
212
+ if (this.#parsed?.[0]?.segment === SimpleRoute.WILDCARD) {
213
+ return matched;
214
+ }
215
+ // Minimum required (non-optional) segment count
216
+ const reqLen = this.#parsed.reduce((memo, p, idx, arr) => {
217
+ const next = arr[idx + 1];
218
+ // If segment is not optional or has non-optional segments still to come
219
+ if (!p.isOptional || (next && !next.isOptional)) {
220
+ memo++;
221
+ }
222
+ return memo;
223
+ }, 0);
224
+ // Quick check: if counts don't match, no match
225
+ if (segments.length < reqLen) {
226
+ return null;
227
+ }
228
+ for (const [i, s] of segments.entries()) {
229
+ const p = this.#parsed[i];
230
+ if (!p || !p.test.test(s)) {
231
+ return null;
232
+ }
233
+ if (p.name) {
234
+ try {
235
+ matched[decodeURIComponent(p.name)] = decodeURIComponent(s);
236
+ }
237
+ catch {
238
+ // Fallback for malformed URI components
239
+ matched[p.name] = s;
240
+ }
241
+ }
242
+ if (p.segment === SimpleRoute.WILDCARD) {
243
+ break;
244
+ }
245
+ }
246
+ return matched;
247
+ }
248
+ }
package/dist/router.d.ts CHANGED
@@ -1,33 +1,246 @@
1
- import { SimpleRoute } from './route';
1
+ /**
2
+ * Route parameters extracted from the URL path and query string.
3
+ * Keys are parameter names, values are the extracted string values.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * // For route "/user/[id]" matching "/user/123"
8
+ * const params: RouteParams = { id: "123" };
9
+ * ```
10
+ */
11
+ export type RouteParams = Record<string, any>;
12
+ /**
13
+ * Callback function executed when a route matches.
14
+ *
15
+ * @param params - Extracted route parameters (null if no match)
16
+ * @param route - The matched route pattern string
17
+ * @returns Any value - useful for returning components, data, or control flow values
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const callback: RouteCallback = (params, route) => {
22
+ * console.log(`Matched ${route} with params:`, params);
23
+ * return MyComponent;
24
+ * };
25
+ * ```
26
+ */
27
+ export type RouteCallback = (params: RouteParams | null, route: string) => any;
28
+ /**
29
+ * Represents the current router state including the matched route,
30
+ * extracted parameters, and optional label.
31
+ */
32
+ export interface RouterCurrent {
33
+ /** The matched route pattern, "*" for catch-all, or null if no match */
34
+ route: string | null;
35
+ /** Extracted parameters from the URL, or null if no match */
36
+ params: RouteParams | null;
37
+ /** Optional label associated with the route for debugging */
38
+ label: string | null;
39
+ }
40
+ /**
41
+ * Options for registering routes with the `on()` method.
42
+ */
43
+ export interface RouterOnOptions {
44
+ /** Optional label for debugging - useful for identifying routes */
45
+ label?: string | null;
46
+ /** Whether to parse query parameters (default: true) */
47
+ allowQueryParams?: boolean;
48
+ }
49
+ /**
50
+ * Configuration object for initializing the router with routes.
51
+ * Maps route patterns to their callback functions.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const config: RouterConfig = {
56
+ * "/": () => HomePage,
57
+ * "/user/[id]": (params) => UserPage(params?.id),
58
+ * "*": () => NotFoundPage
59
+ * };
60
+ * ```
61
+ */
62
+ export type RouterConfig = Record<string, RouteCallback>;
63
+ /**
64
+ * Subscription object returned by the `subscribe()` method.
65
+ * Follows the Svelte store contract and RxJS Observable interface.
66
+ */
67
+ export interface RouterSubscription {
68
+ /** Unsubscribe from router state changes */
69
+ unsubscribe: () => void;
70
+ }
71
+ /**
72
+ * Subscriber callback function that receives router state changes.
73
+ *
74
+ * @param current - The current router state
75
+ */
76
+ export type RouterSubscriber = (current: RouterCurrent) => void;
77
+ /**
78
+ * A simple, framework-agnostic string pattern matcher and router with support
79
+ * for dynamic parameters, wildcards, query strings, and reactive subscriptions.
80
+ *
81
+ * Can match any string identifiers - URLs, file paths, command names, or custom patterns.
82
+ * Primarily designed for client-side SPA routing, but flexible enough for any pattern
83
+ * matching needs.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * import { SimpleRouter } from "@marianmeres/simple-router";
88
+ *
89
+ * // URL routing
90
+ * const router = new SimpleRouter({
91
+ * "/": () => console.log("Home"),
92
+ * "/user/[id]": (params) => console.log("User:", params?.id),
93
+ * "*": () => console.log("Not found")
94
+ * });
95
+ *
96
+ * router.exec("/user/123"); // Logs: "User: 123"
97
+ *
98
+ * // Command routing
99
+ * const cmdRouter = new SimpleRouter({
100
+ * "user:create": () => createUser(),
101
+ * "user:delete:[id]": ({ id }) => deleteUser(id),
102
+ * });
103
+ *
104
+ * cmdRouter.exec("user:delete:123");
105
+ * ```
106
+ */
2
107
  export declare class SimpleRouter {
108
+ #private;
109
+ /** Enable/disable console debug logging for route matching */
3
110
  static debug: boolean;
4
- protected _routes: [SimpleRoute, Function, boolean, string][];
5
- protected _catchAll: Function;
6
- protected _current: {
7
- route: string;
8
- params: any;
9
- label: string;
10
- };
11
- protected _subscriptions: Set<Function>;
12
- constructor(config?: {
13
- [route: string]: Function;
14
- });
15
- protected _dbg(...a: any[]): void;
16
- /** Will report map of registered routes and their labels. Intended for debugging. */
111
+ /**
112
+ * Creates a new SimpleRouter instance.
113
+ *
114
+ * @param config - Optional configuration object mapping route patterns to callbacks
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * const router = new SimpleRouter({
119
+ * "/": () => HomePage,
120
+ * "/about": () => AboutPage
121
+ * });
122
+ * ```
123
+ */
124
+ constructor(config?: RouterConfig | null);
125
+ /**
126
+ * Returns a map of registered route patterns to their labels.
127
+ * Useful for debugging and introspection.
128
+ *
129
+ * @returns Object mapping route patterns to labels
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * router.on("/users", () => {}, { label: "users-list" });
134
+ * console.log(router.info()); // { "/users": "users-list" }
135
+ * ```
136
+ */
17
137
  info(): Record<string, string>;
138
+ /**
139
+ * Clears all registered routes (except catch-all).
140
+ * Useful for testing or dynamic route reconfiguration.
141
+ *
142
+ * @returns The router instance for method chaining
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * router.reset().on("/new-route", () => {});
147
+ * ```
148
+ */
18
149
  reset(): this;
19
- get current(): {
20
- route: string;
21
- params: any;
22
- label: string;
23
- };
24
- on(routes: string | string[], cb: Function, { label, allowQueryParams }?: {
25
- label?: any;
26
- allowQueryParams?: boolean;
27
- }): void;
28
- exec(url: string, fallbackFn?: Function): any;
29
- protected _publishCurrent(route: any, params: any, label: any): void;
30
- subscribe(subscription: Function): {
31
- unsubscribe: () => boolean;
32
- };
150
+ /**
151
+ * Gets the current router state (last matched route and params).
152
+ *
153
+ * @returns Current router state
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * router.exec("/user/123");
158
+ * console.log(router.current); // { route: "/user/[id]", params: { id: "123" }, label: null }
159
+ * ```
160
+ */
161
+ get current(): RouterCurrent;
162
+ /**
163
+ * Registers one or more route patterns with a callback.
164
+ * Routes are matched in the order they are registered (first match wins).
165
+ * Use "*" as a catch-all route.
166
+ *
167
+ * @param routes - Single route pattern or array of patterns
168
+ * @param cb - Callback function to execute when route matches
169
+ * @param options - Optional configuration (label, allowQueryParams)
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * // Single route
174
+ * router.on("/users", () => UsersPage);
175
+ *
176
+ * // Multiple routes to same handler
177
+ * router.on(["/", "/home", "/index.html"], () => HomePage);
178
+ *
179
+ * // With dynamic parameters
180
+ * router.on("/user/[id]", (params) => UserPage(params?.id));
181
+ *
182
+ * // With regex constraint
183
+ * router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));
184
+ *
185
+ * // With label for debugging
186
+ * router.on("/admin", () => AdminPage, { label: "admin-dashboard" });
187
+ *
188
+ * // Catch-all route
189
+ * router.on("*", () => NotFoundPage);
190
+ * ```
191
+ */
192
+ on(routes: string | string[], cb: RouteCallback, { label, allowQueryParams }?: RouterOnOptions): void;
193
+ /**
194
+ * Executes pattern matching against the provided string.
195
+ * Returns the value returned by the matched route's callback.
196
+ * Routes are tested in registration order - first match wins.
197
+ *
198
+ * @param url - String to match against registered patterns (can be a URL, file path, command, etc.)
199
+ * @param fallbackFn - Optional fallback function if no route matches
200
+ * @returns The value returned by the matched callback, or false if no match
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * // URL matching
205
+ * router.exec("/users"); // Returns result of callback
206
+ *
207
+ * // With query parameters
208
+ * router.exec("/users?sort=name");
209
+ *
210
+ * // File path matching
211
+ * router.exec("src/components/Button.ts");
212
+ *
213
+ * // Command matching
214
+ * router.exec("user:delete:123");
215
+ *
216
+ * // With fallback
217
+ * router.exec("/unknown", () => console.log("Not found"));
218
+ *
219
+ * // Can return components, values, etc.
220
+ * const component = router.exec("/home");
221
+ * ```
222
+ */
223
+ exec(url: string, fallbackFn?: RouteCallback): any;
224
+ /**
225
+ * Subscribes to router state changes.
226
+ * Follows the Svelte store contract - subscriber is called immediately with current state.
227
+ * Compatible with RxJS Observables (returns object with `unsubscribe` method).
228
+ *
229
+ * @param subscription - Callback function that receives router state changes
230
+ * @returns Subscription object with `unsubscribe()` method
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * const { unsubscribe } = router.subscribe((state) => {
235
+ * console.log("Route changed:", state.route);
236
+ * console.log("Params:", state.params);
237
+ * });
238
+ *
239
+ * // Later, to unsubscribe
240
+ * unsubscribe();
241
+ * ```
242
+ *
243
+ * @see https://svelte.dev/docs#Store_contract
244
+ */
245
+ subscribe(subscription: RouterSubscriber): RouterSubscription;
33
246
  }