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 = `