lightweight-router 1.0.7 → 1.0.8
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 +26 -12
- package/dist/router.min.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/router.test.js +26 -0
- package/src/router.js +23 -10
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PageFlick
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PageFlick is a minimal lightweight client-side router with intelligent prefetching capabilities for faster websites. This tool can turn any Multi-Page Application (MPA) into a Single-Page Application (SPA) very easily and with just ~1.5KB byte (gzipped).
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -12,6 +12,7 @@ A minimal lightweight client-side router with intelligent prefetching capabiliti
|
|
|
12
12
|
- 📱 Mobile-friendly with data-saver mode support
|
|
13
13
|
- 🎨 Built-in loading animations
|
|
14
14
|
- 🕰️ Based on History API so you can use native browser navigation
|
|
15
|
+
- 🤖 Automatic title change
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
@@ -107,11 +108,11 @@ If you have too many links at once or too many requests, you can add the `prefet
|
|
|
107
108
|
P.S. you can easily test in your website by pasting the ultra minified version into the console.
|
|
108
109
|
The minified version was created with uglify-js, clean.css and then ultra minified with https://packjs.com
|
|
109
110
|
The size of the gzipped version was calculated with: https://dafrok.github.io/gzip-size-online/
|
|
110
|
-
It's worth to note that nonetheless Terser give better results than uglify-js. The final uglify version packed by packjs.com was
|
|
111
|
+
It's worth to note that nonetheless Terser give better results than uglify-js. The final uglify version packed by packjs.com was the smallest.
|
|
111
112
|
|
|
112
113
|
## Browser Support
|
|
113
114
|
|
|
114
|
-
The router
|
|
115
|
+
The router is intended for modern browsers. Required features:
|
|
115
116
|
|
|
116
117
|
- IntersectionObserver
|
|
117
118
|
- Fetch API
|
|
@@ -122,7 +123,7 @@ For older browsers, consider using the following polyfills:
|
|
|
122
123
|
- intersection-observer
|
|
123
124
|
- whatwg-fetch
|
|
124
125
|
|
|
125
|
-
## Server Configuration
|
|
126
|
+
## Optional Server Configuration
|
|
126
127
|
|
|
127
128
|
Configuring your server to return only the route content can make the router much more efficient. Instead of returning the entire page, the server could return only the content for the requested route when it detects a request with the message "onlyRoute".
|
|
128
129
|
|
|
@@ -137,17 +138,17 @@ Once you configured your server to respond to this type of request, wrap the par
|
|
|
137
138
|
```html
|
|
138
139
|
<-- Header menu and parts that don't change -->
|
|
139
140
|
<router>
|
|
140
|
-
<route path="/"
|
|
141
|
+
<route path="/">home content</route>
|
|
141
142
|
</router>
|
|
142
143
|
<-- footer etc.. -->
|
|
143
144
|
```
|
|
144
145
|
|
|
145
|
-
You can also prerender
|
|
146
|
+
You can also prerender most visited routes by rendering them inside the `router` tag in their appropriate `route` tags for faster loading times:
|
|
146
147
|
|
|
147
148
|
```html
|
|
148
149
|
<router>
|
|
149
|
-
<route path="/"
|
|
150
|
-
<route path="/about" style="
|
|
150
|
+
<route path="/">home content</route>
|
|
151
|
+
<route path="/about" style="display:none;">about content</route>
|
|
151
152
|
</router>
|
|
152
153
|
```
|
|
153
154
|
|
|
@@ -155,10 +156,23 @@ In the future you will also be able to pre-render a default route that will be u
|
|
|
155
156
|
|
|
156
157
|
Right now errors are shown without styling as the content of the page.
|
|
157
158
|
|
|
158
|
-
|
|
159
|
+
If you like to use Preact and Deno for easy server config consider using the server side router present in [Singularity](https://github.com/andreafuturi/Singularity/) framework.
|
|
160
|
+
It does exactly what we're talking about automatically by returning full html renders on normal request (sorrounded by an Index.jsx) and returing only partial route html when route is requested from inside the page (with onlyRoute param).
|
|
159
161
|
|
|
160
|
-
## Performance Tips
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
## Performance Tips
|
|
163
164
|
- Implement server-side partial responses for better bandwidth usage
|
|
164
165
|
- Consider using the `prefetch="onHover"` attribute for less important links
|
|
166
|
+
|
|
167
|
+
## Future Development
|
|
168
|
+
- Delay router intialization on first link hover for better performances?
|
|
169
|
+
- Implement html streaming for faster page load
|
|
170
|
+
- Cooler Error handling
|
|
171
|
+
- Disable caching on certain links/routes (that needs to be always up to date)
|
|
172
|
+
- Cache limiting
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
package/dist/router.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(()=>{let l={},
|
|
1
|
+
(()=>{let l={},o=!1,c=(...e)=>o&&console.log("🚦 Router:",...e),r=async()=>{c("Navigation triggered to:",globalThis.location.pathname),document.body.classList.add("loading");var e=globalThis.location.pathname,t=document.querySelector("router");let n=t.querySelector(`route[path="${e}"]`);if(n||(c("Creating new route element for:",e),(n=document.createElement("route")).setAttribute("path",e),t.appendChild(n)),!n.innerHTML){c("Fetching content for:",globalThis.location.href);let e=l[globalThis.location.href];e||(e=await u(globalThis.location.href),l[globalThis.location.href]=e);var o,r=(new DOMParser).parseFromString(e,"text/html"),a=r.querySelector("title"),a=(a&&(c("Updating page title to:",a.textContent),document.title=a.textContent),n.innerHTML=r.body.innerHTML,Array.from(n.querySelectorAll("script")));c("Executing",a.length,"scripts from fetched content");for(o of a){var i=document.createElement("script");o.src?i.src=o.src:i.textContent=o.textContent,o.parentNode.replaceChild(i,o)}}t.querySelectorAll("route").forEach(e=>e.style.display="none"),n.style.display="contents",document.body.classList.remove("loading"),window.scrollTo(0,0),h&&h(e),c("Route change completed")},a=async e=>{l[e.href]||(c("Prefetching content for:",e.href),l[e.href]=await u(e.href))},i=(e,t)=>{e.forEach(e=>{e.isIntersecting&&(e=e.target,l[e.href]||(a(e),t.unobserve(e)))})},s=e=>{var t=e.target.closest("A");t&&t.href&&d(t.href)&&t.origin===location.origin?(c("Internal link clicked:",t.href),e.preventDefault(),globalThis.history.pushState(null,null,t.href),globalThis.dispatchEvent(new Event("popstate"))):c("Invalid link click:",t?.href)};function d(e){if(e&&!e.startsWith("#")&&!e.startsWith("javascript:")){if(e.startsWith("/"))return 1;try{var t=new URL(e,window.location.origin),n=new URL(window.location.href);return n.hostname.replace(/^www\./,"")===t.hostname.replace(/^www\./,"")?n.pathname!==t.pathname||!t.hash:void 0}catch{}}}let h,u=async e=>{e=await t(e);return e.ok?e.text():"Couldn't fetch the route - HTTP error! status: "+e.status},t=async e=>{if(!f){var t=await fetch(e,{method:"POST",body:"onlyRoute"});if(t.ok)return t}return fetch(e)},f=!1,e=(e={})=>{var{onRouteChange:t,debug:n}=e,e=(o=n,c("Router starting...",e),t&&(n=t,h=n),document.createElement("style")),t=(e.textContent=".loading{animation:pulse 1s infinite alternate}@keyframes pulse{from{opacity:.6}to{opacity:.1}}route{content-visibility:auto}",document.head.appendChild(e),document.querySelector("router")),n=globalThis.location.pathname,n=(t||(c("Creating new router element"),t=document.createElement("router"),(e=document.createElement("route")).setAttribute("path",n),e.innerHTML=document.body.innerHTML,t.appendChild(e),document.body.innerHTML="",document.body.appendChild(t),f=!0),globalThis.addEventListener("popstate",r),document.addEventListener("click",s),document.body.addEventListener("mouseover",e=>{"A"===e.target.tagName&&"onHover"===e.target.getAttribute("prefetch")&&(async e=>{e=e.target;!l[e.href]&&d(e.href)&&await a(e)})(e)}),new IntersectionObserver(i,{root:null,threshold:.5}));(t=>{let n=navigator.connection&&navigator.connection.saveData;document.querySelectorAll("a").forEach(e=>{"onHover"===e.getAttribute("prefetch")||n||d(e.href)||t.observe(e)})})(n)};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>e()):e()})();
|
package/package.json
CHANGED
|
@@ -129,4 +129,30 @@ describe("Router", () => {
|
|
|
129
129
|
const route = document.querySelector('route[path="/about"]');
|
|
130
130
|
expect(route.innerHTML).toContain("Couldn't fetch the route - HTTP error! status: 404");
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
test("considers paths with and without trailing slashes as the same route", async () => {
|
|
134
|
+
// Mock the window.location object
|
|
135
|
+
delete window.location;
|
|
136
|
+
window.location = new URL("http://localhost/");
|
|
137
|
+
|
|
138
|
+
// Mock history.pushState to actually update the location
|
|
139
|
+
const originalPushState = window.history.pushState;
|
|
140
|
+
window.history.pushState = jest.fn((state, title, url) => {
|
|
141
|
+
window.location = new URL(url, "http://localhost");
|
|
142
|
+
originalPushState.call(window.history, state, title, url);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
146
|
+
|
|
147
|
+
const linkWithSlash = document.createElement("a");
|
|
148
|
+
linkWithSlash.href = "/about/";
|
|
149
|
+
document.body.appendChild(linkWithSlash);
|
|
150
|
+
linkWithSlash.click();
|
|
151
|
+
|
|
152
|
+
// Wait for async operations
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
154
|
+
|
|
155
|
+
expect(window.location.pathname).toBe("/about");
|
|
156
|
+
expect(fetch).toHaveBeenCalledWith("/about", expect.any(Object));
|
|
157
|
+
});
|
|
132
158
|
});
|
package/src/router.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
let linkData = {};
|
|
2
|
+
let debugMode = false;
|
|
3
|
+
const log = (...args) => debugMode && console.log("🚦 Router:", ...args);
|
|
2
4
|
const handlePopState = async () => {
|
|
5
|
+
log("Navigation triggered to:", globalThis.location.pathname);
|
|
3
6
|
document.body.classList.add("loading");
|
|
4
|
-
const currentPath = globalThis.location.pathname;
|
|
7
|
+
const currentPath = globalThis.location.pathname.replace(/\/$/, ""); // Normalize path by removing trailing slash
|
|
5
8
|
const router = document.querySelector("router");
|
|
6
9
|
|
|
7
10
|
let currentRoute = router.querySelector(`route[path="${currentPath}"]`);
|
|
8
11
|
|
|
9
12
|
// If the route doesn't exist in DOM, create and append it
|
|
10
13
|
if (!currentRoute) {
|
|
14
|
+
log("Creating new route element for:", currentPath);
|
|
11
15
|
currentRoute = document.createElement("route");
|
|
12
16
|
currentRoute.setAttribute("path", currentPath);
|
|
13
17
|
router.appendChild(currentRoute);
|
|
@@ -15,6 +19,7 @@ const handlePopState = async () => {
|
|
|
15
19
|
|
|
16
20
|
// Only fetch and render content if the route is empty
|
|
17
21
|
if (!currentRoute.innerHTML) {
|
|
22
|
+
log("Fetching content for:", globalThis.location.href);
|
|
18
23
|
let content = linkData[globalThis.location.href];
|
|
19
24
|
|
|
20
25
|
// Fetch content if it's not already cached
|
|
@@ -28,12 +33,16 @@ const handlePopState = async () => {
|
|
|
28
33
|
|
|
29
34
|
// Update the page title with the new content's title
|
|
30
35
|
const newTitle = doc.querySelector("title");
|
|
31
|
-
if (newTitle)
|
|
36
|
+
if (newTitle) {
|
|
37
|
+
log("Updating page title to:", newTitle.textContent);
|
|
38
|
+
document.title = newTitle.textContent;
|
|
39
|
+
}
|
|
32
40
|
|
|
33
41
|
currentRoute.innerHTML = doc.body.innerHTML;
|
|
34
42
|
|
|
35
43
|
// Execute scripts from the fetched content
|
|
36
44
|
const scripts = Array.from(currentRoute.querySelectorAll("script"));
|
|
45
|
+
log("Executing", scripts.length, "scripts from fetched content");
|
|
37
46
|
for (const oldScript of scripts) {
|
|
38
47
|
const newScript = document.createElement("script");
|
|
39
48
|
if (oldScript.src) {
|
|
@@ -54,12 +63,14 @@ const handlePopState = async () => {
|
|
|
54
63
|
|
|
55
64
|
// Call the route change handler if it's set
|
|
56
65
|
if (onRouteChange) onRouteChange(currentPath);
|
|
66
|
+
log("Route change completed");
|
|
57
67
|
};
|
|
58
68
|
|
|
59
69
|
//link management
|
|
60
70
|
|
|
61
71
|
const fetchAndSaveContent = async link => {
|
|
62
72
|
if (!linkData[link.href]) {
|
|
73
|
+
log("Prefetching content for:", link.href);
|
|
63
74
|
linkData[link.href] = await fetchContent(link.href);
|
|
64
75
|
}
|
|
65
76
|
};
|
|
@@ -85,8 +96,12 @@ const handleLinkHover = async event => {
|
|
|
85
96
|
|
|
86
97
|
const handleLinkClick = e => {
|
|
87
98
|
const link = e.target.closest("A");
|
|
88
|
-
if (!link || !link.href || !isInternalLink(link.href) || link.origin !== location.origin)
|
|
89
|
-
|
|
99
|
+
if (!link || !link.href || !isInternalLink(link.href) || link.origin !== location.origin) {
|
|
100
|
+
log("Invalid link click:", link?.href);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
log("Internal link clicked:", link.href);
|
|
104
|
+
e.preventDefault();
|
|
90
105
|
globalThis.history.pushState(null, null, link.href);
|
|
91
106
|
globalThis.dispatchEvent(new Event("popstate"));
|
|
92
107
|
};
|
|
@@ -149,7 +164,9 @@ const fetchWithFallback = async url => {
|
|
|
149
164
|
|
|
150
165
|
let routerCreatedManually = false;
|
|
151
166
|
const startRouter = (options = {}) => {
|
|
152
|
-
const { onRouteChange } = options;
|
|
167
|
+
const { onRouteChange, debug } = options;
|
|
168
|
+
debugMode = debug;
|
|
169
|
+
log("Router starting...", options);
|
|
153
170
|
if (onRouteChange) setRouteChangeHandler(onRouteChange);
|
|
154
171
|
const style = document.createElement("style");
|
|
155
172
|
style.textContent = `
|
|
@@ -170,6 +187,7 @@ const startRouter = (options = {}) => {
|
|
|
170
187
|
const currentPath = globalThis.location.pathname;
|
|
171
188
|
|
|
172
189
|
if (!router) {
|
|
190
|
+
log("Creating new router element");
|
|
173
191
|
router = document.createElement("router");
|
|
174
192
|
const route = document.createElement("route");
|
|
175
193
|
route.setAttribute("path", currentPath);
|
|
@@ -197,8 +215,3 @@ export { startRouter };
|
|
|
197
215
|
|
|
198
216
|
// TODO: create ultra minified version or deploy
|
|
199
217
|
// TODO: write proper automated tests
|
|
200
|
-
// - add support for prefetching on hover
|
|
201
|
-
// - add support for prefetching on click
|
|
202
|
-
// - add support for prefetching on scroll
|
|
203
|
-
// - add support for prefetching on focus
|
|
204
|
-
// - add support for prefetching on touch
|