search.js
search.js provides the search functionality for the graphical view at
/map/. It fetches /index.json (Hugo’s search index) and wires up
the #elbi-input field. It integrates with graph.js
via the globals
window.nodeByLink, window.showNode, window.focusByTerm,
window.centreOnGraph, window.resetGraph, window.getCurrentNode,
and window.getNodeLnk.
Since graph.js is loaded asynchronously, initSearch polls until
those globals are available, retrying up to 50 times at 100ms
intervals.
Initialisation Guard
Polls for graph.js globals before proceeding. Gives up after 5
seconds (50 × 100ms) to avoid infinite polling on error.
let _retries = 0;
function initSearch() {
if (!window.nodeByLink || !window.showNode) {
if (++_retries > 50) return;
setTimeout(initSearch, 100);
return;
}
const input = document.getElementById("elbi-input");
const results = document.getElementById("elbi-results");
const exitLink = document.getElementById("graph-exit-link");
if (exitLink) {
exitLink.addEventListener("click", (e) => {
e.preventDefault();
const node = window.getCurrentNode?.();
window.location.href = node ? "/docs/" + window.getNodeLnk(node) + "/" : "/";
});
}
fetch("/index.json")
.then(r => r.json())
.then(pages => {
input.addEventListener("input", function () {
results.innerHTML = "";
window.focusByTerm?.(input.value.trim());
if (!input.value.trim()) {
const activeNode = window.getCurrentNode?.();
if (activeNode) window.showNode(activeNode);
return;
}
window.centreOnGraph?.();
const terms = input.value
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().split(" ").filter(Boolean);
let filtered = pages.filter(page =>
terms.every(term =>
JSON.stringify(page)
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().includes(term)
)
);
filtered.forEach(p => {
p.score = 0;
terms.forEach(term => {
if (p.title?.toLowerCase().includes(term)) p.score += 3;
if (p.summary?.toLowerCase().includes(term)) p.score += 1;
});
});
filtered = filtered
.filter(p => p.score > 0)
.sort((a, b) => b.score - a.score);
filtered.forEach(page => {
const lnk = page.relpermalink.replace(/^\/docs\//, "").replace(/\/$/, "");
const nodeId = window.nodeByLink[lnk];
if (!nodeId) return;
results.insertAdjacentHTML("beforeend",
`<li><a href="#" data-node="${nodeId}">${page.title}</a></li>`);
});
});
input.addEventListener("keyup", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
input.value = "";
results.innerHTML = "";
input.blur();
});
results.addEventListener("click", (e) => {
const a = e.target.closest("a[data-node]");
if (!a) return;
e.preventDefault();
window.resetGraph?.();
window.showNode(a.getAttribute("data-node"));
input.value = "";
results.innerHTML = "";
});
})
.catch(e => {
if (e.name !== 'AbortError') console.warn("index.json fetch failed", e);
});
}
initSearch();
Exit Link
Handles the “Text View” link on the graph page. Navigates to the currently focused note’s doc page if one is active, otherwise to the site root.
const input = document.getElementById("elbi-input");
const results = document.getElementById("elbi-results");
const exitLink = document.getElementById("graph-exit-link");
if (exitLink) {
exitLink.addEventListener("click", (e) => {
e.preventDefault();
const node = window.getCurrentNode?.();
window.location.href = node ? "/docs/" + window.getNodeLnk(node) + "/" : "/";
});
}
Search Index
Fetches /index.json once, then wires up input, keyup, and results
click listeners. Scoring weights title matches (3 points) above summary
matches (1 point). Diacritics are normalised before matching so
accented characters match their base form.
fetch("/index.json")
.then(r => r.json())
.then(pages => {
input.addEventListener("input", function () {
results.innerHTML = "";
window.focusByTerm?.(input.value.trim());
if (!input.value.trim()) {
const activeNode = window.getCurrentNode?.();
if (activeNode) window.showNode(activeNode);
return;
}
window.centreOnGraph?.();
const terms = input.value
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().split(" ").filter(Boolean);
let filtered = pages.filter(page =>
terms.every(term =>
JSON.stringify(page)
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().includes(term)
)
);
filtered.forEach(p => {
p.score = 0;
terms.forEach(term => {
if (p.title?.toLowerCase().includes(term)) p.score += 3;
if (p.summary?.toLowerCase().includes(term)) p.score += 1;
});
});
filtered = filtered
.filter(p => p.score > 0)
.sort((a, b) => b.score - a.score);
filtered.forEach(page => {
const lnk = page.relpermalink.replace(/^\/docs\//, "").replace(/\/$/, "");
const nodeId = window.nodeByLink[lnk];
if (!nodeId) return;
results.insertAdjacentHTML("beforeend",
`<li><a href="#" data-node="${nodeId}">${page.title}</a></li>`);
});
});
input.addEventListener("keyup", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
input.value = "";
results.innerHTML = "";
input.blur();
});
results.addEventListener("click", (e) => {
const a = e.target.closest("a[data-node]");
if (!a) return;
e.preventDefault();
window.resetGraph?.();
window.showNode(a.getAttribute("data-node"));
input.value = "";
results.innerHTML = "";
});
})
.catch(e => {
if (e.name !== 'AbortError') console.warn("index.json fetch failed", e);
});
Input Listener
On each keystroke, normalises the query, filters pages, scores and
sorts results, then renders matching titles as clickable list items.
Also syncs the graph highlight via focusByTerm.
input.addEventListener("input", function () {
results.innerHTML = "";
window.focusByTerm?.(input.value.trim());
if (!input.value.trim()) {
const activeNode = window.getCurrentNode?.();
if (activeNode) window.showNode(activeNode);
return;
}
window.centreOnGraph?.();
const terms = input.value
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().split(" ").filter(Boolean);
let filtered = pages.filter(page =>
terms.every(term =>
JSON.stringify(page)
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().includes(term)
)
);
filtered.forEach(p => {
p.score = 0;
terms.forEach(term => {
if (p.title?.toLowerCase().includes(term)) p.score += 3;
if (p.summary?.toLowerCase().includes(term)) p.score += 1;
});
});
filtered = filtered
.filter(p => p.score > 0)
.sort((a, b) => b.score - a.score);
filtered.forEach(page => {
const lnk = page.relpermalink.replace(/^\/docs\//, "").replace(/\/$/, "");
const nodeId = window.nodeByLink[lnk];
if (!nodeId) return;
results.insertAdjacentHTML("beforeend",
`<li><a href="#" data-node="${nodeId}">${page.title}</a></li>`);
});
});
Keyup Listener
Clears input and results on Escape.
input.addEventListener("keyup", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
input.value = "";
results.innerHTML = "";
input.blur();
});
Results Listener
Handles clicks on search result links. Navigates the graph to the selected node and clears the search state.
results.addEventListener("click", (e) => {
const a = e.target.closest("a[data-node]");
if (!a) return;
e.preventDefault();
window.resetGraph?.();
window.showNode(a.getAttribute("data-node"));
input.value = "";
results.innerHTML = "";
});
Assembly
initSearch uses nested noweb — exit-link and search-index are
referenced inside init-guard, and the three listeners inside
search-index. This reflects the runtime structure: all code runs
only after initSearch confirms the graph.js
globals are present.
Nested noweb references work in org-babel provided `:noweb yes` is declared on every block in the chain that contains references, not just the final Assembly block. See the Org Mode manual: Noweb Reference Syntax↗ for details.
The complete tangled version of this file can be found here↗ .
(function () {
// init-guard
// └── exit-link
// └── search-index
// └── input-listener
// └── keyup-listener
// └── results-listener
})();
© Prabu Anand K 2020-2026