let lunrIndex; let documents = []; let activeIndex = -1; let currentResults = []; let isSyncingHeaderInput = false; let suppressHeaderFocus = false; let searchReady = false; const searchInput = document.getElementById("search-input"); const searchBtn = document.getElementById("search-btn"); const searchForm = searchInput?.closest("form"); const modal = document.createElement("div"); modal.id = "search-modal"; modal.setAttribute("aria-hidden", "true"); modal.innerHTML = `
`; document.body.appendChild(modal); const pane = modal.querySelector(".search-modal-content"); const paneInput = modal.querySelector("#search-pane-input"); const modalResults = modal.querySelector("#search-modal-results"); const paneCount = modal.querySelector("#search-pane-count"); function extractDocuments(node, currentPath = "") { if (node.type === "folder" && node.children) { const nextPath = currentPath ? `${currentPath}/${node.name}` : node.name; node.children.forEach((child) => { extractDocuments(child, nextPath); }); } if (node.type === "file") { documents.push({ id: documents.length.toString(), title: node.name, content: node.content || "", path: node.url, }); } } function buildIndex() { lunrIndex = lunr(function () { this.ref("id"); this.field("title", { boost: 10 }); this.field("url"); this.field("content"); documents.forEach((doc) => this.add(doc)); }); } async function initSearch() { try { const response = await fetch("/test.json"); const json = await response.json(); extractDocuments(json); buildIndex(); searchReady = true; if (isSearchOpen()) renderResults(paneInput.value); } catch (error) { console.warn("Search index could not be loaded", error); if (paneCount) paneCount.textContent = "Search is unavailable right now."; } } initSearch(); function buildExactQuery(query) { return query .toLowerCase() .split(/\s+/) .filter((term) => term.length > 1) .map((term) => `${term}*`) .join(" "); } function buildSnippet(content, terms, radius = 90) { if (!content || terms.length === 0) return ""; const lower = content.toLowerCase(); let index = -1; let matchedTerm = ""; for (const term of terms) { const i = lower.indexOf(term); if (i !== -1 && (index === -1 || i < index)) { index = i; matchedTerm = term; } } if (index === -1) { return content.slice(0, radius * 2) + "…"; } const start = Math.max(0, index - radius); const end = Math.min(content.length, index + matchedTerm.length + radius); const prefix = start > 0 ? "…" : ""; const suffix = end < content.length ? "…" : ""; return prefix + content.slice(start, end) + suffix; } function getSearchTerms(query) { return query .toLowerCase() .split(/\s+/) .filter((t) => t.length > 2); } function escapeHtml(text) { return String(text || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function highlight(text, terms) { const escapedText = escapeHtml(text); if (!escapedText || terms.length === 0) return escapedText; const escapedTerms = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); const regex = new RegExp(`(${escapedTerms.join("|")})`, "gi"); return escapedText.replace(regex, "$1"); } function search(query) { if (!lunrIndex) return []; const q = buildExactQuery(query); if (!q) return []; const terms = getSearchTerms(query); const results = lunrIndex.search(q); return results .map((r) => { const doc = documents.find((d) => d.id === r.ref); if (!doc) return null; const snippet = buildSnippet(doc.content, terms); return { title: highlight(doc.title, terms), path: doc.path, score: r.score, preview: highlight(snippet, terms), }; }) .filter(Boolean); } function setHeaderSearchValue(value, dispatchInput = false) { if (!searchInput) return; isSyncingHeaderInput = true; searchInput.value = value; isSyncingHeaderInput = false; if (dispatchInput) { searchInput.dispatchEvent(new Event("input", { bubbles: true })); } } function isSearchOpen() { return modal.classList.contains("is-open"); } function openSearchPane(initialValue = "") { const value = initialValue || searchInput?.value || paneInput?.value || ""; modal.classList.add("is-open"); modal.setAttribute("aria-hidden", "false"); document.documentElement.classList.add("search-open"); paneInput.value = value; setHeaderSearchValue(value); renderResults(value); window.setTimeout(() => { paneInput.focus(); paneInput.setSelectionRange(paneInput.value.length, paneInput.value.length); }, 0); } function closeSearchPane() { modal.classList.remove("is-open"); modal.setAttribute("aria-hidden", "true"); document.documentElement.classList.remove("search-open"); activeIndex = -1; suppressHeaderFocus = true; searchInput?.focus(); window.setTimeout(() => { suppressHeaderFocus = false; }, 0); } function getResultItems() { return Array.from(modalResults.querySelectorAll(".search-modal-result")); } function updateActiveResult(focusActive = false) { const items = getResultItems(); items.forEach((item, index) => { const isActive = index === activeIndex; item.classList.toggle("active", isActive); item.setAttribute("aria-selected", String(isActive)); }); if (activeIndex >= 0 && activeIndex < items.length) { items[activeIndex].scrollIntoView({ block: "nearest", behavior: "smooth", }); if (focusActive) items[activeIndex].focus({ preventScroll: true }); } } function setActiveResult(index, focusActive = false) { const items = getResultItems(); if (!items.length) { activeIndex = -1; return; } activeIndex = Math.max(0, Math.min(index, items.length - 1)); updateActiveResult(focusActive); } function openActiveResult() { const items = getResultItems(); if (!items.length) return; const index = activeIndex >= 0 ? activeIndex : 0; items[index]?.click(); } function renderResults(query) { const trimmed = query.trim(); currentResults = trimmed.length >= 2 ? search(trimmed) : []; activeIndex = currentResults.length ? 0 : -1; modalResults.innerHTML = ""; if (!trimmed) { paneCount.textContent = "Start typing to search your notes."; modalResults.innerHTML = `
Try a topic, page title, or phrase from a note.
`; return; } if (trimmed.length < 2) { paneCount.textContent = "Keep typing."; modalResults.innerHTML = `
Use at least two characters.
`; return; } if (!searchReady) { paneCount.textContent = "Loading search index."; modalResults.innerHTML = `
Loading results...
`; return; } if (!currentResults.length) { paneCount.textContent = "No results found."; modalResults.innerHTML = `
No matches for "${escapeHtml(trimmed)}".
`; return; } paneCount.textContent = `${currentResults.length} result${currentResults.length === 1 ? "" : "s"}`; currentResults.slice(0, 20).forEach((result, i) => { const item = document.createElement("a"); item.className = "search-modal-result"; item.href = result.path; item.dataset.index = i; item.setAttribute("role", "option"); item.setAttribute("aria-selected", String(i === activeIndex)); item.innerHTML = ` ${result.title} ${result.preview} ${escapeHtml(result.path)} `; item.addEventListener("mouseenter", () => setActiveResult(i)); modalResults.appendChild(item); }); updateActiveResult(); } function handleSearchKeydown(e) { const items = getResultItems(); switch (e.key) { case "ArrowDown": e.preventDefault(); if (items.length) setActiveResult(activeIndex + 1, true); break; case "ArrowUp": e.preventDefault(); if (items.length) setActiveResult(activeIndex <= 0 ? items.length - 1 : activeIndex - 1, true); break; case "Home": if (items.length) { e.preventDefault(); setActiveResult(0, true); } break; case "End": if (items.length) { e.preventDefault(); setActiveResult(items.length - 1, true); } break; case "Enter": e.preventDefault(); openActiveResult(); break; case "Escape": e.preventDefault(); closeSearchPane(); break; } } function shouldUseSlashShortcut(event) { const target = event.target; const isEditable = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable; return event.key === "/" && !isEditable && !event.metaKey && !event.ctrlKey && !event.altKey; } if (searchInput && searchBtn && paneInput && modalResults && paneCount) { searchInput.addEventListener("focus", () => { if (!suppressHeaderFocus) openSearchPane(searchInput.value); }); searchInput.addEventListener("click", () => openSearchPane(searchInput.value)); searchInput.addEventListener("input", () => { if (isSyncingHeaderInput) return; if (!isSearchOpen()) openSearchPane(searchInput.value); paneInput.value = searchInput.value; renderResults(searchInput.value); }); paneInput.addEventListener("input", () => { setHeaderSearchValue(paneInput.value, true); renderResults(paneInput.value); }); paneInput.addEventListener("keydown", handleSearchKeydown); modalResults.addEventListener("keydown", handleSearchKeydown); searchBtn.addEventListener("click", () => openSearchPane(searchInput.value)); modal.addEventListener("click", (e) => { if (e.target.hasAttribute("data-search-close")) { closeSearchPane(); } }); pane.addEventListener("click", (e) => { if (e.target.closest("[data-search-close]")) closeSearchPane(); e.stopPropagation(); }); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openSearchPane(searchInput.value); return; } if (shouldUseSlashShortcut(e)) { e.preventDefault(); openSearchPane(""); } if (e.key === "Escape" && isSearchOpen()) { closeSearchPane(); } }); window.addEventListener("pageshow", () => { searchInput.value = ""; paneInput.value = ""; renderResults(""); }); window.addEventListener("beforeunload", () => { searchInput.value = ""; }); if (searchForm) { searchForm.addEventListener("submit", (e) => { e.preventDefault(); openSearchPane(searchInput.value); }); } }