flare-router
Version:
Blazingly fast SPA-like router for static sites - Pure vanilla JS
356 lines (352 loc) • 10.8 kB
JavaScript
// 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
};