forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.8 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 inFlight = 0; // requests on the wire, drives aria-busy
13 var pending = new WeakSet(); // forms with a request on the wire: a second submit is ignored
14 var shown = location.pathname + location.search;
15 var GET_INIT = { headers: { accept: 'text/html' } };
16
17 // Files, not pages: static assets, the JSON api, archives, raw and LFS objects and release
18 // downloads. These always take the browser's own path.
19 var FILE_PATH = /^\/(assets|api)\/|^\/[^\/]+\/[^\/]+\/(archive|raw|info\/lfs|-\/media|releases\/download)(\/|$)/;
20
21 function busyStart() {
22 inFlight += 1;
23 root.setAttribute('aria-busy', 'true');
24 }
25
26 function busyEnd() {
27 inFlight = Math.max(0, inFlight - 1);
28 if (inFlight === 0) { root.removeAttribute('aria-busy'); }
29 }
30
31 // The URL to fetch for `href`, or null when the browser should handle it: another origin, a file
32 // route, or a value that does not parse.
33 function pageUrl(href) {
34 var url;
35 try { url = new URL(href, location.href); } catch (err) { return null; }
36 if (url.origin !== location.origin || FILE_PATH.test(url.pathname)) { return null; }
37 return url;
38 }
39
40 function withoutHash(url) {
41 var out = new URL(url.href);
42 out.hash = '';
43 return out;
44 }
45
46 // Field preservation: a dirty or focused field keeps its value, and the caret offsets come along.
47 function fieldKey(el) {
48 var form = el.form ? (el.form.getAttribute('action') || '') : '';
49 return form + '|' + (el.name || '') + '|' + (el.type || '') + '|' + el.tagName;
50 }
51
52 function savable(el) {
53 return el.type !== 'hidden' && el.type !== 'password' && el.type !== 'file';
54 }
55
56 function captureFields() {
57 var active = document.activeElement;
58 var out = [];
59 document.querySelectorAll('input, textarea, select').forEach(function (el) {
60 if (!savable(el)) { return; }
61 var moved;
62 if (el.type === 'checkbox' || el.type === 'radio') {
63 moved = el.checked !== el.defaultChecked;
64 } else if (el.tagName === 'SELECT') {
65 var def = -1;
66 for (var j = 0; j < el.options.length; j++) {
67 if (el.options[j].defaultSelected) { def = j; break; }
68 }
69 if (def < 0) { def = 0; }
70 moved = el.selectedIndex !== def;
71 } else {
72 moved = el.value !== el.defaultValue;
73 }
74 var focused = el === active;
75 if (!moved && !focused) { return; }
76 var start = -1, end = -1;
77 try {
78 if (el.selectionStart != null) { start = el.selectionStart; end = el.selectionEnd; }
79 } catch (err) { }
80 out.push({ k: fieldKey(el), v: el.value, c: el.checked, i: el.selectedIndex, f: focused, s: start, e: end });
81 });
82 return out;
83 }
84
85 function restoreFields(saved) {
86 if (!saved || !saved.length) { return; }
87 var fields = document.querySelectorAll('input, textarea, select');
88 saved.forEach(function (was) {
89 var el = null;
90 for (var i = 0; i < fields.length; i++) {
91 if (fieldKey(fields[i]) === was.k) { el = fields[i]; break; }
92 }
93 if (!el) { return; }
94 if (el.type === 'checkbox' || el.type === 'radio') { el.checked = was.c; }
95 else if (el.tagName === 'SELECT') { el.selectedIndex = was.i; }
96 else { el.value = was.v; }
97 if (was.f) {
98 try {
99 el.focus({ preventScroll: true });
100 if (was.s >= 0 && el.setSelectionRange) { el.setSelectionRange(was.s, was.e); }
101 } catch (err) { }
102 }
103 });
104 }
105
106 // Stylesheets in <head> follow the new page: missing ones are added, extra ones removed. Matched by
107 // href, so main.css and the highlight sheet stay in place when they are the same.
108 function syncStyles(doc) {
109 var sheets = 'link[rel="stylesheet"]';
110 var wanted = {};
111 doc.head.querySelectorAll(sheets).forEach(function (l) { wanted[l.getAttribute('href') || ''] = true; });
112 var have = {};
113 document.head.querySelectorAll(sheets).forEach(function (l) { have[l.getAttribute('href') || ''] = true; });
114 doc.head.querySelectorAll(sheets).forEach(function (l) {
115 var href = l.getAttribute('href') || '';
116 if (!have[href]) {
117 have[href] = true;
118 document.head.appendChild(document.importNode(l, true));
119 }
120 });
121 document.head.querySelectorAll(sheets).forEach(function (l) {
122 if (!wanted[l.getAttribute('href') || '']) { l.remove(); }
123 });
124 }
125
126 function adopt(doc, keepFields) {
127 document.title = doc.title;
128 ['lang', 'data-theme', 'data-ui'].forEach(function (name) {
129 if (doc.documentElement.hasAttribute(name)) { root.setAttribute(name, doc.documentElement.getAttribute(name)); }
130 else { root.removeAttribute(name); }
131 });
132 syncStyles(doc);
133 var kept = keepFields ? captureFields() : null;
134 var nodes = Array.prototype.map.call(doc.body.childNodes, function (n) { return document.importNode(n, true); });
135 document.body.replaceChildren.apply(document.body, nodes);
136 // Re-create scripts so they run (a parsed-in script does not). The guard above keeps this file
137 // from starting twice.
138 document.body.querySelectorAll('script').forEach(function (old) {
139 var s = document.createElement('script');
140 Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); });
141 s.textContent = old.textContent;
142 old.replaceWith(s);
143 });
144 if (kept) { restoreFields(kept); }
145 }
146
147 function focusMain() {
148 var main = document.querySelector('main');
149 if (main && main.focus) { main.focus({ preventScroll: true }); }
150 }
151
152 // Scrolls to the fragment's element when there is one. Returns false when there is nothing to scroll to.
153 function placeHash(hash) {
154 if (!hash || hash.length < 2) { return false; }
155 var id;
156 try { id = decodeURIComponent(hash.slice(1)); } catch (err) { id = hash.slice(1); }
157 var el = document.getElementById(id);
158 if (!el) { return false; }
159 el.scrollIntoView();
160 return true;
161 }
162
163 // how: 'nav' (link or GET form: push, top), 'pop' (history: push nothing, restore scroll),
164 // 'post' (form post: replace, keep scroll), 'query' (in place: replace, keep scroll).
165 function swap(html, dest, how, keepFields) {
166 var doc = new DOMParser().parseFromString(html, 'text/html');
167 if (!doc.body) { location.assign(dest.href); return; }
168 var x = window.scrollX, y = window.scrollY;
169 var saved = history.state;
170 adopt(doc, keepFields);
171 if (how === 'nav') { history.pushState(null, '', dest.href); }
172 else if (how === 'post' || how === 'query') { history.replaceState(null, '', dest.href); }
173 shown = dest.pathname + dest.search;
174 if (how === 'nav' || how === 'pop') { focusMain(); }
175 if (placeHash(dest.hash)) { return; }
176 if (how === 'post' || how === 'query') { window.scrollTo(x, y); }
177 else if (how === 'pop') { window.scrollTo(0, saved && typeof saved.y === 'number' ? saved.y : 0); }
178 else { window.scrollTo(0, 0); }
179 }
180
181 // The browser takes over where a soft request cannot: a non-HTML answer, or another origin.
182 // A GET re-requests the page itself. A POST is never sent again (that would repeat the action):
183 // a redirect is followed as a GET, anything else reloads the current page.
184 function leave(res, o) {
185 if (o.post) {
186 if (res.redirected) { location.assign(res.url.href); } else { location.reload(); }
187 return;
188 }
189 var sameOrigin = res.url.origin === location.origin;
190 location.assign(sameOrigin && !res.redirected ? o.dest.href : res.url.href);
191 }
192
193 // One request for a page. `o` carries target (fetched, no hash), dest (shown, with hash), init,
194 // how, keep (keep fields on a re-render of the same path), post, and the form for a POST or GET form.
195 function load(o) {
196 var mark = ++latest;
197 busyStart();
198 fetch(o.target.href, o.init).then(function (r) {
199 var final = new URL(r.url || o.target.href);
200 var html = /^\s*text\/html/i.test(r.headers.get('content-type') || '');
201 if (final.origin !== location.origin || !html) {
202 return { leave: true, url: final, redirected: r.redirected };
203 }
204 return r.text().then(function (text) { return { html: text, url: final }; });
205 }).then(function (res) {
206 if (mark !== latest) { return; }
207 if (res.leave) { leave(res, o); return; }
208 var dest = new URL(res.url.href);
209 dest.hash = o.dest.hash;
210 swap(res.html, dest, o.how, !!o.keep && res.url.pathname === o.target.pathname);
211 }).catch(function () {
212 if (mark !== latest) { return; }
213 if (o.post) { o.form.submit(); } else { location.assign(o.dest.href); }
214 }).then(function () {
215 busyEnd();
216 if (o.form) { pending.delete(o.form); }
217 });
218 }
219
220 function saveScroll() {
221 try { history.replaceState({ y: window.scrollY }, '', location.href); } catch (err) { }
222 }
223
224 document.addEventListener('click', function (e) {
225 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; }
226 var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
227 if (!a || a.target || a.hasAttribute('download') || a.hasAttribute('data-hard')) { return; }
228 var href = a.getAttribute('href') || '';
229 // Same-app paths only: `//host` is protocol-relative, another origin, and goes the browser's way.
230 if (href.charAt(0) !== '/' || href.charAt(1) === '/') { return; }
231 var url = pageUrl(href);
232 if (!url) { return; }
233 // A fragment on the page already shown: the browser scrolls by itself.
234 if (url.pathname + url.search === shown && url.hash) { return; }
235 e.preventDefault();
236 saveScroll();
237 load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'nav', keep: false, post: false, form: null });
238 }, true);
239
240 document.addEventListener('submit', function (e) {
241 var form = e.target;
242 if (!form || form.tagName !== 'FORM' || form.hasAttribute('data-hard') || form.getAttribute('target')) { return; }
243 var submitter = e.submitter || null;
244 // Per-button overrides other than the action are rare: the browser handles those.
245 if (submitter && (submitter.hasAttribute('formmethod') || submitter.hasAttribute('formenctype') ||
246 submitter.hasAttribute('formtarget') || submitter.hasAttribute('data-hard'))) { return; }
247 var action = (submitter && submitter.getAttribute('formaction')) || form.getAttribute('action') || location.href;
248 var url = pageUrl(action);
249 if (!url) { return; }
250 e.preventDefault();
251 if (pending.has(form)) { return; }
252 pending.add(form);
253
254 var data = new FormData(form);
255 if (submitter && submitter.name) { data.append(submitter.name, submitter.value); }
256 var method = (form.getAttribute('method') || 'get').toLowerCase();
257
258 if (method === 'get') {
259 var q = new URLSearchParams(data).toString();
260 var dest = withoutHash(url);
261 dest.search = q ? '?' + q : '';
262 saveScroll();
263 load({ target: dest, dest: dest, init: GET_INIT, how: 'nav', keep: false, post: false, form: form });
264 return;
265 }
266
267 var multipart = (form.enctype || '').toLowerCase().indexOf('multipart') === 0;
268 load({
269 target: withoutHash(url),
270 dest: url,
271 init: {
272 method: 'POST',
273 headers: { accept: 'text/html' },
274 body: multipart ? data : new URLSearchParams(data)
275 },
276 how: 'post',
277 keep: true,
278 post: true,
279 form: form
280 });
281 }, false);
282
283 window.addEventListener('popstate', function () {
284 var here = location.pathname + location.search;
285 if (here === shown) { placeHash(location.hash); return; }
286 var url = pageUrl(location.href);
287 if (!url) { location.reload(); return; }
288 load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'pop', keep: false, post: false, form: null });
289 });
290
291 // Filter controls submit their form on change.
292 document.addEventListener('change', function (e) {
293 var c = e.target && e.target.closest ? e.target.closest('[data-autosubmit]') : null;
294 if (!c || !c.form) { return; }
295 if (c.form.requestSubmit) { c.form.requestSubmit(); } else { c.form.submit(); }
296 });
297
298 // A filtered view asked for in place: the URL is replaced (no history entry per change), fields
299 // and caret come through the swap, and the scroll stays put.
300 window.__forgeQuery = function (href) {
301 var url = pageUrl(href);
302 if (!url) { location.assign(href); return; }
303 load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'query', keep: true, post: false, form: null });
304 };
305})();