app/enhance/search.ts
178 lines and 13 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| currentUrl | 27 to 31 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| renderHit | 33 to 69 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| links | 71 to 73 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| enhanceSearch | 75 to 203 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| ensureList | 84 to 94 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| setBusy | 98 to 102 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| run | 104 to 139 | 15 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| followQuery | 145 to 155 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:157> | 157 to 163 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:162> | 162 to 162 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:165> | 165 to 171 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:173> | 173 to 196 | 9 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:198> | 198 to 202 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports no other module of this repo. No module of this repo imports it.
Source
/**
* Deliberately not the palette's combobox: these results are page content, and `role="option"`
* would flatten each to its accessible name. ArrowDown from the input moves real focus into the
* list; arrow keys anywhere else still scroll the page.
*/
const MIN_QUERY = 3;
const DEBOUNCE_MS = 200;
interface JsonHit {
url: string;
type: string;
title: string;
documentTitle: string | null;
anchor: string | null;
snippet: string;
}
interface JsonResponse {
total?: number;
results?: JsonHit[];
}
let sequence = 0;
let debounce: ReturnType<typeof setTimeout> | undefined;
function currentUrl(form: HTMLFormElement, query: string): string {
const params = new URLSearchParams(new FormData(form) as unknown as string[][]);
params.set("q", query);
return `/search?${params.toString()}`;
}
function renderHit(hit: JsonHit): HTMLLIElement {
const item = document.createElement("li");
item.className = "search-result";
const head = document.createElement("div");
head.className = "search-result-head";
const kind = document.createElement("span");
kind.className = "search-type";
kind.dataset.type = hit.type;
kind.textContent = hit.type;
const heading = document.createElement("h2");
heading.className = "search-result-title";
const link = document.createElement("a");
// Reduced to a path so the link matches the server's exactly and a same-origin check is the whole guard.
const parsed = new URL(hit.url, location.origin);
link.href = parsed.pathname + parsed.hash;
link.textContent = hit.title;
heading.appendChild(link);
head.appendChild(kind);
head.appendChild(heading);
item.appendChild(head);
if (hit.anchor && hit.documentTitle) {
const context = document.createElement("p");
context.className = "search-result-context";
context.textContent = `in ${hit.documentTitle}`;
item.appendChild(context);
}
const snippet = document.createElement("p");
snippet.className = "search-snippet";
// The one field written as HTML: the server escaped the text before substituting its <mark> markers.
snippet.innerHTML = hit.snippet;
item.appendChild(snippet);
return item;
}
function links(list: HTMLOListElement): HTMLAnchorElement[] {
return [...list.querySelectorAll<HTMLAnchorElement>(".search-result-title a")];
}
export function enhanceSearch(): void {
const form = document.querySelector<HTMLFormElement>(".search-form");
const input = document.querySelector<HTMLInputElement>("#q");
const list = document.querySelector<HTMLOListElement>(".search-results");
const count = document.querySelector<HTMLParagraphElement>(".search-count");
if (!form || !input) return;
let results: HTMLOListElement | null = list;
function ensureList(): HTMLOListElement | null {
if (results?.isConnected) return results;
const body = document.querySelector(".search-body");
if (!body) return null;
const created = document.createElement("ol");
created.className = "search-results";
/* insertBefore, never .prepend(): the global Element here is HTMLRewriter's. */
body.insertBefore(created, body.firstChild);
results = created;
return created;
}
/* Not aria-busy: this runs on every pause in typing, and a live region that says "busy"
on each one talks over the count, which already announces the result. */
const setBusy = (on: boolean) => {
if (!form) return;
if (on) form.setAttribute("data-pending", "");
else form.removeAttribute("data-pending");
};
async function run(query: string) {
const mine = ++sequence;
const url = currentUrl(form!, query);
setBusy(true);
let data: JsonResponse;
try {
const response = await fetch(url, { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
data = (await response.json()) as JsonResponse;
} catch {
// Said, not silent: the old results must not stand under a new query as if they answered it.
if (mine !== sequence) return;
setBusy(false);
if (results?.isConnected) results.textContent = "";
if (count) count.textContent = "Live results are unavailable. Press Enter to search.";
return;
}
if (mine !== sequence) return;
setBusy(false);
const target = ensureList();
if (!target) return;
target.textContent = "";
for (const hit of data.results ?? []) target.appendChild(renderHit(hit));
// The server's "Nothing matched" answered the query the page opened with, not this one.
if (target.childElementCount > 0) document.querySelector(".search-zero")?.remove();
if (count) {
const total = data.total ?? 0;
count.textContent = total === 0 ? "No results" : `${total} result${total === 1 ? "" : "s"}`;
}
// Replaced, not pushed, so a reader who typed eight characters need not press Back eight times.
history.replaceState(null, "", url);
followQuery(query);
}
/**
* The server drew the sort, filter and pager links and the Ask question for the query it was sent.
* After retyping they must act on the query on screen, not the old one.
*/
function followQuery(query: string) {
for (const link of document.querySelectorAll<HTMLAnchorElement>(
".search-sort a, .search-facets a, .search-active-filters a, .search-pagination a",
)) {
const next = new URL(link.href, location.origin);
next.searchParams.set("q", query);
link.href = next.pathname + next.search;
}
const mount = document.querySelector<HTMLElement>("[data-ask-mount]");
if (mount) mount.dataset.askQuestion = query;
}
input.addEventListener("input", () => {
const query = input.value.trim();
clearTimeout(debounce);
// Below the floor the server's list is not cleared: blanking it under a reader mid-word is worse.
if (query.length < MIN_QUERY) return;
debounce = setTimeout(() => void run(query), DEBOUNCE_MS);
});
input.addEventListener("keydown", (event) => {
if (event.key !== "ArrowDown") return;
const first = results?.isConnected ? links(results)[0] : undefined;
if (!first) return;
event.preventDefault();
first.focus();
});
document.addEventListener("keydown", (event) => {
/*
* The input's own ArrowDown has already moved focus to the first result by the time this bubbles
* here, and handling it again would skip that result.
*/
if (event.target === input) return;
const target = results;
const active = document.activeElement;
if (!target?.isConnected) return;
if (!(active instanceof HTMLAnchorElement) || !target.contains(active)) return;
const all = links(target);
const index = all.indexOf(active);
if (index === -1) return;
if (event.key === "ArrowDown") {
event.preventDefault();
all[Math.min(index + 1, all.length - 1)]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (index === 0) input!.focus();
else all[index - 1]?.focus();
}
});
input.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || input.value === "") return;
event.preventDefault();
input.value = "";
});
}
enhanceSearch();