let lunrIndex; let documents = []; let activeIndex = -1; const searchInput = document.getElementById("search-input"); const searchBtn = document.getElementById("search-btn"); const resultsBox = document.createElement("div"); const searchForm = searchInput.closest("form"); const modal = document.createElement("div"); const modalResults = modal.querySelector("#search-modal-results"); resultsBox.id = "search-results"; document.body.appendChild(resultsBox); modal.id = "search-modal"; modal.innerHTML = `

Search results

`; document.body.appendChild(modal); 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() { const response = await fetch("/test.json"); const json = await response.json(); extractDocuments(json); buildIndex(); } initSearch(); function buildExactQuery(query) { return query .toLowerCase() .split(/\s+/) .filter((term) => term.length > 1) .map((term) => `${term}*`) .join(" "); } function buildSnippet(content, terms, radius = 80) { 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 highlight(text, terms) { if (!text || terms.length === 0) return text; const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); const regex = new RegExp(`(${escaped.join("|")})`, "gi"); return text.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 showResults(results) { resultsBox.innerHTML = ""; activeIndex = -1; if (results.length === 0) { resultsBox.style.display = "none"; return; } const rect = searchInput.getBoundingClientRect(); resultsBox.style.top = `${rect.bottom + window.scrollY}px`; resultsBox.style.left = `${rect.left + window.scrollX}px`; resultsBox.style.width = `${rect.width}px`; results.forEach((result, i) => { const item = document.createElement("div"); item.className = "search-result"; item.dataset.index = i; item.innerHTML = `
${result.title}
${result.preview}
`; item.onclick = () => { window.location.href = result.path; }; resultsBox.appendChild(item); }); resultsBox.style.display = "block"; } function updateActiveResult() { const items = resultsBox.querySelectorAll(".search-result"); items.forEach((item) => item.classList.remove("active")); if (activeIndex >= 0 && activeIndex < items.length) { const activeItem = items[activeIndex]; activeItem.classList.add("active"); activeItem.scrollIntoView({ block: "nearest", behavior: "smooth", }); } } function openSearchModal(results) { const modal = document.getElementById("search-modal"); const modalResults = modal?.querySelector("#search-modal-results"); modal.tabIndex = -1; if (!modal || !modalResults) { console.warn("Search modal not available"); return; } modalResults.innerHTML = ""; if (results.length === 0) { modalResults.innerHTML = "

No results found.

"; } results.forEach((result) => { const item = document.createElement("div"); item.className = "search-modal-result"; item.innerHTML = `
${result.title}
${result.preview}
`; item.onclick = () => (window.location.href = result.path); modalResults.appendChild(item); }); modal.style.display = "block"; modal.focus(); } function submitSearch() { const q = searchInput.value.trim(); if (!q) return; openSearchModal(search(q)); } /* EVENTS */ searchInput.addEventListener("keydown", (e) => { const items = resultsBox.querySelectorAll(".search-result"); if (!items.length) return; switch (e.key) { case "ArrowDown": e.preventDefault(); if (activeIndex < items.length - 1) { activeIndex++; updateActiveResult(); } break; case "ArrowUp": e.preventDefault(); if (activeIndex > 0) { activeIndex--; updateActiveResult(); } break; case "Enter": e.preventDefault(); if (activeIndex >= 0 && activeIndex < items.length) { items[activeIndex].click(); } else { submitSearch(); } break; case "Escape": resultsBox.style.display = "none"; activeIndex = -1; break; } }); document.addEventListener("click", (e) => { if ( !resultsBox.contains(e.target) && e.target !== searchInput && e.target !== searchBtn ) { resultsBox.style.display = "none"; } }); modal.addEventListener("click", (e) => { if (e.target.classList.contains("search-modal-backdrop")) { modal.style.display = "none"; } }); modal.addEventListener("keydown", (e) => { if (e.key === "Escape") { modal.style.display = "none"; searchInput.focus(); } }); window.addEventListener("pageshow", () => { searchInput.value = ""; resultsBox.style.display = "none"; }); window.addEventListener("beforeunload", () => { searchInput.value = ""; }); if (searchForm) { searchForm.addEventListener("submit", (e) => { e.preventDefault(); submitSearch(); }); } searchBtn.addEventListener("click", submitSearch); searchInput.addEventListener("input", () => { const q = searchInput.value.trim(); if (q.length < 3) { resultsBox.style.display = "none"; return; } showResults(search(q)); }); /* OLD FUNCTIONS */ function buildFuzzyQuery(query) { return query .toLowerCase() .split(/\s+/) .filter((term) => term.length > 2) .map((term) => `${term}~1`) .join(" "); }