]> Git — Sourcephile - haskell/literate-web.git/blob - www/live-shim.js
feat(live): init `Literate.Web.Live`
[haskell/literate-web.git] / www / live-shim.js
1 function htmlToElem(html) {
2 let temp = document.createElement('template');
3 html = html.trim(); // Never return a space text node as a result
4 temp.innerHTML = html;
5 return temp.content.firstChild;
6 };
7
8 // Unlike setInnerHtml, this patches the Dom in place
9 function setHtml(elm, html) {
10 var htmlElem = htmlToElem(html);
11 window.dispatchEvent(new Event('LiveBeforeMorphDOM'));
12 morphdom(elm, html);
13 window.dispatchEvent(new Event('LiveBeforeScriptReload'));
14 // Re-add <script> tags, because just DOM diff applying is not enough.
15 reloadScripts(elm);
16 window.dispatchEvent(new Event('LiveHotReload'));
17 };
18
19 // FIXME: This doesn't reliably work across all JS.
20 // See also the HACK below in one of the invocations.
21 function reloadScripts(elm) {
22 Array.from(elm.querySelectorAll("script")).forEach(oldScript => {
23 const newScript = document.createElement("script");
24 Array.from(oldScript.attributes)
25 .forEach(attr => newScript.setAttribute(attr.name, attr.value));
26 newScript.appendChild(document.createTextNode(oldScript.innerHTML));
27 oldScript.parentNode.replaceChild(newScript, oldScript);
28 });
29 };
30
31 // Live Status indicator
32 const messages = {
33 connected: "Connected",
34 reloading: "Reloading",
35 connecting: "Connecting to the server",
36 disconnected: "Disconnected - try reloading the window"
37 };
38 function setIndicators(connected, reloading, connecting, disconnected) {
39 const is = { connected, reloading, connecting, disconnected }
40
41 for (const i in is) {
42 document.getElementById(`live-${i}`).style.display =
43 is[i] ? "block" : "none"
44 if (is[i])
45 document.getElementById('live-message').innerText = messages[i]
46 };
47 document.getElementById("live-indicator").style.display = "block";
48 };
49 window.connected = () => setIndicators(true, false, false, false)
50 window.reloading = () => setIndicators(false, true, false, false)
51 window.connecting = () => setIndicators(false, false, true, false)
52 window.disconnected = () => setIndicators(false, false, false, true)
53 window.hideIndicator = () => {
54 document.getElementById("live-indicator").style.display = "none";
55 };
56
57 // Base URL path - for when the live site isn't served at "/"
58 const baseHref = document.getElementsByTagName("base")[0]?.href;
59 const basePath = baseHref ? new URL(baseHref).pathname : "/";
60
61 // Use TLS for websocket iff the current page is also served with TLS
62 const wsProto = window.location.protocol === "https:" ? "wss://" : "ws://";
63 const wsUrl = wsProto + window.location.host + basePath;
64
65 // WebSocket logic: watching for server changes & route switching
66 function init(reconnecting) {
67 // The route current DOM is displaying
68 let routeVisible = document.location.pathname;
69
70 const verb = reconnecting ? "Reopening" : "Opening";
71 console.log(`live: ${verb} conn ${wsUrl} ...`);
72 window.connecting();
73 let ws = new WebSocket(wsUrl);
74
75 function sendObservePath(path) {
76 const relPath = path.startsWith(basePath) ? path.slice(basePath.length) : path;
77 console.debug(`live: requesting ${relPath}`);
78 ws.send(relPath);
79 }
80
81 // Call this, then the server will send update *once*. Call again for
82 // continous monitoring.
83 function watchCurrentRoute() {
84 console.log(`live: ⏿ Observing changes to ${document.location.pathname}`);
85 sendObservePath(document.location.pathname);
86 };
87
88 function switchRoute(path, hash = "") {
89 console.log(`live: → Switching to ${path + hash}`);
90 window.history.pushState({}, "", path + hash);
91 sendObservePath(path);
92 }
93
94 function scrollToAnchor(hash) {
95 console.log(`live: Scroll to ${hash}`)
96 var el = document.querySelector(hash);
97 if (el !== null) {
98 el.scrollIntoView({ behavior: 'smooth' });
99 }
100 };
101
102 function getAnchorIfOnPage(linkElement) {
103 const url = new URL(linkElement.href); // Use URL API for parsing
104 return (url.host === window.location.host && url.pathname === window.location.pathname && url.hash)
105 ? url.hash.slice(1) // Return anchor name (slice off '#')
106 : null; // Not an anchor on the current page
107 }
108
109 function handleRouteClicks(e) {
110 const origin = e.target.closest("a");
111 if (origin) {
112 if (window.location.host === origin.host && origin.getAttribute("target") != "_blank") {
113 let anchor = getAnchorIfOnPage(origin);
114 if (anchor !== null) {
115 // Switching to local anchor
116 window.history.pushState({}, "", origin.href);
117 scrollToAnchor(window.location.hash);
118 e.preventDefault();
119 } else {
120 // Switching to another route
121 switchRoute(origin.pathname, origin.hash);
122 e.preventDefault();
123 }
124 };
125 }
126 };
127 // Intercept route click events, and ask server for its HTML whilst
128 // managing history state.
129 window.addEventListener(`click`, handleRouteClicks);
130
131 ws.onopen = () => {
132 console.log(`live: ... connected!`);
133 // window.connected();
134 window.hideIndicator();
135 if (!reconnecting) {
136 // HACK: We have to reload <script>'s here on initial page load
137 // here, so as to make Twind continue to function on the *next*
138 // route change. This is not a problem with *subsequent* (ie. 2nd
139 // or latter) route clicks, because those have already called
140 // reloadScripts at least once.
141 reloadScripts(document.documentElement);
142 };
143 watchCurrentRoute();
144 };
145
146 ws.onclose = () => {
147 console.log("live: reconnecting ..");
148 window.removeEventListener(`click`, handleRouteClicks);
149 window.reloading();
150 // Reconnect after as small a time is possible, then retry again.
151 // ghcid can take 1s or more to reboot. So ideally we need an
152 // exponential retry logic.
153 //
154 // Note that a slow delay (200ms) may often cause websocket
155 // connection error (ghcid hasn't rebooted yet), which cannot be
156 // avoided as it is impossible to trap this error and handle it.
157 // You'll see a big ugly error in the console.
158 setTimeout(function () { init(true); }, 400);
159 };
160
161
162
163 ws.onmessage = evt => {
164 if (evt.data.startsWith("REDIRECT ")) {
165 console.log("live: redirect");
166 document.location.href = evt.data.slice("REDIRECT ".length);
167 } else if (evt.data.startsWith("SWITCH ")) {
168 console.log("live: switch");
169 switchRoute(evt.data.slice("SWITCH ".length));
170 } else {
171 console.log("live: ✍ Patching DOM");
172 setHtml(document.documentElement, evt.data);
173 if (routeVisible != document.location.pathname) {
174 // This is a new route switch; scroll up.
175 window.scrollTo({ top: 0 });
176 routeVisible = document.location.pathname;
177 }
178 if (window.location.hash) {
179 scrollToAnchor(window.location.hash);
180 }
181 };
182 };
183 window.onbeforeunload = evt => { ws.close(); };
184 window.onpagehide = evt => { ws.close(); };
185
186 // When the user clicks the back button, resume watching the URL in
187 // the addressback, which has the effect of loading it immediately.
188 window.onpopstate = function (e) {
189 watchCurrentRoute();
190 };
191
192 // API for user invocations
193 window.live = {
194 switchRoute: switchRoute
195 };
196 };