forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.1 KiB
1// Soft navigation. Same-app links and form posts are fetched and swapped into the page in place,
2// so a button press no longer reloads the whole document. Progressive enhancement: without this
3// script (or on any failure) the browser's own navigation runs. Forms marked `data-hard` (sign-in,
4// sign-out, enrolment, password and account changes) always post natively.
5(function () {
6 'use strict';
7 if (window.__forgeSoft) { return; }
8 window.__forgeSoft = true;
9
10 var root = document.documentElement;
11 var latest = 0; // id of the newest request; older responses are dropped
12 var userBusy = 0; // navigations and form posts on the wire (not live refreshes)
13 var liveDirty = false; // a live change came while a navigation was on the wire
14 var inFlight = 0; // requests on the wire, drives aria-busy
15 var pending = new WeakSet(); // forms with a request on the wire: a second submit is ignored
16 var shown = location.pathname + location.search;
17 var GET_INIT = { headers: { accept: 'text/html' } };
18
19 // Files, not pages: static assets, the JSON api, archives, raw and LFS objects and release
20 // downloads. These always take the browser's own path.
21 var FILE_PATH = /^\/(assets|api)\/|^\/[^\/]+\/[^\/]+\/(archive|raw|info\/lfs|-\/media|releases\/download)(\/|$)/;
22
23 function busyStart() {
24 inFlight += 1;
25 root.setAttribute('aria-busy', 'true');
26 }
27
28 function busyEnd() {
29 inFlight = Math.max(0, inFlight - 1);
30 if (inFlight === 0) { root.removeAttribute('aria-busy'); }
31 }
32
33 // The URL to fetch for `href`, or null when the browser should handle it: another origin, a file
34 // route, or a value that does not parse.
35 function pageUrl(href) {
36 var url;
37 try { url = new URL(href, location.href); } catch (err) { return null; }
38 if (url.origin !== location.origin || FILE_PATH.test(url.pathname)) { return null; }
39 return url;
40 }
41
42 function withoutHash(url) {
43 var out = new URL(url.href);
44 out.hash = '';
45 return out;
46 }
47
48 // Field preservation: a dirty or focused field keeps its value, and the caret offsets come along.
49 function fieldKey(el) {
50 var form = el.form ? (el.form.getAttribute('action') || '') : '';
51 return form + '|' + (el.name || '') + '|' + (el.type || '') + '|' + el.tagName;
52 }
53
54 function savable(el) {
55 return el.type !== 'hidden' && el.type !== 'password' && el.type !== 'file';
56 }
57
58 function captureFields() {
59 var active = document.activeElement;
60 var out = [];
61 document.querySelectorAll('input, textarea, select').forEach(function (el) {
62 if (!savable(el)) { return; }
63 var moved;
64 if (el.type === 'checkbox' || el.type === 'radio') {
65 moved = el.checked !== el.defaultChecked;
66 } else if (el.tagName === 'SELECT') {
67 var def = -1;
68 for (var j = 0; j < el.options.length; j++) {
69 if (el.options[j].defaultSelected) { def = j; break; }
70 }
71 if (def < 0) { def = 0; }
72 moved = el.selectedIndex !== def;
73 } else {
74 moved = el.value !== el.defaultValue;
75 }
76 var focused = el === active;
77 if (!moved && !focused) { return; }
78 var start = -1, end = -1;
79 try {
80 if (el.selectionStart != null) { start = el.selectionStart; end = el.selectionEnd; }
81 } catch (err) { }
82 out.push({ k: fieldKey(el), v: el.value, c: el.checked, i: el.selectedIndex, f: focused, s: start, e: end });
83 });
84 return out;
85 }
86
87 function restoreFields(saved) {
88 if (!saved || !saved.length) { return; }
89 var fields = document.querySelectorAll('input, textarea, select');
90 saved.forEach(function (was) {
91 var el = null;
92 for (var i = 0; i < fields.length; i++) {
93 if (fieldKey(fields[i]) === was.k) { el = fields[i]; break; }
94 }
95 if (!el) { return; }
96 if (el.type === 'checkbox' || el.type === 'radio') { el.checked = was.c; }
97 else if (el.tagName === 'SELECT') { el.selectedIndex = was.i; }
98 else { el.value = was.v; }
99 if (was.f) {
100 try {
101 el.focus({ preventScroll: true });
102 if (was.s >= 0 && el.setSelectionRange) { el.setSelectionRange(was.s, was.e); }
103 } catch (err) { }
104 }
105 });
106 }
107
108 // Stylesheets in <head> follow the new page: missing ones are added, extra ones removed. Matched by
109 // href, so main.css and the highlight sheet stay in place when they are the same.
110 function syncStyles(doc) {
111 var sheets = 'link[rel="stylesheet"]';
112 var wanted = {};
113 doc.head.querySelectorAll(sheets).forEach(function (l) { wanted[l.getAttribute('href') || ''] = true; });
114 var have = {};
115 document.head.querySelectorAll(sheets).forEach(function (l) { have[l.getAttribute('href') || ''] = true; });
116 doc.head.querySelectorAll(sheets).forEach(function (l) {
117 var href = l.getAttribute('href') || '';
118 if (!have[href]) {
119 have[href] = true;
120 document.head.appendChild(document.importNode(l, true));
121 }
122 });
123 document.head.querySelectorAll(sheets).forEach(function (l) {
124 if (!wanted[l.getAttribute('href') || '']) { l.remove(); }
125 });
126 }
127
128 function adopt(doc, keepFields) {
129 document.title = doc.title;
130 ['lang', 'data-theme', 'data-ui'].forEach(function (name) {
131 if (doc.documentElement.hasAttribute(name)) { root.setAttribute(name, doc.documentElement.getAttribute(name)); }
132 else { root.removeAttribute(name); }
133 });
134 syncStyles(doc);
135 var kept = keepFields ? captureFields() : null;
136 var nodes = Array.prototype.map.call(doc.body.childNodes, function (n) { return document.importNode(n, true); });
137 document.body.replaceChildren.apply(document.body, nodes);
138 // Re-create scripts so they run (a parsed-in script does not). The guard above keeps this file
139 // from starting twice.
140 document.body.querySelectorAll('script').forEach(function (old) {
141 var s = document.createElement('script');
142 Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); });
143 s.textContent = old.textContent;
144 old.replaceWith(s);
145 });
146 if (kept) { restoreFields(kept); }
147 syncUnread();
148 syncLive();
149 }
150
151 function focusMain() {
152 var main = document.querySelector('main');
153 if (main && main.focus) { main.focus({ preventScroll: true }); }
154 }
155
156 // Scrolls to the fragment's element when there is one. Returns false when there is nothing to scroll to.
157 function placeHash(hash) {
158 if (!hash || hash.length < 2) { return false; }
159 var id;
160 try { id = decodeURIComponent(hash.slice(1)); } catch (err) { id = hash.slice(1); }
161 var el = document.getElementById(id);
162 if (!el) { return false; }
163 el.scrollIntoView();
164 return true;
165 }
166
167 // how: 'nav' (link or GET form: push, top), 'pop' (history: push nothing, restore scroll),
168 // 'post' (form post: replace, keep scroll), 'query' (in place: replace, keep scroll), 'live' (a
169 // live page's change: no history entry, keep scroll, no fragment jump).
170 function swap(html, dest, how, keepFields) {
171 var doc = new DOMParser().parseFromString(html, 'text/html');
172 if (!doc.body) { location.assign(dest.href); return; }
173 var x = window.scrollX, y = window.scrollY;
174 var saved = history.state;
175 // A live swap keeps the log tail where the reader had it: at the end it stays at the end.
176 var tail = how === 'live' ? document.querySelector('[data-live-log]') : null;
177 var tailAt = tail ? { top: tail.scrollTop, end: tail.scrollTop + tail.clientHeight >= tail.scrollHeight - 8 } : null;
178 closeDropdowns(null);
179 adopt(doc, keepFields);
180 if (tailAt) {
181 var nextTail = document.querySelector('[data-live-log]');
182 if (nextTail) { nextTail.scrollTop = tailAt.end ? nextTail.scrollHeight : tailAt.top; }
183 }
184 if (how === 'nav') { history.pushState(null, '', dest.href); }
185 else if (how === 'post' || how === 'query') { history.replaceState(null, '', dest.href); }
186 shown = dest.pathname + dest.search;
187 if (how === 'nav' || how === 'pop') { focusMain(); }
188 if (how !== 'live' && placeHash(dest.hash)) { return; }
189 if (how === 'post' || how === 'query' || how === 'live') { window.scrollTo(x, y); }
190 else if (how === 'pop') { window.scrollTo(0, saved && typeof saved.y === 'number' ? saved.y : 0); }
191 else { window.scrollTo(0, 0); }
192 }
193
194 // The browser takes over where a soft request cannot: a non-HTML answer, or another origin.
195 // A GET re-requests the page itself. A POST is never sent again (that would repeat the action):
196 // a redirect is followed as a GET, anything else reloads the current page.
197 function leave(res, o) {
198 if (o.post) {
199 if (res.redirected) { location.assign(res.url.href); } else { location.reload(); }
200 return;
201 }
202 var sameOrigin = res.url.origin === location.origin;
203 location.assign(sameOrigin && !res.redirected ? o.dest.href : res.url.href);
204 }
205
206 // One request for a page. `o` carries target (fetched, no hash), dest (shown, with hash), init,
207 // how, keep (keep fields on a re-render of the same path), post, and the form for a POST or GET form.
208 function load(o) {
209 var mark = ++latest;
210 var user = o.how !== 'live';
211 if (user) { userBusy += 1; }
212 busyStart();
213 fetch(o.target.href, o.init).then(function (r) {
214 var final = new URL(r.url || o.target.href);
215 var html = /^\s*text\/html/i.test(r.headers.get('content-type') || '');
216 if (final.origin !== location.origin || !html) {
217 return { leave: true, url: final, redirected: r.redirected };
218 }
219 return r.text().then(function (text) { return { html: text, url: final }; });
220 }).then(function (res) {
221 if (mark !== latest) { return; }
222 if (res.leave) { leave(res, o); return; }
223 var dest = new URL(res.url.href);
224 dest.hash = o.dest.hash;
225 swap(res.html, dest, o.how, !!o.keep && res.url.pathname === o.target.pathname);
226 }).catch(function () {
227 if (mark !== latest || o.how === 'live') { return; }
228 if (o.post) { o.form.submit(); } else { location.assign(o.dest.href); }
229 }).then(function () {
230 busyEnd();
231 if (o.form) { pending.delete(o.form); }
232 if (user) {
233 userBusy -= 1;
234 if (liveDirty) { refreshLive(); }
235 }
236 });
237 }
238
239 function saveScroll() {
240 try { history.replaceState({ y: window.scrollY }, '', location.href); } catch (err) { }
241 }
242
243 // Dropdown menus: the <details> that float over the page (avatar menu, branch selector, clone
244 // popover, phone menu). Inline disclosures are not listed here and keep their state.
245 var DROPDOWN = 'details.menu, details.ref-select, details.clone, details.nav-toggle';
246
247 // Closes every open dropdown except `keep` (pass null to close them all).
248 function closeDropdowns(keep) {
249 document.querySelectorAll(DROPDOWN).forEach(function (d) {
250 if (d.open && d !== keep) { d.open = false; }
251 });
252 }
253
254 // A click outside an open dropdown closes it. A click inside one (a select, a form field) keeps
255 // it open. Opening a dropdown by its summary closes the others: the browser toggles it after this.
256 document.addEventListener('click', function (e) {
257 var inside = e.target && e.target.closest ? e.target.closest(DROPDOWN) : null;
258 closeDropdowns(inside);
259 }, false);
260
261 // Escape closes the open dropdowns and returns focus to the summary of the one that held it.
262 document.addEventListener('keydown', function (e) {
263 if (e.key !== 'Escape') { return; }
264 var back = null;
265 document.querySelectorAll(DROPDOWN).forEach(function (d) {
266 if (!d.open) { return; }
267 if (d.contains(document.activeElement)) { back = d; }
268 d.open = false;
269 });
270 if (back) {
271 var summary = back.querySelector('summary');
272 if (summary) { summary.focus(); }
273 }
274 }, false);
275
276 document.addEventListener('click', function (e) {
277 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; }
278 var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
279 if (!a || a.target || a.hasAttribute('download') || a.hasAttribute('data-hard')) { return; }
280 var href = a.getAttribute('href') || '';
281 // Same-app paths only: `//host` is protocol-relative, another origin, and goes the browser's way.
282 if (href.charAt(0) !== '/' || href.charAt(1) === '/') { return; }
283 var url = pageUrl(href);
284 if (!url) { return; }
285 // A fragment on the page already shown: the browser scrolls by itself.
286 if (url.pathname + url.search === shown && url.hash) { return; }
287 e.preventDefault();
288 saveScroll();
289 load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'nav', keep: false, post: false, form: null });
290 }, true);
291
292 document.addEventListener('submit', function (e) {
293 var form = e.target;
294 if (!form || form.tagName !== 'FORM' || form.hasAttribute('data-hard') || form.getAttribute('target')) { return; }
295 var submitter = e.submitter || null;
296 // Per-button overrides other than the action are rare: the browser handles those.
297 if (submitter && (submitter.hasAttribute('formmethod') || submitter.hasAttribute('formenctype') ||
298 submitter.hasAttribute('formtarget') || submitter.hasAttribute('data-hard'))) { return; }
299 var action = (submitter && submitter.getAttribute('formaction')) || form.getAttribute('action') || location.href;
300 var url = pageUrl(action);
301 if (!url) { return; }
302 e.preventDefault();
303 if (pending.has(form)) { return; }
304 pending.add(form);
305
306 var data = new FormData(form);
307 if (submitter && submitter.name) { data.append(submitter.name, submitter.value); }
308 var method = (form.getAttribute('method') || 'get').toLowerCase();
309
310 if (method === 'get') {
311 var q = new URLSearchParams(data).toString();
312 var dest = withoutHash(url);
313 dest.search = q ? '?' + q : '';
314 saveScroll();
315 load({ target: dest, dest: dest, init: GET_INIT, how: 'nav', keep: false, post: false, form: form });
316 return;
317 }
318
319 var multipart = (form.enctype || '').toLowerCase().indexOf('multipart') === 0;
320 load({
321 target: withoutHash(url),
322 dest: url,
323 init: {
324 method: 'POST',
325 headers: { accept: 'text/html' },
326 body: multipart ? data : new URLSearchParams(data)
327 },
328 how: 'post',
329 keep: true,
330 post: true,
331 form: form
332 });
333 }, false);
334
335 window.addEventListener('popstate', function () {
336 var here = location.pathname + location.search;
337 if (here === shown) { placeHash(location.hash); return; }
338 var url = pageUrl(location.href);
339 if (!url) { location.reload(); return; }
340 load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'pop', keep: false, post: false, form: null });
341 });
342
343 // Filter controls submit their form on change.
344 document.addEventListener('change', function (e) {
345 var c = e.target && e.target.closest ? e.target.closest('[data-autosubmit]') : null;
346 if (!c || !c.form) { return; }
347 if (c.form.requestSubmit) { c.form.requestSubmit(); } else { c.form.submit(); }
348 });
349
350 // A filtered view asked for in place: the URL is replaced (no history entry per change), fields
351 // and caret come through the swap, and the scroll stays put.
352 // Live unread count. The bell is re-rendered by every swap, so it is looked up on each update. One
353 // EventSource serves the page and stays open across swaps. It is opened only while a bell is shown
354 // (signed-in pages) and closed when the bell is gone. The server pushes each change; nothing polls.
355 var unreadStream = null;
356
357 function bell() { return document.querySelector('a.bell'); }
358
359 // Shows the count the way the server renders it: a `.bell-count` span with the number, or no span at zero.
360 function setUnread(n) {
361 var el = bell();
362 if (!el) { return; }
363 var badge = el.querySelector('.bell-count');
364 if (n > 0) {
365 if (!badge) {
366 badge = document.createElement('span');
367 badge.className = 'bell-count';
368 el.appendChild(badge);
369 }
370 badge.textContent = String(n);
371 } else if (badge) {
372 badge.remove();
373 }
374 }
375
376 function syncUnread() {
377 if (!bell()) {
378 if (unreadStream) { unreadStream.close(); unreadStream = null; }
379 return;
380 }
381 if (unreadStream || !window.EventSource) { return; }
382 unreadStream = new EventSource('/notifications/stream');
383 unreadStream.addEventListener('unread', function (e) {
384 setUnread(parseInt(e.data, 10) || 0);
385 });
386 }
387
388 syncUnread();
389
390 // Live pages. A run or job page that is not finished marks its wrapper with data-live, the URL of
391 // its event stream. One EventSource follows it. A `changed` event re-fetches the page and swaps it in
392 // with no history entry and the scroll kept. A job page also has a log tail (data-live-log) whose
393 // `log` events append the new bytes as they come. The stream opens at the tail's data-offset, and
394 // each swap brings a fresh offset, so the stream is opened again after every swap. The server ends
395 // the stream once the status is terminal, and the swap that shows that page (no data-live) closes it.
396 var liveStream = null;
397 var liveUrl = null;
398
399 function closeLive() {
400 if (liveStream) { liveStream.close(); }
401 liveStream = null;
402 liveUrl = null;
403 liveDirty = false;
404 }
405
406 function syncLive() {
407 var el = document.querySelector('[data-live]');
408 var url = el ? el.getAttribute('data-live') : null;
409 var tail = document.querySelector('[data-live-log]');
410 if (url && tail) {
411 url += (url.indexOf('?') < 0 ? '?' : '&') + 'from=' + encodeURIComponent(tail.getAttribute('data-offset') || '0');
412 }
413 if (!url || !window.EventSource) { closeLive(); return; }
414 if (liveStream && liveUrl === url) { return; }
415 closeLive();
416 liveUrl = url;
417 liveStream = new EventSource(url);
418 liveStream.addEventListener('changed', function (e) {
419 // A job page's stream opens with the status the page already shows: nothing to re-render then.
420 var wrap = document.querySelector('[data-live]');
421 if (document.querySelector('[data-live-log]') && wrap && wrap.getAttribute('data-live-state') === e.data) { return; }
422 refreshLive();
423 });
424 liveStream.addEventListener('log', function (e) { appendLog(e.data); });
425 }
426
427 // Appends streamed log text to the tail as a text node, never as HTML. The tail follows the end
428 // only while the reader is already at the end.
429 function appendLog(text) {
430 var pre = document.querySelector('[data-live-log]');
431 if (!pre || !text) { return; }
432 var atEnd = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
433 pre.appendChild(document.createTextNode(text));
434 if (atEnd) { pre.scrollTop = pre.scrollHeight; }
435 }
436
437 // A change re-renders the current page. It waits while a navigation is on the wire, since that
438 // response could be superseded: the change is kept and sent after that request completes.
439 function refreshLive() {
440 if (!liveStream) { return; }
441 if (userBusy > 0) { liveDirty = true; return; }
442 liveDirty = false;
443 var here = new URL(location.href);
444 load({ target: withoutHash(here), dest: here, init: GET_INIT, how: 'live', keep: true, post: false, form: null });
445 }
446
447 syncLive();
448
449 window.__forgeQuery = function (href) {
450 var url = pageUrl(href);
451 if (!url) { location.assign(href); return; }
452 load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'query', keep: true, post: false, form: null });
453 };
454})();