143 lines
4.7 KiB
JavaScript
143 lines
4.7 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const input = document.getElementById("ops-search-input");
|
|
const results = document.getElementById("ops-search-results");
|
|
if (!input || !results) return;
|
|
|
|
const indexURL = input.dataset.indexUrl;
|
|
let documentsPromise = null;
|
|
|
|
const normalize = (value) => String(value ?? "")
|
|
.normalize("NFD")
|
|
.replace(/\p{Diacritic}/gu, "")
|
|
.toLocaleLowerCase("es")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
const loadDocuments = () => {
|
|
if (documentsPromise) return documentsPromise;
|
|
documentsPromise = fetch(indexURL, { credentials: "same-origin" })
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return response.json();
|
|
})
|
|
.then((index) => {
|
|
const docs = index?.documentStore?.docs ?? {};
|
|
return Object.entries(docs).map(([ref, doc]) => ({ ref, ...doc }));
|
|
});
|
|
return documentsPromise;
|
|
};
|
|
|
|
const scoreDocument = (doc, tokens, wholeQuery) => {
|
|
const title = normalize(doc.title);
|
|
const description = normalize(doc.description);
|
|
const content = normalize(doc.content ?? doc.body);
|
|
const path = normalize(doc.path ?? doc.ref);
|
|
const fullText = `${title} ${description} ${path} ${content}`;
|
|
|
|
if (!tokens.every((token) => fullText.includes(token))) return -1;
|
|
|
|
let score = 0;
|
|
if (title === wholeQuery) score += 100;
|
|
if (title.startsWith(wholeQuery)) score += 40;
|
|
if (title.includes(wholeQuery)) score += 25;
|
|
if (description.includes(wholeQuery)) score += 12;
|
|
|
|
for (const token of tokens) {
|
|
if (title.includes(token)) score += 12;
|
|
if (description.includes(token)) score += 6;
|
|
if (path.includes(token)) score += 4;
|
|
if (content.includes(token)) score += 2;
|
|
}
|
|
return score;
|
|
};
|
|
|
|
const clearResults = () => {
|
|
results.hidden = true;
|
|
results.replaceChildren();
|
|
};
|
|
|
|
const appendMessage = (title, detail) => {
|
|
const item = document.createElement("div");
|
|
item.className = "ops-search-result";
|
|
const strong = document.createElement("strong");
|
|
strong.textContent = title;
|
|
const small = document.createElement("small");
|
|
small.textContent = detail;
|
|
item.append(strong, small);
|
|
results.replaceChildren(item);
|
|
results.hidden = false;
|
|
};
|
|
|
|
const renderMatches = (matches) => {
|
|
const fragment = document.createDocumentFragment();
|
|
for (const { doc } of matches) {
|
|
const link = document.createElement("a");
|
|
link.className = "ops-search-result";
|
|
link.href = doc.permalink ?? doc.ref ?? doc.path ?? "/";
|
|
|
|
const title = document.createElement("strong");
|
|
title.textContent = doc.title || link.href;
|
|
const detail = document.createElement("small");
|
|
detail.textContent = doc.description || doc.path || "";
|
|
link.append(title, detail);
|
|
fragment.append(link);
|
|
}
|
|
results.replaceChildren(fragment);
|
|
results.hidden = false;
|
|
};
|
|
|
|
let requestSerial = 0;
|
|
const render = async (rawQuery) => {
|
|
const serial = ++requestSerial;
|
|
const query = normalize(rawQuery);
|
|
const tokens = [...new Set(query.split(" ").filter((token) => token.length > 1))];
|
|
if (query.length < 2 || tokens.length === 0) {
|
|
clearResults();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const documents = await loadDocuments();
|
|
if (serial !== requestSerial) return;
|
|
const matches = documents
|
|
.map((doc) => ({ doc, score: scoreDocument(doc, tokens, query) }))
|
|
.filter((item) => item.score >= 0)
|
|
.sort((a, b) => b.score - a.score || String(a.doc.title).localeCompare(String(b.doc.title), "es"))
|
|
.slice(0, 12);
|
|
|
|
if (matches.length === 0) {
|
|
appendMessage("Sin resultados", "Pruebe otro término o una etiqueta más corta.");
|
|
return;
|
|
}
|
|
renderMatches(matches);
|
|
} catch (error) {
|
|
console.warn("No se pudo cargar el índice de búsqueda", error);
|
|
appendMessage("Búsqueda no disponible", "Compruebe que el sitio se construyó con el índice habilitado.");
|
|
}
|
|
};
|
|
|
|
let debounceTimer = null;
|
|
input.addEventListener("input", () => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => render(input.value), 100);
|
|
});
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
input.value = "";
|
|
clearResults();
|
|
input.blur();
|
|
}
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "/" && document.activeElement !== input && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
event.preventDefault();
|
|
input.focus();
|
|
}
|
|
});
|
|
document.addEventListener("click", (event) => {
|
|
if (!results.contains(event.target) && event.target !== input) results.hidden = true;
|
|
});
|
|
})();
|