@marianmeres/simple-router 1.9.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 ADDED
@@ -0,0 +1,75 @@
1
+ # @marianmeres/simple-router
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.
6
+
7
+ ## Installation
8
+
9
+ ```shell
10
+ npm i @marianmeres/simple-router
11
+ ```
12
+
13
+ ## Quick example
14
+
15
+ ```js
16
+ // routes definitions can be added via ctor config object
17
+ const router = new SimpleRouter({
18
+ '/': () => pageIndex(),
19
+ '*': () => page404(), // catch all (last resort fallback)
20
+ });
21
+
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
+
44
+ Few notes on segments separators (which is slash `/` by default):
45
+ - multiple ones are always normalized to single,
46
+ - separator is always trimmed (both left and right) before matching
47
+
48
+ | Example route definition | Example input | Result |
49
+ | ------------------------- | ------------------ | --------------------------- |
50
+ | `/foo` | `/bar` | `null` |
51
+ | `/foo` | (empty string) | `null` |
52
+ | `/foo/[bar]` | `/foo` | `null` |
53
+ | `/[foo]/bar` | `/foo` | `null` |
54
+ | `/[foo]/[bar]` | `/foo` | `null` |
55
+ | `/` | (empty string) | `{}` |
56
+ | (empty string) | `///` | `{}` |
57
+ | `foo` | `foo` | `{}` |
58
+ | `//foo///bar.baz/` | `foo/bar.baz` | `{}` |
59
+ | `foo/bar/baz` | `//foo//bar/baz//` | `{}` |
60
+ | `/[foo]` | `/bar` | `{"foo":"bar"}` |
61
+ | `#/foo/[bar]` | `#/foo/bat` | `{"bar":"bat"}` |
62
+ | `#/[foo]/[bar]` | `#/baz/bat` | `{"foo":"baz","bar":"bat"}` |
63
+ | `#/[foo]/bar` | `#/baz/bar` | `{"foo":"baz"}` |
64
+ | `/[id([0-9]+)]` | `/123` | `{"id":"123"}` |
65
+ | `/[id(\d-\d)]` | `/1-2` | `{"id":"1-2"}` |
66
+ | `/[id([0-9]+)]` | `/foo` | `null` |
67
+ | `/foo/[bar]/[id([0-9]+)]` | `/foo/baz/123` | `{"bar":"baz","id":"123"}` |
68
+ | `/foo/[id([0-9]+)]/[bar]` | `/foo/bar/baz` | `null` |
69
+ | `/foo/[([0-9]+)]` | `/foo/123` | `null` (missing name before regex) |
70
+ | `/foo/[bar]?` | `/foo` | `{}` |
71
+ | `/foo/[bar]?` | `/foo/bar` | `{ bar: 'bar' }` |
72
+ | `/foo/[bar]?/baz` | `/foo/bar/baz` | `{ bar: 'bar' }` |
73
+ | `/[...path]/[file]` | `/foo/bar/baz.js` | `{ path': 'foo/bar', file: 'baz.js' }`|
74
+ See [tests](tests) or [examples](examples) for more.
75
+
package/dist/index.cjs ADDED
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ class SimpleRoute {
4
+ route;
5
+ static SPLITTER = '/';
6
+ _parsed;
7
+ constructor(route) {
8
+ this.route = route;
9
+ this._parsed = SimpleRoute._parse(route);
10
+ }
11
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
12
+ static _escapeRegExp(str) {
13
+ return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
14
+ }
15
+ static _sanitizeAndSplit(str) {
16
+ const s = SimpleRoute._escapeRegExp(SimpleRoute.SPLITTER);
17
+ return (`${str}`
18
+ .trim()
19
+ // splitter trim left and right
20
+ .replace(new RegExp(`^(${s})+`), '')
21
+ .replace(new RegExp(`(${s})+$`), '')
22
+ .split(SimpleRoute.SPLITTER)
23
+ // remove empty segments... will "normalize" multiple splitters into one
24
+ .filter(Boolean));
25
+ }
26
+ static _parse(route) {
27
+ let wasSpread = false;
28
+ return SimpleRoute._sanitizeAndSplit(route).reduce((memo, segment) => {
29
+ let name = null;
30
+ // if optional, remove trailing '?' marker
31
+ let isOptional = segment.endsWith('?');
32
+ if (isOptional)
33
+ segment = segment.slice(0, -1);
34
+ let isSpread = segment.startsWith('[...');
35
+ if (isSpread) {
36
+ if (isOptional) {
37
+ throw new Error('Spread segment must not be marked as optional');
38
+ }
39
+ if (wasSpread) {
40
+ throw new Error('Multiple spread segments are invalid');
41
+ }
42
+ wasSpread = true;
43
+ segment = '[' + segment.substr(4);
44
+ }
45
+ let test = new RegExp('^' + SimpleRoute._escapeRegExp(segment) + '$');
46
+ // starting with at least one word char within brackets...
47
+ let m = segment.match(/^\[(\w.+)]$/);
48
+ if (m) {
49
+ name = m[1];
50
+ test = /.+/;
51
+ // id([0-9]+)
52
+ let m2 = m[1].match(/^(\w.*)\((.+)\)$/);
53
+ if (m2) {
54
+ name = m2[1];
55
+ test = new RegExp('^' + m2[2] + '$');
56
+ }
57
+ }
58
+ memo.push({ segment, name, test, isOptional, isSpread });
59
+ return memo;
60
+ }, []);
61
+ }
62
+ static parseQueryString(str) {
63
+ return `${str}`
64
+ .replace(/^&/, '')
65
+ .replace(/&$/, '')
66
+ .split('&')
67
+ .reduce((memo, kvpair) => {
68
+ const [k, v] = kvpair.split('=').map(decodeURIComponent);
69
+ if (k.length)
70
+ memo[k] = v;
71
+ return memo;
72
+ }, {});
73
+ }
74
+ parse(url, allowQueryParams = true) {
75
+ let matched = {};
76
+ const qPos = url.indexOf('?');
77
+ if (allowQueryParams && ~qPos) {
78
+ const _backup = url;
79
+ url = _backup.slice(0, qPos);
80
+ matched = SimpleRoute.parseQueryString(_backup.slice(qPos + 1));
81
+ }
82
+ let segments = SimpleRoute._sanitizeAndSplit(url);
83
+ // SPREAD PARAMS DANCING BLOCK - if there are "spread" definitions we need to adjust input
84
+ // that is "group" (join) segments that were initially splitted
85
+ const hasSpread = !!this._parsed.filter((v) => v.isSpread).length;
86
+ if (hasSpread) {
87
+ let newSegments = [];
88
+ this._parsed.forEach((p, i) => {
89
+ if (p.isSpread) {
90
+ // there are defined segments after the "spread" definition
91
+ if (this._parsed[i + 1]) {
92
+ // SimpleRoute
93
+ newSegments.push(segments.slice(0, this._parsed.length - i).join(SimpleRoute.SPLITTER));
94
+ segments = segments.slice(this._parsed.length - i);
95
+ }
96
+ // there are no more defined segments
97
+ else {
98
+ newSegments.push(segments.join(SimpleRoute.SPLITTER));
99
+ }
100
+ }
101
+ else {
102
+ newSegments = newSegments.concat(segments.slice(0, 1));
103
+ segments = segments.slice(1);
104
+ }
105
+ });
106
+ segments = newSegments;
107
+ }
108
+ // minimum required (not optional) segments length
109
+ const reqLen = this._parsed.reduce((memo, p, idx, arr) => {
110
+ const next = arr[idx + 1];
111
+ // if is not optional or has not optional still to come
112
+ if (!p.isOptional || (next && !next.isOptional)) {
113
+ memo++;
114
+ }
115
+ return memo;
116
+ }, 0);
117
+ // quick cheap check: if counts dont match = no match
118
+ if (segments.length < reqLen) {
119
+ return null;
120
+ }
121
+ for (const [i, s] of segments.entries()) {
122
+ const p = this._parsed[i];
123
+ if (!p || !p.test.test(s)) {
124
+ return null;
125
+ }
126
+ if (p.name) {
127
+ matched[decodeURIComponent(p.name)] = decodeURIComponent(s);
128
+ }
129
+ }
130
+ return matched;
131
+ }
132
+ }
133
+
134
+ class SimpleRouter {
135
+ // console log debug on/off switch
136
+ static debug = false;
137
+ _routes = [];
138
+ _catchAll;
139
+ // current (last matched) route and params (in the shape { route: "...", params: {} } )
140
+ _current = {
141
+ route: null,
142
+ params: null,
143
+ label: null,
144
+ };
145
+ // https://svelte.dev/docs#Store_contract
146
+ _subscriptions = new Set();
147
+ constructor(config = null) {
148
+ Object.entries(config || {}).forEach(([route, cb]) => {
149
+ this.on(route, cb);
150
+ });
151
+ }
152
+ _dbg(...a) {
153
+ SimpleRouter.debug && console.log('[SimpleRouter]', ...a);
154
+ }
155
+ reset() {
156
+ this._routes = [];
157
+ return this;
158
+ }
159
+ get current() {
160
+ return this._current;
161
+ }
162
+ on(routes, cb, { label = null, allowQueryParams = true } = {}) {
163
+ if (!Array.isArray(routes))
164
+ routes = [routes];
165
+ routes.forEach((route) => {
166
+ if (route === '*') {
167
+ this._catchAll = cb;
168
+ }
169
+ else {
170
+ this._routes.push([new SimpleRoute(route), cb, allowQueryParams, label]);
171
+ }
172
+ });
173
+ }
174
+ exec(url, fallbackFn) {
175
+ const dbgPrefix = `'${url}' -> `;
176
+ const isFn = (v) => typeof v === 'function';
177
+ for (const [route, cb, allowQueryParams, label] of this._routes) {
178
+ // first match wins
179
+ // parse returns null or params object (which can be empty)
180
+ const params = route.parse(url, allowQueryParams);
181
+ if (params) {
182
+ this._publishCurrent(route.route, params, label);
183
+ this._dbg(`${dbgPrefix}matches '${route.route}' with`, params);
184
+ return isFn(cb) ? cb(params, route.route) : true;
185
+ }
186
+ }
187
+ if (isFn(fallbackFn)) {
188
+ this._publishCurrent(null, null, null);
189
+ this._dbg(`${dbgPrefix}fallback...`);
190
+ return fallbackFn();
191
+ }
192
+ if (isFn(this._catchAll)) {
193
+ this._publishCurrent('*', null, null);
194
+ this._dbg(`${dbgPrefix}catchall...`);
195
+ return this._catchAll(null, '*');
196
+ }
197
+ this._publishCurrent(null, null, null);
198
+ this._dbg(`${dbgPrefix}no match...`);
199
+ return false;
200
+ }
201
+ _publishCurrent(route, params, label) {
202
+ this._current = { route, params, label };
203
+ this._subscriptions.forEach((cb) => cb(this._current));
204
+ }
205
+ // https://svelte.dev/docs#Store_contract
206
+ subscribe(subscription) {
207
+ if (typeof subscription !== 'function') {
208
+ throw new TypeError('Subscription is not a function');
209
+ }
210
+ this._subscriptions.add(subscription);
211
+ subscription(this._current);
212
+ // For interoperability with RxJS Observables, the .subscribe method is also
213
+ // allowed to return an object with an .unsubscribe method
214
+ return {
215
+ unsubscribe: () => this._subscriptions.delete(subscription),
216
+ };
217
+ }
218
+ }
219
+
220
+ exports.SimpleRoute = SimpleRoute;
221
+ exports.SimpleRouter = SimpleRouter;
@@ -0,0 +1,2 @@
1
+ export { SimpleRoute } from './route';
2
+ export { SimpleRouter } from './router';
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ class SimpleRoute {
2
+ route;
3
+ static SPLITTER = '/';
4
+ _parsed;
5
+ constructor(route) {
6
+ this.route = route;
7
+ this._parsed = SimpleRoute._parse(route);
8
+ }
9
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
10
+ static _escapeRegExp(str) {
11
+ return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
12
+ }
13
+ static _sanitizeAndSplit(str) {
14
+ const s = SimpleRoute._escapeRegExp(SimpleRoute.SPLITTER);
15
+ return (`${str}`
16
+ .trim()
17
+ // splitter trim left and right
18
+ .replace(new RegExp(`^(${s})+`), '')
19
+ .replace(new RegExp(`(${s})+$`), '')
20
+ .split(SimpleRoute.SPLITTER)
21
+ // remove empty segments... will "normalize" multiple splitters into one
22
+ .filter(Boolean));
23
+ }
24
+ static _parse(route) {
25
+ let wasSpread = false;
26
+ return SimpleRoute._sanitizeAndSplit(route).reduce((memo, segment) => {
27
+ let name = null;
28
+ // if optional, remove trailing '?' marker
29
+ let isOptional = segment.endsWith('?');
30
+ if (isOptional)
31
+ segment = segment.slice(0, -1);
32
+ let isSpread = segment.startsWith('[...');
33
+ if (isSpread) {
34
+ if (isOptional) {
35
+ throw new Error('Spread segment must not be marked as optional');
36
+ }
37
+ if (wasSpread) {
38
+ throw new Error('Multiple spread segments are invalid');
39
+ }
40
+ wasSpread = true;
41
+ segment = '[' + segment.substr(4);
42
+ }
43
+ let test = new RegExp('^' + SimpleRoute._escapeRegExp(segment) + '$');
44
+ // starting with at least one word char within brackets...
45
+ let m = segment.match(/^\[(\w.+)]$/);
46
+ if (m) {
47
+ name = m[1];
48
+ test = /.+/;
49
+ // id([0-9]+)
50
+ let m2 = m[1].match(/^(\w.*)\((.+)\)$/);
51
+ if (m2) {
52
+ name = m2[1];
53
+ test = new RegExp('^' + m2[2] + '$');
54
+ }
55
+ }
56
+ memo.push({ segment, name, test, isOptional, isSpread });
57
+ return memo;
58
+ }, []);
59
+ }
60
+ static parseQueryString(str) {
61
+ return `${str}`
62
+ .replace(/^&/, '')
63
+ .replace(/&$/, '')
64
+ .split('&')
65
+ .reduce((memo, kvpair) => {
66
+ const [k, v] = kvpair.split('=').map(decodeURIComponent);
67
+ if (k.length)
68
+ memo[k] = v;
69
+ return memo;
70
+ }, {});
71
+ }
72
+ parse(url, allowQueryParams = true) {
73
+ let matched = {};
74
+ const qPos = url.indexOf('?');
75
+ if (allowQueryParams && ~qPos) {
76
+ const _backup = url;
77
+ url = _backup.slice(0, qPos);
78
+ matched = SimpleRoute.parseQueryString(_backup.slice(qPos + 1));
79
+ }
80
+ let segments = SimpleRoute._sanitizeAndSplit(url);
81
+ // SPREAD PARAMS DANCING BLOCK - if there are "spread" definitions we need to adjust input
82
+ // that is "group" (join) segments that were initially splitted
83
+ const hasSpread = !!this._parsed.filter((v) => v.isSpread).length;
84
+ if (hasSpread) {
85
+ let newSegments = [];
86
+ this._parsed.forEach((p, i) => {
87
+ if (p.isSpread) {
88
+ // there are defined segments after the "spread" definition
89
+ if (this._parsed[i + 1]) {
90
+ // SimpleRoute
91
+ newSegments.push(segments.slice(0, this._parsed.length - i).join(SimpleRoute.SPLITTER));
92
+ segments = segments.slice(this._parsed.length - i);
93
+ }
94
+ // there are no more defined segments
95
+ else {
96
+ newSegments.push(segments.join(SimpleRoute.SPLITTER));
97
+ }
98
+ }
99
+ else {
100
+ newSegments = newSegments.concat(segments.slice(0, 1));
101
+ segments = segments.slice(1);
102
+ }
103
+ });
104
+ segments = newSegments;
105
+ }
106
+ // minimum required (not optional) segments length
107
+ const reqLen = this._parsed.reduce((memo, p, idx, arr) => {
108
+ const next = arr[idx + 1];
109
+ // if is not optional or has not optional still to come
110
+ if (!p.isOptional || (next && !next.isOptional)) {
111
+ memo++;
112
+ }
113
+ return memo;
114
+ }, 0);
115
+ // quick cheap check: if counts dont match = no match
116
+ if (segments.length < reqLen) {
117
+ return null;
118
+ }
119
+ for (const [i, s] of segments.entries()) {
120
+ const p = this._parsed[i];
121
+ if (!p || !p.test.test(s)) {
122
+ return null;
123
+ }
124
+ if (p.name) {
125
+ matched[decodeURIComponent(p.name)] = decodeURIComponent(s);
126
+ }
127
+ }
128
+ return matched;
129
+ }
130
+ }
131
+
132
+ class SimpleRouter {
133
+ // console log debug on/off switch
134
+ static debug = false;
135
+ _routes = [];
136
+ _catchAll;
137
+ // current (last matched) route and params (in the shape { route: "...", params: {} } )
138
+ _current = {
139
+ route: null,
140
+ params: null,
141
+ label: null,
142
+ };
143
+ // https://svelte.dev/docs#Store_contract
144
+ _subscriptions = new Set();
145
+ constructor(config = null) {
146
+ Object.entries(config || {}).forEach(([route, cb]) => {
147
+ this.on(route, cb);
148
+ });
149
+ }
150
+ _dbg(...a) {
151
+ SimpleRouter.debug && console.log('[SimpleRouter]', ...a);
152
+ }
153
+ reset() {
154
+ this._routes = [];
155
+ return this;
156
+ }
157
+ get current() {
158
+ return this._current;
159
+ }
160
+ on(routes, cb, { label = null, allowQueryParams = true } = {}) {
161
+ if (!Array.isArray(routes))
162
+ routes = [routes];
163
+ routes.forEach((route) => {
164
+ if (route === '*') {
165
+ this._catchAll = cb;
166
+ }
167
+ else {
168
+ this._routes.push([new SimpleRoute(route), cb, allowQueryParams, label]);
169
+ }
170
+ });
171
+ }
172
+ exec(url, fallbackFn) {
173
+ const dbgPrefix = `'${url}' -> `;
174
+ const isFn = (v) => typeof v === 'function';
175
+ for (const [route, cb, allowQueryParams, label] of this._routes) {
176
+ // first match wins
177
+ // parse returns null or params object (which can be empty)
178
+ const params = route.parse(url, allowQueryParams);
179
+ if (params) {
180
+ this._publishCurrent(route.route, params, label);
181
+ this._dbg(`${dbgPrefix}matches '${route.route}' with`, params);
182
+ return isFn(cb) ? cb(params, route.route) : true;
183
+ }
184
+ }
185
+ if (isFn(fallbackFn)) {
186
+ this._publishCurrent(null, null, null);
187
+ this._dbg(`${dbgPrefix}fallback...`);
188
+ return fallbackFn();
189
+ }
190
+ if (isFn(this._catchAll)) {
191
+ this._publishCurrent('*', null, null);
192
+ this._dbg(`${dbgPrefix}catchall...`);
193
+ return this._catchAll(null, '*');
194
+ }
195
+ this._publishCurrent(null, null, null);
196
+ this._dbg(`${dbgPrefix}no match...`);
197
+ return false;
198
+ }
199
+ _publishCurrent(route, params, label) {
200
+ this._current = { route, params, label };
201
+ this._subscriptions.forEach((cb) => cb(this._current));
202
+ }
203
+ // https://svelte.dev/docs#Store_contract
204
+ subscribe(subscription) {
205
+ if (typeof subscription !== 'function') {
206
+ throw new TypeError('Subscription is not a function');
207
+ }
208
+ this._subscriptions.add(subscription);
209
+ subscription(this._current);
210
+ // For interoperability with RxJS Observables, the .subscribe method is also
211
+ // allowed to return an object with an .unsubscribe method
212
+ return {
213
+ unsubscribe: () => this._subscriptions.delete(subscription),
214
+ };
215
+ }
216
+ }
217
+
218
+ export { SimpleRoute, SimpleRouter };
@@ -0,0 +1,18 @@
1
+ export interface RouteConfig {
2
+ segment: string;
3
+ name: string;
4
+ test: RegExp;
5
+ isOptional: boolean;
6
+ isSpread: boolean;
7
+ }
8
+ export declare class SimpleRoute {
9
+ readonly route: any;
10
+ static SPLITTER: string;
11
+ protected _parsed: RouteConfig[];
12
+ constructor(route: any);
13
+ protected static _escapeRegExp(str: string): string;
14
+ protected static _sanitizeAndSplit(str: any): string[];
15
+ protected static _parse(route: any): any[];
16
+ static parseQueryString(str: any): {};
17
+ parse(url: any, allowQueryParams?: boolean): {};
18
+ }
@@ -0,0 +1,31 @@
1
+ import { SimpleRoute } from './route';
2
+ export declare class SimpleRouter {
3
+ 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
+ reset(): this;
17
+ get current(): {
18
+ route: string;
19
+ params: any;
20
+ label: string;
21
+ };
22
+ on(routes: string | string[], cb: Function, { label, allowQueryParams }?: {
23
+ label?: any;
24
+ allowQueryParams?: boolean;
25
+ }): void;
26
+ exec(url: string, fallbackFn?: Function): any;
27
+ protected _publishCurrent(route: any, params: any, label: any): void;
28
+ subscribe(subscription: Function): {
29
+ unsubscribe: () => boolean;
30
+ };
31
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@marianmeres/simple-router",
3
+ "version": "1.9.0",
4
+ "description": "Minimalistic route parser for sapper-like regex routes",
5
+ "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
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "clean": "rimraf dist/*",
17
+ "build": "npm run clean && rollup -c",
18
+ "dev": "npm run clean && rollup -c -w",
19
+ "prettier": "prettier --write \"{src,tests,examples}/**/*.{js,ts,json}\"",
20
+ "release": "release",
21
+ "test": "test-runner",
22
+ "test:watch": "nodemon -q -w dist -w tests -x test-runner --"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/marianmeres/simple-router.git"
27
+ },
28
+ "keywords": [
29
+ "router"
30
+ ],
31
+ "author": "Marian Meres <marian@meres.sk>",
32
+ "license": "WTFPL",
33
+ "bugs": {
34
+ "url": "https://github.com/marianmeres/simple-router/issues"
35
+ },
36
+ "homepage": "https://github.com/marianmeres/simple-router#readme",
37
+ "devDependencies": {
38
+ "@marianmeres/release": "^0.0.7",
39
+ "@marianmeres/test-runner": "^2.0.8",
40
+ "@rollup/plugin-typescript": "^9.0.2",
41
+ "@types/node": "^14.18.33",
42
+ "lodash": "^4.17.21",
43
+ "nodemon": "^2.0.20",
44
+ "prettier": "^2.2.1",
45
+ "rimraf": "^3.0.2",
46
+ "rollup": "^3.3.0",
47
+ "tslib": "^2.4.1",
48
+ "typescript": "^4.8.4"
49
+ }
50
+ }