259 lines
8.1 KiB
JavaScript
259 lines
8.1 KiB
JavaScript
class Util {
|
||
static pad(num, digits = 2) {
|
||
let zeroes = new Array(digits + 1).join(0);
|
||
return `${zeroes}${num}`.slice(-1 * digits);
|
||
}
|
||
|
||
static log(message) {
|
||
Util.output("log", message);
|
||
}
|
||
static error(message, error) {
|
||
Util.output("error", message, error);
|
||
}
|
||
static output(type, ...messages) {
|
||
let now = new Date();
|
||
let date = `${Util.pad(now.getUTCHours())}:${Util.pad(
|
||
now.getUTCMinutes()
|
||
)}:${Util.pad(now.getUTCSeconds())}.${Util.pad(
|
||
now.getUTCMilliseconds(),
|
||
3
|
||
)}`;
|
||
console[type](`[11ty][${date} UTC]`, ...messages);
|
||
}
|
||
|
||
static capitalize(word) {
|
||
return word.substr(0, 1).toUpperCase() + word.substr(1);
|
||
}
|
||
|
||
static matchRootAttributes(htmlContent) {
|
||
// Workaround for morphdom bug with attributes on <html> https://github.com/11ty/eleventy-dev-server/issues/6
|
||
// Note also `childrenOnly: true` above
|
||
const parser = new DOMParser();
|
||
let parsed = parser.parseFromString(htmlContent, "text/html");
|
||
let parsedDoc = parsed.documentElement;
|
||
let newAttrs = parsedDoc.getAttributeNames();
|
||
|
||
let docEl = document.documentElement;
|
||
// Remove old
|
||
let removedAttrs = docEl.getAttributeNames().filter(name => !newAttrs.includes(name));
|
||
for(let attr of removedAttrs) {
|
||
docEl.removeAttribute(attr);
|
||
}
|
||
|
||
// Add new
|
||
for(let attr of newAttrs) {
|
||
docEl.setAttribute(attr, parsedDoc.getAttribute(attr));
|
||
}
|
||
}
|
||
|
||
static isEleventyLinkNodeMatch(from, to) {
|
||
// Issue #18 https://github.com/11ty/eleventy-dev-server/issues/18
|
||
// Don’t update a <link> if the _11ty searchParam is the only thing that’s different
|
||
if(from.tagName !== "LINK" || to.tagName !== "LINK") {
|
||
return false;
|
||
}
|
||
|
||
let oldWithoutHref = from.cloneNode();
|
||
let newWithoutHref = to.cloneNode();
|
||
|
||
oldWithoutHref.removeAttribute("href");
|
||
newWithoutHref.removeAttribute("href");
|
||
|
||
// if all other attributes besides href match
|
||
if(!oldWithoutHref.isEqualNode(newWithoutHref)) {
|
||
return false;
|
||
}
|
||
|
||
let oldUrl = new URL(from.href);
|
||
let newUrl = new URL(to.href);
|
||
|
||
// morphdom wants to force href="style.css?_11ty" => href="style.css"
|
||
let isErasing = oldUrl.searchParams.has("_11ty") && !newUrl.searchParams.has("_11ty");
|
||
if(!isErasing) {
|
||
// not a match if _11ty has a new value (not being erased)
|
||
return false;
|
||
}
|
||
|
||
oldUrl.searchParams.set("_11ty", "");
|
||
newUrl.searchParams.set("_11ty", "");
|
||
|
||
// is a match if erasing and the rest of the href matches too
|
||
return oldUrl.toString() === newUrl.toString();
|
||
}
|
||
|
||
// https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769
|
||
static runScript(source, target) {
|
||
let script = document.createElement('script');
|
||
|
||
//copy over the attributes
|
||
for(let attr of [...source.attributes]) {
|
||
script.setAttribute(attr.nodeName ,attr.nodeValue);
|
||
}
|
||
|
||
script.innerHTML = source.innerHTML;
|
||
(target || source).replaceWith(script);
|
||
}
|
||
}
|
||
|
||
class EleventyReload {
|
||
constructor() {
|
||
this.connectionMessageShown = false;
|
||
this.reconnectEventCallback = this.reconnect.bind(this);
|
||
}
|
||
|
||
init(options = {}) {
|
||
if (!("WebSocket" in window)) {
|
||
return;
|
||
}
|
||
|
||
let { protocol, host } = new URL(document.location.href);
|
||
|
||
// works with http (ws) and https (wss)
|
||
let websocketProtocol = protocol.replace("http", "ws");
|
||
|
||
let socket = new WebSocket(`${websocketProtocol}//${host}`);
|
||
|
||
socket.addEventListener("message", async (event) => {
|
||
try {
|
||
let data = JSON.parse(event.data);
|
||
// Util.log( JSON.stringify(data, null, 2) );
|
||
|
||
let { type } = data;
|
||
|
||
if (type === "eleventy.reload") {
|
||
await this.onreload(data);
|
||
} else if (type === "eleventy.msg") {
|
||
Util.log(`${data.message}`);
|
||
} else if (type === "eleventy.error") {
|
||
// Log Eleventy build errors
|
||
// Extra parsing for Node Error objects
|
||
let e = JSON.parse(data.error);
|
||
Util.error(`Build error: ${e.message}`, e);
|
||
} else if (type === "eleventy.status") {
|
||
// Full page reload on initial reconnect
|
||
if (data.status === "connected" && options.mode === "reconnect") {
|
||
window.location.reload();
|
||
}
|
||
|
||
if(data.status === "connected") {
|
||
// With multiple windows, only show one connection message
|
||
if(!this.isConnected) {
|
||
Util.log(Util.capitalize(data.status));
|
||
}
|
||
|
||
this.connectionMessageShown = true;
|
||
} else {
|
||
if(data.status === "disconnected") {
|
||
this.addReconnectListeners();
|
||
}
|
||
|
||
Util.log(Util.capitalize(data.status));
|
||
}
|
||
} else {
|
||
Util.log("Unknown event type", data);
|
||
}
|
||
} catch (e) {
|
||
Util.error(`Error parsing ${event.data}: ${e.message}`, e);
|
||
}
|
||
});
|
||
|
||
socket.addEventListener("open", () => {
|
||
// no reconnection when the connect is already open
|
||
this.removeReconnectListeners();
|
||
});
|
||
|
||
socket.addEventListener("close", () => {
|
||
this.connectionMessageShown = false;
|
||
this.addReconnectListeners();
|
||
});
|
||
}
|
||
|
||
reconnect() {
|
||
Util.log( "Reconnecting…" );
|
||
this.init({ mode: "reconnect" });
|
||
}
|
||
|
||
async onreload({ subtype, files, build }) {
|
||
if (subtype === "css") {
|
||
for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) {
|
||
let url = new URL(link.href);
|
||
url.searchParams.set("_11ty", Date.now());
|
||
link.href = url.toString();
|
||
}
|
||
Util.log(`CSS updated without page reload.`);
|
||
} else {
|
||
let morphed = false;
|
||
|
||
try {
|
||
if((build.templates || []).length > 0) {
|
||
// Important: using `./` in `./morphdom.js` allows the special `.11ty` folder to be changed upstream
|
||
const { default: morphdom } = await import(`./morphdom.js`);
|
||
|
||
// { url, inputPath, content }
|
||
for (let template of build.templates || []) {
|
||
if (template.url === document.location.pathname) {
|
||
// Importantly, if this does not match but is still relevant (layout/include/etc), a full reload happens below. This could be improved.
|
||
if ((files || []).includes(template.inputPath)) {
|
||
// Notable limitation: this won’t re-run script elements or JavaScript page lifecycle events (load/DOMContentLoaded)
|
||
morphed = true;
|
||
|
||
morphdom(document.documentElement, template.content, {
|
||
childrenOnly: true,
|
||
onBeforeElUpdated: function (fromEl, toEl) {
|
||
if (fromEl.nodeName === "SCRIPT" && toEl.nodeName === "SCRIPT") {
|
||
Util.runScript(toEl, fromEl);
|
||
return false;
|
||
}
|
||
|
||
// Speed-up trick from morphdom docs
|
||
// https://dom.spec.whatwg.org/#concept-node-equals
|
||
if (fromEl.isEqualNode(toEl)) {
|
||
return false;
|
||
}
|
||
|
||
if(Util.isEleventyLinkNodeMatch(fromEl, toEl)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
},
|
||
onNodeAdded: function (node) {
|
||
if (node.nodeName === 'SCRIPT') {
|
||
Util.runScript(node);
|
||
}
|
||
},
|
||
});
|
||
|
||
Util.matchRootAttributes(template.content);
|
||
Util.log(`HTML delta applied without page reload.`);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {
|
||
Util.error( "Morphdom error", e );
|
||
}
|
||
|
||
if (!morphed) {
|
||
Util.log(`Page reload initiated.`);
|
||
window.location.reload();
|
||
}
|
||
}
|
||
}
|
||
|
||
addReconnectListeners() {
|
||
this.removeReconnectListeners();
|
||
|
||
window.addEventListener("focus", this.reconnectEventCallback);
|
||
window.addEventListener("visibilitychange", this.reconnectEventCallback);
|
||
}
|
||
|
||
removeReconnectListeners() {
|
||
window.removeEventListener("focus", this.reconnectEventCallback);
|
||
window.removeEventListener("visibilitychange", this.reconnectEventCallback);
|
||
}
|
||
}
|
||
|
||
let reloader = new EleventyReload();
|
||
reloader.init(); |