bootstrap: iniciar bitácora operativa
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
(() => {
|
||||
"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;
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user