@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 +3 -0
- package/README.md +205 -0
- package/build/router.min.js +1 -0
- package/example/assets/css/styles.css +46 -0
- package/example/assets/js/app.js +8 -0
- package/example/assets/js/router.min.js +1 -0
- package/example/index.html +26 -0
- package/example/package.json +18 -0
- package/example/server.js +18 -0
- package/index.js +5 -0
- package/package.json +43 -0
- package/src/dto/route.js +18 -0
- package/src/exception.js +6 -0
- package/src/page.js +92 -0
- package/src/route.js +104 -0
- package/src/router.js +208 -0
- package/src/utils/element.js +23 -0
- package/src/utils/selector.js +58 -0
- package/src/view.js +25 -0
- package/tests/unit/element.test.js +33 -0
- package/tests/unit/page.test.js +49 -0
- package/tests/unit/route.test.js +83 -0
- package/tests/unit/router.test.js +56 -0
- package/tests/unit/selector.test.js +42 -0
- package/tests/unit/view.test.js +47 -0
package/.babelrc
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+

|
|
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
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
|
+
}
|
package/src/dto/route.js
ADDED
package/src/exception.js
ADDED
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
|
+
})
|