130 lines
5.0 KiB
JavaScript
130 lines
5.0 KiB
JavaScript
// Sidebar toggle, "on this page" highlighting, and client-side search.
|
|
|
|
const toggle = document.querySelector(".nav-toggle");
|
|
const sidebar = document.getElementById("sidebar");
|
|
if (toggle && sidebar) {
|
|
const setOpen = (open) => {
|
|
sidebar.classList.toggle("is-open", open);
|
|
toggle.setAttribute("aria-expanded", String(open));
|
|
};
|
|
toggle.addEventListener("click", () => setOpen(!sidebar.classList.contains("is-open")));
|
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOpen(false); });
|
|
document.addEventListener("click", (e) => {
|
|
if (sidebar.classList.contains("is-open") && !sidebar.contains(e.target) && !toggle.contains(e.target)) setOpen(false);
|
|
});
|
|
const current = sidebar.querySelector('[aria-current="page"]');
|
|
if (current) current.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
// Highlight the current heading in the table of contents.
|
|
const tocLinks = [...document.querySelectorAll(".toc a")];
|
|
if (tocLinks.length && "IntersectionObserver" in window) {
|
|
const map = new Map(tocLinks.map((a) => [decodeURIComponent(a.hash.slice(1)), a]));
|
|
const headings = [...map.keys()].map((id) => document.getElementById(id)).filter(Boolean);
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
tocLinks.forEach((a) => a.classList.remove("is-active"));
|
|
map.get(entry.target.id)?.classList.add("is-active");
|
|
}
|
|
});
|
|
}, { rootMargin: "0px 0px -70% 0px" });
|
|
headings.forEach((h) => observer.observe(h));
|
|
}
|
|
|
|
// Search
|
|
const input = document.getElementById("search-input");
|
|
const results = document.getElementById("search-results");
|
|
const script = document.currentScript || document.querySelector("script[data-index]");
|
|
const indexURL = script ? script.dataset.index : "/index.json";
|
|
let index = null;
|
|
let selected = -1;
|
|
|
|
async function loadIndex() {
|
|
if (index) return index;
|
|
try {
|
|
const res = await fetch(indexURL);
|
|
index = await res.json();
|
|
} catch (err) {
|
|
index = [];
|
|
}
|
|
return index;
|
|
}
|
|
|
|
const norm = (s) => (s || "").toLowerCase();
|
|
|
|
function score(item, terms) {
|
|
let total = 0;
|
|
for (const t of terms) {
|
|
const title = norm(item.title);
|
|
let s = 0;
|
|
if (title === t) s += 100;
|
|
else if (title.startsWith(t)) s += 60;
|
|
else if (title.includes(t)) s += 40;
|
|
if (norm(item.replaces).includes(t)) s += 30;
|
|
if (norm(item.tagline).includes(t)) s += 15;
|
|
if (norm(item.headings).includes(t)) s += 12;
|
|
if (norm(item.body).includes(t)) s += 4;
|
|
if (!s) return 0; // every term must match somewhere
|
|
total += s;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function escapeHTML(s) {
|
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
|
|
function render(items, query) {
|
|
selected = -1;
|
|
if (!query) { results.hidden = true; input.setAttribute("aria-expanded", "false"); return; }
|
|
results.hidden = false;
|
|
input.setAttribute("aria-expanded", "true");
|
|
if (!items.length) {
|
|
results.innerHTML = `<li class="r-empty">No pages match “${escapeHTML(query)}”. Try an app name or a service you want to replace, like “Dropbox”.</li>`;
|
|
return;
|
|
}
|
|
results.innerHTML = items.slice(0, 8).map((it) => {
|
|
const meta = it.replaces ? `${it.section}. Replaces ${it.replaces}` : it.section;
|
|
return `<li><a href="${it.url}"><span class="r-title">${escapeHTML(it.title)}</span><span class="r-meta">${escapeHTML(meta)}</span></a></li>`;
|
|
}).join("");
|
|
}
|
|
|
|
async function search() {
|
|
const q = input.value.trim();
|
|
const data = await loadIndex();
|
|
const terms = norm(q).split(/\s+/).filter(Boolean);
|
|
const hits = data
|
|
.map((item) => ({ item, s: score(item, terms) }))
|
|
.filter((x) => x.s > 0)
|
|
.sort((a, b) => b.s - a.s)
|
|
.map((x) => x.item);
|
|
render(hits, q);
|
|
}
|
|
|
|
function move(delta) {
|
|
const links = [...results.querySelectorAll("a")];
|
|
if (!links.length) return;
|
|
selected = (selected + delta + links.length) % links.length;
|
|
links.forEach((a, i) => a.setAttribute("aria-selected", String(i === selected)));
|
|
links[selected].scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
if (input && results) {
|
|
input.addEventListener("focus", loadIndex, { once: true });
|
|
input.addEventListener("input", search);
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
|
|
else if (e.key === "Enter") {
|
|
const links = results.querySelectorAll("a");
|
|
const target = links[selected >= 0 ? selected : 0];
|
|
if (target) { e.preventDefault(); window.location.href = target.href; }
|
|
} else if (e.key === "Escape") { input.value = ""; render([], ""); input.blur(); }
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "/" && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); input.focus(); }
|
|
});
|
|
document.addEventListener("click", (e) => { if (!e.target.closest(".search")) { results.hidden = true; input.setAttribute("aria-expanded", "false"); } });
|
|
}
|