first commit
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
// 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: "center" });
|
||||
}
|
||||
|
||||
// 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"); } });
|
||||
}
|
||||
Reference in New Issue
Block a user