clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / site / assets / search.ts
search.ts 14.1K
1// Search, on "/" or the header button. In a repo it covers commits, reviews,2// wiki pages and file paths on the default branch; on the index and owner3// pages, repo names and descriptions. The index is a static JSON file (see4// lib/search.ts) fetched the first time the search opens. A query is terms5// split on whitespace, "a phrase" in quotes being one, read the same way in6// every section (query.ts). Every term must match somewhere; matches in7// titles rank above matches in text.8//9// With a word of 3 characters it also searches file contents on the default10// branch, for lines with every term: the query goes to a worker11// (codesearch.ts) that queries the repo's code index with sqlite-wasm over12// range requests. The worker and sqlite-wasm load the first time someone13// searches code.14import { hasAll, marks, narrow, score as match, snippet, terms } from './query.ts';1516(() => {17 const button = document.querySelector<HTMLButtonElement>('button[data-search]');18 if (!button) return;19 button.hidden = false;2021 type Row = (string | number)[];22 interface Index {23 home?: string;24 blob?: string;25 files?: string | null;26 code?: string | null;27 commits?: Row[];28 reviews?: Row[];29 wiki?: Row[];30 repos?: Row[];31 }32 // One searchable thing. `key` matches by prefix (a commit's full sha).33 interface Rec {34 kind: string;35 href: string;36 title: string;37 meta: string;38 text: string;39 key: string;40 mono: boolean;41 low: [string, string, string];42 }43 interface Hit {44 r: Rec;45 s: number;46 inText: boolean;47 }4849 const KINDS = ['repositories', 'reviews', 'commits', 'wiki', 'files'];50 const PER_KIND = 6;5152 // Code search: lines from the worker, for the query they answer.53 interface CodeHit {54 path: string;55 line: number;56 text: string;57 source: boolean;58 }59 interface CodeResult {60 q: string;61 hits: CodeHit[];62 more: boolean;63 limit: number;64 error?: string;65 }66 // As in codesearch.ts: trigrams need 3 characters; 12 chunks for a first67 // look, 100 when asked for more. A line has every term, so a term inside68 // another goes.69 const CODE_MIN = 3;70 const CODE_FIRST = 12;71 const CODE_MORE = 100;72 const codeWords = (q: string): string[] => narrow(terms(q));73 const codeable = (q: string): boolean => codeWords(q).some((w) => [...w].length >= CODE_MIN);74 let codeUrl: string | null = null;75 let blobBase = '';76 let worker: Worker | null = null;77 let asked = 0;78 const waiting = new Map<number, (r: Omit<CodeResult, 'q'>) => void>();79 function codeQuery(q: string, limit: number): Promise<Omit<CodeResult, 'q'>> {80 if (!worker) {81 const abs = (u: string): string => new URL(u, location.href).href;82 worker = new Worker(abs(button!.dataset.code!), { type: 'module' });83 worker.postMessage({ sqlite: abs(button!.dataset.sqlite!), wasm: abs(button!.dataset.wasm!), url: abs(codeUrl!) });84 worker.addEventListener('message', (e: MessageEvent) => {85 const m = e.data as { id: number; hits?: CodeHit[]; more?: boolean; limit?: number; error?: string };86 waiting.get(m.id)?.({ hits: m.hits ?? [], more: !!m.more, limit: m.limit ?? 0, error: m.error });87 waiting.delete(m.id);88 });89 // A worker that can't start fails everything asked of it.90 worker.addEventListener('error', () => {91 for (const done of waiting.values()) done({ hits: [], more: false, limit: 0, error: 'Code search could not be loaded.' });92 waiting.clear();93 worker = null;94 });95 }96 const id = ++asked;97 worker.postMessage({ id, q, limit });98 return new Promise((done) => waiting.set(id, done));99 }100 const enc = (p: string): string => p.split('/').map(encodeURIComponent).join('/');101 const rec = (kind: string, href: string, title: string, meta = '', text = '', key = '', mono = false): Rec =>102 ({ kind, href, title, meta, text, key, mono, low: [title.toLowerCase(), meta.toLowerCase(), text.toLowerCase()] });103104 let loading: Promise<Rec[]> | null = null;105 async function load(): Promise<Rec[]> {106 const ix: Index = await (await fetch(button!.dataset.search!)).json();107 const owner = button!.dataset.owner;108 const list: Rec[] = [];109 for (const [slug, desc, href] of ix.repos ?? []) {110 if (!owner || String(slug).startsWith(owner + '/')) list.push(rec('repositories', String(href), String(slug), '', String(desc)));111 }112 const home = ix.home ?? '/';113 for (const [id, title, branch, author, status] of ix.reviews ?? []) {114 list.push(rec('reviews', `${home}reviews/${id}/`, `#${id} ${title}`, `${branch}, ${author ? author + ', ' : ''}${status}`));115 }116 for (const [sha, subject, author, date, body] of ix.commits ?? []) {117 list.push(rec('commits', `${home}commit/${sha}/`, String(subject), `${String(sha).slice(0, 7)} ${author}, ${date}`, String(body), String(sha)));118 }119 for (const [href, title, text] of ix.wiki ?? []) list.push(rec('wiki', String(href), String(title), '', String(text)));120 codeUrl = ix.code ?? null;121 blobBase = ix.blob ?? '';122 if (ix.files) {123 const paths = (await (await fetch(ix.files)).text()).split('\n').filter(Boolean);124 for (const p of paths) list.push(rec('files', `${ix.blob}${enc(p)}/`, p, '', '', '', true));125 }126 return list;127 }128129 function score(r: Rec, ts: string[]): Hit | null {130 const m = match(r.low, r.key, ts);131 return m && { r, ...m };132 }133134 // Text with every match of a term (a phrase whole) in <b>, built as nodes135 // (never HTML).136 function marked(s: string, ts: string[]): Node[] {137 return marks(s, ts).map(([text, hit]) => {138 const t = document.createTextNode(text);139 if (!hit) return t;140 const b = document.createElement('b');141 b.append(t);142 return b;143 });144 }145146 function open(): void {147 const existing = document.querySelector<HTMLInputElement>('#search input');148 if (existing) return existing.focus();149 const box = document.createElement('div');150 box.id = 'search';151 box.innerHTML = '<div class="search-box" role="dialog" aria-label="Search"><input type="search" autocomplete="off" spellcheck="false" aria-label="search"><ol></ol><p class="hint"></p></div>';152 document.body.append(box);153 const input = box.querySelector('input')!;154 const ol = box.querySelector('ol')!;155 const hint = box.querySelector<HTMLElement>('.hint')!;156 const repos = !!button!.dataset.owner || button!.dataset.search!.endsWith('/repos.json');157 input.placeholder = repos ? 'Search repositories' : 'Search commits, reviews, wiki, files';158 let all: Rec[] | null = null;159 let failed = false;160 let sel = 0;161 // Selectable rows in order: a link to open, or a "more" row that expands its kind.162 let rows: { li: HTMLLIElement; go: () => void }[] = [];163 const expanded = new Set<string>();164165 // The last code results that still apply, and the pending query's timer.166 let code: CodeResult | null = null;167 let timer = 0;168 const go = (href: string): void => {169 close();170 location.href = href;171 };172173 const heading = (kind: string, count: string): void => {174 const head = document.createElement('li');175 head.className = 'group';176 head.textContent = `${kind} `;177 const n = document.createElement('span');178 n.textContent = count;179 head.append(n);180 ol.append(head);181 };182 // A row that opens a link: its title, and a second line if any.183 const link = (href: string, title: Node[], mono: boolean, extra: Node[] | null, extraMono = false): void => {184 const li = document.createElement('li');185 const a = document.createElement('a');186 a.href = href;187 const t = document.createElement('span');188 t.className = mono ? 't mono' : 't';189 t.append(...title);190 a.append(t);191 if (extra?.length) {192 const m = document.createElement('span');193 m.className = extraMono ? 'm mono' : 'm';194 m.append(...extra);195 a.append(m);196 }197 li.append(a);198 ol.append(li);199 rows.push({ li, go: () => go(href) });200 };201 // A row that shows more of a kind.202 const moreRow = (label: string, fn: () => void): void => {203 const li = document.createElement('li');204 li.className = 'more';205 const b = document.createElement('button');206 b.type = 'button';207 b.textContent = label;208 const run = (): void => {209 fn();210 draw();211 input.focus();212 };213 b.addEventListener('click', run);214 li.append(b);215 ol.append(li);216 rows.push({ li, go: run });217 };218 const note = (text: string): void => {219 const li = document.createElement('li');220 li.className = 'note';221 li.textContent = text;222 ol.append(li);223 };224225 // Lines in files with every word, ignoring case. While a longer query226 // runs, the last results narrow to the lines that still match.227 const drawCode = (q: string): boolean => {228 const ws = codeWords(q);229 const fresh = code?.q === q;230 if (fresh && code!.error) {231 heading('code', '');232 note(code!.error);233 return true;234 }235 const hits = !code ? [] : fresh ? code.hits : code.hits.filter((h) => hasAll(h.text, ws));236 if (!hits.length) {237 if (fresh) return false;238 heading('code', '');239 note('Searching file contents');240 return true;241 }242 const more = fresh && code!.more;243 heading('code', `${hits.length}${more ? '+' : ''}`);244 const shown = expanded.has('code') ? hits : hits.slice(0, PER_KIND);245 for (const h of shown) {246 link(`${blobBase}${enc(h.path)}/${h.source ? 'source/' : ''}#l${h.line}`, [document.createTextNode(`${h.path}:${h.line}`)], true, marked(h.text, ws), true);247 }248 const rest = hits.length - shown.length;249 if (rest || (more && code!.limit < CODE_MORE)) {250 moreRow(rest ? `${rest}${more ? '+' : ''} more` : 'more', () => {251 expanded.add('code');252 if (more && code!.limit < CODE_MORE) runCode(q, CODE_MORE);253 });254 } else if (more) note('More lines match than are shown here; a longer query narrows them.');255 return true;256 };257 const runCode = (q: string, limit: number): void => {258 codeQuery(q, limit).then((r) => {259 // Results for an older query still help while they narrow to this260 // one: each of its words is in a word of this one.261 const now = codeWords(input.value);262 if (!box.isConnected || !codeWords(q).every((w) => now.some((n) => n.includes(w)))) return;263 code = { q, ...r, limit: Math.max(limit, r.limit) };264 draw();265 });266 };267 const queueCode = (): void => {268 clearTimeout(timer);269 const q = input.value.trim();270 if (!codeUrl || !codeable(q) || code?.q === q) return;271 timer = window.setTimeout(() => runCode(q, CODE_FIRST), 120);272 };273274 const draw = (): void => {275 const ts = terms(input.value);276 const q = input.value.trim();277 ol.replaceChildren();278 rows = [];279 if (!all) {280 hint.textContent = failed ? 'The search index could not be loaded.' : 'Loading the search index';281 return;282 }283 if (!ts.length) {284 hint.textContent = repos ? 'Repository names and descriptions; "a phrase" in quotes matches as written.' : codeUrl285 ? 'Searches commit messages, reviews, wiki pages, file paths and, for words of 3 characters or more, the lines of the files themselves, on the default branch; "a phrase" in quotes matches as written. Press t to find a file on this branch.'286 : 'Searches commit messages, reviews, wiki pages and file paths on the default branch; "a phrase" in quotes matches as written. Press t to find a file on this branch.';287 return;288 }289 const hits = all.map((r) => score(r, ts)).filter((h): h is Hit => !!h);290 for (const kind of KINDS) {291 const list = hits.filter((h) => h.r.kind === kind).sort((a, b) => b.s - a.s);292 if (!list.length) continue;293 heading(kind, String(list.length));294 const shown = expanded.has(kind) ? list : list.slice(0, PER_KIND);295 for (const h of shown) {296 const extra = h.inText ? snippet(h.r.text, ts) : h.r.meta || h.r.text.slice(0, 120);297 link(h.r.href, marked(h.r.title, ts), h.r.mono, extra ? marked(extra, ts) : null);298 }299 if (shown.length < list.length) moreRow(`${list.length - shown.length} more`, () => expanded.add(kind));300 }301 const short = !!codeUrl && !codeable(q);302 const busy = !!codeUrl && !short && drawCode(q);303 hint.textContent = rows.length || busy ? '' : short ? 'No matches. File contents are searched for a word of 3 characters or more.' : 'No matches.';304 if (rows.length && short) hint.textContent = 'File contents are searched for a word of 3 characters or more.';305 sel = Math.min(sel, Math.max(0, rows.length - 1));306 rows[sel]?.li.classList.add('sel');307 };308 const move = (to: number): void => {309 rows[sel]?.li.classList.remove('sel');310 sel = Math.max(0, Math.min(rows.length - 1, to));311 rows[sel]?.li.classList.add('sel');312 rows[sel]?.li.scrollIntoView({ block: 'nearest' });313 };314 const close = (): void => {315 clearTimeout(timer);316 box.remove();317 };318319 input.addEventListener('input', () => {320 sel = 0;321 expanded.clear();322 draw();323 queueCode();324 });325 input.addEventListener('keydown', (e) => {326 if (e.key === 'Escape') close();327 else if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) move(sel + 1);328 else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) move(sel - 1);329 else if (e.key === 'Enter' && rows[sel]) rows[sel].go();330 else return;331 e.preventDefault();332 });333 // A tap or click outside the box closes it.334 box.addEventListener('click', (e) => {335 if (e.target === box) close();336 });337 loading ??= load();338 loading.then((list) => {339 all = list;340 draw();341 queueCode();342 }, () => {343 loading = null;344 failed = true;345 draw();346 });347 draw();348 input.focus();349 }350351 button.addEventListener('click', open);352 document.addEventListener('keydown', (e) => {353 if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey || (e.target as Element | null)?.closest('input, textarea, select')) return;354 e.preventDefault();355 open();356 });357})();