Skip to content

Commit ca64719

Browse files
committed
docs(nav[spa]): add SPA-like navigation to avoid full page reloads
why: Every page navigation re-downloads and re-parses 8 CSS files, 7 JS files, 3 fonts, re-renders the sidebar, SVG icons, and the entire layout. Only the article content, right-panel TOC, and active sidebar link actually change between pages. what: - Create docs/_static/js/spa-nav.js (~170 lines, vanilla JS, no deps) - Intercept internal link clicks via event delegation on document - Fetch target page, parse with DOMParser, swap three DOM regions: .article-container, .sidebar-tree, .toc-drawer - Preserve sidebar scroll position, theme state, all CSS/JS/fonts - Replicate Furo's cycleThemeOnce for swapped .content-icon-container - Inject copy buttons on new code blocks via cloneNode from template (ClipboardJS event delegation picks up new .copybtn elements) - Minimal scrollspy (~25 lines) replaces Gumshoe for swapped TOC - AbortController cancels in-flight fetches on rapid navigation - history.pushState/popstate for back/forward browser navigation - Debounced prefetch on hover (65ms) for near-instant transitions - Progressive enhancement: no-op if fetch/DOMParser/pushState absent - Skip interception for search.html, genindex, external links, modifier-key clicks, download attrs, and #sidebar-projects links - Fallback to window.location.href on network error or missing DOM - Register script in conf.py setup() with loading_method="defer"
1 parent 98a4798 commit ca64719

File tree

2 files changed

+229
-0
lines changed

2 files changed

+229
-0
lines changed

docs/_static/js/spa-nav.js

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* SPA-like navigation for Sphinx/Furo docs.
3+
*
4+
* Intercepts internal link clicks and swaps only the content that changes
5+
* (article, sidebar nav tree, TOC drawer), preserving sidebar scroll
6+
* position, theme state, and avoiding full-page reloads.
7+
*
8+
* Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable.
9+
*/
10+
(function () {
11+
"use strict";
12+
13+
if (!window.fetch || !window.DOMParser || !window.history?.pushState) return;
14+
15+
// --- Theme toggle (replicates Furo's cycleThemeOnce) ---
16+
17+
function cycleTheme() {
18+
var current = localStorage.getItem("theme") || "auto";
19+
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
20+
var next;
21+
if (current === "auto") next = prefersDark ? "light" : "dark";
22+
else if (current === "dark") next = prefersDark ? "auto" : "light";
23+
else next = prefersDark ? "dark" : "auto";
24+
document.body.dataset.theme = next;
25+
localStorage.setItem("theme", next);
26+
}
27+
28+
// --- Copy button injection ---
29+
30+
var copyBtnTemplate = null;
31+
32+
function captureCopyIcon() {
33+
var btn = document.querySelector(".copybtn");
34+
if (btn) copyBtnTemplate = btn.cloneNode(true);
35+
}
36+
37+
function addCopyButtons() {
38+
if (!copyBtnTemplate) captureCopyIcon();
39+
if (!copyBtnTemplate) return;
40+
var cells = document.querySelectorAll("div.highlight pre");
41+
cells.forEach(function (cell, i) {
42+
cell.id = "codecell" + i;
43+
var next = cell.nextElementSibling;
44+
if (next && next.classList.contains("copybtn")) {
45+
next.setAttribute("data-clipboard-target", "#codecell" + i);
46+
} else {
47+
var btn = copyBtnTemplate.cloneNode(true);
48+
btn.setAttribute("data-clipboard-target", "#codecell" + i);
49+
cell.insertAdjacentElement("afterend", btn);
50+
}
51+
});
52+
}
53+
54+
// --- Minimal scrollspy ---
55+
56+
var scrollCleanup = null;
57+
58+
function initScrollSpy() {
59+
if (scrollCleanup) scrollCleanup();
60+
scrollCleanup = null;
61+
62+
var links = document.querySelectorAll(".toc-tree a");
63+
if (!links.length) return;
64+
65+
var entries = [];
66+
links.forEach(function (a) {
67+
var id = (a.getAttribute("href") || "").split("#")[1];
68+
var el = id && document.getElementById(id);
69+
var li = a.closest("li");
70+
if (el && li) entries.push({ el: el, li: li });
71+
});
72+
if (!entries.length) return;
73+
74+
function update() {
75+
var offset =
76+
parseFloat(getComputedStyle(document.documentElement).fontSize) * 4;
77+
var active = null;
78+
for (var i = entries.length - 1; i >= 0; i--) {
79+
if (entries[i].el.getBoundingClientRect().top <= offset) {
80+
active = entries[i];
81+
break;
82+
}
83+
}
84+
entries.forEach(function (e) {
85+
e.li.classList.remove("scroll-current");
86+
});
87+
if (active) active.li.classList.add("scroll-current");
88+
}
89+
90+
window.addEventListener("scroll", update, { passive: true });
91+
update();
92+
scrollCleanup = function () {
93+
window.removeEventListener("scroll", update);
94+
};
95+
}
96+
97+
// --- Link interception ---
98+
99+
function shouldIntercept(link, e) {
100+
if (e.defaultPrevented || e.button !== 0) return false;
101+
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
102+
if (link.origin !== location.origin) return false;
103+
if (link.target && link.target !== "_self") return false;
104+
if (link.hasAttribute("download")) return false;
105+
106+
var path = link.pathname;
107+
if (!path.endsWith(".html") && !path.endsWith("/")) return false;
108+
109+
var base = path.split("/").pop() || "";
110+
if (
111+
base === "search.html" ||
112+
base === "genindex.html" ||
113+
base === "py-modindex.html"
114+
)
115+
return false;
116+
117+
if (link.closest("#sidebar-projects")) return false;
118+
if (link.pathname === location.pathname && link.hash) return false;
119+
120+
return true;
121+
}
122+
123+
// --- DOM swap ---
124+
125+
function swap(doc) {
126+
[".article-container", ".sidebar-tree", ".toc-drawer"].forEach(
127+
function (sel) {
128+
var fresh = doc.querySelector(sel);
129+
var stale = document.querySelector(sel);
130+
if (fresh && stale) stale.replaceWith(fresh);
131+
},
132+
);
133+
var title = doc.querySelector("title");
134+
if (title) document.title = title.textContent || "";
135+
}
136+
137+
function reinit() {
138+
addCopyButtons();
139+
initScrollSpy();
140+
var btn = document.querySelector(".content-icon-container .theme-toggle");
141+
if (btn) btn.addEventListener("click", cycleTheme);
142+
}
143+
144+
// --- Navigation ---
145+
146+
var currentCtrl = null;
147+
148+
async function navigate(url, isPop) {
149+
if (currentCtrl) currentCtrl.abort();
150+
var ctrl = new AbortController();
151+
currentCtrl = ctrl;
152+
153+
try {
154+
var resp = await fetch(url, { signal: ctrl.signal });
155+
if (!resp.ok) throw new Error(resp.status);
156+
157+
var html = await resp.text();
158+
var doc = new DOMParser().parseFromString(html, "text/html");
159+
160+
if (!doc.querySelector(".article-container"))
161+
throw new Error("no article");
162+
163+
swap(doc);
164+
165+
if (!isPop) history.pushState({ spa: true }, "", url);
166+
167+
if (!isPop) {
168+
var hash = new URL(url, location.href).hash;
169+
if (hash) {
170+
var el = document.querySelector(hash);
171+
if (el) el.scrollIntoView();
172+
} else {
173+
window.scrollTo(0, 0);
174+
}
175+
}
176+
177+
reinit();
178+
} catch (err) {
179+
if (err.name === "AbortError") return;
180+
window.location.href = url;
181+
} finally {
182+
if (currentCtrl === ctrl) currentCtrl = null;
183+
}
184+
}
185+
186+
// --- Events ---
187+
188+
document.addEventListener("click", function (e) {
189+
var link = e.target.closest("a[href]");
190+
if (link && shouldIntercept(link, e)) {
191+
e.preventDefault();
192+
navigate(link.href, false);
193+
}
194+
});
195+
196+
history.replaceState({ spa: true }, "");
197+
198+
window.addEventListener("popstate", function (e) {
199+
if (e.state && e.state.spa) navigate(location.href, true);
200+
});
201+
202+
// --- Hover prefetch ---
203+
204+
var prefetchTimer = null;
205+
206+
document.addEventListener("mouseover", function (e) {
207+
var link = e.target.closest("a[href]");
208+
if (!link || link.origin !== location.origin) return;
209+
if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/"))
210+
return;
211+
212+
clearTimeout(prefetchTimer);
213+
prefetchTimer = setTimeout(function () {
214+
fetch(link.href, { priority: "low" }).catch(function () {});
215+
}, 65);
216+
});
217+
218+
document.addEventListener("mouseout", function (e) {
219+
if (e.target.closest("a[href]")) clearTimeout(prefetchTimer);
220+
});
221+
222+
// --- Init ---
223+
224+
// Copy buttons are injected by copybutton.js on DOMContentLoaded.
225+
// This defer script runs before DOMContentLoaded, so our handler
226+
// fires after copybutton's handler (registration order preserved).
227+
document.addEventListener("DOMContentLoaded", captureCopyIcon);
228+
})();

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,5 @@ def remove_tabs_js(app: Sphinx, exc: Exception) -> None:
265265

266266
def setup(app: Sphinx) -> None:
267267
"""Sphinx setup hook."""
268+
app.add_js_file("js/spa-nav.js", loading_method="defer")
268269
app.connect("build-finished", remove_tabs_js)

0 commit comments

Comments
 (0)