UNPKG

flare-router

Version:

Blazingly fast SPA-like router for static sites - Pure vanilla JS

356 lines (352 loc) 10.8 kB
// lib/handlers.js function scrollTo(type, id) { if (["link", "go"].includes(type)) { if (id) { const el = document.querySelector(id); el ? el.scrollIntoView({ behavior: "smooth", block: "start" }) : window.scrollTo({ top: 0 }); } else { window.scrollTo({ top: 0 }); } } } function fullURL(url) { const href = new URL(url || window.location.href).href; return href.endsWith("/") || href.includes(".") || href.includes("#") ? href : `${href}/`; } function addToPushState(url) { if (!window.history.state || window.history.state.url !== url) { window.history.pushState({ url }, "internalLink", url); } } function scrollToAnchor(anchor) { document.querySelector(anchor).scrollIntoView({ behavior: "smooth", block: "start" }); } function handlePopState(_) { const next = fullURL(); return { type: "popstate", next }; } function handleLinkClick(e) { let anchor; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return { type: "disqualified" }; } for (let n = e.target; n.parentNode; n = n.parentNode) { if (n.nodeName === "A") { anchor = n; break; } } if (anchor && anchor.host !== location.host) { anchor.target = "_blank"; return { type: "external" }; } if (anchor && "cold" in anchor?.dataset) { return { type: "disqualified" }; } if (anchor?.hasAttribute("href")) { const ahref = anchor.getAttribute("href"); const url = new URL(ahref, location.href); e.preventDefault(); if (ahref?.startsWith("#")) { scrollToAnchor(ahref); return { type: "scrolled" }; } const scrollId = ahref.match(/#([\w'-]+)\b/g)?.[0]; const next = fullURL(url.href); const prev = fullURL(); return { type: "link", next, prev, scrollId }; } else { return { type: "noop" }; } } // lib/dom.js function formatNextDocument(html) { const parser = new DOMParser(); return parser.parseFromString(html, "text/html"); } function replaceBody(nextDoc) { const nodesToPreserve = document.body.querySelectorAll("[flare-preserve]"); nodesToPreserve.forEach((oldDocElement) => { let nextDocElement = nextDoc.body.querySelector('[flare-preserve][id="' + oldDocElement.id + '"]'); if (nextDocElement) { const clone = oldDocElement.cloneNode(true); nextDocElement.replaceWith(clone); } }); document.body.replaceWith(nextDoc.body); } function mergeHead(nextDoc) { const getValidNodes = (doc) => Array.from(doc.querySelectorAll('head>:not([rel="prefetch"]')); const oldNodes = getValidNodes(document); const nextNodes = getValidNodes(nextDoc); const { staleNodes, freshNodes } = partitionNodes(oldNodes, nextNodes); staleNodes.forEach((node) => node.remove()); document.head.append(...freshNodes); } function partitionNodes(oldNodes, nextNodes) { const staleNodes = []; const freshNodes = []; let oldMark = 0; let nextMark = 0; while (oldMark < oldNodes.length || nextMark < nextNodes.length) { const old = oldNodes[oldMark]; const next = nextNodes[nextMark]; if (old?.isEqualNode(next)) { oldMark++; nextMark++; continue; } const oldInFresh = old ? freshNodes.findIndex((node) => node.isEqualNode(old)) : -1; if (oldInFresh !== -1) { freshNodes.splice(oldInFresh, 1); oldMark++; continue; } const nextInStale = next ? staleNodes.findIndex((node) => node.isEqualNode(next)) : -1; if (nextInStale !== -1) { staleNodes.splice(nextInStale, 1); nextMark++; continue; } old && staleNodes.push(old); next && freshNodes.push(next); oldMark++; nextMark++; } return { staleNodes, freshNodes }; } function runScripts() { const headScripts = document.head.querySelectorAll("[data-reload]"); headScripts.forEach(replaceAndRunScript); const bodyScripts = document.body.querySelectorAll("script"); bodyScripts.forEach(replaceAndRunScript); } function replaceAndRunScript(oldScript) { const newScript = document.createElement("script"); const attrs = Array.from(oldScript.attributes); for (const { name, value } of attrs) { newScript[name] = value; } newScript.append(oldScript.textContent); oldScript.replaceWith(newScript); } // lib/router.js var defaultOpts = { log: false, pageTransitions: false }; var Router = class { constructor(opts = {}) { this.enabled = true; this.prefetched = /* @__PURE__ */ new Set(); this.observer = null; this.opts = { ...defaultOpts, ...opts }; if (window?.history) { document.addEventListener("click", (e) => this.onClick(e)); window.addEventListener("popstate", (e) => this.onPop(e)); this.prefetch(); } else { console.warn("flare router not supported in this browser or environment"); this.enabled = false; } } /** * @param {string} path * Navigate to a url */ go(path) { const prev = window.location.href; const next = new URL(path, location.origin).href; return this.reconstructDOM({ type: "go", next, prev }); } /** * Navigate back */ back() { window.history.back(); } /** * Navigate forward */ forward() { window.history.forward(); } /** * Find all links on page */ get allLinks() { return Array.from(document.links).filter( (node) => node.href.includes(document.location.origin) && // on origin url !node.href.includes("#") && // not an id anchor node.href !== (document.location.href || document.location.href + "/") && // not current page !this.prefetched.has(node.href) // not already prefetched ); } log(...args) { this.opts.log && console.log(...args); } /** * Check if the route is qualified for prefetching and prefetch it with chosen method */ prefetch() { if (this.opts.prefetch === "visible") { this.prefetchVisible(); } else if (this.opts.prefetch === "hover") { this.prefetchOnHover(); } else { return; } } /** * Finds links on page and prefetches them on hover */ prefetchOnHover() { this.allLinks.forEach((node) => { const url = node.getAttribute("href"); node.addEventListener("pointerenter", () => this.createLink(url), { once: true }); }); } /** * Prefetch all visible links */ prefetchVisible() { const intersectionOpts = { root: null, rootMargin: "0px", threshold: 1 }; if ("IntersectionObserver" in window) { this.observer = this.observer || new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { const url = entry.target.getAttribute("href"); if (this.prefetched.has(url)) { observer.unobserve(entry.target); return; } if (entry.isIntersecting) { this.createLink(url); observer.unobserve(entry.target); } }); }, intersectionOpts); this.allLinks.forEach((node) => this.observer.observe(node)); } } /** * @param {string} url * Create a link to prefetch */ createLink(url) { const linkEl = document.createElement("link"); linkEl.rel = "prefetch"; linkEl.href = url; linkEl.as = "document"; linkEl.onload = () => this.log("\u{1F329}\uFE0F prefetched", url); linkEl.onerror = (err) => this.log("\u{1F915} can't prefetch", url, err); document.head.appendChild(linkEl); this.prefetched.add(url); } /** * @param {MouseEvent} e * Handle clicks on links */ onClick(e) { this.reconstructDOM(handleLinkClick(e)); } /** * @param {PopStateEvent} e * Handle popstate events like back/forward */ onPop(e) { this.reconstructDOM(handlePopState(e)); } /** * @param {Object} routeChangeData * Main process for reconstructing the DOM */ async reconstructDOM({ type, next, prev, scrollId }) { if (!this.enabled) { this.log("router disabled"); return; } try { this.log("\u26A1", type); if (["popstate", "link", "go"].includes(type) && next !== prev) { this.opts.log && console.time("\u23F1\uFE0F"); window.dispatchEvent(new CustomEvent("flare:router:fetch")); if (type != "popstate") { addToPushState(next); } const res = await fetch(next, { headers: { "X-Flare": "1" } }).then((res2) => { const reader = res2.body.getReader(); const length = parseInt(res2.headers.get("Content-Length")); let bytesReceived = 0; return new ReadableStream({ start(controller) { function push() { reader.read().then(({ done, value }) => { if (done) { controller.close(); return; } bytesReceived += value.length; window.dispatchEvent( new CustomEvent("flare:router:fetch-progress", { detail: { // length may be NaN if no Content-Length header was found progress: Number.isNaN(length) ? 0 : bytesReceived / length * 100, received: bytesReceived, length: length || 0 } }) ); controller.enqueue(value); push(); }); } push(); } }); }).then((stream) => new Response(stream, { headers: { "Content-Type": "text/html" } })); const html = await res.text(); const nextDoc = formatNextDocument(html); mergeHead(nextDoc); if (this.opts.pageTransitions && document.createDocumentTransition) { const transition = document.createDocumentTransition(); transition.start(() => { replaceBody(nextDoc); runScripts(); scrollTo(type, scrollId); }); } else { replaceBody(nextDoc); runScripts(); scrollTo(type, scrollId); } window.dispatchEvent(new CustomEvent("flare:router:end")); setTimeout(() => { this.prefetch(); }, 200); this.opts.log && console.timeEnd("\u23F1\uFE0F"); } } catch (err) { window.dispatchEvent(new CustomEvent("flare:router:error", err)); this.opts.log && console.timeEnd("\u23F1\uFE0F"); console.error("\u{1F4A5} router fetch failed", err); return false; } } }; // lib/main.js var main_default = (opts = {}) => { const router = new Router(opts); opts.log && console.log("\u{1F525} flare engaged"); if (window) { window.flare = router; } return router; }; export { main_default as default };