flare-router
Version:
Blazingly fast SPA-like router for static sites - Pure vanilla JS
2 lines (1 loc) • 5.82 kB
JavaScript
(function(h,a){typeof exports=="object"&&typeof module<"u"?module.exports=a():typeof define=="function"&&define.amd?define(a):(h=typeof globalThis<"u"?globalThis:h||self,h.Flare=a())})(this,function(){"use strict";function h(n,e){if(["link","go"].includes(n))if(e){const t=document.querySelector(e);t?t.scrollIntoView({behavior:"smooth",block:"start"}):window.scrollTo({top:0})}else window.scrollTo({top:0})}function a(n){const e=new URL(n||window.location.href).href;return e.endsWith("/")||e.includes(".")||e.includes("#")?e:`${e}/`}function y(n){(!window.history.state||window.history.state.url!==n)&&window.history.pushState({url:n},"internalLink",n)}function k(n){document.querySelector(n).scrollIntoView({behavior:"smooth",block:"start"})}function E(n){const e=a();return{type:"popstate",next:e}}function S(n){var t;let e;if(n.altKey||n.ctrlKey||n.metaKey||n.shiftKey)return{type:"disqualified"};for(let o=n.target;o.parentNode;o=o.parentNode)if(o.nodeName==="A"){e=o;break}if(e&&e.host!==location.host)return e.target="_blank",{type:"external"};if(e&&"cold"in(e==null?void 0:e.dataset))return{type:"disqualified"};if(e!=null&&e.hasAttribute("href")){const o=e.getAttribute("href"),r=new URL(o,location.href);if(n.preventDefault(),o!=null&&o.startsWith("#"))return k(o),{type:"scrolled"};const s=(t=o.match(/#([\w'-]+)\b/g))==null?void 0:t[0],i=a(r.href),c=a();return{type:"link",next:i,prev:c,scrollId:s}}else return{type:"noop"}}function N(n){return new DOMParser().parseFromString(n,"text/html")}function p(n){document.body.querySelectorAll("[flare-preserve]").forEach(t=>{let o=n.body.querySelector('[flare-preserve][id="'+t.id+'"]');if(o){const r=t.cloneNode(!0);o.replaceWith(r)}}),document.body.replaceWith(n.body)}function x(n){const e=i=>Array.from(i.querySelectorAll('head>:not([rel="prefetch"]')),t=e(document),o=e(n),{staleNodes:r,freshNodes:s}=L(t,o);r.forEach(i=>i.remove()),document.head.append(...s)}function L(n,e){const t=[],o=[];let r=0,s=0;for(;r<n.length||s<e.length;){const i=n[r],c=e[s];if(i!=null&&i.isEqualNode(c)){r++,s++;continue}const l=i?o.findIndex(d=>d.isEqualNode(i)):-1;if(l!==-1){o.splice(l,1),r++;continue}const f=c?t.findIndex(d=>d.isEqualNode(c)):-1;if(f!==-1){t.splice(f,1),s++;continue}i&&t.push(i),c&&o.push(c),r++,s++}return{staleNodes:t,freshNodes:o}}function w(){document.head.querySelectorAll("[data-reload]").forEach(m),document.body.querySelectorAll("script").forEach(m)}function m(n){const e=document.createElement("script"),t=Array.from(n.attributes);for(const{name:o,value:r}of t)e[o]=r;e.append(n.textContent),n.replaceWith(e)}const T={log:!1,pageTransitions:!1};class A{constructor(e={}){this.enabled=!0,this.prefetched=new Set,this.observer=null,this.opts={...T,...e},window!=null&&window.history?(document.addEventListener("click",t=>this.onClick(t)),window.addEventListener("popstate",t=>this.onPop(t)),this.prefetch()):(console.warn("flare router not supported in this browser or environment"),this.enabled=!1)}go(e){const t=window.location.href,o=new URL(e,location.origin).href;return this.reconstructDOM({type:"go",next:o,prev:t})}back(){window.history.back()}forward(){window.history.forward()}get allLinks(){return Array.from(document.links).filter(e=>e.href.includes(document.location.origin)&&!e.href.includes("#")&&e.href!==(document.location.href||document.location.href+"/")&&!this.prefetched.has(e.href))}log(...e){this.opts.log&&console.log(...e)}prefetch(){if(this.opts.prefetch==="visible")this.prefetchVisible();else if(this.opts.prefetch==="hover")this.prefetchOnHover();else return}prefetchOnHover(){this.allLinks.forEach(e=>{const t=e.getAttribute("href");e.addEventListener("pointerenter",()=>this.createLink(t),{once:!0})})}prefetchVisible(){const e={root:null,rootMargin:"0px",threshold:1};"IntersectionObserver"in window&&(this.observer=this.observer||new IntersectionObserver((t,o)=>{t.forEach(r=>{const s=r.target.getAttribute("href");if(this.prefetched.has(s)){o.unobserve(r.target);return}r.isIntersecting&&(this.createLink(s),o.unobserve(r.target))})},e),this.allLinks.forEach(t=>this.observer.observe(t)))}createLink(e){const t=document.createElement("link");t.rel="prefetch",t.href=e,t.as="document",t.onload=()=>this.log("\u{1F329}\uFE0F prefetched",e),t.onerror=o=>this.log("\u{1F915} can't prefetch",e,o),document.head.appendChild(t),this.prefetched.add(e)}onClick(e){this.reconstructDOM(S(e))}onPop(e){this.reconstructDOM(E())}async reconstructDOM({type:e,next:t,prev:o,scrollId:r}){if(!this.enabled){this.log("router disabled");return}try{if(this.log("\u26A1",e),["popstate","link","go"].includes(e)&&t!==o){this.opts.log&&console.time("\u23F1\uFE0F"),window.dispatchEvent(new CustomEvent("flare:router:fetch")),e!="popstate"&&y(t);const i=await(await fetch(t,{headers:{"X-Flare":"1"}}).then(l=>{const f=l.body.getReader(),d=parseInt(l.headers.get("Content-Length"));let u=0;return new ReadableStream({start(g){function b(){f.read().then(({done:q,value:v})=>{if(q){g.close();return}u+=v.length,window.dispatchEvent(new CustomEvent("flare:router:fetch-progress",{detail:{progress:Number.isNaN(d)?0:u/d*100,received:u,length:d||0}})),g.enqueue(v),b()})}b()}})}).then(l=>new Response(l,{headers:{"Content-Type":"text/html"}}))).text(),c=N(i);x(c),this.opts.pageTransitions&&document.createDocumentTransition?document.createDocumentTransition().start(()=>{p(c),w(),h(e,r)}):(p(c),w(),h(e,r)),window.dispatchEvent(new CustomEvent("flare:router:end")),setTimeout(()=>{this.prefetch()},200),this.opts.log&&console.timeEnd("\u23F1\uFE0F")}}catch(s){return window.dispatchEvent(new CustomEvent("flare:router:error",s)),this.opts.log&&console.timeEnd("\u23F1\uFE0F"),console.error("\u{1F4A5} router fetch failed",s),!1}}}return(n={})=>{const e=new A(n);return n.log&&console.log("\u{1F525} flare engaged"),window&&(window.flare=e),e}});