search.js (5146B)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | (() => { const input = document.getElementById("search"); if (!input) return; const container = input.closest(".search-container"); const panel = document.getElementById("search-results"); const items = panel.querySelector(".search-results__items"); const status = panel.querySelector('[role="status"]'); let engine; let library; let timer; let revision = 0; function loadEngine() { if (engine) return engine; if (!library) { library = new Promise((resolve, reject) => { if (window.Fuse) return resolve(); const script = document.createElement("script"); script.src = input.dataset.library; script.onload = resolve; script.onerror = () => { script.remove(); library = null; reject(new Error("Search library unavailable")); }; document.head.appendChild(script); }); } engine = Promise.all([ library, fetch(input.dataset.index).then((response) => { if (!response.ok) throw new Error("Search index unavailable"); return response.json(); }), ]) .then( ([, index]) => new Fuse(index, { keys: [ { name: "title", weight: 2 }, { name: "body", weight: 1 }, ], minMatchCharLength: 2, threshold: 0.3, ignoreLocation: true, }), ) .catch((error) => { engine = null; throw error; }); return engine; } function show(visible) { panel.style.display = visible ? "block" : "none"; } function close() { revision++; clearTimeout(timer); panel.removeAttribute("aria-busy"); show(false); } function teaser(body, terms) { const lower = body.toLowerCase(); const matches = terms .map((term) => lower.indexOf(term.toLowerCase())) .filter((index) => index >= 0); const start = Math.max(0, (matches.length ? Math.min(...matches) : 0) - 40); const text = (start ? "…" : "") + body.slice(start, start + 200) + (body.length > start + 200 ? "…" : ""); const paragraph = document.createElement("div"); const pattern = new RegExp( terms .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join("|"), "gi", ); let position = 0; for (const match of text.matchAll(pattern)) { paragraph.append(text.slice(position, match.index)); const bold = document.createElement("b"); bold.textContent = match[0]; paragraph.append(bold); position = match.index + match[0].length; } paragraph.append(text.slice(position)); return paragraph; } async function search() { const term = input.value.trim(); const request = ++revision; items.replaceChildren(); if (!term) return show(false); show(true); status.textContent = "Searching…"; panel.setAttribute("aria-busy", "true"); try { const fuse = await loadEngine(); if (request !== revision) return; const results = fuse.search(term); status.textContent = results.length ? `${results.length} ${results.length === 1 ? "result" : "results"}${results.length > 10 ? " (showing 10)" : ""}` : "No results found."; const fragment = document.createDocumentFragment(); for (const { item } of results.slice(0, 10)) { const url = new URL(item.url, location.href); if (url.origin !== location.origin) continue; const row = document.createElement("li"); row.className = "search-results__item"; const title = document.createElement("h2"); title.className = "title"; const link = document.createElement("a"); link.href = url.href; link.textContent = item.title; title.append(link); row.append( title, teaser((item.body || "").replace(/§/g, ""), term.split(/\s+/)), ); const more = document.createElement("a"); more.href = url.href; const label = document.createElement("i"); label.textContent = "Read More →"; more.append(label); row.append(more); fragment.append(row); } items.append(fragment); } catch { if (request === revision) status.textContent = "Search could not load. Try typing again."; } finally { if (request === revision) panel.removeAttribute("aria-busy"); } } input.addEventListener("focus", () => { loadEngine().catch(() => {}); if (input.value.trim()) search(); }); input.addEventListener("input", () => { revision++; clearTimeout(timer); if (!input.value.trim()) return close(); timer = setTimeout(search, 150); }); container.addEventListener("keydown", (event) => { if (event.key === "Escape") { input.focus(); close(); } }); document.addEventListener("click", (event) => { if (!container.contains(event.target)) close(); }); container.addEventListener("focusout", (event) => { if (!container.contains(event.relatedTarget)) close(); }); })(); |