breeze-router 0.1.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,48 @@
1
+ # Breeze Router
2
+ A lightweight, zero-dependency client-side router for single page applications (SPAs).
3
+
4
+ **Note: This project is not production ready and is still in development.**
5
+ ## Installation
6
+
7
+ To use this router in your project, install the router using npm:
8
+
9
+ ```bash
10
+ npm install breeze-router
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ To use the router in your application, you need to import `BreezeRouter` and define routes and handlers using the `Router` class:
16
+
17
+ ```javascript
18
+ import BeezeRouter from 'breeze-router';
19
+
20
+ // Create a new `BreezeRouter` instance.
21
+ const ROUTER = new BreezeRouter();
22
+
23
+ // Define routes using the `add()` method.
24
+ ROUTER.add('/', async () => {
25
+ // Handle the root route
26
+ });
27
+
28
+ ROUTER.add('/about', async () => {
29
+ // Handle the about route
30
+ });
31
+
32
+ ROUTER.add('/users/:userId', async ({ route, params }) => {
33
+ // Handle the users route with a dynamic parameter :userId
34
+ const userId = params.userId;
35
+ });
36
+
37
+ ROUTER.add("/users/:username/posts/:postId", async ({ route, params }) => {
38
+ // Handle the posts route with a dynamic parameter :username and :userId
39
+ const { username, postId } = params;
40
+ });
41
+
42
+ // Call the `start` method to start the router.
43
+ ROUTER.start();
44
+ ```
45
+
46
+ ## License
47
+
48
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,274 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Check if given param is function
5
+ * @param {Function} fn
6
+ * @returns {boolean}
7
+ */
8
+ const isFunction = (fn) => {
9
+ return fn.constructor.name.toLowerCase() === "function";
10
+ };
11
+
12
+ /**
13
+ * Check if given param is async function
14
+ * @param {Function} fn
15
+ * @returns {boolean}
16
+ */
17
+ const isAsyncFunction = (fn) => {
18
+ return fn.constructor.name.toLowerCase() === "asyncfunction";
19
+ };
20
+
21
+ /**
22
+ * Remove trailing slash of a give url
23
+ * @param {string} url
24
+ * @returns {string}
25
+ */
26
+ const removeTrailingSlash = (url) => {
27
+ if (url.endsWith("/")) {
28
+ url = url.replace(/\/$/, "");
29
+ }
30
+
31
+ return url;
32
+ };
33
+
34
+ /**
35
+ * Find anchor element from click event.
36
+ * @param {Event} e - The click event.
37
+ * @returns {HTMLAnchorElement|undefined}
38
+ */
39
+ const findAnchor = (e) => {
40
+ return e.composedPath().find((elem) => {
41
+ return elem.tagName === "A";
42
+ });
43
+ };
44
+
45
+ /**
46
+ * Check if the router should handle a click event on an anchor element.
47
+ * @param {Event} e - The click event.
48
+ * @param {HTMLAnchorElement} anchor - The anchor element.
49
+ * @returns {boolean} - True if the router should handle the event, false otherwise.
50
+ */
51
+ const shouldRouterHandleClick = (e, anchor) => {
52
+ // If the event has already been handled by another event listener
53
+ if (e.defaultPrevented) {
54
+ return false;
55
+ }
56
+
57
+ // If the user is holding down the meta, control, or shift key
58
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
59
+ return false;
60
+ }
61
+
62
+ if (!anchor) {
63
+ return false;
64
+ }
65
+
66
+ if (anchor.target) {
67
+ return false;
68
+ }
69
+
70
+ if (
71
+ anchor.hasAttribute("download") ||
72
+ anchor.getAttribute("rel") === "external"
73
+ ) {
74
+ return false;
75
+ }
76
+
77
+ const href = anchor.href;
78
+ if (!href || href.startsWith("mailto:")) {
79
+ return false;
80
+ }
81
+
82
+ // If the href attribute does not start with the same origin
83
+ if (!href.startsWith(location.origin)) {
84
+ return false;
85
+ }
86
+
87
+ return true;
88
+ };
89
+
90
+ // @ts-check
91
+
92
+ /**
93
+ * Class representing a router.
94
+ */
95
+ class BreezeRouter {
96
+ /**
97
+ * Creates a new BreezeRouter instance.
98
+ * @constructor
99
+ */
100
+ constructor() {
101
+ /**
102
+ * Object containing all registered routes.
103
+ * @type {import('./types.js').Route}
104
+ * @private
105
+ */
106
+ this._routes = {};
107
+
108
+ /**
109
+ * The previous route that was navigated to
110
+ * @type {import('./types.js').Route}
111
+ * @private
112
+ */
113
+ this._previousRoute = {};
114
+
115
+ // Bind event listeners
116
+ window.addEventListener("popstate", this._onChanged.bind(this));
117
+ document.body.addEventListener("click", this._handleClick.bind(this));
118
+ }
119
+
120
+ /**
121
+ * Starts the router.
122
+ * @returns {void}
123
+ */
124
+ start() {
125
+ this._onChanged();
126
+ }
127
+
128
+ /**
129
+ * Adds a new route to the router.
130
+ * @param {string} route - The route path to add.
131
+ * @param {Function} handler - The async function to handle the route
132
+ * @returns {BreezeRouter|void} The BreezeRouter instance.
133
+ */
134
+ add(route, handler) {
135
+ route = route.trim();
136
+ if (route !== "/") {
137
+ route = removeTrailingSlash(route.trim());
138
+ }
139
+
140
+ if (this._routes[route]) {
141
+ return console.warn(`Route already exists: ${route}`);
142
+ }
143
+
144
+ if (typeof handler !== "function") {
145
+ return console.error(`handler on route '${route}' is not a function.`);
146
+ }
147
+
148
+ this._routes[route] = {
149
+ path: route,
150
+ handler,
151
+ };
152
+
153
+ return this;
154
+ }
155
+
156
+ /**
157
+ * Navigates to the specified URL.
158
+ * @param {string} url - The URL to navigate to
159
+ * @returns {void}
160
+ */
161
+ navigateTo(url) {
162
+ window.history.pushState({ url }, "", url);
163
+ this._onChanged();
164
+ }
165
+
166
+ /**
167
+ * Redirects a URL
168
+ * @param {string} url
169
+ * @returns {void}
170
+ */
171
+ redirect(url) {
172
+ this.navigateTo(url);
173
+ }
174
+
175
+ async _onChanged() {
176
+ const path = window.location.pathname;
177
+ const { route, params } = this._matchUrlToRoute(path);
178
+
179
+ // If no matching route found, route will be '404' route
180
+ // which has been handled by _matchUrlToRoute already
181
+ await this._handleRoute({ route, params });
182
+ }
183
+
184
+ /**
185
+ * Processes route callbacks registered by app
186
+ * @param {import('./types.js').MatchedRoute} options
187
+ * @returns {Promise<void>}
188
+ */
189
+ async _handleRoute({ route, params }) {
190
+ if (isFunction(route.handler)) {
191
+ route.handler({ route, params });
192
+ }
193
+
194
+ if (isAsyncFunction(route.handler)) {
195
+ await route.handler({ route, params });
196
+ }
197
+ }
198
+
199
+ /**
200
+ *
201
+ * @param {string} url - Current url users visite or nagivate to.
202
+ * @returns {import('./types.js').MatchedRoute}
203
+ */
204
+ _matchUrlToRoute(url) {
205
+ /** @type {import('./types.js').RouteParams} */
206
+ const params = {};
207
+
208
+ if (url !== "/") {
209
+ url = removeTrailingSlash(url);
210
+ }
211
+
212
+ const matchedRoute = Object.keys(this._routes).find((route) => {
213
+ if (url.split("/").length !== route.split("/").length) {
214
+ return false;
215
+ }
216
+
217
+ let routeSegments = route.split("/").slice(1);
218
+ let urlSegments = url.split("/").slice(1);
219
+
220
+ // If each segment in the url matches the corresponding segment in the route path,
221
+ // or the route path segment starts with a ':' then the route is matched.
222
+ const match = routeSegments.every((segment, i) => {
223
+ return segment === urlSegments[i] || segment.startsWith(":");
224
+ });
225
+
226
+ if (!match) {
227
+ return false;
228
+ }
229
+
230
+ // If the route matches the URL, pull out any params from the URL.
231
+ routeSegments.forEach((segment, i) => {
232
+ if (segment.startsWith(":")) {
233
+ const propName = segment.slice(1);
234
+ params[propName] = decodeURIComponent(urlSegments[i]);
235
+ }
236
+ });
237
+
238
+ return true;
239
+ });
240
+
241
+ if (matchedRoute) {
242
+ return { route: this._routes[matchedRoute], params };
243
+ } else {
244
+ return { route: this._routes[404], params };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Handles <a> link clicks
250
+ * @param {Event} event
251
+ * @returns {void}
252
+ */
253
+ _handleClick(event) {
254
+ const anchor = findAnchor(event);
255
+ if (!anchor) {
256
+ return;
257
+ }
258
+
259
+ if (!shouldRouterHandleClick(event, anchor)) {
260
+ return;
261
+ }
262
+
263
+ event.preventDefault();
264
+ let href = anchor.getAttribute("href").trim();
265
+ if (!href.startsWith("/")) {
266
+ href = "/" + href;
267
+ }
268
+
269
+ this.navigateTo(href);
270
+ }
271
+ }
272
+
273
+ export { BreezeRouter as default };
274
+ //# sourceMappingURL=BreezeRouter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BreezeRouter.js","sources":["../src/utils.js","../src/index.js"],"sourcesContent":["// @ts-check\n\n/**\n * Check if given param is function\n * @param {Function} fn\n * @returns {boolean}\n */\nexport const isFunction = (fn) => {\n return fn.constructor.name.toLowerCase() === \"function\";\n};\n\n/**\n * Check if given param is async function\n * @param {Function} fn\n * @returns {boolean}\n */\nexport const isAsyncFunction = (fn) => {\n return fn.constructor.name.toLowerCase() === \"asyncfunction\";\n};\n\n/**\n * Remove trailing slash of a give url\n * @param {string} url\n * @returns {string}\n */\nexport const removeTrailingSlash = (url) => {\n if (url.endsWith(\"/\")) {\n url = url.replace(/\\/$/, \"\");\n }\n\n return url;\n};\n\n/**\n * Find anchor element from click event.\n * @param {Event} e - The click event.\n * @returns {HTMLAnchorElement|undefined}\n */\nexport const findAnchor = (e) => {\n return e.composedPath().find((elem) => {\n return elem.tagName === \"A\";\n });\n};\n\n/**\n * Check if the router should handle a click event on an anchor element.\n * @param {Event} e - The click event.\n * @param {HTMLAnchorElement} anchor - The anchor element.\n * @returns {boolean} - True if the router should handle the event, false otherwise.\n */\nexport const shouldRouterHandleClick = (e, anchor) => {\n // If the event has already been handled by another event listener\n if (e.defaultPrevented) {\n return false;\n }\n\n // If the user is holding down the meta, control, or shift key\n if (e.metaKey || e.ctrlKey || e.shiftKey) {\n return false;\n }\n\n if (!anchor) {\n return false;\n }\n\n if (anchor.target) {\n return false;\n }\n\n if (\n anchor.hasAttribute(\"download\") ||\n anchor.getAttribute(\"rel\") === \"external\"\n ) {\n return false;\n }\n\n const href = anchor.href;\n if (!href || href.startsWith(\"mailto:\")) {\n return false;\n }\n\n // If the href attribute does not start with the same origin\n if (!href.startsWith(location.origin)) {\n return false;\n }\n\n return true;\n};\n","// @ts-check\nimport {\n isFunction,\n isAsyncFunction,\n removeTrailingSlash,\n findAnchor,\n shouldRouterHandleClick,\n} from \"./utils.js\";\n\n/**\n * Class representing a router.\n */\nexport default class BreezeRouter {\n /**\n * Creates a new BreezeRouter instance.\n * @constructor\n */\n constructor() {\n /**\n * Object containing all registered routes.\n * @type {import('./types.js').Route}\n * @private\n */\n this._routes = {};\n\n /**\n * The previous route that was navigated to\n * @type {import('./types.js').Route}\n * @private\n */\n this._previousRoute = {};\n\n // Bind event listeners\n window.addEventListener(\"popstate\", this._onChanged.bind(this));\n document.body.addEventListener(\"click\", this._handleClick.bind(this));\n }\n\n /**\n * Starts the router.\n * @returns {void}\n */\n start() {\n this._onChanged();\n }\n\n /**\n * Adds a new route to the router.\n * @param {string} route - The route path to add.\n * @param {Function} handler - The async function to handle the route\n * @returns {BreezeRouter|void} The BreezeRouter instance.\n */\n add(route, handler) {\n route = route.trim();\n if (route !== \"/\") {\n route = removeTrailingSlash(route.trim());\n }\n\n if (this._routes[route]) {\n return console.warn(`Route already exists: ${route}`);\n }\n\n if (typeof handler !== \"function\") {\n return console.error(`handler on route '${route}' is not a function.`);\n }\n\n this._routes[route] = {\n path: route,\n handler,\n };\n\n return this;\n }\n\n /**\n * Navigates to the specified URL.\n * @param {string} url - The URL to navigate to\n * @returns {void}\n */\n navigateTo(url) {\n window.history.pushState({ url }, \"\", url);\n this._onChanged();\n }\n\n /**\n * Redirects a URL\n * @param {string} url\n * @returns {void}\n */\n redirect(url) {\n this.navigateTo(url);\n }\n\n async _onChanged() {\n const path = window.location.pathname;\n const { route, params } = this._matchUrlToRoute(path);\n\n // If no matching route found, route will be '404' route\n // which has been handled by _matchUrlToRoute already\n await this._handleRoute({ route, params });\n }\n\n /**\n * Processes route callbacks registered by app\n * @param {import('./types.js').MatchedRoute} options\n * @returns {Promise<void>}\n */\n async _handleRoute({ route, params }) {\n if (isFunction(route.handler)) {\n route.handler({ route, params });\n }\n\n if (isAsyncFunction(route.handler)) {\n await route.handler({ route, params });\n }\n }\n\n /**\n *\n * @param {string} url - Current url users visite or nagivate to.\n * @returns {import('./types.js').MatchedRoute}\n */\n _matchUrlToRoute(url) {\n /** @type {import('./types.js').RouteParams} */\n const params = {};\n\n if (url !== \"/\") {\n url = removeTrailingSlash(url);\n }\n\n const matchedRoute = Object.keys(this._routes).find((route) => {\n if (url.split(\"/\").length !== route.split(\"/\").length) {\n return false;\n }\n\n let routeSegments = route.split(\"/\").slice(1);\n let urlSegments = url.split(\"/\").slice(1);\n\n // If each segment in the url matches the corresponding segment in the route path,\n // or the route path segment starts with a ':' then the route is matched.\n const match = routeSegments.every((segment, i) => {\n return segment === urlSegments[i] || segment.startsWith(\":\");\n });\n\n if (!match) {\n return false;\n }\n\n // If the route matches the URL, pull out any params from the URL.\n routeSegments.forEach((segment, i) => {\n if (segment.startsWith(\":\")) {\n const propName = segment.slice(1);\n params[propName] = decodeURIComponent(urlSegments[i]);\n }\n });\n\n return true;\n });\n\n if (matchedRoute) {\n return { route: this._routes[matchedRoute], params };\n } else {\n return { route: this._routes[404], params };\n }\n }\n\n /**\n * Handles <a> link clicks\n * @param {Event} event\n * @returns {void}\n */\n _handleClick(event) {\n const anchor = findAnchor(event);\n if (!anchor) {\n return;\n }\n\n if (!shouldRouterHandleClick(event, anchor)) {\n return;\n }\n\n event.preventDefault();\n let href = anchor.getAttribute(\"href\").trim();\n if (!href.startsWith(\"/\")) {\n href = \"/\" + href;\n }\n\n this.navigateTo(href);\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const t=t=>(t.endsWith("/")&&(t=t.replace(/\/$/,"")),t);class e{constructor(){this._routes={},this._previousRoute={},window.addEventListener("popstate",this._onChanged.bind(this)),document.body.addEventListener("click",this._handleClick.bind(this))}start(){this._onChanged()}add(e,r){return"/"!==(e=e.trim())&&(e=t(e.trim())),this._routes[e]?console.warn(`Route already exists: ${e}`):"function"!=typeof r?console.error(`handler on route '${e}' is not a function.`):(this._routes[e]={path:e,handler:r},this)}navigateTo(t){window.history.pushState({url:t},"",t),this._onChanged()}redirect(t){this.navigateTo(t)}async _onChanged(){const t=window.location.pathname,{route:e,params:r}=this._matchUrlToRoute(t);await this._handleRoute({route:e,params:r})}async _handleRoute({route:t,params:e}){"function"===t.handler.constructor.name.toLowerCase()&&t.handler({route:t,params:e}),(t=>"asyncfunction"===t.constructor.name.toLowerCase())(t.handler)&&await t.handler({route:t,params:e})}_matchUrlToRoute(e){const r={};"/"!==e&&(e=t(e));const n=Object.keys(this._routes).find((t=>{if(e.split("/").length!==t.split("/").length)return!1;let n=t.split("/").slice(1),a=e.split("/").slice(1);return!!n.every(((t,e)=>t===a[e]||t.startsWith(":")))&&(n.forEach(((t,e)=>{if(t.startsWith(":")){const n=t.slice(1);r[n]=decodeURIComponent(a[e])}})),!0)}));return n?{route:this._routes[n],params:r}:{route:this._routes[404],params:r}}_handleClick(t){const e=t.composedPath().find((t=>"A"===t.tagName));if(!e)return;if(!((t,e)=>{if(t.defaultPrevented)return!1;if(t.metaKey||t.ctrlKey||t.shiftKey)return!1;if(!e)return!1;if(e.target)return!1;if(e.hasAttribute("download")||"external"===e.getAttribute("rel"))return!1;const r=e.href;return!(!r||r.startsWith("mailto:")||!r.startsWith(location.origin))})(t,e))return;t.preventDefault();let r=e.getAttribute("href").trim();r.startsWith("/")||(r="/"+r),this.navigateTo(r)}}export{e as default};
2
+ //# sourceMappingURL=BreezeRouter.min.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BreezeRouter.min.js","sources":["../src/utils.js","../src/index.js"],"sourcesContent":["// @ts-check\n\n/**\n * Check if given param is function\n * @param {Function} fn\n * @returns {boolean}\n */\nexport const isFunction = (fn) => {\n return fn.constructor.name.toLowerCase() === \"function\";\n};\n\n/**\n * Check if given param is async function\n * @param {Function} fn\n * @returns {boolean}\n */\nexport const isAsyncFunction = (fn) => {\n return fn.constructor.name.toLowerCase() === \"asyncfunction\";\n};\n\n/**\n * Remove trailing slash of a give url\n * @param {string} url\n * @returns {string}\n */\nexport const removeTrailingSlash = (url) => {\n if (url.endsWith(\"/\")) {\n url = url.replace(/\\/$/, \"\");\n }\n\n return url;\n};\n\n/**\n * Find anchor element from click event.\n * @param {Event} e - The click event.\n * @returns {HTMLAnchorElement|undefined}\n */\nexport const findAnchor = (e) => {\n return e.composedPath().find((elem) => {\n return elem.tagName === \"A\";\n });\n};\n\n/**\n * Check if the router should handle a click event on an anchor element.\n * @param {Event} e - The click event.\n * @param {HTMLAnchorElement} anchor - The anchor element.\n * @returns {boolean} - True if the router should handle the event, false otherwise.\n */\nexport const shouldRouterHandleClick = (e, anchor) => {\n // If the event has already been handled by another event listener\n if (e.defaultPrevented) {\n return false;\n }\n\n // If the user is holding down the meta, control, or shift key\n if (e.metaKey || e.ctrlKey || e.shiftKey) {\n return false;\n }\n\n if (!anchor) {\n return false;\n }\n\n if (anchor.target) {\n return false;\n }\n\n if (\n anchor.hasAttribute(\"download\") ||\n anchor.getAttribute(\"rel\") === \"external\"\n ) {\n return false;\n }\n\n const href = anchor.href;\n if (!href || href.startsWith(\"mailto:\")) {\n return false;\n }\n\n // If the href attribute does not start with the same origin\n if (!href.startsWith(location.origin)) {\n return false;\n }\n\n return true;\n};\n","// @ts-check\nimport {\n isFunction,\n isAsyncFunction,\n removeTrailingSlash,\n findAnchor,\n shouldRouterHandleClick,\n} from \"./utils.js\";\n\n/**\n * Class representing a router.\n */\nexport default class BreezeRouter {\n /**\n * Creates a new BreezeRouter instance.\n * @constructor\n */\n constructor() {\n /**\n * Object containing all registered routes.\n * @type {import('./types.js').Route}\n * @private\n */\n this._routes = {};\n\n /**\n * The previous route that was navigated to\n * @type {import('./types.js').Route}\n * @private\n */\n this._previousRoute = {};\n\n // Bind event listeners\n window.addEventListener(\"popstate\", this._onChanged.bind(this));\n document.body.addEventListener(\"click\", this._handleClick.bind(this));\n }\n\n /**\n * Starts the router.\n * @returns {void}\n */\n start() {\n this._onChanged();\n }\n\n /**\n * Adds a new route to the router.\n * @param {string} route - The route path to add.\n * @param {Function} handler - The async function to handle the route\n * @returns {BreezeRouter|void} The BreezeRouter instance.\n */\n add(route, handler) {\n route = route.trim();\n if (route !== \"/\") {\n route = removeTrailingSlash(route.trim());\n }\n\n if (this._routes[route]) {\n return console.warn(`Route already exists: ${route}`);\n }\n\n if (typeof handler !== \"function\") {\n return console.error(`handler on route '${route}' is not a function.`);\n }\n\n this._routes[route] = {\n path: route,\n handler,\n };\n\n return this;\n }\n\n /**\n * Navigates to the specified URL.\n * @param {string} url - The URL to navigate to\n * @returns {void}\n */\n navigateTo(url) {\n window.history.pushState({ url }, \"\", url);\n this._onChanged();\n }\n\n /**\n * Redirects a URL\n * @param {string} url\n * @returns {void}\n */\n redirect(url) {\n this.navigateTo(url);\n }\n\n async _onChanged() {\n const path = window.location.pathname;\n const { route, params } = this._matchUrlToRoute(path);\n\n // If no matching route found, route will be '404' route\n // which has been handled by _matchUrlToRoute already\n await this._handleRoute({ route, params });\n }\n\n /**\n * Processes route callbacks registered by app\n * @param {import('./types.js').MatchedRoute} options\n * @returns {Promise<void>}\n */\n async _handleRoute({ route, params }) {\n if (isFunction(route.handler)) {\n route.handler({ route, params });\n }\n\n if (isAsyncFunction(route.handler)) {\n await route.handler({ route, params });\n }\n }\n\n /**\n *\n * @param {string} url - Current url users visite or nagivate to.\n * @returns {import('./types.js').MatchedRoute}\n */\n _matchUrlToRoute(url) {\n /** @type {import('./types.js').RouteParams} */\n const params = {};\n\n if (url !== \"/\") {\n url = removeTrailingSlash(url);\n }\n\n const matchedRoute = Object.keys(this._routes).find((route) => {\n if (url.split(\"/\").length !== route.split(\"/\").length) {\n return false;\n }\n\n let routeSegments = route.split(\"/\").slice(1);\n let urlSegments = url.split(\"/\").slice(1);\n\n // If each segment in the url matches the corresponding segment in the route path,\n // or the route path segment starts with a ':' then the route is matched.\n const match = routeSegments.every((segment, i) => {\n return segment === urlSegments[i] || segment.startsWith(\":\");\n });\n\n if (!match) {\n return false;\n }\n\n // If the route matches the URL, pull out any params from the URL.\n routeSegments.forEach((segment, i) => {\n if (segment.startsWith(\":\")) {\n const propName = segment.slice(1);\n params[propName] = decodeURIComponent(urlSegments[i]);\n }\n });\n\n return true;\n });\n\n if (matchedRoute) {\n return { route: this._routes[matchedRoute], params };\n } else {\n return { route: this._routes[404], params };\n }\n }\n\n /**\n * Handles <a> link clicks\n * @param {Event} event\n * @returns {void}\n */\n _handleClick(event) {\n const anchor = findAnchor(event);\n if (!anchor) {\n return;\n }\n\n if (!shouldRouterHandleClick(event, anchor)) {\n return;\n }\n\n event.preventDefault();\n let href = anchor.getAttribute(\"href\").trim();\n if (!href.startsWith(\"/\")) {\n href = \"/\" + href;\n }\n\n this.navigateTo(href);\n }\n}\n"],"names":["removeTrailingSlash","url","endsWith","replace","BreezeRouter","constructor","this","_routes","_previousRoute","window","addEventListener","_onChanged","bind","document","body","_handleClick","start","add","route","handler","trim","console","warn","error","path","navigateTo","history","pushState","redirect","async","location","pathname","params","_matchUrlToRoute","_handleRoute","name","toLowerCase","fn","isAsyncFunction","matchedRoute","Object","keys","find","split","length","routeSegments","slice","urlSegments","every","segment","i","startsWith","forEach","propName","decodeURIComponent","event","anchor","composedPath","elem","tagName","e","defaultPrevented","metaKey","ctrlKey","shiftKey","target","hasAttribute","getAttribute","href","origin","shouldRouterHandleClick","preventDefault"],"mappings":"AAOO,MAkBMA,EAAuBC,IAC9BA,EAAIC,SAAS,OACfD,EAAMA,EAAIE,QAAQ,MAAO,KAGpBF,GClBM,MAAMG,EAKnBC,cAMEC,KAAKC,QAAU,GAOfD,KAAKE,eAAiB,GAGtBC,OAAOC,iBAAiB,WAAYJ,KAAKK,WAAWC,KAAKN,OACzDO,SAASC,KAAKJ,iBAAiB,QAASJ,KAAKS,aAAaH,KAAKN,MAChE,CAMDU,QACEV,KAAKK,YACN,CAQDM,IAAIC,EAAOC,GAMT,MAJc,OADdD,EAAQA,EAAME,UAEZF,EAAQlB,EAAoBkB,EAAME,SAGhCd,KAAKC,QAAQW,GACRG,QAAQC,KAAK,yBAAyBJ,KAGxB,mBAAZC,EACFE,QAAQE,MAAM,qBAAqBL,0BAG5CZ,KAAKC,QAAQW,GAAS,CACpBM,KAAMN,EACNC,WAGKb,KACR,CAODmB,WAAWxB,GACTQ,OAAOiB,QAAQC,UAAU,CAAE1B,OAAO,GAAIA,GACtCK,KAAKK,YACN,CAODiB,SAAS3B,GACPK,KAAKmB,WAAWxB,EACjB,CAED4B,mBACE,MAAML,EAAOf,OAAOqB,SAASC,UACvBb,MAAEA,EAAKc,OAAEA,GAAW1B,KAAK2B,iBAAiBT,SAI1ClB,KAAK4B,aAAa,CAAEhB,QAAOc,UAClC,CAODH,oBAAmBX,MAAEA,EAAKc,OAAEA,IDlGiB,aCmG5Bd,EAAMC,QDnGbd,YAAY8B,KAAKC,eCoGvBlB,EAAMC,QAAQ,CAAED,QAAOc,WD5FE,CAACK,GACe,kBAAtCA,EAAGhC,YAAY8B,KAAKC,cC8FrBE,CAAgBpB,EAAMC,gBAClBD,EAAMC,QAAQ,CAAED,QAAOc,UAEhC,CAODC,iBAAiBhC,GAEf,MAAM+B,EAAS,CAAA,EAEH,MAAR/B,IACFA,EAAMD,EAAoBC,IAG5B,MAAMsC,EAAeC,OAAOC,KAAKnC,KAAKC,SAASmC,MAAMxB,IACnD,GAAIjB,EAAI0C,MAAM,KAAKC,SAAW1B,EAAMyB,MAAM,KAAKC,OAC7C,OAAO,EAGT,IAAIC,EAAgB3B,EAAMyB,MAAM,KAAKG,MAAM,GACvCC,EAAc9C,EAAI0C,MAAM,KAAKG,MAAM,GAQvC,QAJcD,EAAcG,OAAM,CAACC,EAASC,IACnCD,IAAYF,EAAYG,IAAMD,EAAQE,WAAW,SAQ1DN,EAAcO,SAAQ,CAACH,EAASC,KAC9B,GAAID,EAAQE,WAAW,KAAM,CAC3B,MAAME,EAAWJ,EAAQH,MAAM,GAC/Bd,EAAOqB,GAAYC,mBAAmBP,EAAYG,GACnD,MAGI,EAAI,IAGb,OAAIX,EACK,CAAErB,MAAOZ,KAAKC,QAAQgC,GAAeP,UAErC,CAAEd,MAAOZ,KAAKC,QAAQ,KAAMyB,SAEtC,CAODjB,aAAawC,GACX,MAAMC,EAAoBD,EDpInBE,eAAef,MAAMgB,GACJ,MAAjBA,EAAKC,UCoIZ,IAAKH,EACH,OAGF,ID9HmC,EAACI,EAAGJ,KAEzC,GAAII,EAAEC,iBACJ,OAAO,EAIT,GAAID,EAAEE,SAAWF,EAAEG,SAAWH,EAAEI,SAC9B,OAAO,EAGT,IAAKR,EACH,OAAO,EAGT,GAAIA,EAAOS,OACT,OAAO,EAGT,GACET,EAAOU,aAAa,aACW,aAA/BV,EAAOW,aAAa,OAEpB,OAAO,EAGT,MAAMC,EAAOZ,EAAOY,KACpB,SAAKA,GAAQA,EAAKjB,WAAW,aAKxBiB,EAAKjB,WAAWrB,SAASuC,QAInB,EC0FJC,CAAwBf,EAAOC,GAClC,OAGFD,EAAMgB,iBACN,IAAIH,EAAOZ,EAAOW,aAAa,QAAQ/C,OAClCgD,EAAKjB,WAAW,OACnBiB,EAAO,IAAMA,GAGf9D,KAAKmB,WAAW2C,EACjB"}
package/dist/types.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @typedef {Object} Route
3
+ * @property {string} path - The path of the route
4
+ * @property {Function} handler - The handler function for the route.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object.<string, string>} RouteParams
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} MatchedRoute
13
+ * @property {Route} route
14
+ * @property {RouteParams} params
15
+ */
16
+
17
+ export { Route, RouteParams, MatchedRoute };
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import BreezeRouter from "./dist/BreezeRouter.js";
2
+ export default BreezeRouter;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "breeze-router",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight, zero-dependency client-side router for single page applications (SPAs).",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "files": [
8
+ "index.js",
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "test:dev": "rollup -c & pnpm run -r dev",
13
+ "dev": "vite",
14
+ "build": "rollup -c",
15
+ "prepare": "husky install"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/heybran/breeze-spa-router.git"
20
+ },
21
+ "author": "Brandon Zhang",
22
+ "license": "MIT",
23
+ "bugs": {
24
+ "url": "https://github.com/heybran/breeze-spa-router/issues"
25
+ },
26
+ "homepage": "https://github.com/heybran/breeze-spa-router#readme",
27
+ "keywords": [
28
+ "Router",
29
+ "JavaScript",
30
+ "Frontend",
31
+ "Spa",
32
+ "Vanilla"
33
+ ],
34
+ "devDependencies": {
35
+ "@rollup/plugin-terser": "^0.4.3",
36
+ "husky": "^8.0.0",
37
+ "prettier": "^2.8.8",
38
+ "pretty-quick": "^3.1.3",
39
+ "rollup": "^3.23.0",
40
+ "rollup-plugin-copy": "^3.4.0",
41
+ "vite": "^4.3.9"
42
+ }
43
+ }