| 1 | /* rastrillo.js — the fragment shim. First-party, dependency-free, and |
| 2 | almost entirely inert: the polling section answers only to an opt-in |
| 3 | data attribute, and everything it enhances also works with scripts |
| 4 | disabled (a status page's <noscript> meta refresh). This file is |
| 5 | app-owned from the moment it is scaffolded — edit it like any other |
| 6 | static file. |
| 7 | |
| 8 | One section is on by default instead, because it has no per-instance |
| 9 | decision to make: light dismiss for the menu idioms, keyed off the |
| 10 | rst-dropdown / rst-row-menu attributes — a menu that closes on an |
| 11 | outside click on one screen and not the next is worse than either |
| 12 | rule applied everywhere. Delete it to opt the whole app out. |
| 13 | |
| 14 | The busy rule, which used to live here too, is busy.js: link it and |
| 15 | every submit button in every form is covered; leave it out and none |
| 16 | is. |
| 17 | |
| 18 | Vocabulary: |
| 19 | data-poll="URL" fetch URL for an HTML fragment, replace this |
| 20 | element with it, and keep going only if the |
| 21 | replacement element itself carries data-poll |
| 22 | data-poll-every="2" seconds between polls (default 2) |
| 23 | data-poll-push="URL" optional, beside data-poll: open an |
| 24 | EventSource to URL and re-fetch the fragment |
| 25 | when the server says so, instead of on a |
| 26 | timer. Any EventSource error downgrades this |
| 27 | element to timer polling for good (once, |
| 28 | no flapping); a browser without EventSource |
| 29 | never leaves the timer path |
| 30 | |
| 31 | select.js is a sibling file following exactly these rules, kept |
| 32 | separate so this one stays small enough to read in one sitting. It |
| 33 | answers data-rst-select on a <select>, which field-select emits past |
| 34 | ten options. |
| 35 | |
| 36 | Every poll carries the request header Rastrillo-Fragment: 1, which |
| 37 | marks the request as a shim poll so a handler can tell it apart from |
| 38 | direct navigation. Reserved: the framework's own handlers do not |
| 39 | read it today. |
| 40 | |
| 41 | Menus: an open <details rst-dropdown>, <details rst-row-menu> or a |
| 42 | nested <details rst-menu-group> closes on an outside click and on |
| 43 | Escape, in EITHER spelling, so upgrading this file before running |
| 44 | `rastrillo markup` leaves no dead menus. Exclusivity stays native. |
| 45 | |
| 46 | A polled response may answer 204 with a Rastrillo-Location header |
| 47 | instead of a fragment; the shim navigates there, but only to a local |
| 48 | path. 403 and 404 end the poll for good — the job was swept, or was |
| 49 | never yours. Any other fetch error backs off (doubling to a 30s cap) |
| 50 | and keeps trying — a network blip must not strand a status page. */ |
| 51 | (function () { |
| 52 | "use strict"; |
| 53 | |
| 54 | // The same rule sessions.SafeReturn enforces server-side: a same-site |
| 55 | // absolute path — starts with exactly one "/", no scheme, no |
| 56 | // backslash, no control characters (browsers strip tab/CR/LF before |
| 57 | // parsing, so "/\t/evil.example" would resolve scheme-relative). |
| 58 | // Anything laxer is an open redirect, here driven by a response |
| 59 | // header instead of a form field. |
| 60 | function localPath(to) { |
| 61 | return to.charAt(0) === "/" && to.charAt(1) !== "/" && |
| 62 | to.indexOf("\\") === -1 && !/[\u0000-\u001f\u007f]/.test(to); |
| 63 | } |
| 64 | |
| 65 | function poll(el) { |
| 66 | var base = (parseFloat(el.getAttribute("data-poll-every")) || 2) * 1000; |
| 67 | var wait = base; |
| 68 | var src = null; // open EventSource while pushing; null on the timer path |
| 69 | var busy = false; // a fetch is in flight |
| 70 | var queued = false; // an update landed mid-fetch; tick again after it |
| 71 | function stop() { |
| 72 | if (src) { src.close(); src = null; } |
| 73 | } |
| 74 | // One downgrade, never back: push hands this element to the timer. |
| 75 | function fallback() { |
| 76 | stop(); |
| 77 | schedule(); |
| 78 | } |
| 79 | function tick() { |
| 80 | if (busy) { queued = true; return; } |
| 81 | busy = true; |
| 82 | fetch(el.getAttribute("data-poll"), { headers: { "Rastrillo-Fragment": "1" } }) |
| 83 | .then(function (res) { |
| 84 | // Terminal, not an error to retry: retrying a 404 forever is |
| 85 | // noise, and following a signed-out redirect would swap a |
| 86 | // sign-in page into the fragment's slot. |
| 87 | if (res.status === 403 || res.status === 404) return null; |
| 88 | if (!res.ok && res.status !== 204) throw new Error("status " + res.status); |
| 89 | var to = res.headers.get("Rastrillo-Location"); |
| 90 | if (to) { |
| 91 | if (localPath(to)) window.location.assign(to); |
| 92 | return null; // navigating, or refusing to — either way, done |
| 93 | } |
| 94 | return res.text(); |
| 95 | }) |
| 96 | .then(function (html) { |
| 97 | busy = false; |
| 98 | if (html === null) { stop(); return; } // stopped |
| 99 | wait = base; // a healthy response resets the backoff |
| 100 | var tpl = document.createElement("template"); |
| 101 | tpl.innerHTML = html; |
| 102 | var next = tpl.content.firstElementChild; |
| 103 | if (!next) { stop(); return; } // fragment with no element: stop politely |
| 104 | el.replaceWith(next); |
| 105 | el = next; |
| 106 | if (!el.hasAttribute("data-poll")) { stop(); return; } |
| 107 | if (!src) { schedule(); return; } |
| 108 | // Pushing: a fragment that dropped data-poll-push falls back |
| 109 | // to the timer; otherwise catch up if an update was queued. |
| 110 | if (!el.hasAttribute("data-poll-push")) { fallback(); return; } |
| 111 | if (queued) { queued = false; tick(); } |
| 112 | }) |
| 113 | .catch(function () { |
| 114 | busy = false; |
| 115 | wait = Math.min(wait * 2, 30000); |
| 116 | if (!src) schedule(); // pushing: the next event retries instead |
| 117 | }); |
| 118 | } |
| 119 | function schedule() { setTimeout(tick, wait); } |
| 120 | var push = el.getAttribute("data-poll-push"); |
| 121 | if (push && window.EventSource) { |
| 122 | src = new EventSource(push); |
| 123 | src.addEventListener("update", function () { tick(); }); |
| 124 | src.addEventListener("done", function () { stop(); tick(); }); |
| 125 | src.addEventListener("gone", stop); |
| 126 | src.onerror = fallback; |
| 127 | return; |
| 128 | } |
| 129 | schedule(); |
| 130 | } |
| 131 | |
| 132 | // Light dismiss — the one behaviour the native disclosure genuinely |
| 133 | // cannot do, which is the shim's whole admission rule. A <details> |
| 134 | // menu closes on a second click of its own summary and on nothing |
| 135 | // else: not on a click elsewhere on the page, not on Escape. Both are |
| 136 | // what every menu anywhere else does, and neither is expressible in |
| 137 | // HTML or CSS. |
| 138 | // |
| 139 | // Two delegated listeners on the document, never one per element, so a |
| 140 | // dropdown that arrives inside a polled fragment is covered the moment |
| 141 | // it lands and re-scanning can never double-bind. Capture phase, so a |
| 142 | // page whose own handler stops propagation cannot leave a menu stuck |
| 143 | // open. |
| 144 | // |
| 145 | // The scriptless baseline is untouched: with this file removed, the |
| 146 | // menus still toggle and the native <details name> group still keeps |
| 147 | // one open at a time. |
| 148 | // |
| 149 | // The nested rst-menu-group is in MENUS even though it is never in the |
| 150 | // <details name> group with its parent — the two mechanisms answer |
| 151 | // different questions. The name group decides which menus may be open |
| 152 | // at once, and a submenu must be exempt from it or opening one would |
| 153 | // close the menu around it. Dismissal is not exclusivity: a submenu |
| 154 | // left open behind its closing parent is still open the next time the |
| 155 | // parent opens, which is a menu remembering a state the user has no |
| 156 | // way to see. Listing it here also gives the natural behaviour of |
| 157 | // clicking elsewhere INSIDE the parent closing the submenu — the |
| 158 | // contains(except) test below already draws that line for free. |
| 159 | // |
| 160 | // Shell chrome and the toggle-block are deliberately absent from |
| 161 | // MENUS. A sidebar's disclosure strip and a settings switch are not |
| 162 | // menus; closing them because a click landed elsewhere would fight the |
| 163 | // user rather than help. |
| 164 | var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]"; |
| 165 | MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1"); |
| 166 | |
| 167 | // except is the clicked node: the menu containing it stays open, which |
| 168 | // is what keeps a click on a menu item — or on the summary of a menu |
| 169 | // being opened right now — from closing the thing being used. |
| 170 | function closeMenus(except) { |
| 171 | document.querySelectorAll(MENUS).forEach(function (d) { |
| 172 | if (!except || !d.contains(except)) d.open = false; |
| 173 | }); |
| 174 | } |
| 175 | |
| 176 | function dismissMenus(e) { |
| 177 | if (e.type === "click") { closeMenus(e.target); return; } |
| 178 | if (e.key !== "Escape") return; |
| 179 | // Focus is about to be inside a subtree that is no longer rendered, |
| 180 | // which strands a keyboard user at the top of the document. Hand it |
| 181 | // back to the summary that opened the menu. |
| 182 | var el = document.activeElement; |
| 183 | var host = el && el.closest ? el.closest(MENUS) : null; |
| 184 | // Climb to the OUTERMOST open menu around the focus. closest() finds |
| 185 | // the innermost, which for focus inside a submenu is the submenu — |
| 186 | // and its summary is inside the parent that is about to close too, so |
| 187 | // focusing it would hand focus to something no longer rendered, which |
| 188 | // is no hand-back at all. |
| 189 | while (host && host.parentElement && host.parentElement.closest(MENUS)) { |
| 190 | host = host.parentElement.closest(MENUS); |
| 191 | } |
| 192 | closeMenus(null); |
| 193 | if (host) { |
| 194 | var summary = host.querySelector("summary"); |
| 195 | if (summary) summary.focus(); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | document.addEventListener("click", dismissMenus, true); |
| 200 | document.addEventListener("keydown", dismissMenus, true); |
| 201 | |
| 202 | function scan() { |
| 203 | document.querySelectorAll("[data-poll]").forEach(poll); |
| 204 | } |
| 205 | |
| 206 | if (document.readyState === "loading") { |
| 207 | document.addEventListener("DOMContentLoaded", scan); |
| 208 | } else { |
| 209 | scan(); |
| 210 | } |
| 211 | })(); |
| 212 | |