oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

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