lightweight-router 1.0.9 β†’ 1.0.12

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 CHANGED
@@ -13,7 +13,7 @@ PageFlick is a minimal lightweight client-side router with intelligent prefetchi
13
13
  - 🎨 Built-in loading animations
14
14
  - πŸ•°οΈ Based on History API so you can use native browser navigation
15
15
  - πŸ€– Automatic title change
16
-
16
+ - πŸ“œ **Scroll routes** β€” multi-section landing pages: full-height `<route scroll>` blocks, smooth in-page navigation, and URL bar sync while scrolling
17
17
 
18
18
  ## Installation
19
19
 
@@ -35,14 +35,15 @@ Example:
35
35
  ```javascript
36
36
  import { startRouter } from "lightweight-router";
37
37
 
38
- startRouter()
38
+ startRouter();
39
39
 
40
40
  //or with your callback
41
41
 
42
42
  startRouter({
43
- onRouteChange: currentRoute => {
44
- console.log("Route changed:", currentRoute);
43
+ onRouteChange: path => {
44
+ console.log("Route changed:", path);
45
45
  },
46
+ debug: true, // optional: log router activity to the console
46
47
  });
47
48
  ```
48
49
 
@@ -74,7 +75,8 @@ Initializes the router with the given options.
74
75
  #### Parameters
75
76
 
76
77
  - `options` (Object): Configuration options for the router.
77
- - `onRouteChange` (Function): Callback function to be called when the route changes.
78
+ - `onRouteChange` (Function): Called with the **current path string** when the route changes (including when the URL is updated while scrolling through scroll routes).
79
+ - `debug` (Boolean): When `true`, logs navigation and prefetch activity to the console.
78
80
 
79
81
  ## Examples
80
82
 
@@ -86,19 +88,20 @@ Your website content
86
88
  import { startRouter } from "./router.js";
87
89
 
88
90
  startRouter({
89
- onRouteChange: currentRoute => {
90
- console.log("Route changed:", currentRoute);
91
+ onRouteChange: path => {
92
+ console.log("Route changed:", path);
91
93
  },
92
94
  });
93
95
  </script>
94
96
  ```
95
97
 
98
+ See **`src/index.html`** in this repo for a full **scroll routes** landing example (`<route scroll>` sections and nav).
99
+
96
100
  ## Prefetching
97
101
 
98
102
  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.
99
103
  This behaviour is automatically disabled if the user has data saving preferences.
100
104
 
101
-
102
105
  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):
103
106
 
104
107
  ```html
@@ -110,6 +113,31 @@ The minified version was created with uglify-js, clean.css and then ultra minifi
110
113
  The size of the gzipped version was calculated with: https://dafrok.github.io/gzip-size-online/
111
114
  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.
112
115
 
116
+ ## Scroll routes (landing sections)
117
+
118
+ Use this for a **single HTML page** that behaves like several β€œroutes”: tall sections that share one document, each with its own pathname. Mark sections with **`scroll`** on `<route>` and a **`path`** that matches the URL you want in the address bar.
119
+
120
+ ```html
121
+ <router>
122
+ <route scroll path="/">
123
+ <!-- hero -->
124
+ </route>
125
+ <route scroll path="/features">
126
+ <!-- features -->
127
+ </route>
128
+ <route scroll path="/pricing.html">
129
+ <!-- pricing -->
130
+ </route>
131
+ </router>
132
+ ```
133
+
134
+ **Behavior**
135
+
136
+ - When the current URL matches a **`route[scroll]`** `path`, all scroll routes are shown (non-scroll routes inside `<router>` are hidden), and the matching section is scrolled into view. Internal links to another scroll route use **smooth** scrolling.
137
+ - While the user scrolls, an **IntersectionObserver** picks the most visible section and updates the URL with **`history.replaceState`** (and fires `onRouteChange`) so the address bar tracks the section in view.
138
+ - Empty scroll routes still **fetch** HTML from the server like normal routes (prefetching applies the same way).
139
+ - **Deep links**: opening `/features` directly only works if your static server **falls back to this HTML** for unknown paths (for example `npx serve . --single`).
140
+
113
141
  ## Browser Support
114
142
 
115
143
  The router is intended for modern browsers. Required features:
@@ -136,11 +164,11 @@ This allows only the changing part of the document to be updated, improving perf
136
164
  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:
137
165
 
138
166
  ```html
139
- <-- Header menu and parts that don't change -->
167
+ <!-- Header menu and parts that don't change -->
140
168
  <router>
141
169
  <route path="/">home content</route>
142
170
  </router>
143
- <-- footer etc.. -->
171
+ <!-- footer etc.. -->
144
172
  ```
145
173
 
146
174
  You can also prerender most visited routes by rendering them inside the `router` tag in their appropriate `route` tags for faster loading times:
@@ -159,20 +187,15 @@ Right now errors are shown without styling as the content of the page.
159
187
  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
188
  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).
161
189
 
162
-
163
190
  ## Performance Tips
191
+
164
192
  - Implement server-side partial responses for better bandwidth usage
165
193
  - Consider using the `prefetch="onHover"` attribute for less important links
166
194
 
167
195
  ## Future Development
196
+
168
197
  - Delay router intialization on first link hover for better performances?
169
198
  - Implement html streaming for faster page load
170
199
  - Cooler Error handling
171
200
  - Disable caching on certain links/routes (that needs to be always up to date)
172
201
  - Cache limiting
173
-
174
-
175
-
176
-
177
-
178
-
@@ -1 +1 @@
1
- (()=>{let l={},n=!1,c=(...e)=>n&&console.log("🚦 Router:",...e),o=async()=>{c("Navigation triggered to:",globalThis.location.pathname),document.body.classList.add("loading");var e=globalThis.location.pathname.replace(/\/$/,""),t=document.querySelector("router");let r=t.querySelector(`route[path="${e}"]`);if(r||(c("Creating new route element for:",e),(r=document.createElement("route")).setAttribute("path",e),t.appendChild(r)),!r.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 n,o=(new DOMParser).parseFromString(e,"text/html"),a=o.querySelector("title"),a=(a&&(c("Updating page title to:",a.textContent),document.title=a.textContent),r.innerHTML=o.body.innerHTML,Array.from(r.querySelectorAll("script")));c("Executing",a.length,"scripts from fetched content");for(n of a){var i=document.createElement("script");n.src?i.src=n.src:i.textContent=n.textContent,n.parentNode.replaceChild(i,n)}}t.querySelectorAll("route").forEach(e=>e.style.display="none"),r.style.display="contents",document.body.classList.remove("loading"),window.scrollTo(0,0),d&&d(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,r)=>{c("πŸ” Intersection Observer triggered for",e.length,"entries"),e.forEach(e=>{var t=e.target;c(`🎯 Link ${t.href} intersection:`,{isIntersecting:e.isIntersecting,intersectionRatio:e.intersectionRatio,alreadyCached:!!l[t.href]}),e.isIntersecting&&(l[t.href]?c("πŸ“¦ Content already cached for:",t.href):(a(t),c("πŸ‘οΈ Unobserving link after prefetch initiated:",t.href),r.unobserve(t)))})},s=e=>{var t=e.target.closest("A");t&&t.href&&h(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 h(e){if(!e||e.startsWith("#")||e.startsWith("javascript:"))return!1;if(e.startsWith("/"))return!0;try{var t=new URL(e,window.location.origin),r=new URL(window.location.href);return r.hostname.replace(/^www\./,"")!==t.hostname.replace(/^www\./,"")?!1:r.pathname!==t.pathname||!t.hash}catch{return!1}}let d,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:r}=e,e=(n=r,c("Router starting...",e),t&&(r=t,d=r),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")),r=globalThis.location.pathname,r=(t||(c("Creating new router element"),t=document.createElement("router"),(e=document.createElement("route")).setAttribute("path",r),e.innerHTML=document.body.innerHTML,t.appendChild(e),document.body.innerHTML="",document.body.appendChild(t),f=!0),globalThis.addEventListener("popstate",o),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]&&h(e.href)&&await a(e)})(e)}),new IntersectionObserver(i,{root:null,threshold:.5}));c("🎭 Created Intersection Observer with config:",{root:"viewport",threshold:.5}),(r=>{let n=navigator.connection&&navigator.connection.saveData;var e=document.querySelectorAll("a");c("πŸ”„ Starting link observation...",{totalLinks:e.length,saveDataMode:n}),e.forEach(e=>{var t="onHover"!==e.getAttribute("prefetch")&&!n&&h(e.href);c("πŸ”— Link evaluation:",{href:e.href,prefetchAttr:e.getAttribute("prefetch"),isInternal:h(e.href),willObserve:t}),t&&(r.observe(e),c("πŸ‘€ Now observing link:",e.href))})})(r)};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>e()):e()})();
1
+ (()=>{let c={},o=!1,s=(...e)=>o&&console.log("🚦 Router:",...e),h="auto",t=null,u=0,n=e=>(e??"").replace(/\/$/,""),d=(e,t)=>{var r,o=n(t);for(r of e.querySelectorAll("route[scroll]"))if(n(r.getAttribute("path"))===o)return r;return null},f=()=>{t?.disconnect(),t=null},g=e=>{f();e=[...e.querySelectorAll("route[scroll]")];e.length&&(t=new IntersectionObserver(e=>{var t,r;Date.now()<u||(e=e.filter(e=>e.isIntersecting)).length&&(e.sort((e,t)=>t.intersectionRatio-e.intersectionRatio),e=e[0].target.getAttribute("path"))&&(t=n(e))!==n(globalThis.location.pathname)&&((r=new URL(globalThis.location.href)).pathname=e.startsWith("/")?e:"/"+e,globalThis.history.replaceState(null,"",r.pathname+r.search+r.hash),s("πŸ“ Scroll URL sync:",t),y)&&y(t)},{root:null,rootMargin:"-38% 0px -38% 0px",threshold:[0,.1,.25,.5,.75,1]}),e.forEach(e=>t.observe(e)))},a=async()=>{s("Navigation triggered to:",globalThis.location.pathname),document.body.classList.add("loading");var e=globalThis.location.pathname.replace(/\/$/,"");let t=document.querySelector("router"),r=t.querySelector(`route[path="${e}"]`);if(r||(s("Creating new route element for:",e),(r=document.createElement("route")).setAttribute("path",e),t.appendChild(r)),!r.innerHTML){s("Fetching content for:",globalThis.location.href);let e=c[globalThis.location.href];e||(e=await v(globalThis.location.href),c[globalThis.location.href]=e);var o,n=(new DOMParser).parseFromString(e,"text/html"),a=n.querySelector("title"),a=(a&&(s("Updating page title to:",a.textContent),document.title=a.textContent),r.innerHTML=n.body.innerHTML,Array.from(r.querySelectorAll("script")));s("Executing",a.length,"scripts from fetched content");for(o of a){var l=document.createElement("script");o.src?l.src=o.src:l.textContent=o.textContent,o.parentNode.replaceChild(l,o)}}n=[...t.querySelectorAll("route[scroll]")],a=[...t.querySelectorAll("route:not([scroll])")];let i=d(t,e);if(Boolean(i)){n.forEach(e=>{e.style.display=""}),a.forEach(e=>{e.style.display="none"});let e=h;h="auto","smooth"===e&&(u=Date.now()+550),requestAnimationFrame(()=>{i.scrollIntoView({behavior:e,block:"start"}),requestAnimationFrame(()=>g(t))})}else f(),n.forEach(e=>{e.style.display="none"}),t.querySelectorAll("route").forEach(e=>e.style.display="none"),r.style.display="contents",window.scrollTo(0,0);document.body.classList.remove("loading"),y&&y(e),s("Route change completed")},l=async e=>{c[e.href]||(s("Prefetching content for:",e.href),c[e.href]=await v(e.href))},i=(e,r)=>{s("πŸ” Intersection Observer triggered for",e.length,"entries"),e.forEach(e=>{var t=e.target;s(`🎯 Link ${t.href} intersection:`,{isIntersecting:e.isIntersecting,intersectionRatio:e.intersectionRatio,alreadyCached:!!c[t.href]}),e.isIntersecting&&(c[t.href]?s("πŸ“¦ Content already cached for:",t.href):(l(t),s("πŸ‘οΈ Unobserving link after prefetch initiated:",t.href),r.unobserve(t)))})},p=e=>{var t,r,o=e.target.closest("A");o&&o.href&&m(o.href)&&o.origin===location.origin?(t=document.querySelector("router"),r=new URL(o.href).pathname.replace(/\/$/,""),t&&d(t,r)?(s("πŸ”— Scroll-route link β†’ smooth scroll:",o.href),e.preventDefault(),h="smooth"):(s("Internal link clicked:",o.href),e.preventDefault()),globalThis.history.pushState(null,null,o.href),globalThis.dispatchEvent(new Event("popstate"))):s("Invalid link click:",o?.href)};function m(e){if(!e||e.startsWith("#")||e.startsWith("javascript:"))return!1;if(e.startsWith("/"))return!0;try{var t=new URL(e,window.location.origin),r=new URL(window.location.href);return r.hostname.replace(/^www\./,"")!==t.hostname.replace(/^www\./,"")?!1:r.pathname!==t.pathname||!t.hash}catch{return!1}}let y,v=async e=>{e=await r(e);return e.ok?e.text():"Couldn't fetch the route - HTTP error! status: "+e.status},r=async e=>{if(!b){var t=await fetch(e,{method:"POST",body:"onlyRoute"});if(t.ok)return t}return fetch(e)},b=!1,e=(e={})=>{var{onRouteChange:t,debug:r}=e,e=(o=r,s("Router starting...",e),t&&(r=t,y=r),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")),r=globalThis.location.pathname,r=(t||(s("Creating new router element"),t=document.createElement("router"),(e=document.createElement("route")).setAttribute("path",r),e.innerHTML=document.body.innerHTML,t.appendChild(e),document.body.innerHTML="",document.body.appendChild(t),b=!0),globalThis.addEventListener("popstate",a),document.addEventListener("click",p),document.body.addEventListener("mouseover",e=>{"A"===e.target.tagName&&"onHover"===e.target.getAttribute("prefetch")&&(async e=>{e=e.target;!c[e.href]&&m(e.href)&&await l(e)})(e)}),new IntersectionObserver(i,{root:null,threshold:.5}));s("🎭 Created Intersection Observer with config:",{root:"viewport",threshold:.5}),(r=>{let o=navigator.connection&&navigator.connection.saveData;var e=document.querySelectorAll("a");s("πŸ”„ Starting link observation...",{totalLinks:e.length,saveDataMode:o}),e.forEach(e=>{var t="onHover"!==e.getAttribute("prefetch")&&!o&&m(e.href);s("πŸ”— Link evaluation:",{href:e.href,prefetchAttr:e.getAttribute("prefetch"),isInternal:m(e.href),willObserve:t}),t&&(r.observe(e),s("πŸ‘€ Now observing link:",e.href))})})(r),a()};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>e()):e()})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightweight-router",
3
- "version": "1.0.9",
3
+ "version": "1.0.12",
4
4
  "main": "src/router.js",
5
5
  "scripts": {
6
6
  "dev": "npx http-server src -o",
package/src/blog.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 Blog</title>
6
+ </head>
7
+ <body style="background-color: bisque">
8
+ <menu class="flex m-0 p-0"></menu>
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
+ BLOG
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/home.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 Index</title>
6
+ </head>
7
+ <body>
8
+ <menu class="flex m-0 p-0"></menu>
9
+ <a prefetch="onHover" href="/home.html">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
+ <a href="/">Landingpage</a>
12
+ </menu>
13
+ HOME
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
+ document.querySelector(`route[path='${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/index.html CHANGED
@@ -1,22 +1,175 @@
1
+ <!doctype html>
2
+ <!-- Serve this file so the URL path is /landingpage.html (e.g. npx serve src). If your path is /src/landingpage.html, update route path="/landingpage.html" and the Hero nav href to match. -->
1
3
  <html lang="en">
2
4
  <head>
3
5
  <meta charset="utf-8" />
4
6
  <meta name="viewport" content="width=device-width,initial-scale=1" />
5
- <title>Router manual test Index</title>
7
+ <title>Scroll routes β€” landing demo</title>
8
+ <style>
9
+ :root {
10
+ --bg: #0f1419;
11
+ --surface: #1a2332;
12
+ --text: #e8eef5;
13
+ --muted: #8b9cb3;
14
+ --accent: #3dd6c3;
15
+ --nav-h: 3.25rem;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ margin: 0;
24
+ font-family: system-ui, sans-serif;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ line-height: 1.5;
28
+ }
29
+
30
+ .site-nav {
31
+ position: sticky;
32
+ top: 0;
33
+ z-index: 10;
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ align-items: center;
37
+ gap: 0.5rem 1rem;
38
+ min-height: var(--nav-h);
39
+ padding: 0.5rem 1rem;
40
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
41
+ backdrop-filter: blur(8px);
42
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
43
+ }
44
+
45
+ .site-nav a {
46
+ color: var(--accent);
47
+ text-decoration: none;
48
+ font-weight: 600;
49
+ font-size: 0.9rem;
50
+ }
51
+
52
+ .site-nav a:hover,
53
+ .site-nav a:focus-visible {
54
+ text-decoration: underline;
55
+ outline: none;
56
+ }
57
+
58
+ .site-nav .label {
59
+ color: var(--muted);
60
+ font-size: 0.75rem;
61
+ margin-right: 0.25rem;
62
+ }
63
+
64
+ route[scroll] {
65
+ display: block;
66
+ min-height: 100vh;
67
+ scroll-margin-top: calc(var(--nav-h) + 0.75rem);
68
+ padding: 2rem 1.25rem 4rem;
69
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
70
+ }
71
+
72
+ route[scroll]:nth-of-type(odd) {
73
+ background: linear-gradient(165deg, var(--surface) 0%, var(--bg) 100%);
74
+ }
75
+
76
+ route[scroll]:nth-of-type(even) {
77
+ background: linear-gradient(-5deg, #121a24 0%, var(--surface) 100%);
78
+ }
79
+
80
+ .section-inner {
81
+ max-width: 42rem;
82
+ margin: 0 auto;
83
+ }
84
+
85
+ .eyebrow {
86
+ font-size: 0.75rem;
87
+ letter-spacing: 0.12em;
88
+ text-transform: uppercase;
89
+ color: var(--muted);
90
+ margin: 0 0 0.5rem;
91
+ }
92
+
93
+ h1,
94
+ h2 {
95
+ margin: 0 0 1rem;
96
+ font-weight: 700;
97
+ letter-spacing: -0.02em;
98
+ }
99
+
100
+ h1 {
101
+ font-size: clamp(1.75rem, 4vw, 2.25rem);
102
+ }
103
+
104
+ h2 {
105
+ font-size: clamp(1.35rem, 3vw, 1.75rem);
106
+ }
107
+
108
+ p {
109
+ margin: 0 0 1rem;
110
+ color: color-mix(in srgb, var(--text) 88%, var(--muted));
111
+ }
112
+
113
+ .pill {
114
+ display: inline-block;
115
+ padding: 0.2rem 0.6rem;
116
+ border-radius: 999px;
117
+ font-size: 0.7rem;
118
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
119
+ color: var(--accent);
120
+ margin-bottom: 1rem;
121
+ }
122
+
123
+ .loading body {
124
+ opacity: 0.85;
125
+ }
126
+ </style>
6
127
  </head>
7
128
  <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
129
+ <nav class="site-nav" aria-label="Scroll sections">
130
+ <span class="label">Jump</span>
131
+ <a href="/">Hero</a>
132
+ <a href="/landing-features">Features</a>
133
+ <a href="/pricing.html">Pricing</a>
134
+ <a href="/blog">Blog</a>
135
+ <span class="label" style="margin-left: auto"><a href="index.html">← Index</a></span>
136
+ </nav>
137
+
138
+ <router>
139
+ <route scroll path="/">
140
+ <div class="section-inner">
141
+ <p class="eyebrow">Scroll mode</p>
142
+ <h1>Landing hero</h1>
143
+ <p>Each <code>&lt;route scroll&gt;</code> is a tall section. Internal links smooth-scroll; all sections stay in the DOM.</p>
144
+ <p class="pill">min-height: 100vh</p>
145
+ </div>
146
+ </route>
147
+
148
+ <route scroll path="/landing-features">
149
+ <div class="section-inner">
150
+ <p class="eyebrow">Section 2</p>
151
+ <h2>Features</h2>
152
+ <p>Lazy content loads when this block is near the viewport (IntersectionObserver + 200px) or when you hover a linkβ€”same as the rest of the router.</p>
153
+ <p class="pill">min-height: 100vh</p>
154
+ </div>
155
+ </route>
156
+
157
+ <route scroll path="/pricing.html">
158
+ <div class="section-inner">
159
+ <p class="eyebrow">Section 3</p>
160
+ <h2>Pricing</h2>
161
+ <p>Empty routes fetch HTML from the server when you land here; inline placeholders skip the network.</p>
162
+ <p class="pill">min-height: 100vh</p>
163
+ </div>
164
+ </route>
165
+ </router>
166
+
13
167
  <script type="module">
14
168
  import { startRouter } from "./router.js";
15
- // Initialize the router with a simple onRouteChange callback to test functionality
16
169
  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)
170
+ debug: true,
171
+ onRouteChange: path => {
172
+ console.log(`πŸ“ Route (scroll demo): ${path}`);
20
173
  },
21
174
  });
22
175
  </script>
@@ -0,0 +1,177 @@
1
+ <!doctype html>
2
+ <!-- Serve this file so the URL path is /landingpage.html (e.g. npx serve src). If your path is /src/landingpage.html, update route path="/landingpage.html" and the Hero nav href to match. -->
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
7
+ <title>Pricing</title>
8
+ <style>
9
+ :root {
10
+ --bg: #0f1419;
11
+ --surface: #1a2332;
12
+ --text: #e8eef5;
13
+ --muted: #8b9cb3;
14
+ --accent: #3dd6c3;
15
+ --nav-h: 3.25rem;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ margin: 0;
24
+ font-family: system-ui, sans-serif;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ line-height: 1.5;
28
+ }
29
+
30
+ .site-nav {
31
+ position: sticky;
32
+ top: 0;
33
+ z-index: 10;
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ align-items: center;
37
+ gap: 0.5rem 1rem;
38
+ min-height: var(--nav-h);
39
+ padding: 0.5rem 1rem;
40
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
41
+ backdrop-filter: blur(8px);
42
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
43
+ }
44
+
45
+ .site-nav a {
46
+ color: var(--accent);
47
+ text-decoration: none;
48
+ font-weight: 600;
49
+ font-size: 0.9rem;
50
+ }
51
+
52
+ .site-nav a:hover,
53
+ .site-nav a:focus-visible {
54
+ text-decoration: underline;
55
+ outline: none;
56
+ }
57
+
58
+ .site-nav .label {
59
+ color: var(--muted);
60
+ font-size: 0.75rem;
61
+ margin-right: 0.25rem;
62
+ }
63
+
64
+ route[scroll] {
65
+ display: block;
66
+ min-height: 100vh;
67
+ scroll-margin-top: calc(var(--nav-h) + 0.75rem);
68
+ padding: 2rem 1.25rem 4rem;
69
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
70
+ }
71
+
72
+ route[scroll]:nth-of-type(odd) {
73
+ background: linear-gradient(165deg, var(--surface) 0%, var(--bg) 100%);
74
+ }
75
+
76
+ route[scroll]:nth-of-type(even) {
77
+ background: linear-gradient(-5deg, #121a24 0%, var(--surface) 100%);
78
+ }
79
+
80
+ .section-inner {
81
+ max-width: 42rem;
82
+ margin: 0 auto;
83
+ }
84
+
85
+ .eyebrow {
86
+ font-size: 0.75rem;
87
+ letter-spacing: 0.12em;
88
+ text-transform: uppercase;
89
+ color: var(--muted);
90
+ margin: 0 0 0.5rem;
91
+ }
92
+
93
+ h1,
94
+ h2 {
95
+ margin: 0 0 1rem;
96
+ font-weight: 700;
97
+ letter-spacing: -0.02em;
98
+ }
99
+
100
+ h1 {
101
+ font-size: clamp(1.75rem, 4vw, 2.25rem);
102
+ }
103
+
104
+ h2 {
105
+ font-size: clamp(1.35rem, 3vw, 1.75rem);
106
+ }
107
+
108
+ p {
109
+ margin: 0 0 1rem;
110
+ color: color-mix(in srgb, var(--text) 88%, var(--muted));
111
+ }
112
+
113
+ .pill {
114
+ display: inline-block;
115
+ padding: 0.2rem 0.6rem;
116
+ border-radius: 999px;
117
+ font-size: 0.7rem;
118
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
119
+ color: var(--accent);
120
+ margin-bottom: 1rem;
121
+ }
122
+
123
+ .loading body {
124
+ opacity: 0.85;
125
+ }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <nav class="site-nav" aria-label="Scroll sections">
130
+ <span class="label">Jump</span>
131
+ <a href="/">Hero</a>
132
+ <a href="/landing-features">Features</a>
133
+ <a href="/pricing.html">Pricing</a>
134
+ <a href="/blog">Blog</a>
135
+ <span class="label" style="margin-left: auto"><a href="index.html" data-router-ignore>← Index</a></span>
136
+ </nav>
137
+
138
+ <router>
139
+ <route scroll path="/">
140
+ <div class="section-inner">
141
+ <p class="eyebrow">Scroll mode</p>
142
+ <h1>Landing hero</h1>
143
+ <p>Each <code>&lt;route scroll&gt;</code> is a tall section. Internal links smooth-scroll; all sections stay in the DOM.</p>
144
+ <p class="pill">min-height: 100vh</p>
145
+ </div>
146
+ </route>
147
+
148
+ <route scroll path="/landing-features">
149
+ <div class="section-inner">
150
+ <p class="eyebrow">Section 2</p>
151
+ <h2>Features</h2>
152
+ <p>Lazy content loads when this block is near the viewport (IntersectionObserver + 200px) or when you hover a linkβ€”same as the rest of the router.</p>
153
+ <p class="pill">min-height: 100vh</p>
154
+ </div>
155
+ </route>
156
+
157
+ <route scroll path="/pricing.html">
158
+ <div class="section-inner">
159
+ <p class="eyebrow">Section 3</p>
160
+ <h2>Pricing</h2>
161
+ <p>Empty routes fetch HTML from the server when you land here; inline placeholders skip the network.</p>
162
+ <p class="pill">min-height: 100vh</p>
163
+ </div>
164
+ </route>
165
+ </router>
166
+
167
+ <script type="module">
168
+ import { startRouter } from "./router.js";
169
+ startRouter({
170
+ debug: true,
171
+ onRouteChange: path => {
172
+ console.log(`πŸ“ Route (scroll demo): ${path}`);
173
+ },
174
+ });
175
+ </script>
176
+ </body>
177
+ </html>
package/src/router.js CHANGED
@@ -1,6 +1,60 @@
1
1
  let linkData = {};
2
2
  let debugMode = false;
3
3
  const log = (...args) => debugMode && console.log("🚦 Router:", ...args);
4
+
5
+ /** @type {'auto' | 'smooth'} */
6
+ let pendingScrollBehavior = "auto";
7
+ let scrollUrlObserver = null;
8
+ let suppressScrollUrlSyncUntil = 0;
9
+
10
+ const normalizePath = p => {
11
+ const s = (p ?? "").replace(/\/$/, "");
12
+ return s;
13
+ };
14
+
15
+ /** @param {Element} router */
16
+ const pathToScrollRoute = (router, path) => {
17
+ const n = normalizePath(path);
18
+ for (const r of router.querySelectorAll("route[scroll]")) {
19
+ if (normalizePath(r.getAttribute("path")) === n) return r;
20
+ }
21
+ return null;
22
+ };
23
+
24
+ const teardownScrollUrlObserver = () => {
25
+ scrollUrlObserver?.disconnect();
26
+ scrollUrlObserver = null;
27
+ };
28
+
29
+ /** @param {Element} router */
30
+ const setupScrollUrlObserver = router => {
31
+ teardownScrollUrlObserver();
32
+ const scrollRoutes = [...router.querySelectorAll("route[scroll]")];
33
+ if (!scrollRoutes.length) return;
34
+
35
+ scrollUrlObserver = new IntersectionObserver(
36
+ entries => {
37
+ if (Date.now() < suppressScrollUrlSyncUntil) return;
38
+ const visible = entries.filter(e => e.isIntersecting);
39
+ if (!visible.length) return;
40
+ visible.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
41
+ const best = visible[0].target;
42
+ const pathAttr = best.getAttribute("path");
43
+ if (!pathAttr) return;
44
+ const nextPath = normalizePath(pathAttr);
45
+ const here = normalizePath(globalThis.location.pathname);
46
+ if (nextPath === here) return;
47
+ const u = new URL(globalThis.location.href);
48
+ u.pathname = pathAttr.startsWith("/") ? pathAttr : `/${pathAttr}`;
49
+ globalThis.history.replaceState(null, "", u.pathname + u.search + u.hash);
50
+ log("πŸ“ Scroll URL sync:", nextPath);
51
+ if (onRouteChange) onRouteChange(nextPath);
52
+ },
53
+ { root: null, rootMargin: "-38% 0px -38% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] },
54
+ );
55
+ scrollRoutes.forEach(r => scrollUrlObserver.observe(r));
56
+ };
57
+
4
58
  const handlePopState = async () => {
5
59
  log("Navigation triggered to:", globalThis.location.pathname);
6
60
  document.body.classList.add("loading");
@@ -54,12 +108,38 @@ const handlePopState = async () => {
54
108
  }
55
109
  }
56
110
 
57
- // Display only the current route
58
- router.querySelectorAll("route").forEach(route => (route.style.display = "none"));
59
- currentRoute.style.display = "contents";
111
+ const scrollRoutes = [...router.querySelectorAll("route[scroll]")];
112
+ const nonScrollRoutes = [...router.querySelectorAll("route:not([scroll])")];
113
+ const scrollTarget = pathToScrollRoute(router, currentPath);
114
+ const inScrollMode = Boolean(scrollTarget);
115
+
116
+ if (inScrollMode) {
117
+ scrollRoutes.forEach(r => {
118
+ r.style.display = "";
119
+ });
120
+ nonScrollRoutes.forEach(r => {
121
+ r.style.display = "none";
122
+ });
123
+ const behavior = pendingScrollBehavior;
124
+ pendingScrollBehavior = "auto";
125
+ if (behavior === "smooth") {
126
+ suppressScrollUrlSyncUntil = Date.now() + 550;
127
+ }
128
+ requestAnimationFrame(() => {
129
+ scrollTarget.scrollIntoView({ behavior, block: "start" });
130
+ requestAnimationFrame(() => setupScrollUrlObserver(router));
131
+ });
132
+ } else {
133
+ teardownScrollUrlObserver();
134
+ scrollRoutes.forEach(r => {
135
+ r.style.display = "none";
136
+ });
137
+ router.querySelectorAll("route").forEach(route => (route.style.display = "none"));
138
+ currentRoute.style.display = "contents";
139
+ window.scrollTo(0, 0);
140
+ }
60
141
 
61
142
  document.body.classList.remove("loading");
62
- window.scrollTo(0, 0);
63
143
 
64
144
  // Call the route change handler if it's set
65
145
  if (onRouteChange) onRouteChange(currentPath);
@@ -110,6 +190,17 @@ const handleLinkClick = e => {
110
190
  log("Invalid link click:", link?.href);
111
191
  return;
112
192
  }
193
+ const router = document.querySelector("router");
194
+ const url = new URL(link.href);
195
+ const targetPath = url.pathname.replace(/\/$/, "");
196
+ if (router && pathToScrollRoute(router, targetPath)) {
197
+ log("πŸ”— Scroll-route link β†’ smooth scroll:", link.href);
198
+ e.preventDefault();
199
+ pendingScrollBehavior = "smooth";
200
+ globalThis.history.pushState(null, null, link.href);
201
+ globalThis.dispatchEvent(new Event("popstate"));
202
+ return;
203
+ }
113
204
  log("Internal link clicked:", link.href);
114
205
  e.preventDefault();
115
206
  globalThis.history.pushState(null, null, link.href);
@@ -244,6 +335,8 @@ const startRouter = (options = {}) => {
244
335
  });
245
336
 
246
337
  observeLinks(observer);
338
+
339
+ void handlePopState();
247
340
  };
248
341
 
249
342
  export { startRouter };