// Soft navigation. Same-app links and form posts are fetched and swapped into the page in place, // so a button press no longer reloads the whole document. Progressive enhancement: without this // script (or on any failure) the browser's own navigation runs. Forms marked `data-hard` (sign-in, // sign-out, enrolment, password and account changes) always post natively. (function () { 'use strict'; if (window.__forgeSoft) { return; } window.__forgeSoft = true; var root = document.documentElement; var latest = 0; // id of the newest request; older responses are dropped var inFlight = 0; // requests on the wire, drives aria-busy var pending = new WeakSet(); // forms with a request on the wire: a second submit is ignored var shown = location.pathname + location.search; var GET_INIT = { headers: { accept: 'text/html' } }; // Files, not pages: static assets, the JSON api, archives, raw and LFS objects and release // downloads. These always take the browser's own path. var FILE_PATH = /^\/(assets|api)\/|^\/[^\/]+\/[^\/]+\/(archive|raw|info\/lfs|-\/media|releases\/download)(\/|$)/; function busyStart() { inFlight += 1; root.setAttribute('aria-busy', 'true'); } function busyEnd() { inFlight = Math.max(0, inFlight - 1); if (inFlight === 0) { root.removeAttribute('aria-busy'); } } // The URL to fetch for `href`, or null when the browser should handle it: another origin, a file // route, or a value that does not parse. function pageUrl(href) { var url; try { url = new URL(href, location.href); } catch (err) { return null; } if (url.origin !== location.origin || FILE_PATH.test(url.pathname)) { return null; } return url; } function withoutHash(url) { var out = new URL(url.href); out.hash = ''; return out; } // Field preservation: a dirty or focused field keeps its value, and the caret offsets come along. function fieldKey(el) { var form = el.form ? (el.form.getAttribute('action') || '') : ''; return form + '|' + (el.name || '') + '|' + (el.type || '') + '|' + el.tagName; } function savable(el) { return el.type !== 'hidden' && el.type !== 'password' && el.type !== 'file'; } function captureFields() { var active = document.activeElement; var out = []; document.querySelectorAll('input, textarea, select').forEach(function (el) { if (!savable(el)) { return; } var moved; if (el.type === 'checkbox' || el.type === 'radio') { moved = el.checked !== el.defaultChecked; } else if (el.tagName === 'SELECT') { var def = -1; for (var j = 0; j < el.options.length; j++) { if (el.options[j].defaultSelected) { def = j; break; } } if (def < 0) { def = 0; } moved = el.selectedIndex !== def; } else { moved = el.value !== el.defaultValue; } var focused = el === active; if (!moved && !focused) { return; } var start = -1, end = -1; try { if (el.selectionStart != null) { start = el.selectionStart; end = el.selectionEnd; } } catch (err) { } out.push({ k: fieldKey(el), v: el.value, c: el.checked, i: el.selectedIndex, f: focused, s: start, e: end }); }); return out; } function restoreFields(saved) { if (!saved || !saved.length) { return; } var fields = document.querySelectorAll('input, textarea, select'); saved.forEach(function (was) { var el = null; for (var i = 0; i < fields.length; i++) { if (fieldKey(fields[i]) === was.k) { el = fields[i]; break; } } if (!el) { return; } if (el.type === 'checkbox' || el.type === 'radio') { el.checked = was.c; } else if (el.tagName === 'SELECT') { el.selectedIndex = was.i; } else { el.value = was.v; } if (was.f) { try { el.focus({ preventScroll: true }); if (was.s >= 0 && el.setSelectionRange) { el.setSelectionRange(was.s, was.e); } } catch (err) { } } }); } // Stylesheets in follow the new page: missing ones are added, extra ones removed. Matched by // href, so main.css and the highlight sheet stay in place when they are the same. function syncStyles(doc) { var sheets = 'link[rel="stylesheet"]'; var wanted = {}; doc.head.querySelectorAll(sheets).forEach(function (l) { wanted[l.getAttribute('href') || ''] = true; }); var have = {}; document.head.querySelectorAll(sheets).forEach(function (l) { have[l.getAttribute('href') || ''] = true; }); doc.head.querySelectorAll(sheets).forEach(function (l) { var href = l.getAttribute('href') || ''; if (!have[href]) { have[href] = true; document.head.appendChild(document.importNode(l, true)); } }); document.head.querySelectorAll(sheets).forEach(function (l) { if (!wanted[l.getAttribute('href') || '']) { l.remove(); } }); } function adopt(doc, keepFields) { document.title = doc.title; ['lang', 'data-theme', 'data-ui'].forEach(function (name) { if (doc.documentElement.hasAttribute(name)) { root.setAttribute(name, doc.documentElement.getAttribute(name)); } else { root.removeAttribute(name); } }); syncStyles(doc); var kept = keepFields ? captureFields() : null; var nodes = Array.prototype.map.call(doc.body.childNodes, function (n) { return document.importNode(n, true); }); document.body.replaceChildren.apply(document.body, nodes); // Re-create scripts so they run (a parsed-in script does not). The guard above keeps this file // from starting twice. document.body.querySelectorAll('script').forEach(function (old) { var s = document.createElement('script'); Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); }); s.textContent = old.textContent; old.replaceWith(s); }); if (kept) { restoreFields(kept); } } function focusMain() { var main = document.querySelector('main'); if (main && main.focus) { main.focus({ preventScroll: true }); } } // Scrolls to the fragment's element when there is one. Returns false when there is nothing to scroll to. function placeHash(hash) { if (!hash || hash.length < 2) { return false; } var id; try { id = decodeURIComponent(hash.slice(1)); } catch (err) { id = hash.slice(1); } var el = document.getElementById(id); if (!el) { return false; } el.scrollIntoView(); return true; } // how: 'nav' (link or GET form: push, top), 'pop' (history: push nothing, restore scroll), // 'post' (form post: replace, keep scroll), 'query' (in place: replace, keep scroll). function swap(html, dest, how, keepFields) { var doc = new DOMParser().parseFromString(html, 'text/html'); if (!doc.body) { location.assign(dest.href); return; } var x = window.scrollX, y = window.scrollY; var saved = history.state; adopt(doc, keepFields); if (how === 'nav') { history.pushState(null, '', dest.href); } else if (how === 'post' || how === 'query') { history.replaceState(null, '', dest.href); } shown = dest.pathname + dest.search; if (how === 'nav' || how === 'pop') { focusMain(); } if (placeHash(dest.hash)) { return; } if (how === 'post' || how === 'query') { window.scrollTo(x, y); } else if (how === 'pop') { window.scrollTo(0, saved && typeof saved.y === 'number' ? saved.y : 0); } else { window.scrollTo(0, 0); } } // The browser takes over where a soft request cannot: a non-HTML answer, or another origin. // A GET re-requests the page itself. A POST is never sent again (that would repeat the action): // a redirect is followed as a GET, anything else reloads the current page. function leave(res, o) { if (o.post) { if (res.redirected) { location.assign(res.url.href); } else { location.reload(); } return; } var sameOrigin = res.url.origin === location.origin; location.assign(sameOrigin && !res.redirected ? o.dest.href : res.url.href); } // One request for a page. `o` carries target (fetched, no hash), dest (shown, with hash), init, // how, keep (keep fields on a re-render of the same path), post, and the form for a POST or GET form. function load(o) { var mark = ++latest; busyStart(); fetch(o.target.href, o.init).then(function (r) { var final = new URL(r.url || o.target.href); var html = /^\s*text\/html/i.test(r.headers.get('content-type') || ''); if (final.origin !== location.origin || !html) { return { leave: true, url: final, redirected: r.redirected }; } return r.text().then(function (text) { return { html: text, url: final }; }); }).then(function (res) { if (mark !== latest) { return; } if (res.leave) { leave(res, o); return; } var dest = new URL(res.url.href); dest.hash = o.dest.hash; swap(res.html, dest, o.how, !!o.keep && res.url.pathname === o.target.pathname); }).catch(function () { if (mark !== latest) { return; } if (o.post) { o.form.submit(); } else { location.assign(o.dest.href); } }).then(function () { busyEnd(); if (o.form) { pending.delete(o.form); } }); } function saveScroll() { try { history.replaceState({ y: window.scrollY }, '', location.href); } catch (err) { } } document.addEventListener('click', function (e) { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; } var a = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (!a || a.target || a.hasAttribute('download') || a.hasAttribute('data-hard')) { return; } var href = a.getAttribute('href') || ''; // Same-app paths only: `//host` is protocol-relative, another origin, and goes the browser's way. if (href.charAt(0) !== '/' || href.charAt(1) === '/') { return; } var url = pageUrl(href); if (!url) { return; } // A fragment on the page already shown: the browser scrolls by itself. if (url.pathname + url.search === shown && url.hash) { return; } e.preventDefault(); saveScroll(); load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'nav', keep: false, post: false, form: null }); }, true); document.addEventListener('submit', function (e) { var form = e.target; if (!form || form.tagName !== 'FORM' || form.hasAttribute('data-hard') || form.getAttribute('target')) { return; } var submitter = e.submitter || null; // Per-button overrides other than the action are rare: the browser handles those. if (submitter && (submitter.hasAttribute('formmethod') || submitter.hasAttribute('formenctype') || submitter.hasAttribute('formtarget') || submitter.hasAttribute('data-hard'))) { return; } var action = (submitter && submitter.getAttribute('formaction')) || form.getAttribute('action') || location.href; var url = pageUrl(action); if (!url) { return; } e.preventDefault(); if (pending.has(form)) { return; } pending.add(form); var data = new FormData(form); if (submitter && submitter.name) { data.append(submitter.name, submitter.value); } var method = (form.getAttribute('method') || 'get').toLowerCase(); if (method === 'get') { var q = new URLSearchParams(data).toString(); var dest = withoutHash(url); dest.search = q ? '?' + q : ''; saveScroll(); load({ target: dest, dest: dest, init: GET_INIT, how: 'nav', keep: false, post: false, form: form }); return; } var multipart = (form.enctype || '').toLowerCase().indexOf('multipart') === 0; load({ target: withoutHash(url), dest: url, init: { method: 'POST', headers: { accept: 'text/html' }, body: multipart ? data : new URLSearchParams(data) }, how: 'post', keep: true, post: true, form: form }); }, false); window.addEventListener('popstate', function () { var here = location.pathname + location.search; if (here === shown) { placeHash(location.hash); return; } var url = pageUrl(location.href); if (!url) { location.reload(); return; } load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'pop', keep: false, post: false, form: null }); }); // Filter controls submit their form on change. document.addEventListener('change', function (e) { var c = e.target && e.target.closest ? e.target.closest('[data-autosubmit]') : null; if (!c || !c.form) { return; } if (c.form.requestSubmit) { c.form.requestSubmit(); } else { c.form.submit(); } }); // A filtered view asked for in place: the URL is replaced (no history entry per change), fields // and caret come through the swap, and the scroll stays put. window.__forgeQuery = function (href) { var url = pageUrl(href); if (!url) { location.assign(href); return; } load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'query', keep: true, post: false, form: null }); }; })();