packages feed

ema-0.10.2.0: www/ema-shim.js

function htmlToElem(html) {
    let temp = document.createElement('template');
    html = html.trim(); // Never return a space text node as a result
    temp.innerHTML = html;
    return temp.content.firstChild;
};

// Unlike setInnerHtml, this patches the Dom in place
function setHtml(elm, html) {
    var htmlElem = htmlToElem(html);
    window.dispatchEvent(new Event('EMABeforeMorphDOM'));
    morphdom(elm, html);
    window.dispatchEvent(new Event('EMABeforeScriptReload'));
    // Re-add <script> tags, because just DOM diff applying is not enough.
    reloadScripts(elm);
    window.dispatchEvent(new Event('EMAHotReload'));
};

// FIXME: This doesn't reliably work across all JS.
// See also the HACK below in one of the invocations.
function reloadScripts(elm) {
    Array.from(elm.querySelectorAll("script")).forEach(oldScript => {
        const newScript = document.createElement("script");
        Array.from(oldScript.attributes)
            .forEach(attr => newScript.setAttribute(attr.name, attr.value));
        newScript.appendChild(document.createTextNode(oldScript.innerHTML));
        oldScript.parentNode.replaceChild(newScript, oldScript);
    });
};

// Ema Status indicator
const messages = {
    connected: "Connected",
    reloading: "Reloading",
    connecting: "Connecting to the server",
    disconnected: "Disconnected - try reloading the window"
};
function setIndicators(connected, reloading, connecting, disconnected) {
    const is = { connected, reloading, connecting, disconnected }

    for (const i in is) {
        document.getElementById(`ema-${i}`).style.display =
            is[i] ? "block" : "none"
        if (is[i])
            document.getElementById('ema-message').innerText = messages[i]
    };
    document.getElementById("ema-indicator").style.display = "block";
};
window.connected = () => setIndicators(true, false, false, false)
window.reloading = () => setIndicators(false, true, false, false)
window.connecting = () => setIndicators(false, false, true, false)
window.disconnected = () => setIndicators(false, false, false, true)
window.hideIndicator = () => {
    document.getElementById("ema-indicator").style.display = "none";
};

// Base URL path - for when the ema site isn't served at "/"
const baseHref = document.getElementsByTagName("base")[0]?.href;
const basePath = baseHref ? new URL(baseHref).pathname : "/";

// Use TLS for websocket iff the current page is also served with TLS
const wsProto = window.location.protocol === "https:" ? "wss://" : "ws://";
const wsUrl = wsProto + window.location.host + basePath;

// WebSocket logic: watching for server changes & route switching
function init(reconnecting) {
    // The route current DOM is displaying
    let routeVisible = document.location.pathname;

    const verb = reconnecting ? "Reopening" : "Opening";
    console.log(`ema: ${verb} conn ${wsUrl} ...`);
    window.connecting();
    let ws = new WebSocket(wsUrl);

    function sendObservePath(path) {
        const relPath = path.startsWith(basePath) ? path.slice(basePath.length) : path;
        console.debug(`ema: requesting ${relPath}`);
        ws.send(relPath);
    }

    // Call this, then the server will send update *once*. Call again for
    // continous monitoring.
    function watchCurrentRoute() {
        console.log(`ema: ⏿ Observing changes to ${document.location.pathname}`);
        sendObservePath(document.location.pathname);
    };

    function switchRoute(path, hash = "") {
        console.log(`ema: → Switching to ${path + hash}`);
        window.history.pushState({}, "", path + hash);
        sendObservePath(path);
    }

    function scrollToAnchor(hash) {
        console.log(`ema: Scroll to ${hash}`)
        var el = document.querySelector(hash);
        if (el !== null) {
            el.scrollIntoView({ behavior: 'smooth' });
        }
    };

    function handleRouteClicks(e) {
        const origin = e.target.closest("a");
        if (origin) {
            if (window.location.host === origin.host && origin.getAttribute("target") != "_blank") {
                if (origin.getAttribute("href").startsWith("#")) {
                    // Switching to local anchor
                    window.history.pushState({}, "", window.location.pathname + origin.hash);
                    scrollToAnchor(window.location.hash);
                    e.preventDefault();
                } else {
                    // Switching to another route
                    switchRoute(origin.pathname, origin.hash);
                    e.preventDefault();
                }
            };
        }
    };
    // Intercept route click events, and ask server for its HTML whilst
    // managing history state.
    window.addEventListener(`click`, handleRouteClicks);

    ws.onopen = () => {
        console.log(`ema: ... connected!`);
        // window.connected();
        window.hideIndicator();
        if (!reconnecting) {
            // HACK: We have to reload <script>'s here on initial page load
            // here, so as to make Twind continue to function on the *next*
            // route change. This is not a problem with *subsequent* (ie. 2nd
            // or latter) route clicks, because those have already called
            // reloadScripts at least once.
            reloadScripts(document.documentElement);
        };
        watchCurrentRoute();
    };

    ws.onclose = () => {
        console.log("ema: reconnecting ..");
        window.removeEventListener(`click`, handleRouteClicks);
        window.reloading();
        // Reconnect after as small a time is possible, then retry again. 
        // ghcid can take 1s or more to reboot. So ideally we need an
        // exponential retry logic.
        // 
        // Note that a slow delay (200ms) may often cause websocket
        // connection error (ghcid hasn't rebooted yet), which cannot be
        // avoided as it is impossible to trap this error and handle it.
        // You'll see a big ugly error in the console.
        setTimeout(function () { init(true); }, 400);
    };



    ws.onmessage = evt => {
        if (evt.data.startsWith("REDIRECT ")) {
            console.log("ema: redirect");
            document.location.href = evt.data.slice("REDIRECT ".length);
        } else {
            console.log("ema: ✍ Patching DOM");
            setHtml(document.documentElement, evt.data);
            if (routeVisible != document.location.pathname) {
                // This is a new route switch; scroll up.
                window.scrollTo({ top: 0 });
                routeVisible = document.location.pathname;
            }
            if (window.location.hash) {
                scrollToAnchor(window.location.hash);
            }
        };
    };
    window.onbeforeunload = evt => { ws.close(); };
    window.onpagehide = evt => { ws.close(); };

    // When the user clicks the back button, resume watching the URL in
    // the addressback, which has the effect of loading it immediately.
    window.onpopstate = function (e) {
        watchCurrentRoute();
    };

    // API for user invocations 
    window.ema = {
        switchRoute: switchRoute
    };
};