@aref-shojaei/router 1.0.1

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/.babelrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "presets": ["@babel/preset-env"]
3
+ }
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ ![SPA (1)](https://github.com/user-attachments/assets/7e76a68b-84f0-4fa7-b961-26319d3ed3bc)
2
+
3
+
4
+ <h1 align='center'>Router - SPA Application</h1>
5
+
6
+
7
+ > app.js
8
+ ```js
9
+ import { Router, Route } from "@aref-shojaei/router"
10
+
11
+
12
+ // Confgiure router system
13
+ Router.configure({ window, document })
14
+
15
+
16
+ // Single Route
17
+ Route.addRoute("/", () => "Welcome Page")
18
+
19
+ // Group Routes
20
+ Route.group("/auth", () => {
21
+ Route.addRoute("/login", () => "Login Page")
22
+ Route.addRoute("/register", () => "Register Page")
23
+ })
24
+
25
+ // Dynamic Route
26
+ Route.addRoute("/users/{id}", ({ params : { name } }) => `User#${id} Page`)
27
+
28
+
29
+ // Redirect Routes
30
+ Route.addRoute("/redirection", () => Route.redirect("/"))
31
+
32
+
33
+ // Initialize the router
34
+ Router.run()
35
+ ```
36
+
37
+ > index.html
38
+ ```html
39
+ <html>
40
+ <head>
41
+ <title>SPA App</title>
42
+ </head>
43
+ <body>
44
+ <ul>
45
+ <!-- Redirect to local links -->
46
+ <li><a href="/">Home</a></li>
47
+ <li><a href="/auth/login">Login</a></li>
48
+ <li><a href="/auth/register">Register</a></li>
49
+ <li><a href="/users/2904152">Admin Page</a></li>
50
+ <li><a href="/redirection">Redirect to a page</a></li>
51
+
52
+ <!-- Redirect to external resources -->
53
+ <li><a href="https://github.com/ArefShojaei/Router" data-link>Github</a></li>
54
+ </ul>
55
+
56
+ <div id="root"></div>
57
+
58
+ <script src="app.js"></script>
59
+ </body>
60
+ </html>
61
+ ```
62
+
63
+
64
+ ## Guide :
65
+ 1. [Introduction](#introduction)
66
+ * [What is this library ?](#what-is-this-library)
67
+ * [Why should I use it ?](#why-should-i-use-it)
68
+ * [Is it work like SPA ?](#is-it-work-like-spa)
69
+ 2. [installation](#installation)
70
+ 3. [tutorial](#tutorial)
71
+
72
+ <br/>
73
+
74
+ ## Introduction
75
+
76
+ <br/>
77
+
78
+ ### What is this library ?
79
+ > This library is for handling routes in the browser env by HTTP requests!
80
+
81
+ ```bash
82
+ * Back-end:
83
+ [Request] GET /blog => [Response] HTML | JSON
84
+
85
+
86
+ * Front-end:
87
+ Page -> [Route] /blog -> Update content -> without refereshing -> Render template
88
+ ```
89
+
90
+ <br>
91
+
92
+ ### Why should I use it ?
93
+ > If you like to have application or pages not to need to referesh again for every action | request + high performance for loading content (SPA)
94
+
95
+ <br>
96
+
97
+ ### Is it work like SPA ?
98
+ > Sure You can get this options to use the library:
99
+ * Add Signle Route
100
+ * Add the group routes
101
+ * Add middleware to every routes (back-end concept)
102
+ * Add route redirection
103
+ * Add pages as component or template files to specific route
104
+ * More ...
105
+
106
+ <br>
107
+
108
+ ## Installation
109
+
110
+ > NPM
111
+ ```bash
112
+ npm i @aref-shojaei/router
113
+ ```
114
+
115
+ > Yarn
116
+ ```bash
117
+ yarn add @aref-shojaei/router
118
+ ```
119
+
120
+ <br/>
121
+
122
+ > Setup - app.js
123
+
124
+ ```js
125
+ import { Router , Route } from "@aref-shojaei/router"
126
+
127
+ Route.addRoute("/", () => "Welcome Page")
128
+
129
+ Router.run()
130
+ ```
131
+ **Note: Don't forget to read guides again!**
132
+
133
+ <br/>
134
+
135
+ ## Tutorial
136
+
137
+ > Route
138
+ * Single type
139
+ * Group type
140
+ * Dynamic type
141
+
142
+ ```js
143
+ import { Route } from "@aref-shojaei/router"
144
+
145
+ // Single route
146
+ Route.addRoute("/", () => "Welcome Page")
147
+
148
+ // Group routes
149
+ Route.group("/auth", () => {
150
+ Route.addRoute("/login", () => "Welcome Page")
151
+ Route.addRoute("/register", () => "Welcome Page")
152
+ })
153
+
154
+ // Dynamic route with params
155
+ Route.addRoute("/users/{id}", ({ params : { id } }) => `User #{id} Page`)
156
+
157
+ Route.addRoute("/courses/{category}/{name}", ({ params : { category, name } }) => `Course Detail: '${category}/${name}' Page`)
158
+ ```
159
+
160
+ <br/>
161
+
162
+ > Middleware
163
+
164
+ * Note: Midddleware is a function to call before every routes
165
+
166
+ ```js
167
+ import { Route } from "@aref-shojaei/router"
168
+
169
+
170
+ // Middleware
171
+ const logger = () => console.log("[LOG] my custom middleware")
172
+
173
+
174
+ // Single route with middleware
175
+ Route.addRoute("/", () => "Welcome Page").middleware([logger])
176
+
177
+ // Group routes with middleware
178
+ Route.group("/auth", () => {
179
+ Route.addRoute("/login", () => "Welcome Page")
180
+ Route.addRoute("/register", () => "Welcome Page")
181
+ }).middleware([logger])
182
+ ```
183
+
184
+ <br/>
185
+
186
+ > Route Redirection
187
+
188
+ ```js
189
+ import { Route } from "@aref-shojaei/router"
190
+
191
+ Route.addRoute("/auth/login", () => "Welcome Page")
192
+ Route.addRoute("/dashboard", () => Route.redirect("/auth/login"))
193
+ ```
194
+
195
+ <br/>
196
+
197
+ > Route page title
198
+
199
+ * Note: If I don't use it, page title is applied from document.title by default!
200
+
201
+ ```js
202
+ import { Route } from "@aref-shojaei/router"
203
+
204
+ Route.addRoute("/", () => "Welcome Page").title("Custom Page Title | SPA")
205
+ ```
@@ -0,0 +1 @@
1
+ (()=>{"use strict";class t extends Error{constructor(t){super(t),this.name="Invalid argument error"}}class e{static#t="";static#e="";static#s;constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static setDocument(e){if("object"!=typeof e)throw new t("'document' must be a Document object!");this.#s=e}static _getDocument(){return this.#s}static setTitle(e){if("string"!=typeof e)throw new t("'title' must be a string!");this.#e=e,this.#i()}static getTitle(){return this.#e||this.#t}static setRootTitle(e){if("string"!=typeof e)throw new t("'value' must be a string!");this.#t=e,this.#i()}static getRootTitle(){return this.#t}static#i(){const t=this._getDocument();this.getTitle()?t.title=this.getTitle():t.title=this.getRootTitle()}}class s{constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static render(e,s={}){if("function"!=typeof e)throw new t("'template' must be a function!");try{return e(s)}catch(t){console.error("Error during template rendering: ",t)}}}class i{static#o=[];constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static findAll(e,s){if("string"!=typeof e)throw new t("'element' must be an HTMLElement object!");if("object"!=typeof s)throw new t("'document' must be a Document object!");const i=s.querySelectorAll(e);return this._setElements(i),this}static each(e){if("function"!=typeof e)throw new t("'callback' must be a function!");const s=this._getElements();this.#o.length&&s.forEach(e)}static _setElements(t){this.#o.push(...t)}static _getElements(){return this.#o}}class o{constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static onClick(e,s){if("object"!=typeof e)throw new t("'element' must be an HTMLElement object!");if("function"!=typeof s)throw new t("'callback' must be a function!");e.addEventListener("click",s)}}class r{title="";template;middlewares=[];meta={params:{},query:{}};constructor({title:t,template:e}){this.title=t??this.title,this.template=e}}class n{static _window;static _document;static _rootElement="#root";static _routes={};static _currentRoute="";static _routePrefix="";static _defaultRoute=new r({title:"404",template:()=>"404 | Page not found!"});constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static configure({window:e,document:s,selector:i}){if("object"!=typeof e)throw new t("'window' must be a Window object!");if("object"!=typeof s)throw new t("'document' must be a Document object!");if(i&&"string"!=typeof i)throw new t("'selector' must be a string!");i&&(this._rootElement=i),this._window=e,this._document=s}static#r(t){for(const e in this._routes){const s=new RegExp(`^${e.replace(/\{(\w+)\}/g,"(?<$1>[^/{}]+)")}$`);if(!s.test(t))continue;const{groups:i}=s.exec(t);return this.#n(e),this.#a(e,i),this._routes[e]}}static#n(t){const e={},{search:s}=location;if(!s.length)return!1;s.slice(1).split("&").forEach((t=>{const[s,i]=t.split("=");e[s]=i})),this._routes[t].meta.query={...e}}static#a(t,e){this._routes[t].meta.params={...e}}static _setRouteToURL(e){if("string"!=typeof e||!e.startsWith("/"))throw new t("'route' must be a string starting with \"/\"!");this._window.history.pushState({},"",e)}static#c(t){try{const{title:i,template:o,middlewares:r,meta:n}=this.#r(t)??this._defaultRoute;e.setTitle(i),this.#u(r),this._document.querySelector(this._rootElement).innerHTML=s.render(o,n)}catch(e){console.error("Error to inject route template:",t,e)}}static#l(){this._window.addEventListener("popstate",(t=>{const e=t.target.location.pathname;this.#c(e)}))}static#h(){const{pathname:t}=this._window.location;this.#c(t)}static#w(){i.findAll("a",this._document).each((t=>{o.onClick(t,(t=>{if(t.target.hasAttribute("data-link"))return!1;t.preventDefault();const e=t.target.getAttribute("href");this._setRouteToURL(e),this.#c(e)}))}))}static#u(t){try{t.length&&t.forEach((t=>t()))}catch(t){console.error("Error executing middleware:",t)}}static run(s=(()=>{})){if("function"!=typeof s)throw new t("'callback' must be a function!");e.setDocument(this._document),e.setRootTitle(this._document.title),this.#l(),this.#h(),this.#w(),s()}}window.Router=n,window.Route=class extends n{constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static addRoute(e,s){if("string"!=typeof e)throw new t("'route' must be a string!");if("function"!=typeof s)throw new t("'callback' must be a function!");return this._routes[this._routePrefix+e]=new r({template:s}),this._currentRoute=e,this}static group(e,s){if("string"!=typeof e||!e.startsWith("/"))throw new t("'prefix' must be a string!");if("function"!=typeof s)throw new t("'callback' must be a function!");const i=this._routePrefix;return this._routePrefix=e,s(),this._routePrefix=i,this}static middleware(e){if(!Array.isArray(e))throw new t("'middlewares' must be an array!");if(this._routePrefix)for(const t in this._routes)t.startsWith(this._routePrefix)&&this._routes[t].middlewares.push(...e);else this._routes[this._routePrefix+this._currentRoute].middlewares.push(...e)}static title(e){if("string"!=typeof e)throw new t("'value' must be a string!");this._routes[this._routePrefix+this._currentRoute].title=e}static redirect(e){if("string"!=typeof e||!e.startsWith("/"))throw new t("'to' must be a string starting route with \"/\"!");this._setRouteToURL(e);const{template:i,meta:o}=this._routes[e]??this._defaultRoute;return s.render(i,o)}}})();
@@ -0,0 +1,46 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ padding: 0;
4
+ margin: 0;
5
+ font-family: sans-serif;
6
+ }
7
+
8
+ body {
9
+ display: flex;
10
+ flex-direction: column;
11
+ align-items: center;
12
+ padding: 24px;
13
+ }
14
+
15
+ ul {
16
+ display: flex;
17
+ align-items: center;
18
+ margin-top: 16px;
19
+ }
20
+
21
+ ul li {
22
+ margin: 0 24px;
23
+ list-style: none;
24
+ }
25
+
26
+ ul li a {
27
+ padding: 4 8px;
28
+ color: #81a6f4;
29
+ text-decoration: none;
30
+ }
31
+
32
+ ul li a:hover {
33
+ color: #2b2b2a;
34
+ transition: all .5s ease;
35
+ }
36
+
37
+
38
+ #root {
39
+ padding: 16px;
40
+ text-align: center;
41
+ margin-top: 40px;
42
+ background: #ded7d7;
43
+ width: 600px;
44
+ height: 250px;
45
+ border-radius: 16px;
46
+ }
@@ -0,0 +1,8 @@
1
+ Router.configure({ window, document })
2
+
3
+ Route.addRoute("/", () => "Welcome Page")
4
+ Route.addRoute("/users", () => "Users Page")
5
+ Route.addRoute("/users/{name}", ({ params : { name } }) => `User #${name} Page`)
6
+ Route.addRoute("/redirection", () => Route.redirect("/"))
7
+
8
+ Router.run()
@@ -0,0 +1 @@
1
+ (()=>{"use strict";class t extends Error{constructor(t){super(t),this.name="Invalid argument error"}}class e{static#t="";static#e="";static#s;constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static setDocument(e){if("object"!=typeof e)throw new t("'document' must be a Document object!");this.#s=e}static _getDocument(){return this.#s}static setTitle(e){if("string"!=typeof e)throw new t("'title' must be a string!");this.#e=e,this.#i()}static getTitle(){return this.#e||this.#t}static setRootTitle(e){if("string"!=typeof e)throw new t("'value' must be a string!");this.#t=e,this.#i()}static getRootTitle(){return this.#t}static#i(){const t=this._getDocument();this.getTitle()?t.title=this.getTitle():t.title=this.getRootTitle()}}class s{constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static render(e,s={}){if("function"!=typeof e)throw new t("'template' must be a function!");try{return e(s)}catch(t){console.error("Error during template rendering: ",t)}}}class i{static#o=[];constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static findAll(e,s){if("string"!=typeof e)throw new t("'element' must be an HTMLElement object!");if("object"!=typeof s)throw new t("'document' must be a Document object!");const i=s.querySelectorAll(e);return this._setElements(i),this}static each(e){if("function"!=typeof e)throw new t("'callback' must be a function!");const s=this._getElements();this.#o.length&&s.forEach(e)}static _setElements(t){this.#o.push(...t)}static _getElements(){return this.#o}}class o{constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static onClick(e,s){if("object"!=typeof e)throw new t("'element' must be an HTMLElement object!");if("function"!=typeof s)throw new t("'callback' must be a function!");e.addEventListener("click",s)}}class r{title="";template;middlewares=[];meta={params:{},query:{}};constructor({title:t,template:e}){this.title=t??this.title,this.template=e}}class n{static _window;static _document;static _rootElement="#root";static _routes={};static _currentRoute="";static _routePrefix="";static _defaultRoute=new r({title:"404",template:()=>"404 | Page not found!"});constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static configure({window:e,document:s,selector:i}){if("object"!=typeof e)throw new t("'window' must be a Window object!");if("object"!=typeof s)throw new t("'document' must be a Document object!");if(i&&"string"!=typeof i)throw new t("'selector' must be a string!");i&&(this._rootElement=i),this._window=e,this._document=s}static#r(t){for(const e in this._routes){const s=new RegExp(`^${e.replace(/\{(\w+)\}/g,"(?<$1>[^/{}]+)")}$`);if(!s.test(t))continue;const{groups:i}=s.exec(t);return this.#n(e),this.#a(e,i),this._routes[e]}}static#n(t){const e={},{search:s}=location;if(!s.length)return!1;s.slice(1).split("&").forEach((t=>{const[s,i]=t.split("=");e[s]=i})),this._routes[t].meta.query={...e}}static#a(t,e){this._routes[t].meta.params={...e}}static _setRouteToURL(e){if("string"!=typeof e||!e.startsWith("/"))throw new t("'route' must be a string starting with \"/\"!");this._window.history.pushState({},"",e)}static#c(t){try{const{title:i,template:o,middlewares:r,meta:n}=this.#r(t)??this._defaultRoute;e.setTitle(i),this.#u(r),this._document.querySelector(this._rootElement).innerHTML=s.render(o,n)}catch(e){console.error("Error to inject route template:",t,e)}}static#l(){this._window.addEventListener("popstate",(t=>{const e=t.target.location.pathname;this.#c(e)}))}static#h(){const{pathname:t}=this._window.location;this.#c(t)}static#w(){i.findAll("a",this._document).each((t=>{o.onClick(t,(t=>{if(t.target.hasAttribute("data-link"))return!1;t.preventDefault();const e=t.target.getAttribute("href");this._setRouteToURL(e),this.#c(e)}))}))}static#u(t){try{t.length&&t.forEach((t=>t()))}catch(t){console.error("Error executing middleware:",t)}}static run(s=(()=>{})){if("function"!=typeof s)throw new t("'callback' must be a function!");e.setDocument(this._document),e.setRootTitle(this._document.title),this.#l(),this.#h(),this.#w(),s()}}window.Router=n,window.Route=class extends n{constructor(){throw new Error(`${new.target.name} class must not be called with "new" keyword!`)}static addRoute(e,s){if("string"!=typeof e)throw new t("'route' must be a string!");if("function"!=typeof s)throw new t("'callback' must be a function!");return this._routes[this._routePrefix+e]=new r({template:s}),this._currentRoute=e,this}static group(e,s){if("string"!=typeof e||!e.startsWith("/"))throw new t("'prefix' must be a string!");if("function"!=typeof s)throw new t("'callback' must be a function!");const i=this._routePrefix;return this._routePrefix=e,s(),this._routePrefix=i,this}static middleware(e){if(!Array.isArray(e))throw new t("'middlewares' must be an array!");if(this._routePrefix)for(const t in this._routes)t.startsWith(this._routePrefix)&&this._routes[t].middlewares.push(...e);else this._routes[this._routePrefix+this._currentRoute].middlewares.push(...e)}static title(e){if("string"!=typeof e)throw new t("'value' must be a string!");this._routes[this._routePrefix+this._currentRoute].title=e}static redirect(e){if("string"!=typeof e||!e.startsWith("/"))throw new t("'to' must be a string starting route with \"/\"!");this._setRouteToURL(e);const{template:i,meta:o}=this._routes[e]??this._defaultRoute;return s.render(i,o)}}})();
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>SPA | JavaScript</title>
8
+ <link rel="stylesheet" href="/assets/css/styles.css">
9
+ </head>
10
+ <body>
11
+ <h1>SPA Page</h1>
12
+ <ul>
13
+ <li><a href="/">Welcome</a></li>
14
+ <li><a href="/users">Users</a></li>
15
+ <li><a href="/blog">Blog</a></li>
16
+ <li><a href="/test">404 | Not Found</a></li>
17
+ <li><a href="/redirection">Redirection</a></li>
18
+ <li><a href="https://github.com/ArefShojaei/Lite-PHP/" data-link>Github</a></li>
19
+ </ul>
20
+
21
+ <div id="root"></div>
22
+
23
+ <script src="/assets/js/router.min.js"></script>
24
+ <script src="/assets/js/app.js"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "example",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "nodemon server.js"
8
+ },
9
+ "keywords": [],
10
+ "author": "",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "express": "^4.21.2"
14
+ },
15
+ "devDependencies": {
16
+ "nodemon": "^3.1.9"
17
+ }
18
+ }
@@ -0,0 +1,18 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const app = express()
4
+ const PORT = 8000
5
+
6
+ app.use(express.static(path.join(__dirname)))
7
+
8
+ app.get("/", (req, res) => {
9
+ try {
10
+ const filePath = path.join(__dirname, "\\index.html")
11
+
12
+ res.status(200).sendFile(filePath)
13
+ } catch (error) {
14
+ console.log(error);
15
+ }
16
+ })
17
+
18
+ app.listen(PORT, () => console.log(`Server is running -> http://localhost:${PORT}`))
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import Router from "./src/router.js";
2
+ import Route from "./src/route.js";
3
+
4
+
5
+ export { Router, Route }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@aref-shojaei/router",
3
+ "version": "1.0.1",
4
+ "description": "JavaScript Router (SPA)",
5
+ "main": "index.js",
6
+ "directories": {
7
+ "test": "tests",
8
+ "src": "src",
9
+ "build": "build",
10
+ "example": "example"
11
+ },
12
+ "scripts": {
13
+ "test": "jest"
14
+ },
15
+ "jest": {
16
+ "transform": {
17
+ "^.+\\.js$": "babel-jest"
18
+ }
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ArefShojaei/Router.git"
23
+ },
24
+ "keywords": [
25
+ "router",
26
+ "routing",
27
+ "route",
28
+ "spa"
29
+ ],
30
+ "author": "Aref Shojaei",
31
+ "license": "MIT",
32
+ "bugs": {
33
+ "url": "https://github.com/ArefShojaei/Router/issues"
34
+ },
35
+ "homepage": "https://github.com/ArefShojaei/Router#readme",
36
+ "devDependencies": {
37
+ "@babel/core": "^7.26.0",
38
+ "@babel/preset-env": "^7.26.0",
39
+ "babel-jest": "^29.7.0",
40
+ "jest": "^29.7.0",
41
+ "jsdom": "^25.0.1"
42
+ }
43
+ }
@@ -0,0 +1,18 @@
1
+ export default class Route {
2
+ title = ""
3
+
4
+ template
5
+
6
+ middlewares = []
7
+
8
+ meta = {
9
+ params : {},
10
+ query : {},
11
+ }
12
+
13
+
14
+ constructor({ title, template }) {
15
+ this.title = title ?? this.title
16
+ this.template = template
17
+ }
18
+ }
@@ -0,0 +1,6 @@
1
+ export class InvalidArgumentTypeError extends Error {
2
+ constructor(message) {
3
+ super(message)
4
+ this.name = "Invalid argument error"
5
+ }
6
+ }
package/src/page.js ADDED
@@ -0,0 +1,92 @@
1
+ import { InvalidArgumentTypeError } from "./exception.js"
2
+
3
+ /**
4
+ * @abstract
5
+ */
6
+ export default class Page {
7
+ /**
8
+ * Default page title
9
+ */
10
+ static #root = ""
11
+
12
+ /**
13
+ * Updated page title
14
+ */
15
+ static #title = ""
16
+
17
+
18
+ static #document
19
+
20
+
21
+ constructor() {
22
+ throw new Error(`${new.target.name} class must not be called with \"new\" keyword!`)
23
+ }
24
+
25
+ /**
26
+ * @param {Document} document
27
+ */
28
+ static setDocument(document) {
29
+ if (typeof document !== "object") throw new InvalidArgumentTypeError("'document' must be a Document object!")
30
+
31
+ this.#document = document
32
+ }
33
+
34
+ /**
35
+ * @returns {Document}
36
+ */
37
+ static _getDocument() {
38
+ return this.#document
39
+ }
40
+
41
+ /**
42
+ * @param {string} value
43
+ * @returns {void}
44
+ */
45
+ static setTitle(value) {
46
+ if (typeof value !== "string") throw new InvalidArgumentTypeError("'title' must be a string!")
47
+
48
+
49
+ this.#title = value
50
+
51
+ this.#updateTitle()
52
+ }
53
+
54
+ /**
55
+ * @param {string} value
56
+ * @returns {void}
57
+ */
58
+ static getTitle() {
59
+ return this.#title || this.#root
60
+ }
61
+
62
+ /**
63
+ * @param {string} value
64
+ * @returns {void}
65
+ */
66
+ static setRootTitle(value) {
67
+ if (typeof value !== "string") throw new InvalidArgumentTypeError("'value' must be a string!")
68
+
69
+
70
+ this.#root = value
71
+
72
+ this.#updateTitle()
73
+ }
74
+
75
+ /**
76
+ * @returns {string}
77
+ */
78
+ static getRootTitle() {
79
+ return this.#root
80
+ }
81
+
82
+ /**
83
+ * @returns {void}
84
+ */
85
+ static #updateTitle() {
86
+ const document = this._getDocument()
87
+
88
+ !this.getTitle()
89
+ ? (document.title = this.getRootTitle())
90
+ : (document.title = this.getTitle())
91
+ }
92
+ }
package/src/route.js ADDED
@@ -0,0 +1,104 @@
1
+ import Router from "./router.js"
2
+ import View from "./view.js";
3
+ import { InvalidArgumentTypeError } from "./exception.js"
4
+ import RouteDTO from "./dto/route.js"
5
+
6
+
7
+ /**
8
+ * @abstract
9
+ */
10
+ export default class Route extends Router {
11
+ constructor() {
12
+ throw new Error(`${new.target.name} class must not be called with \"new\" keyword!`)
13
+ }
14
+
15
+ /**
16
+ * @param {string} route
17
+ * @param {fucntion} callback
18
+ * @returns {Route}
19
+ */
20
+ static addRoute(route, callback) {
21
+ if (typeof route !== "string") throw new InvalidArgumentTypeError("'route' must be a string!")
22
+
23
+ if (typeof callback !== "function") throw new InvalidArgumentTypeError("'callback' must be a function!")
24
+
25
+
26
+ this._routes[this._routePrefix + route] = new RouteDTO({ template : callback })
27
+
28
+ this._currentRoute = route;
29
+
30
+ return this;
31
+ }
32
+
33
+ /**
34
+ * @param {string} prefix
35
+ * @param {function} callback
36
+ * @returns {Route}
37
+ */
38
+ static group(prefix, callback) {
39
+ if (typeof prefix !== "string" || !prefix.startsWith("/")) throw new InvalidArgumentTypeError("'prefix' must be a string!")
40
+
41
+ if (typeof callback !== "function") throw new InvalidArgumentTypeError("'callback' must be a function!")
42
+
43
+
44
+ const previousPrefix = this._routePrefix
45
+
46
+ this._routePrefix = prefix;
47
+
48
+ callback();
49
+
50
+ this._routePrefix = previousPrefix
51
+
52
+ return this;
53
+ }
54
+
55
+ /**
56
+ * @param {array} middlewares
57
+ * @returns {void}
58
+ */
59
+ static middleware(middlewares) {
60
+ if (!Array.isArray(middlewares)) throw new InvalidArgumentTypeError("'middlewares' must be an array!")
61
+
62
+ const isDefinedRoutePrefix = this._routePrefix ? true : false
63
+
64
+ // Add middlewares to single route
65
+ if (!isDefinedRoutePrefix) {
66
+ this._routes[this._routePrefix + this._currentRoute]["middlewares"].push(...middlewares);
67
+
68
+ return;
69
+ }
70
+
71
+ // Add middlewares to the group of routes
72
+ for (const route in this._routes) {
73
+ if (!route.startsWith(this._routePrefix)) continue;
74
+
75
+ this._routes[route]["middlewares"].push(...middlewares);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Set route page title
81
+ * @param {string} value
82
+ * @returns {void}
83
+ */
84
+ static title(value) {
85
+ if (typeof value !== "string") throw new InvalidArgumentTypeError("'value' must be a string!")
86
+
87
+ this._routes[this._routePrefix + this._currentRoute]["title"] = value
88
+ }
89
+
90
+ /**
91
+ * @param {string} to - Route pointer
92
+ * @returns {string}
93
+ */
94
+ static redirect(to) {
95
+ if (typeof to !== "string" || !to.startsWith("/")) throw new InvalidArgumentTypeError("'to' must be a string starting route with \"/\"!")
96
+
97
+
98
+ this._setRouteToURL(to)
99
+
100
+ const { template, meta } = this._routes[to] ?? this._defaultRoute;
101
+
102
+ return View.render(template, meta)
103
+ }
104
+ }
package/src/router.js ADDED
@@ -0,0 +1,208 @@
1
+ import Page from "./page.js"
2
+ import View from "./view.js";
3
+ import Selector from "./utils/selector.js";
4
+ import Element from "./utils/element.js";
5
+ import { InvalidArgumentTypeError } from "./exception.js";
6
+ import RouteDTO from "./dto/route.js"
7
+
8
+
9
+ /**
10
+ * @abstract
11
+ */
12
+ export default class Router {
13
+ static _window
14
+
15
+ static _document
16
+
17
+ static _rootElement = "#root"
18
+
19
+ static _routes = {};
20
+
21
+ static _currentRoute = "";
22
+
23
+ static _routePrefix = "";
24
+
25
+ static _defaultRoute = new RouteDTO({
26
+ title : "404",
27
+ template : () => "404 | Page not found!"
28
+ })
29
+
30
+
31
+
32
+
33
+ constructor() {
34
+ throw new Error(`${new.target.name} class must not be called with \"new\" keyword!`)
35
+ }
36
+
37
+ /**
38
+ * @param {object} params
39
+ */
40
+ static configure({ window, document, selector }) {
41
+ if (typeof window !== "object") throw new InvalidArgumentTypeError("'window' must be a Window object!")
42
+
43
+ if (typeof document !== "object") throw new InvalidArgumentTypeError("'document' must be a Document object!")
44
+
45
+ if (selector && typeof selector !== "string") throw new InvalidArgumentTypeError("'selector' must be a string!")
46
+
47
+
48
+ if (selector) this._rootElement = selector
49
+
50
+
51
+ this._window = window
52
+
53
+ this._document = document
54
+ }
55
+
56
+ /**
57
+ * @param {string} target
58
+ * @returns {object}
59
+ */
60
+ static #findRoute(target) {
61
+ for (const route in this._routes) {
62
+ const regex = new RegExp(`^${route.replace(/\{(\w+)\}/g, '(?<$1>[^/{}]+)')}$`);
63
+
64
+ if (!regex.test(target)) continue
65
+
66
+ const { groups } = regex.exec(target)
67
+
68
+ this.#setRouteQuery(route)
69
+
70
+ this.#setRouteParams(route, groups)
71
+
72
+ return this._routes[route]
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {string} route
78
+ * @returns {boolean}
79
+ */
80
+ static #setRouteQuery(route) {
81
+ const query = {}
82
+
83
+ const { search } = location
84
+
85
+ if (!search.length) return false
86
+
87
+
88
+ search.slice(1).split("&").forEach(item => {
89
+ const [key, value] = item.split("=")
90
+
91
+ query[key] = value
92
+ })
93
+
94
+ this._routes[route]["meta"]["query"] = {...query}
95
+ }
96
+
97
+ /**
98
+ * @param {string} route
99
+ * @param {object} params
100
+ * @returns {void}
101
+ */
102
+ static #setRouteParams(route, params) {
103
+ this._routes[route]["meta"]["params"] = {...params}
104
+ }
105
+
106
+ /**
107
+ * @param {string} route
108
+ * @param {Window} window
109
+ * @returns {void}
110
+ */
111
+ static _setRouteToURL(route) {
112
+ if (typeof route !== "string" || !route.startsWith("/")) throw new InvalidArgumentTypeError("'route' must be a string starting with \"/\"!")
113
+
114
+ this._window.history.pushState({}, "", route)
115
+ }
116
+
117
+ /**
118
+ * @param {string} route
119
+ * @returns {void}
120
+ */
121
+ static #injectTemplateToDOM(route) {
122
+ try {
123
+ const { title, template, middlewares, meta } = this.#findRoute(route) ?? this._defaultRoute
124
+
125
+ Page.setTitle(title)
126
+
127
+ this.#applyMiddlewares(middlewares)
128
+
129
+ this._document.querySelector(this._rootElement).innerHTML = View.render(template, meta)
130
+ } catch (error) {
131
+ console.error("Error to inject route template:", route, error);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * @returns {void}
137
+ */
138
+ static #activeHistroyNavigation() {
139
+ this._window.addEventListener("popstate", event => {
140
+ const route = event.target.location.pathname
141
+
142
+ this.#injectTemplateToDOM(route)
143
+ })
144
+ }
145
+
146
+ /**
147
+ * @returns {void}
148
+ */
149
+ static #changeRoutebyRequest() {
150
+ const { pathname } = this._window.location
151
+
152
+ this.#injectTemplateToDOM(pathname)
153
+ }
154
+
155
+ /**
156
+ * @returns {void}
157
+ */
158
+ static #changeRoutebyLink() {
159
+ Selector.findAll("a", this._document).each(anchor => {
160
+ Element.onClick(anchor, (event) => {
161
+ if (event.target.hasAttribute("data-link")) return false
162
+
163
+ event.preventDefault()
164
+
165
+ const route = event.target.getAttribute("href")
166
+
167
+ this._setRouteToURL(route)
168
+
169
+ this.#injectTemplateToDOM(route)
170
+ })
171
+ })
172
+ }
173
+
174
+ /**
175
+ * Executes middleware functions
176
+ * @param {array} middlewares
177
+ * @returns {void}
178
+ */
179
+ static #applyMiddlewares(middlewares) {
180
+ try {
181
+ middlewares.length && middlewares.forEach(middleware => middleware())
182
+ } catch (error) {
183
+ console.error("Error executing middleware:", error);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Initializes the router
189
+ * @param {function} callback
190
+ * @returns {void}
191
+ */
192
+ static run(callback = () => {}) {
193
+ if (typeof callback !== "function") throw new InvalidArgumentTypeError("'callback' must be a function!")
194
+
195
+
196
+ Page.setDocument(this._document)
197
+
198
+ Page.setRootTitle(this._document.title)
199
+
200
+ this.#activeHistroyNavigation()
201
+
202
+ this.#changeRoutebyRequest()
203
+
204
+ this.#changeRoutebyLink()
205
+
206
+ callback()
207
+ }
208
+ }
@@ -0,0 +1,23 @@
1
+ import { InvalidArgumentTypeError } from "../exception.js"
2
+
3
+ /**
4
+ * @abstract
5
+ */
6
+ export default class Element {
7
+ constructor() {
8
+ throw new Error(`${new.target.name} class must not be called with \"new\" keyword!`)
9
+ }
10
+
11
+ /**
12
+ * @param {HTMLElement} element
13
+ * @param {function} callback
14
+ */
15
+ static onClick(element, callback) {
16
+ if (typeof element !== "object") throw new InvalidArgumentTypeError("'element' must be an HTMLElement object!")
17
+
18
+ if (typeof callback !== "function") throw new InvalidArgumentTypeError("'callback' must be a function!")
19
+
20
+
21
+ element.addEventListener("click", callback)
22
+ }
23
+ }
@@ -0,0 +1,58 @@
1
+ import { InvalidArgumentTypeError } from "../exception.js"
2
+
3
+ /**
4
+ * @abstract
5
+ */
6
+ export default class Selector {
7
+ static #elements = []
8
+
9
+
10
+ constructor() {
11
+ throw new Error(`${new.target.name} class must not be called with \"new\" keyword!`)
12
+ }
13
+
14
+ /**
15
+ * @param {HTMLAnchorElement} element
16
+ * @param {Document} document
17
+ * @returns {Selector}
18
+ */
19
+ static findAll(element, document) {
20
+ if (typeof element !== "string") throw new InvalidArgumentTypeError("'element' must be an HTMLElement object!")
21
+
22
+ if (typeof document !== "object") throw new InvalidArgumentTypeError("'document' must be a Document object!")
23
+
24
+
25
+ const elements = document.querySelectorAll(element)
26
+
27
+ this._setElements(elements)
28
+
29
+ return this
30
+ }
31
+
32
+ /**
33
+ * @param {function} callback
34
+ * @returns {void}
35
+ */
36
+ static each(callback) {
37
+ if (typeof callback !== "function") throw new InvalidArgumentTypeError("'callback' must be a function!")
38
+
39
+
40
+ const elements = this._getElements()
41
+
42
+ if (this.#elements.length) elements.forEach(callback)
43
+ }
44
+
45
+ /**
46
+ * @param {array} elements
47
+ */
48
+ static _setElements(elements) {
49
+ this.#elements.push(...elements)
50
+ }
51
+
52
+ /**
53
+ * @returns {array}
54
+ */
55
+ static _getElements() {
56
+ return this.#elements
57
+ }
58
+ }
package/src/view.js ADDED
@@ -0,0 +1,25 @@
1
+ import { InvalidArgumentTypeError } from "./exception.js"
2
+
3
+ /**
4
+ * @abstract
5
+ */
6
+ export default class View {
7
+ constructor() {
8
+ throw new Error(`${new.target.name} class must not be called with \"new\" keyword!`)
9
+ }
10
+
11
+ /**
12
+ * @param {function} template
13
+ * @param {object} data
14
+ * @returns {string}
15
+ */
16
+ static render(template, data = {}) {
17
+ if (typeof template !== "function") throw new InvalidArgumentTypeError("'template' must be a function!")
18
+
19
+ try {
20
+ return template(data)
21
+ } catch (error) {
22
+ console.error("Error during template rendering: ", error);
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,33 @@
1
+ import { JSDOM } from "jsdom"
2
+ import Element from "../../src/utils/element"
3
+
4
+
5
+ describe("Element tests", () => {
6
+ let documentInstance
7
+
8
+ beforeEach(() => {
9
+ const { window : { document } } = new JSDOM(`
10
+ <html>
11
+ <head>
12
+ <title>SPA Page</title>
13
+ </head>
14
+ <body>
15
+ <a href='/'>Home page</a>
16
+ </body>
17
+ </html>
18
+ `)
19
+
20
+ documentInstance = document
21
+ })
22
+
23
+
24
+ it("should prevent instantiation of the class", () => {
25
+ expect(() => new Element).toThrow()
26
+ })
27
+
28
+ it("should fire click event", () => {
29
+ const link = documentInstance.querySelector("a")
30
+
31
+ Element.onClick(link , (event) => expect(typeof event).toBe(Event))
32
+ })
33
+ })
@@ -0,0 +1,49 @@
1
+ import { JSDOM } from "jsdom"
2
+ import Page from "../../src/page.js"
3
+
4
+
5
+ describe("Page tests", () => {
6
+ let documentInstance
7
+ const titles = {
8
+ default : "SPA Page",
9
+ custom : "Custom Page",
10
+ }
11
+
12
+ beforeEach(() => {
13
+ const { window : { document } } = new JSDOM(`
14
+ <html>
15
+ <head>
16
+ <title>${titles["default"]}</title>
17
+ </head>
18
+ <body>
19
+ <a href='/'>Home page</a>
20
+ </body>
21
+ </html>
22
+ `)
23
+
24
+ documentInstance = document
25
+
26
+ Page.setDocument(documentInstance)
27
+ })
28
+
29
+
30
+ it("should prevent instantiation of the class", () => {
31
+ expect(() => new Page).toThrow()
32
+ })
33
+
34
+ it("should set page default title", () => {
35
+ Page.setRootTitle(documentInstance.title)
36
+
37
+ expect(Page.getRootTitle()).toBeDefined()
38
+ expect(Page.getRootTitle()).toBe(titles["default"])
39
+ expect(typeof Page.getRootTitle()).toBe("string")
40
+ })
41
+
42
+ it("should set custom page title", () => {
43
+ Page.setTitle(titles["custom"])
44
+
45
+ expect(Page.getTitle()).toBeDefined()
46
+ expect(Page.getTitle()).toBe(titles["custom"])
47
+ expect(typeof Page.getTitle()).toBe("string")
48
+ })
49
+ })
@@ -0,0 +1,83 @@
1
+ import Route from "../../src/route.js"
2
+
3
+
4
+ describe("Route tests", () => {
5
+ it("should add a single route", () => {
6
+ const route = "/"
7
+ const template = () => "Welcome Page"
8
+
9
+ Route.addRoute(route, template)
10
+
11
+ const routes = Route._routes
12
+
13
+ expect(routes[route]).toBeDefined()
14
+ })
15
+
16
+ it("should add the group route", () => {
17
+ const routePrefix = "/auth"
18
+ const route = "/login"
19
+ const template = () => "Login Page"
20
+
21
+ Route.group(routePrefix, () => {
22
+ Route.addRoute(route, template)
23
+ })
24
+
25
+ const routes = Route._routes
26
+
27
+ expect(routes[routePrefix + route]).toBeDefined()
28
+ expect(typeof routes[routePrefix + route]).toBe("object")
29
+ })
30
+
31
+ it("should add a dynamic route", () => {
32
+ const route = "/users/{id}"
33
+ const template = ({ params: { id } }) => `User #${id}`
34
+
35
+ Route.addRoute(route, template)
36
+
37
+ const routes = Route._routes
38
+
39
+ expect(routes[route]).toBeDefined()
40
+ expect(typeof routes[route]).toBe("object")
41
+ })
42
+
43
+ it("should add middleware to a route", () => {
44
+ const route = "/admin"
45
+ const template = () => "Admin Page"
46
+ const loggerMiddleware = () => { message : "Custom Log Message!" }
47
+
48
+ Route.addRoute(route, template).middleware([loggerMiddleware])
49
+
50
+ const routes = Route._routes
51
+
52
+ expect(routes[route]["middlewares"]).toBeDefined()
53
+ expect(typeof routes[route]["middlewares"]).toBe("object")
54
+ })
55
+
56
+ it("should add page title to a route", () => {
57
+ const route = "/product"
58
+ const template = () => "SPA Page"
59
+
60
+ Route.addRoute(route, template).title("Custom page title (SPA)")
61
+
62
+ const routes = Route._routes
63
+
64
+ expect(routes[route]["title"]).toBeDefined()
65
+ expect(typeof routes[route]["title"]).toBe("string")
66
+ })
67
+
68
+ it("should redirect route", () => {
69
+ const welcomeRoute = "/"
70
+ const redirectionRoute = "/redirection"
71
+ const distRoute = welcomeRoute
72
+
73
+ Route.addRoute("/", () => "Welcome Page")
74
+ Route.addRoute("/redirection", () => Route.redirect(distRoute))
75
+
76
+ const routes = Route._routes
77
+
78
+ expect(routes[redirectionRoute]).toBeDefined()
79
+ expect(typeof routes[redirectionRoute]).toBe("object")
80
+ expect(routes[welcomeRoute]).toBeDefined()
81
+ expect(typeof routes[welcomeRoute]).toBe("object")
82
+ })
83
+ })
@@ -0,0 +1,56 @@
1
+ import { JSDOM } from "jsdom"
2
+ import Router from "../../src/router.js"
3
+
4
+
5
+ describe("Router tests", () => {
6
+ let documentInstance
7
+ let windowtInstance
8
+ let selectorID
9
+
10
+ beforeEach(() => {
11
+ const { window } = new JSDOM(`
12
+ <html>
13
+ <head>
14
+ <title>SPA Page</title>
15
+ </head>
16
+ <body>
17
+ <div id='root'></div>
18
+ </body>
19
+ </html>
20
+ `)
21
+
22
+ const { document } = window
23
+
24
+
25
+ documentInstance = document
26
+ windowtInstance = window
27
+ selectorID = "#root"
28
+ })
29
+
30
+
31
+ it("should prevent instantiation of the class", () => {
32
+ expect(() => new Router).toThrow()
33
+ })
34
+
35
+ it("should configure router", () => {
36
+ expect(() => {
37
+ Router.configure({
38
+ window : windowtInstance,
39
+ document : documentInstance,
40
+ selector : selectorID
41
+ })
42
+ }).not.toThrow()
43
+ })
44
+
45
+ it("should run router", () => {
46
+ expect(() => {
47
+ Router.configure({
48
+ window : windowtInstance,
49
+ document : documentInstance,
50
+ selector : selectorID
51
+ })
52
+
53
+ Router.run()
54
+ }).not.toThrow()
55
+ })
56
+ })
@@ -0,0 +1,42 @@
1
+ import { JSDOM } from "jsdom";
2
+ import Selector from "../../src/utils/selector.js";
3
+
4
+
5
+ describe("Selector tests", () => {
6
+ let documentInstance
7
+
8
+ beforeEach(() => {
9
+ const { window : { document } } = new JSDOM(`
10
+ <html>
11
+ <head></head>
12
+ <body>
13
+ <a href='/'>Home page</a>
14
+ <a href='/products'>Products page</a>
15
+ <a href='/blog'>Blog page</a>
16
+ </body>
17
+ </html>
18
+ `)
19
+
20
+ documentInstance = document
21
+ })
22
+
23
+
24
+ it("should prevent instantiation of the class", () => {
25
+ expect(() => new Selector).toThrow();
26
+ });
27
+
28
+ it("should get all anchor elements", () => {
29
+ const links = Selector.findAll("a", documentInstance)._getElements();
30
+
31
+
32
+ expect(typeof links).toBe("object")
33
+ });
34
+
35
+ it("should get title of link", () => {
36
+ Selector.findAll("a", documentInstance).each(anchor => {
37
+ const link = anchor.getAttribute("href")
38
+
39
+ expect(typeof link).toBe("string")
40
+ })
41
+ })
42
+ });
@@ -0,0 +1,47 @@
1
+ import View from "../../src/view.js"
2
+
3
+
4
+ describe("View tests", () => {
5
+ it("should prevent instantiation of the class", () => {
6
+ expect(() => new View).toThrow()
7
+ })
8
+
9
+ it("should render a template without params", () => {
10
+ const template = () => {
11
+ return `
12
+ <div>
13
+ <h1>Welcome Page</h1>
14
+ <p>This is SPA page!</p>
15
+ </div>
16
+ `
17
+ }
18
+
19
+ const renderedTemplate = View.render(template)
20
+
21
+ expect(typeof renderedTemplate).toBe("string")
22
+ expect(renderedTemplate).toContain("<h1>Welcome Page</h1>")
23
+ expect(renderedTemplate).toContain("<p>This is SPA page!</p>")
24
+ })
25
+
26
+ it("should render a template with params", () => {
27
+ const template = ({ id, name }) => {
28
+ return `
29
+ <div>
30
+ <h1>User Page</h1>
31
+ <p>ID: ${id} - Name: ${name}</p>
32
+ </div>
33
+ `
34
+ }
35
+
36
+ const user = {
37
+ id : 1,
38
+ name : "Robert"
39
+ }
40
+
41
+ const renderedTemplate = View.render(template, user)
42
+
43
+ expect(typeof renderedTemplate).toBe("string")
44
+ expect(renderedTemplate).toContain(`<h1>User Page</h1>`)
45
+ expect(renderedTemplate).toContain(`<p>ID: ${user.id} - Name: ${user.name}</p>`)
46
+ })
47
+ })