Files
documentation/assets/js/site.js
T
2026-10-20 10:58:30 -06:00

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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"); } });
}