1// docs.js â in-place navigation for hub.exe.dev/docs. 2// 3// Every docs URL is a complete server-rendered page. This script fetches the 4// next page on click, swaps the article in, updates the address bar, and 5// flips the sidebar between its two shapes: 6// 7// overview every section, its featured docs, then "⦠N more" 8// section one section in full, with a way back up to the overview 9// 10// The sidebar only changes shape when it has to: if the panel on screen 11// already lists the page you open, it stays put. The one page (/docs/all) 12// has a third shape, its own table of contents, which follows the reader. 13// 14// The API explorer is one of those sections. Its terminal is mounted the 15// first time you visit and then kept alive, hidden, while you read docs. 16 17const EXPLORER = "/api"; // the API explorer, a docs section at its own path 18const STEP = EXPLORER + "/step/"; // + a tour step's id 19 20const $ = (sel, root = document) => root.querySelector(sel); 21const docView = $("#doc-view"); 22const explorerView = $("#explorer-view"); 23const sidebar = $("#docs-sidebar"); 24const searchForm = $(".docs-search"); 25const searchInput = searchForm?.querySelector("input"); 26let nav = $("#docs-nav"); 27 28let explorer = null; 29let explorerLoading = null; 30let navToken = 0; 31let pageKey = location.pathname + location.search; 32const prefetched = new Map(); // url -> { at, page: Promise<{url, html}> } 33 34const isExplorerPath = (p) => p === EXPLORER || p.startsWith(STEP); 35const stepOf = (p) => (p.startsWith(STEP) ? p.slice(STEP.length) : ""); 36 37// The explorer keeps the latest command it ran in ?cmd=, so the address bar 38// is a link that runs it again. Spaces read as +, and flags keep their =. 39function withCommand(url, command) { 40 const params = new URLSearchParams(url.search); 41 params.delete("cmd"); 42 const rest = params.toString(); 43 const value = encodeURIComponent(command).replace(/%20/g, "+").replace(/%(3D|2C|2F|3A|40)/g, (m) => decodeURIComponent(m)); 44 url.search = (rest ? rest + "&" : "") + "cmd=" + value; 45 return url; 46} 47 48// Opening an explorer URL: a tour step shows its tip and types its command, 49// and ?cmd= types that command instead. Back and forward only show the tip. 50function openExplorerAt(ex, url, fromPop) { 51 const step = stepOf(url.pathname); 52 const cmd = fromPop ? "" : url.searchParams.get("cmd")?.trim() || ""; 53 if (step) ex.selectStep(step, { run: !fromPop && !cmd }); 54 if (cmd) ex.typeAndRun(cmd); 55} 56 57// Only docs pages and the explorer are routed in place; markdown, images, 58// and the rest of the hub load normally. 59function routable(url) { 60 if (url.origin !== location.origin) return false; 61 const p = url.pathname; 62 if (p !== "/docs" && !p.startsWith("/docs/") && !isExplorerPath(p)) return false; 63 if (p.startsWith("/docs/_")) return false; // assets, and the admins' editor 64 return !p.slice(p.lastIndexOf("/") + 1).includes("."); 65} 66 67// ------------------------------------------------------------ sidebar 68 69const isTOC = (n) => n.hasAttribute("data-toc"); 70 71function showPanel(section, { resetScroll = false } = {}) { 72 let shown = null; 73 for (const panel of nav.querySelectorAll(".nav-section")) { 74 const on = Boolean(section) && panel.dataset.section === section; 75 panel.hidden = !on; 76 if (on) shown = panel; 77 } 78 const overview = $(".nav-overview", nav); 79 if (overview) overview.hidden = Boolean(shown); 80 if (resetScroll) sidebar.scrollTop = 0; 81 return shown || overview || nav; 82} 83 84// Marks the sidebar's link to path. Links to #anchors are places on this 85// page, not pages; the table of contents marks those as you scroll. 86function markActive(path) { 87 const links = document.querySelectorAll("#docs-nav a[href], .explorer-steps a[href]"); 88 for (const a of links) { 89 if (a.hasAttribute("data-nav-back") || a.dataset.navPeek || a.getAttribute("href").startsWith("#")) continue; 90 const on = new URL(a.href, location.href).pathname === path; 91 a.classList.toggle("active", on); 92 if (on && !a.classList.contains("sidebar-heading")) a.setAttribute("aria-current", "page"); 93 else a.removeAttribute("aria-current"); 94 } 95} 96 97// The phone-sized tour rail scrolls sideways; keep the current step in view 98// without ever scrolling the page. 99function revealStepChip() { 100 const rail = $(".explorer-steps"); 101 const chip = rail?.querySelector("a.active")?.parentElement; 102 if (!chip || rail.offsetParent === null) return; 103 if (chip.offsetLeft < rail.scrollLeft || chip.offsetLeft + chip.offsetWidth > rail.scrollLeft + rail.clientWidth) { 104 rail.scrollLeft = chip.offsetLeft - 12; 105 } 106} 107 108function revealActive(panel) { 109 const link = panel.querySelector("a.active:not(.sidebar-heading)"); 110 if (!link) return; 111 const box = sidebar.getBoundingClientRect(); 112 const r = link.getBoundingClientRect(); 113 if (r.top < box.top || r.bottom > box.bottom) { 114 sidebar.scrollTop = Math.max(0, sidebar.scrollTop + r.top - box.top - sidebar.clientHeight / 3); 115 } 116} 117 118// ------------------------------------------------------------ one page 119 120// On the one page the sidebar lists every doc; mark the one being read and 121// keep it in view. toc is [{ link, target }] in page order, or null. 122let toc = null; 123let tocFrame = 0; 124 125function followTOC() { 126 toc = null; 127 if (!isTOC(nav)) return; 128 toc = [...nav.querySelectorAll('a.sidebar-link[href^="#"]')] 129 .map((link) => ({ link, target: document.getElementById(decodeURIComponent(link.hash.slice(1))) })) 130 .filter((t) => t.target); 131 markReading(); 132} 133 134function markReading() { 135 tocFrame = 0; 136 if (!toc) return; 137 // The doc whose heading last scrolled past the top bar is the one on screen. 138 const line = $(".docs-topbar").offsetHeight + 40; 139 let reading = toc[0]; 140 for (const t of toc) { 141 if (t.target.getBoundingClientRect().top > line) break; 142 reading = t; 143 } 144 for (const { link } of toc) { 145 const on = link === reading?.link; 146 if (link.classList.contains("active") === on) continue; 147 link.classList.toggle("active", on); 148 if (on) link.setAttribute("aria-current", "location"); 149 else link.removeAttribute("aria-current"); 150 } 151 if (reading) revealActive(nav); 152} 153 154addEventListener("scroll", () => { 155 if (toc && !tocFrame) tocFrame = requestAnimationFrame(markReading); 156}, { passive: true }); 157 158const visiblePanel = () => nav.querySelector(".nav-overview:not([hidden]), .nav-section:not([hidden])"); 159const panelKey = (panel) => (panel?.classList.contains("nav-section") ? panel.dataset.section : ""); 160 161// Does this panel list the page as an entry (not as a heading or "more")? 162function lists(panel, path) { 163 return [...panel.querySelectorAll("a.sidebar-link[href]")].some((a) => !a.dataset.navPeek && a.pathname === path); 164} 165 166// The panel to show for a page: the one on screen if it already lists the 167// page, otherwise the server's choice for it. 168function panelFor(path, suggested) { 169 const current = visiblePanel(); 170 return current && lists(current, path) ? panelKey(current) : suggested; 171} 172 173function applyNav(section, path) { 174 const before = nav.dataset.section; 175 nav.dataset.section = section || ""; 176 const panel = showPanel(section, { resetScroll: before !== nav.dataset.section }); 177 markActive(path); 178 revealActive(panel); 179 revealStepChip(); 180} 181 182// "All sections": back up to the overview without leaving the page, with 183// the section you were in scrolled into view. 184function showOverview() { 185 const current = nav.dataset.section; 186 nav.dataset.section = "";
187 const overview = showPanel(""); 188 const heading = [...overview.querySelectorAll(".sidebar-heading")] 189 .find((a) => a.pathname === (current === "api-explorer" ? EXPLORER : "/docs/section/" + current)); 190 if (heading) { 191 sidebar.scrollTop = Math.max(0, heading.offsetTop - sidebar.offsetTop - 60); 192 heading.focus({ preventScroll: true }); 193 } 194} 195 196// "⦠N more": open one section in the sidebar; the article stays put. 197function peekSection(section) { 198 nav.dataset.section = section; 199 const panel = showPanel(section, { resetScroll: true }); 200 $(".nav-back", panel)?.focus({ preventScroll: true }); 201} 202 203// ------------------------------------------------------------ navigation 204 205function saveScroll() { 206 history.replaceState({ ...(history.state || {}), docs: true, scrollY: window.scrollY }, ""); 207} 208 209function fetchPage(url) { 210 const key = url.pathname + url.search; 211 const hit = prefetched.get(key); 212 if (hit && Date.now() - hit.at < 15000) { 213 prefetched.delete(key); 214 return hit.page; 215 } 216 return fetch(url, { credentials: "same-origin", headers: { "X-Docs-Navigation": "1" } }).then(async (res) => { 217 const type = res.headers.get("content-type") || ""; 218 if (!type.includes("text/html")) throw new Error("not a page"); 219 return { url: res.url, html: await res.text() }; 220 }); 221} 222 223function prefetch(url) { 224 const key = url.pathname + url.search; 225 if (key === pageKey || prefetched.has(key)) return; 226 const page = fetchPage(url); 227 page.catch(() => prefetched.delete(key)); 228 prefetched.set(key, { at: Date.now(), page }); 229} 230 231async function navigate(url, { push = true, replace = false, fromPop = false, scrollY = 0 } = {}) { 232 const token = ++navToken; 233 if (!fromPop) saveScroll(); 234 235 // Moving between tour steps needs no server round trip. 236 if (isExplorerPath(url.pathname) && document.body.dataset.view === "explorer" && explorer) { 237 commit(url, { push, replace }); 238 const overview = $(".nav-overview", nav); 239 applyNav(panelFor(url.pathname, lists(overview, url.pathname) ? "" : "api-explorer"), url.pathname); 240 const title = [...nav.querySelectorAll(".nav-step")].find((a) => a.pathname === url.pathname)?.querySelector(".nav-step-title")?.textContent; 241 document.title = title ? `API explorer: ${title} - exe.dev docs` : "API explorer - exe.dev docs"; 242 openExplorerAt(explorer, url, fromPop); 243 return; 244 } 245 246 document.body.classList.add("docs-loading"); 247 let page; 248 try { 249 page = await fetchPage(url); 250 } catch { 251 location.assign(url.href); 252 return; 253 } finally { 254 if (token === navToken) document.body.classList.remove("docs-loading"); 255 } 256 if (token !== navToken) return; 257 258 const finalURL = new URL(page.url || url.href); 259 finalURL.hash = url.hash; 260 const doc = new DOMParser().parseFromString(page.html, "text/html"); 261 if (!routable(finalURL) || !doc.getElementById("docs-main")) { 262 location.assign(finalURL.href); 263 return; 264 } 265 commit(finalURL, { push, replace }); 266 await render(doc, finalURL, { fromPop, scrollY }); 267} 268 269function commit(url, { push, replace }) { 270 const state = { docs: true, scrollY: 0 }; 271 if (push && url.href !== location.href) history.pushState(state, "", url); 272 else if (replace || url.href !== location.href) history.replaceState(state, "", url); 273 pageKey = url.pathname + url.search; 274} 275 276function syncHead(doc) { 277 document.title = doc.title; 278 for (const sel of ['meta[name="description"]', 'link[rel="canonical"]', 'link[rel="alternate"][type="text/markdown"]:not([href="/llms.txt"])']) { 279 const next = doc.head.querySelector(sel); 280 const cur = document.head.querySelector(sel); 281 if (cur && next) cur.replaceWith(next); 282 else if (cur) cur.remove(); 283 else if (next) document.head.append(next); 284 } 285 // The sidebar's own foot (One Page or Many Pages), not a section panel's. 286 const foot = doc.querySelector("#docs-sidebar > .sidebar-foot"); 287 if (foot) $("#docs-sidebar > .sidebar-foot").innerHTML = foot.innerHTML; 288} 289 290function setView(view) { 291 const was = document.body.dataset.view; 292 document.body.dataset.view = view; 293 docView.hidden = view === "explorer";
294 explorerView.hidden = view !== "explorer"; 295 if (was === "explorer" && view !== "explorer") explorer?.deactivate(); 296} 297 298async function render(doc, url, { fromPop, scrollY }) { 299 toc = null; 300 // Take the fresh sidebar when a docs deploy landed while this tab was 301 // open, or when moving onto or off the one page, whose sidebar is its 302 // table of contents. 303 const version = doc.body.dataset.docsVersion || ""; 304 const fresh = doc.getElementById("docs-nav"); 305 const suggested = fresh?.dataset.section || ""; // read before fresh moves out of doc 306 const reshaped = fresh && isTOC(fresh) !== isTOC(nav); 307 if (fresh && (reshaped || version !== (document.body.dataset.docsVersion || ""))) { 308 nav.replaceWith(document.adoptNode(fresh)); 309 nav = $("#docs-nav"); 310 document.body.dataset.docsVersion = version; 311 if (reshaped) sidebar.scrollTop = 0; 312 } 313 syncHead(doc); 314 const view = doc.body.dataset.view; 315 applyNav(panelFor(url.pathname, suggested), url.pathname); 316 317 if (view === "explorer") { 318 if (!explorerView.firstElementChild) { 319 explorerView.replaceChildren(...[...doc.getElementById("explorer-view").childNodes].map((n) => document.adoptNode(n))); 320 } 321 setView("explorer"); 322 window.scrollTo(0, 0); 323 const ex = await loadExplorer(); 324 ex.activate(); 325 markActive(url.pathname); 326 revealStepChip(); 327 openExplorerAt(ex, url, fromPop); 328 return; 329 } 330 331 const refocus = document.activeElement?.closest?.(".search-page-form") ? document.activeElement : null; 332 const caret = refocus ? [refocus.selectionStart, refocus.selectionEnd] : null; 333 docView.replaceChildren(...[...doc.getElementById("doc-view").childNodes].map((n) => document.adoptNode(n))); 334 setView(view); 335 enhance(docView); 336 337 if (refocus) { 338 const input = $(".search-page-form input", docView); 339 input?.focus({ preventScroll: true }); 340 if (input && caret) input.setSelectionRange(...caret); 341 } 342 if (document.activeElement !== searchInput && searchInput) { 343 searchInput.value = view === "search" ? url.searchParams.get("q") || "" : ""; 344 } 345 346 const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1))); 347 if (target) target.scrollIntoView(); 348 else window.scrollTo(0, fromPop ? scrollY : 0); 349 followTOC(); 350} 351 352function loadExplorer() { 353 if (explorer) return Promise.resolve(explorer); 354 explorerLoading ||= import(explorerView.dataset.module).then((mod) => (explorer = mod)); 355 return explorerLoading; 356} 357 358// ------------------------------------------------------------ doc content 359 360const COPY_ICON = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; 361const CHECK_ICON = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 362 363async function copyText(text) { 364 try { 365 await navigator.clipboard.writeText(text); 366 } catch { 367 const ta = Object.assign(document.createElement("textarea"), { value: text }); 368 ta.style.cssText = "position:fixed;top:0;left:0;opacity:0"; 369 document.body.append(ta); 370 ta.select(); 371 document.execCommand("copy"); 372 ta.remove(); 373 } 374} 375 376function enhance(root) { 377 for (const content of root.querySelectorAll(".doc-content")) { 378 for (const code of content.querySelectorAll("pre > code")) { 379 const pre = code.parentElement; 380 if (pre.parentElement.classList.contains("copy-code-wrap")) continue; 381 const wrap = document.createElement("div"); 382 wrap.className = "copy-code-wrap"; 383 pre.replaceWith(wrap); 384 wrap.append(pre); 385 const btn = document.createElement("button"); 386 btn.type = "button"; 387 btn.className = "copy-code-button"; 388 btn.setAttribute("aria-label", "Copy code"); 389 btn.innerHTML = COPY_ICON; 390 btn.addEventListener("click", async () => { 391 await copyText(code.textContent); 392 btn.innerHTML = CHECK_ICON; 393 btn.classList.add("is-copied"); 394 setTimeout(() => { 395 btn.innerHTML = COPY_ICON; 396 btn.classList.remove("is-copied"); 397 }, 2000); 398 }); 399 wrap.append(btn); 400 } 401 for (const h of content.querySelectorAll("h1[id], h2[id], h3[id], h4[id]")) { 402 if (h.querySelector(".anchor-link")) continue; 403 const a = Object.assign(document.createElement("a"), { href: "#" + h.id, className: "anchor-link", textContent: "#" }); 404 a.setAttribute("aria-label", "Link to this section"); 405 h.append(a); 406 } 407 for (const el of content.querySelectorAll("#dns-checker")) mountDNSChecker(el); 408 for (const el of content.querySelectorAll("#command-quoter")) mountQuoter(el); 409 } 410 const md = $("[data-copy-markdown]", root); 411 md?.addEventListener("click", async () => { 412 const res = await fetch(md.dataset.copyMarkdown); 413 if (!res.ok) return; 414 await copyText(await res.text()); 415 md.classList.add("copied"); 416 md.dataset.tooltip = "Copied!"; 417 setTimeout(() => { 418 md.classList.remove("copied"); 419 md.dataset.tooltip = "Copy as Markdown"; 420 }, 2000); 421 }); 422} 423 424// The DNS checker asks exed's /api/dns-check, so it only works where an exe 425// stack serves these pages (they carry data-public-host). On the hub, point 426// at exe.dev's copy, keeping a support link's ?domain=&vm=. 427function mountDNSChecker(el) { 428 if (document.body.dataset.publicHost) { 429 import("./dns-check.js").then((m) => m.mountDNSChecker(el)); 430 return; 431 } 432 const there = new URL("https://exe.dev/docs/cnames#check-your-dns"); 433 const here = new URLSearchParams(location.search); 434 for (const k of ["domain", "vm"]) if (here.has(k)) there.searchParams.set(k, here.get(k)); 435 const note = Object.assign(document.createElement("p"), { className: "doc-widget-note" });
436 note.append("The DNS checker runs on exe.dev: ", Object.assign(document.createElement("a"), { href: there.href, textContent: "check your DNS there" }), "."); 437 el.replaceWith(note); 438} 439 440// A command bound for a VM through POST /exec is parsed twice: by the 441// exe.dev lobby's shell lexer, then by the VM's shell. Show both layers. 442function shellWord(s) { 443 if (s === "") return "''"; 444 if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s; 445 if (!s.includes("'")) return `'${s}'`; 446 if (!/["\\$`]/.test(s)) return `"${s}"`; 447 return `'${s.replace(/'/g, `'\\''`)}'`; 448} 449 450function mountQuoter(el) { 451 const examples = [ 452 ["simple", "whoami"], 453 ["pipeline", "cd /srv && ls | head -3"], 454 ["quoted", "echo 'hello world'"], 455 ["both quotes", `printf '%s\\n' "it's ready"`], 456 ["detached", "setsid nohup ./build.sh > /tmp/build.log 2>&1 & echo started"], 457 ]; 458 const box = document.createElement("div"); 459 box.className = "command-quoter"; 460 box.innerHTML = `<label for="quoter-command">Command to run on the VM</label> 461 <textarea id="quoter-command" rows="2" spellcheck="false"></textarea> 462 <div class="command-quoter-examples"></div> 463 <div class="command-quoter-out" aria-live="polite"> 464 <strong>ssh exe.dev</strong><pre><code data-out="lobby"></code></pre> 465 <strong>curl</strong><pre><code data-out="curl"></code></pre> 466 </div>`; 467 const input = $("textarea", box); 468 const update = () => { 469 const lobby = `ssh my-vm ${shellWord(input.value)}`; 470 $('[data-out="lobby"]', box).textContent = lobby; 471 $('[data-out="curl"]', box).textContent = 472 `curl -X POST https://exe.dev/exec \\\n -H "Authorization: Bearer $TOKEN" \\\n -d ${shellWord(lobby)}`; 473 }; 474 for (const [label, cmd] of examples) { 475 const b = Object.assign(document.createElement("button"), { type: "button", textContent: label }); 476 b.addEventListener("click", () => { input.value = cmd; update(); }); 477 $(".command-quoter-examples", box).append(b); 478 } 479 input.value = examples[3][1]; 480 input.addEventListener("input", update); 481 update(); 482 el.replaceWith(box); 483} 484 485// ------------------------------------------------------------ wiring 486 487document.addEventListener("click", (e) => { 488 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 489 const a = e.target.closest?.("a[href]"); 490 if (!a || (a.target && a.target !== "_self") || a.hasAttribute("download")) return; 491 if (a.hasAttribute("data-nav-back")) { 492 e.preventDefault(); 493 showOverview(); 494 return; 495 } 496 if (a.dataset.navPeek) { 497 e.preventDefault(); 498 peekSection(a.dataset.navPeek); 499 return; 500 } 501 const url = new URL(a.href, location.href); 502 if (!routable(url)) return; 503 if (url.pathname + url.search === pageKey && url.hash) return; // in-page anchor 504 e.preventDefault(); 505 navigate(url); 506}); 507 508document.addEventListener("pointerover", (e) => { 509 const a = e.target.closest?.("a[href]"); 510 if (!a || a.dataset.navPeek || a.hasAttribute("data-nav-back") || e.pointerType === "touch") return; 511 const url = new URL(a.href, location.href); 512 if (routable(url) && !isExplorerPath(url.pathname)) prefetch(url); 513}); 514 515addEventListener("popstate", (e) => { 516 const url = new URL(location.href); 517 if (url.pathname + url.search === pageKey) { 518 const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1))); 519 if (target) target.scrollIntoView(); 520 return; 521 } 522 navigate(url, { push: false, fromPop: true, scrollY: e.state?.scrollY || 0 }); 523}); 524 525// Searching from the sidebar (or the search page on phones) updates the 526// results as you type. 527function searchURL(q) { 528 const url = new URL("/docs/search", location.origin); 529 if (q.trim()) url.searchParams.set("q", q.trim()); 530 return url; 531} 532let searchTimer = 0; 533function onSearchInput(value, immediate) { 534 clearTimeout(searchTimer); 535 const onSearch = location.pathname === "/docs/search"; 536 if (!value.trim() && !onSearch) return; 537 const go = () => navigate(searchURL(value), { push: !onSearch, replace: onSearch }); 538 if (immediate) go(); 539 else searchTimer = setTimeout(go, 160); 540} 541searchInput?.addEventListener("input", () => onSearchInput(searchInput.value, false)); 542document.addEventListener("input", (e) => { 543 if (e.target.closest?.(".search-page-form")) onSearchInput(e.target.value, false); 544}); 545document.addEventListener("submit", (e) => { 546 const form = e.target.closest?.(".docs-search, .search-page-form"); 547 if (!form) return; 548 e.preventDefault(); 549 onSearchInput($("input", form).value, true); 550}); 551 552document.addEventListener("keydown", (e) => { 553 if (e.key === "Escape" && e.target === searchInput) { 554 searchInput.blur(); 555 return; 556 } 557 if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return; 558 if (document.body.dataset.view === "explorer") return; // the terminal wants slashes 559 const t = e.target; 560 if (t.closest?.("input, textarea, select, [contenteditable]")) return; 561 e.preventDefault(); 562 if (searchInput && sidebar.offsetParent !== null) searchInput.focus(); 563 else navigate(searchURL("")); 564}); 565
566explorerView.addEventListener("explorer:command", (e) => { 567 if (!isExplorerPath(location.pathname)) return; 568 commit(withCommand(new URL(location.href), e.detail.command), { replace: true }); 569}); 570 571explorerView.addEventListener("explorer:reset", () => { 572 if (isExplorerPath(location.pathname) && (stepOf(location.pathname) || location.search)) { 573 commit(new URL(EXPLORER, location.origin), { replace: true }); 574 document.title = "API explorer - exe.dev docs"; 575 } 576 markActive(EXPLORER); 577}); 578 579// Sign in brings you back to where you are now, even after moves that 580// fetched no page (tour steps, anchors). data-login names the parameter: 581// "redirect" for the hub's login, "next" for the hub's /signin on exe.dev. 582const login = $("[data-login]"); 583function aimLogin() { 584 const u = new URL(login.href); 585 u.searchParams.set(login.dataset.login, location.pathname + location.search + location.hash); 586 login.href = u.href; 587} 588for (const ev of ["pointerenter", "focus", "click"]) login?.addEventListener(ev, aimLogin); 589 590// ------------------------------------------------------------ boot 591 592history.scrollRestoration = "manual"; 593// An admin's "committed" notice is for this visit, not for the address bar. 594if (new URLSearchParams(location.search).has("saved")) { 595 const clean = new URL(location.href); 596 clean.searchParams.delete("saved"); 597 history.replaceState(history.state, "", clean); 598 pageKey = clean.pathname + clean.search; 599} 600history.replaceState({ ...(history.state || {}), docs: true }, ""); 601enhance(docView); 602revealActive(nav.querySelector(".nav-section:not([hidden])") || nav); 603{ 604 const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1))); 605 if (target) target.scrollIntoView(); 606} 607followTOC(); 608if (document.body.dataset.view === "explorer") { 609 loadExplorer().then((ex) => { 610 revealStepChip(); 611 ex.activate(); 612 openExplorerAt(ex, new URL(location.href), false); 613 }); 614}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.