330 lines
7.5 KiB
JavaScript
330 lines
7.5 KiB
JavaScript
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 = `
|
|
<div class="search-modal-backdrop"></div>
|
|
<div class="search-modal-content">
|
|
<h2>Search results</h2>
|
|
<div id="search-modal-results"></div>
|
|
</div>
|
|
`;
|
|
|
|
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, "<mark>$1</mark>");
|
|
}
|
|
|
|
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 = `
|
|
<div class="search-result-title">${result.title}</div>
|
|
<div class="search-result-preview">${result.preview}</div>
|
|
`;
|
|
|
|
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 = "<p>No results found.</p>";
|
|
}
|
|
|
|
results.forEach((result) => {
|
|
const item = document.createElement("div");
|
|
item.className = "search-modal-result";
|
|
item.innerHTML = `
|
|
<div class="search-modal-title">${result.title}</div>
|
|
<div class="search-modal-preview">${result.preview}</div>
|
|
`;
|
|
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(" ");
|
|
}
|