lightweight-router 1.0.0 → 1.0.2
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/.vscode/settings.json +11 -0
- package/README.md +150 -4
- package/build.js +51 -0
- package/dist/router.min.js +1 -0
- package/jest.config.js +8 -0
- package/jest.setup.js +32 -0
- package/package.json +15 -4
- package/src/__tests__/router.test.js +132 -0
- package/src/about.html +24 -0
- package/src/index.html +24 -0
- package/src/router.html +25 -0
- package/src/router.js +148 -54
- package/package.tmp.json +0 -0
- package/src/index.js +0 -7
- package/src/utils.js +0 -20
package/README.md
CHANGED
|
@@ -1,13 +1,159 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Lightweight Router
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🚀 Zero dependencies
|
|
8
|
+
- 🔄 Smooth client-side navigation
|
|
9
|
+
- 📥 Intelligent link prefetching
|
|
10
|
+
- 🎯 Multiple prefetching strategies
|
|
11
|
+
- 🔍 SEO-friendly (works with Wordpress)
|
|
12
|
+
- 📱 Mobile-friendly with data-saver mode support
|
|
13
|
+
- 🎨 Built-in loading animations
|
|
14
|
+
- 🕰️ Based on History API so you can use native browser navigation
|
|
2
15
|
|
|
3
|
-
A lightweight client-side router with prefetching capabilities.
|
|
4
16
|
|
|
5
17
|
## Installation
|
|
6
18
|
|
|
7
|
-
|
|
19
|
+
### NPM
|
|
20
|
+
|
|
21
|
+
```sh
|
|
8
22
|
npm install lightweight-router
|
|
9
23
|
```
|
|
10
24
|
|
|
11
25
|
## Usage
|
|
12
26
|
|
|
13
|
-
|
|
27
|
+
To use the lightweight router in your project, follow these steps:
|
|
28
|
+
|
|
29
|
+
1. Import the `startRouter` function from the router module.
|
|
30
|
+
2. Call the `startRouter` function to initialize the router.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
|
|
34
|
+
```javascript
|
|
35
|
+
import { startRouter } from "lightweight-router";
|
|
36
|
+
|
|
37
|
+
startRouter({
|
|
38
|
+
onRouteChange: currentRoute => {
|
|
39
|
+
console.log("Route changed:", currentRoute);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Direct Import
|
|
45
|
+
|
|
46
|
+
You can also directly import the minified version of the router in your HTML file or paste its content inside a script tag:
|
|
47
|
+
|
|
48
|
+
```html
|
|
49
|
+
<!DOCTYPE html>
|
|
50
|
+
<html lang="en">
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="UTF-8" />
|
|
53
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
54
|
+
<title>My App</title>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<!-- Your website content -->
|
|
58
|
+
<script src="path/to/dist/router.min.js"></script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
### `startRouter(options)`
|
|
66
|
+
|
|
67
|
+
Initializes the router with the given options.
|
|
68
|
+
|
|
69
|
+
#### Parameters
|
|
70
|
+
|
|
71
|
+
- `options` (Object): Configuration options for the router.
|
|
72
|
+
- `onRouteChange` (Function): Callback function to be called when the route changes.
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
### Basic Example
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
Your website content
|
|
80
|
+
<script type="module">
|
|
81
|
+
import { startRouter } from "./router.js";
|
|
82
|
+
|
|
83
|
+
startRouter({
|
|
84
|
+
onRouteChange: currentRoute => {
|
|
85
|
+
console.log("Route changed:", currentRoute);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Server Configuration
|
|
92
|
+
|
|
93
|
+
Configuring your server to return only the route content can make the router much more efficient. Instead of returning the entire page, the server should return only the content for the requested route when it detects a request with the message "onlyRoute".
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
await fetch(url, { method: "POST", body: "onlyRoute" });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This allows only the changing part of the document to be updated, improving performance and reducing bandwidth usage.
|
|
100
|
+
|
|
101
|
+
Once you configured your server to respond to this type of request, wrap the part of your document that changes in a `router` tag. Inside the `router` tag, render the current initial route inside a `route` tag like this:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<-- Header menu and parts that don't change -->
|
|
105
|
+
<router>
|
|
106
|
+
<route path="/" style="content-visibility: auto">home content</route>
|
|
107
|
+
</router>
|
|
108
|
+
<-- footer etc.. -->
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can also prerender other important routes by rendering them inside the `router` tag in their appropriate `route` tags for faster loading times:
|
|
112
|
+
|
|
113
|
+
```html
|
|
114
|
+
<router>
|
|
115
|
+
<route path="/" style="content-visibility: auto">home content</route>
|
|
116
|
+
<route path="/about" style="content-visibility: auto; display:none;">about content</route>
|
|
117
|
+
</router>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
In the future you will also be able to pre-render a default route that will be used as 404 by having it at /404 or /default
|
|
121
|
+
|
|
122
|
+
Right now errors are shown without styling as the content of the page.
|
|
123
|
+
|
|
124
|
+
Soon there will be a DenoJS library that will help you deal with all these routes stuff. It will also come with api routes functionality 🔥
|
|
125
|
+
|
|
126
|
+
## Prefetching
|
|
127
|
+
|
|
128
|
+
By default, links are prefetched when they get in the user's screen using an `IntersectionObserver`. This ensures that the content is loaded in the background before the user clicks on the link, providing a smoother navigation experience.
|
|
129
|
+
|
|
130
|
+
If you have too many links at once or too many requests, you can add the `prefetch="onHover"` attribute to your links or some of them (usually links to huge pages that are not often visited):
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<a href="/archive" prefetch="onHover">Archive</a>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
P.S. you can easily test in your website by pasting this minified version into the console.
|
|
137
|
+
|
|
138
|
+
The minified version was created with uglify-js, clean.css and then minified again with https://packjs.com
|
|
139
|
+
The size of the gzipped version was calculated with: https://dafrok.github.io/gzip-size-online/
|
|
140
|
+
It's worth to note that nonethewise Terser give better results than uglify-js. The final uglify version packed by packjs.com was even smaller.
|
|
141
|
+
|
|
142
|
+
## Browser Support
|
|
143
|
+
|
|
144
|
+
The router supports all modern browsers. Required features:
|
|
145
|
+
|
|
146
|
+
- IntersectionObserver
|
|
147
|
+
- Fetch API
|
|
148
|
+
- History API
|
|
149
|
+
|
|
150
|
+
For older browsers, consider using the following polyfills:
|
|
151
|
+
|
|
152
|
+
- intersection-observer
|
|
153
|
+
- whatwg-fetch
|
|
154
|
+
|
|
155
|
+
## Performance Tips
|
|
156
|
+
|
|
157
|
+
- Use `content-visibility: auto` on route elements to improve rendering performance
|
|
158
|
+
- Implement server-side partial responses for better bandwidth usage
|
|
159
|
+
- Consider using the `prefetch="onHover"` attribute for less important links
|
package/build.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const uglifyJS = require("uglify-js");
|
|
4
|
+
const CleanCSS = require("clean-css");
|
|
5
|
+
|
|
6
|
+
// Read the router source
|
|
7
|
+
const routerSource = fs.readFileSync(path.join(__dirname, "src/router.js"), "utf8");
|
|
8
|
+
|
|
9
|
+
// Extract CSS from the source
|
|
10
|
+
const cssRegex = /style\.textContent = `\s*([\s\S]*?)\s*`;/;
|
|
11
|
+
const cssMatch = routerSource.match(cssRegex);
|
|
12
|
+
const css = cssMatch ? cssMatch[1] : "";
|
|
13
|
+
|
|
14
|
+
// Minify CSS
|
|
15
|
+
const minifiedCSS = new CleanCSS({}).minify(css).styles;
|
|
16
|
+
|
|
17
|
+
// Replace original CSS with minified version
|
|
18
|
+
const updatedSource = routerSource.replace(cssRegex, `style.textContent = '${minifiedCSS}';`);
|
|
19
|
+
|
|
20
|
+
// Convert ES module to self-initializing IIFE
|
|
21
|
+
const iife = `
|
|
22
|
+
(function() {
|
|
23
|
+
${updatedSource.replace("export { startRouter };", "")}
|
|
24
|
+
|
|
25
|
+
// Auto-initialize when DOM is ready
|
|
26
|
+
if (document.readyState === 'loading') {
|
|
27
|
+
document.addEventListener('DOMContentLoaded', () => startRouter());
|
|
28
|
+
} else {
|
|
29
|
+
startRouter();
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
// Minify the JavaScript
|
|
35
|
+
const minified = uglifyJS.minify(iife);
|
|
36
|
+
|
|
37
|
+
if (minified.error) {
|
|
38
|
+
console.error("Minification error:", minified.error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Create dist directory if it doesn't exist
|
|
43
|
+
const distDir = path.join(__dirname, "dist");
|
|
44
|
+
if (!fs.existsSync(distDir)) {
|
|
45
|
+
fs.mkdirSync(distDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Write minified file
|
|
49
|
+
fs.writeFileSync(path.join(distDir, "router.min.js"), minified.code);
|
|
50
|
+
|
|
51
|
+
console.log("Build complete! Output saved to dist/router.min.js");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$="(£ìc={},n=async£ìܵ¨¢t=êð;¦úè(`ä[pÓh=${t}]`÷n=(oÊ((o´êð÷ú½(o)÷Æç.addöÛ³¼nÊ(n=×d(êó³=n¼Ür,a=(ÍDOMParsõ).parseF°mString(n,ýxt/html¢i=a.èötàle¢i=(iÞ(¾tàlµi.Ý÷¥î=a.¬.î,Array.f°m(¥¹Éé)¼for(r of i){Ül=ßÉé;r«?l«=r«:l.Ý=r.Ý,r.paùïNodÐChild(l,rØú¹äé.fÔ=>ú²nëeé÷¥²c¡sÑÆç.ùmo·öÛ¢Ïsc°llTo(0,0÷sÞs(tØ,r=âËÊ(Ë=×dªØ,a=ô,t)ìúfÔìúisIÀngÞôí,ËÊ(rô÷Õun®))±Ø,i=eìÜtí.closeçöAé;tÞÕóÞl(Õó)ÞÕÈÒ=lüÈÞ(úpù·ïDefault(ºhiçory.pushStaý(¸¸ÕóºdispÓchE·ï(ÍE·ïö¯é)Ø;functië lô){ifô©#é©javaÉ:é){ Öhö/é)á1;try{Üt=ÍURLô,ÏlüÈ÷¦ÍURL(Ïlüó¼á¥¤Ò=Õ¤?¥ð!ÒÕðÊ!Õhash:void 0}cÓch{}}}s,d=âµ×tô¼áúok?úýxt£:Couldn't Ì the ä - HTTP õ°r! çÓus: +úçÓus},t=â !h){Üt=×Ìô,{mÚhod:POSTѬ:ëlyRouý}¼ Õok)át}áÌôØ,h=!1,µô={})ìÜt,µúëRouýChange,µôÞô=e,s=e÷ßçyleé÷µ(úÝ=¬.Û{animÓië:¶ 1s infiniý alýrnaý}@keyframes ¶{f°m§8}to§3}}Ѿhead.½ô÷¨é÷¦êð,¦ôÊô=ßär¢(t´o÷Õçylúc¡Visibilày=autoÑÕî=òî,ú½(t÷òî=Ñò½ô÷h=!0ºÙ¯Ñn÷¾ÙclickÑi÷òÙmouseovõÑôìAÒí.tagNameÞ»í.¿Þ(âeí;!ËÞlªÞ×rô±ô±÷ÍIÀëObsõvõ(a,{°ot:¸thùshold:.5})¼(tì¦naÁÞnaÁ.sa·DÓa;¾¹aé.fÔì»=ú¿ÊoÊlªÊÕ®±±(oØ;ÛÒ=¾ùadyStaý?¾ÙDOMC¡LoadedÑ(£=>e£)):e(±(¼";l="if(ëýï÷()hoÂéo.o={oÎ:.¾èöärÞ!Öhö(úó).srcbodylÚ obsõ·ôpopçaýroØ)çylÇ=÷c[êó]=ßäÅÓhÑe=pulseOÎvenull,èAllö÷glû.ëHovõÒ);aãlddþgÚAåpùÌénýrsectivigÃctiëÄwww./,Óor.cëneçnamÐ(/^é).sÚAåpòclassLiúdisplayoriginscript||c[úó]fÚchnew pacàywindow.úùplace,==atorEach(ôt.úçartsWàawaà )}addEveæetloadingvar ýxtCëýï&&dñmeïöitùturn async eìppendChirouýttribuýöïLisýnõöstquõør)glû.lüon=>{=útargetinnõHTMLntpathnameþcùaýEledþbody.hùf(eer(),ySelectoree.obalThisocation.teocument.".split('');_=" ¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ";for(i=0;i<95;i++){$=$.split(_[i]).join(l[i])};eval($.replace(//g,'"').replace(//g,'\\').replace(//g,String.fromCharCode(10)));
|
package/jest.config.js
ADDED
package/jest.setup.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Mock window.scrollTo
|
|
2
|
+
window.scrollTo = jest.fn();
|
|
3
|
+
|
|
4
|
+
// Mock IntersectionObserver
|
|
5
|
+
global.IntersectionObserver = class IntersectionObserver {
|
|
6
|
+
constructor(callback) {
|
|
7
|
+
this.callback = callback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
observe = jest.fn();
|
|
11
|
+
unobserve = jest.fn();
|
|
12
|
+
disconnect = jest.fn();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Mock URL API if needed
|
|
16
|
+
global.URL = class URL {
|
|
17
|
+
constructor(url) {
|
|
18
|
+
this.url = url;
|
|
19
|
+
this.pathname = url;
|
|
20
|
+
this.origin = "http://localhost";
|
|
21
|
+
this.hash = "";
|
|
22
|
+
this.hostname = "localhost";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Mock window.location
|
|
27
|
+
const mockLocation = new URL("http://localhost/");
|
|
28
|
+
delete window.location;
|
|
29
|
+
window.location = mockLocation;
|
|
30
|
+
|
|
31
|
+
// Mock history API
|
|
32
|
+
window.history.pushState = jest.fn();
|
package/package.json
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightweight-router",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"main": "
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"main": "dist/router.min.js",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"
|
|
6
|
+
"dev": "npx http-server src -o",
|
|
7
|
+
"build": "node build.js",
|
|
8
|
+
"deploy": "npm run build && npm version patch && git publish && npm publish",
|
|
9
|
+
"test": "jest",
|
|
10
|
+
"test:watch": "jest --watch"
|
|
7
11
|
},
|
|
8
12
|
"keywords": [],
|
|
9
13
|
"author": "",
|
|
10
14
|
"license": "ISC",
|
|
11
|
-
"description": ""
|
|
15
|
+
"description": "A lightweight client-side router with prefetching capabilities.",
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"http-server": "^14.1.1",
|
|
18
|
+
"uglify-js": "^3.17.4",
|
|
19
|
+
"clean-css": "^5.3.1",
|
|
20
|
+
"jest": "^29.7.0",
|
|
21
|
+
"jest-environment-jsdom": "^29.7.0"
|
|
22
|
+
}
|
|
12
23
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
describe("Router", () => {
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
// Setup a clean DOM environment before each test
|
|
8
|
+
document.body.innerHTML = `
|
|
9
|
+
<div>
|
|
10
|
+
<h1>Test Page</h1>
|
|
11
|
+
<a href="/about">About</a>
|
|
12
|
+
<a href="/contact" prefetch="onHover">Contact</a>
|
|
13
|
+
</div>
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
// Mock fetch API
|
|
17
|
+
global.fetch = jest.fn(() =>
|
|
18
|
+
Promise.resolve({
|
|
19
|
+
ok: true,
|
|
20
|
+
text: () => Promise.resolve("<div>New Content</div>"),
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Load the router after DOM is set up
|
|
25
|
+
require("../../dist/router.min.js");
|
|
26
|
+
|
|
27
|
+
// Wait for next tick to allow router initialization
|
|
28
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
// Clear the require cache so we can reload the router in next test
|
|
34
|
+
jest.resetModules();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("initializes router and wraps existing content", async () => {
|
|
38
|
+
const router = document.querySelector("router");
|
|
39
|
+
const route = document.querySelector("route");
|
|
40
|
+
|
|
41
|
+
expect(router).toBeTruthy();
|
|
42
|
+
expect(route).toBeTruthy();
|
|
43
|
+
expect(route.getAttribute("path")).toBe(window.location.pathname);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("handles internal navigation", async () => {
|
|
47
|
+
// Mock the window.location object
|
|
48
|
+
delete window.location;
|
|
49
|
+
window.location = new URL("http://localhost/");
|
|
50
|
+
|
|
51
|
+
// Mock history.pushState to actually update the location
|
|
52
|
+
const originalPushState = window.history.pushState;
|
|
53
|
+
window.history.pushState = jest.fn((state, title, url) => {
|
|
54
|
+
window.location = new URL(url, "http://localhost");
|
|
55
|
+
originalPushState.call(window.history, state, title, url);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
59
|
+
|
|
60
|
+
const link = document.querySelector('a[href="/about"]');
|
|
61
|
+
link.click();
|
|
62
|
+
|
|
63
|
+
// Wait for async operations
|
|
64
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
65
|
+
|
|
66
|
+
expect(window.location.pathname).toBe("/about");
|
|
67
|
+
expect(fetch).toHaveBeenCalledWith("/about", expect.any(Object));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("prefetches content on hover for marked links", async () => {
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
72
|
+
|
|
73
|
+
const link = document.querySelector('a[prefetch="onHover"]');
|
|
74
|
+
link.dispatchEvent(new MouseEvent("mouseover"));
|
|
75
|
+
|
|
76
|
+
// Wait for async operations
|
|
77
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
78
|
+
|
|
79
|
+
expect(fetch).toHaveBeenCalledWith("/contact", expect.any(Object));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("updates document title when navigating", async () => {
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
84
|
+
|
|
85
|
+
// Mock fetch to return content with a title
|
|
86
|
+
global.fetch = jest.fn(() =>
|
|
87
|
+
Promise.resolve({
|
|
88
|
+
ok: true,
|
|
89
|
+
text: () =>
|
|
90
|
+
Promise.resolve(`
|
|
91
|
+
<html>
|
|
92
|
+
<head>
|
|
93
|
+
<title>New Page</title>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<div>New Content</div>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
99
|
+
`),
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const link = document.querySelector('a[href="/about"]');
|
|
104
|
+
link.click();
|
|
105
|
+
|
|
106
|
+
// Wait for async operations
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
108
|
+
|
|
109
|
+
expect(document.title).toBe("New Page");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("handles navigation errors gracefully", async () => {
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
114
|
+
|
|
115
|
+
// Mock fetch to simulate an error
|
|
116
|
+
global.fetch = jest.fn(() =>
|
|
117
|
+
Promise.resolve({
|
|
118
|
+
ok: false,
|
|
119
|
+
status: 404,
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const link = document.querySelector('a[href="/about"]');
|
|
124
|
+
link.click();
|
|
125
|
+
|
|
126
|
+
// Wait for async operations
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
128
|
+
|
|
129
|
+
const route = document.querySelector('route[path="/about"]');
|
|
130
|
+
expect(route.innerHTML).toContain("Couldn't fetch the route - HTTP error! status: 404");
|
|
131
|
+
});
|
|
132
|
+
});
|
package/src/about.html
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<html lang="en">
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
5
|
+
<title>Router manual test About</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body style="background-color: bisque">
|
|
8
|
+
<menu class="flex m-0 p-0"
|
|
9
|
+
><a prefetch="onHover" href="/">Home</a><a href="/about.html">About</a><a href="/admin" prefetch="onHover">Admin</a
|
|
10
|
+
><a href="/irmfirmiror" prefetch="onHover">404</a> <a href="https://andreafuturi.com" prefetch="onHover">Google Test</a>
|
|
11
|
+
</menu>
|
|
12
|
+
ABOUT
|
|
13
|
+
<script type="module">
|
|
14
|
+
import { startRouter } from "./router.js";
|
|
15
|
+
// Initialize the router with a simple onRouteChange callback to test functionality
|
|
16
|
+
startRouter({
|
|
17
|
+
onRouteChange: currentRoute => {
|
|
18
|
+
console.log("Route changed:", currentRoute);
|
|
19
|
+
currentRoute.classList.add("fade-in"); // Example: simple CSS class for animation (define it in CSS if needed)
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
package/src/index.html
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<html lang="en">
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
5
|
+
<title>Router manual test Index</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<menu class="flex m-0 p-0"
|
|
9
|
+
><a prefetch="onHover" href="/">Home</a><a preFetch="onHover" href="/about.html">About</a><a href="/admin" prefetch="onHover">Admin</a
|
|
10
|
+
><a href="/irmfirmiror" prefetch="onHover">404</a> <a href="https://andreafuturi.com" prefetch="onHover">Google Test</a>
|
|
11
|
+
</menu>
|
|
12
|
+
HOME
|
|
13
|
+
<script type="module">
|
|
14
|
+
import { startRouter } from "./router.js";
|
|
15
|
+
// Initialize the router with a simple onRouteChange callback to test functionality
|
|
16
|
+
startRouter({
|
|
17
|
+
onRouteChange: currentRoute => {
|
|
18
|
+
console.log("Route changed:", currentRoute);
|
|
19
|
+
currentRoute.classList.add("fade-in"); // Example: simple CSS class for animation (define it in CSS if needed)
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
package/src/router.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<html lang="en">
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
5
|
+
<title>Router manual test About</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body style="background-color: bisque">
|
|
8
|
+
<menu class="flex m-0 p-0"
|
|
9
|
+
><a prefetch="onHover" href="/">Home</a><a href="/about.html">About</a><a href="/admin" prefetch="onHover">Admin</a
|
|
10
|
+
><a href="/irmfirmiror" prefetch="onHover">404</a> <a href="https://andreafuturi.com" prefetch="onHover">Google Test</a> </menu
|
|
11
|
+
><router>
|
|
12
|
+
<route path="/about.html" style="content-visibility: auto"><h1>About</h1></route><route path="/onlyServer" style="content-visibility: auto"></route
|
|
13
|
+
></router>
|
|
14
|
+
<script type="module">
|
|
15
|
+
import { startRouter } from "./router.js";
|
|
16
|
+
// Initialize the router with a simple onRouteChange callback to test functionality
|
|
17
|
+
startRouter({
|
|
18
|
+
onRouteChange: currentRoute => {
|
|
19
|
+
console.log("Route changed:", currentRoute);
|
|
20
|
+
currentRoute.classList.add("fade-in"); // Example: simple CSS class for animation (define it in CSS if needed)
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
package/src/router.js
CHANGED
|
@@ -1,34 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
let linkData = {};
|
|
2
|
+
const handlePopState = async () => {
|
|
3
|
+
const router = document.querySelector("router");
|
|
4
|
+
const currentPath = globalThis.location.pathname;
|
|
5
|
+
|
|
6
|
+
let currentRoute = router.querySelector(`route[path="${currentPath}"]`);
|
|
7
|
+
|
|
8
|
+
if (!currentRoute) {
|
|
9
|
+
currentRoute = document.createElement("route");
|
|
10
|
+
currentRoute.setAttribute("path", globalThis.location.pathname);
|
|
11
|
+
router.appendChild(currentRoute);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
document.body.classList.add("loading");
|
|
15
|
+
|
|
16
|
+
let content = linkData[globalThis.location.href];
|
|
17
|
+
if (!content) {
|
|
18
|
+
content = await fetchContent(globalThis.location.href);
|
|
19
|
+
linkData[globalThis.location.href] = content;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parser = new DOMParser();
|
|
23
|
+
const doc = parser.parseFromString(content, "text/html");
|
|
24
|
+
|
|
25
|
+
// Update the page title with the new content's title
|
|
26
|
+
const newTitle = doc.querySelector("title");
|
|
27
|
+
if (newTitle) document.title = newTitle.textContent;
|
|
28
|
+
|
|
29
|
+
currentRoute.innerHTML = doc.body.innerHTML;
|
|
30
|
+
|
|
31
|
+
// Execute scripts from the fetched content
|
|
32
|
+
const scripts = Array.from(currentRoute.querySelectorAll("script"));
|
|
33
|
+
for (const oldScript of scripts) {
|
|
34
|
+
const newScript = document.createElement("script");
|
|
35
|
+
if (oldScript.src) {
|
|
36
|
+
newScript.src = oldScript.src;
|
|
37
|
+
} else {
|
|
38
|
+
newScript.textContent = oldScript.textContent;
|
|
13
39
|
}
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
});
|
|
40
|
+
oldScript.parentNode.replaceChild(newScript, oldScript);
|
|
41
|
+
}
|
|
17
42
|
|
|
18
|
-
|
|
43
|
+
router.querySelectorAll("route").forEach(route => (route.style.display = "none"));
|
|
44
|
+
currentRoute.style.display = "contents";
|
|
19
45
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
document.body.classList.remove("loading");
|
|
47
|
+
// Reset scroll position to the top
|
|
48
|
+
window.scrollTo(0, 0);
|
|
23
49
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
linkData[link.href] = await fetchContent(link.href);
|
|
50
|
+
// Call the route change handler if it's set
|
|
51
|
+
if (onRouteChange) onRouteChange(currentPath);
|
|
27
52
|
};
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
54
|
+
//link management
|
|
55
|
+
|
|
56
|
+
const fetchAndSaveContent = async link => {
|
|
57
|
+
if (!linkData[link.href]) {
|
|
58
|
+
linkData[link.href] = await fetchContent(link.href);
|
|
59
|
+
}
|
|
32
60
|
};
|
|
33
61
|
|
|
34
62
|
const handleLinkIntersection = (entries, observer) => {
|
|
@@ -43,49 +71,113 @@ const handleLinkIntersection = (entries, observer) => {
|
|
|
43
71
|
});
|
|
44
72
|
};
|
|
45
73
|
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
49
|
-
|
|
50
|
-
const currentRoute = router.querySelector(`route[path="${globalThis.location.pathname}"]`) ||
|
|
51
|
-
router.querySelector(`route[path="/default"]`);
|
|
52
|
-
|
|
53
|
-
router.querySelectorAll("route").forEach(route => (route.style.display = "none"));
|
|
54
|
-
currentRoute.style.display = "contents";
|
|
55
|
-
|
|
56
|
-
if (!currentRoute.innerHTML) {
|
|
57
|
-
currentRoute.innerHTML = await linkData[globalThis.location.href];
|
|
58
|
-
if (typeof onRouteChange === 'function') onRouteChange(currentRoute);
|
|
74
|
+
const handleLinkHover = async event => {
|
|
75
|
+
const link = event.target;
|
|
76
|
+
if (!linkData[link.href] && isInternalLink(link.href)) {
|
|
77
|
+
await fetchAndSaveContent(link);
|
|
59
78
|
}
|
|
60
79
|
};
|
|
61
80
|
|
|
62
|
-
const handleLinkClick =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
globalThis.dispatchEvent(new Event("popstate"));
|
|
69
|
-
}
|
|
70
|
-
}
|
|
81
|
+
const handleLinkClick = e => {
|
|
82
|
+
const link = e.target.closest("A");
|
|
83
|
+
if (!link || !link.href || !isInternalLink(link.href) || link.origin !== location.origin) return;
|
|
84
|
+
else e.preventDefault();
|
|
85
|
+
globalThis.history.pushState(null, null, link.href);
|
|
86
|
+
globalThis.dispatchEvent(new Event("popstate"));
|
|
71
87
|
};
|
|
72
88
|
|
|
73
|
-
|
|
89
|
+
const observeLinks = observer => {
|
|
74
90
|
const saveDataOn = navigator.connection && navigator.connection.saveData;
|
|
75
91
|
const links = document.querySelectorAll("a");
|
|
76
|
-
|
|
92
|
+
|
|
77
93
|
links.forEach(link => {
|
|
78
|
-
if (link.getAttribute("prefetch") !== "onHover" && !saveDataOn) observer.observe(link);
|
|
94
|
+
if (link.getAttribute("prefetch") !== "onHover" && !saveDataOn && !isInternalLink(link.href)) observer.observe(link);
|
|
79
95
|
});
|
|
80
96
|
};
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
if (
|
|
98
|
+
function isInternalLink(href) {
|
|
99
|
+
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return false;
|
|
100
|
+
if (href.startsWith("/")) return true;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(href, window.location.origin);
|
|
104
|
+
const currentUrl = new URL(window.location.href);
|
|
105
|
+
|
|
106
|
+
const currentHost = currentUrl.hostname.replace(/^www\./, "");
|
|
107
|
+
const targetHost = url.hostname.replace(/^www\./, "");
|
|
108
|
+
|
|
109
|
+
// Compare hosts
|
|
110
|
+
if (currentHost !== targetHost) return false;
|
|
111
|
+
|
|
112
|
+
// Compare paths (ignoring parameters and fragments)
|
|
113
|
+
const currentPath = currentUrl.pathname;
|
|
114
|
+
const targetPath = url.pathname;
|
|
115
|
+
|
|
116
|
+
return currentPath !== targetPath || !url.hash;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let onRouteChange;
|
|
123
|
+
|
|
124
|
+
const setRouteChangeHandler = handler => {
|
|
125
|
+
onRouteChange = handler;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const fetchContent = async url => {
|
|
129
|
+
const response = await fetchWithFallback(url);
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
return `Couldn't fetch the route - HTTP error! status: ${response.status}`;
|
|
132
|
+
}
|
|
133
|
+
return await response.text();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Updated fetchWithFallback to check the flag
|
|
137
|
+
const fetchWithFallback = async url => {
|
|
138
|
+
if (!routerCreatedManually) {
|
|
139
|
+
const res = await fetch(url, { method: "POST", body: "onlyRoute" });
|
|
140
|
+
if (res.ok) return res;
|
|
141
|
+
}
|
|
142
|
+
return await fetch(url);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let routerCreatedManually = false;
|
|
146
|
+
const startRouter = (options = {}) => {
|
|
147
|
+
const { onRouteChange } = options;
|
|
148
|
+
if (onRouteChange) setRouteChangeHandler(onRouteChange);
|
|
149
|
+
const style = document.createElement("style");
|
|
150
|
+
style.textContent = `
|
|
151
|
+
body.loading {
|
|
152
|
+
animation: pulseOpacity 1s infinite alternate;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@keyframes pulseOpacity {
|
|
156
|
+
from { opacity: 0.8; }
|
|
157
|
+
to { opacity: 0.3; }
|
|
158
|
+
}
|
|
159
|
+
`;
|
|
160
|
+
document.head.appendChild(style);
|
|
161
|
+
|
|
162
|
+
let router = document.querySelector("router");
|
|
163
|
+
const currentPath = globalThis.location.pathname;
|
|
164
|
+
|
|
165
|
+
if (!router) {
|
|
166
|
+
router = document.createElement("router");
|
|
167
|
+
const route = document.createElement("route");
|
|
168
|
+
route.setAttribute("path", currentPath);
|
|
169
|
+
route.style.contentVisibility = "auto";
|
|
170
|
+
route.innerHTML = document.body.innerHTML;
|
|
171
|
+
router.appendChild(route);
|
|
172
|
+
document.body.innerHTML = "";
|
|
173
|
+
document.body.appendChild(router);
|
|
174
|
+
routerCreatedManually = true;
|
|
175
|
+
}
|
|
84
176
|
|
|
85
177
|
globalThis.addEventListener("popstate", handlePopState);
|
|
86
178
|
document.addEventListener("click", handleLinkClick);
|
|
87
|
-
|
|
88
|
-
document.body.addEventListener("mouseover",
|
|
179
|
+
|
|
180
|
+
document.body.addEventListener("mouseover", event => {
|
|
89
181
|
if (event.target.tagName === "A" && event.target.getAttribute("prefetch") === "onHover") {
|
|
90
182
|
handleLinkHover(event);
|
|
91
183
|
}
|
|
@@ -94,3 +186,5 @@ export const initializeRouter = () => {
|
|
|
94
186
|
const observer = new IntersectionObserver(handleLinkIntersection, { root: null, threshold: 0.5 });
|
|
95
187
|
observeLinks(observer);
|
|
96
188
|
};
|
|
189
|
+
|
|
190
|
+
export { startRouter };
|
package/package.tmp.json
DELETED
|
File without changes
|
package/src/index.js
DELETED
package/src/utils.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export const getPathname = () => {
|
|
2
|
-
const { pathname } = globalThis.location;
|
|
3
|
-
return pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
export const fetchContent = async (url) => {
|
|
7
|
-
const response = await fetch(url, { method: "POST", body: "onlyRoute" });
|
|
8
|
-
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
9
|
-
|
|
10
|
-
const reader = response.body.getReader();
|
|
11
|
-
const decoder = new TextDecoder("utf-8");
|
|
12
|
-
let content = "";
|
|
13
|
-
|
|
14
|
-
while (true) {
|
|
15
|
-
const { done, value } = await reader.read();
|
|
16
|
-
if (done) break;
|
|
17
|
-
content += decoder.decode(value);
|
|
18
|
-
}
|
|
19
|
-
return content;
|
|
20
|
-
};
|