From d836ee588ff42d2356ee7aaa66a6c50c978bf645 Mon Sep 17 00:00:00 2001 From: dtonon Date: Thu, 11 Jun 2026 20:03:31 +0100 Subject: [PATCH] Highlight the search terms in the results --- src/lib/components/SearchBox.svelte | 33 +++++++++++++++++++++++++++-- src/lib/search.ts | 21 ++++++++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/lib/components/SearchBox.svelte b/src/lib/components/SearchBox.svelte index 67fcaa0..cdcd24e 100644 --- a/src/lib/components/SearchBox.svelte +++ b/src/lib/components/SearchBox.svelte @@ -4,6 +4,7 @@ let query = $state(""); let results = $state([]); + let resultsQuery = $state(""); // The query that produced the current results let open = $state(false); let searching = $state(false); let activeIndex = $state(-1); @@ -29,6 +30,7 @@ const r = await searchThreads(q); if (id !== seq) return; results = r; + resultsQuery = q; } finally { if (id === seq) searching = false; } @@ -63,6 +65,25 @@ } } + const terms = $derived( + resultsQuery.split(/\s+/).filter((t) => t.length >= 2), + ); + + function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + // Split into alternating plain/matched segments for rendering + function highlight(text: string): { text: string; hit: boolean }[] { + if (!text || terms.length === 0) return [{ text, hit: false }]; + const alts = terms.map(escapeRe).join("|"); + const exact = new RegExp(`^(${alts})$`, "i"); + return text + .split(new RegExp(`(${alts})`, "gi")) + .filter((s) => s !== "") + .map((s) => ({ text: s, hit: exact.test(s) })); + } + function onFocus() { if (query.trim().length >= 2) open = true; } @@ -133,6 +154,14 @@ No results {/if} + {#snippet marked(text: string)} + {#each highlight(text) as p} + {#if p.hit}{p.text}{:else}{p.text}{/if} + {/each} + {/snippet} {#each results as r, i (r.threadId)} - {r.title} + {@render marked(r.title)} {#if r.matchKind === "reply"} - {r.snippet} + {@render marked(r.snippet)} {/if} diff --git a/src/lib/search.ts b/src/lib/search.ts index 7d22e01..bd23add 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -12,9 +12,22 @@ export type SearchResult = { createdAt: number; }; -function snippetOf(content: string): string { +// Window the snippet around the first term match so the highlight is visible +// even when the match sits deep in a long post. +function snippetOf(content: string, query: string): string { const flat = content.replace(/\s+/g, " ").trim(); - return flat.length > 140 ? flat.slice(0, 140) + "…" : flat; + const MAX = 140; + if (flat.length <= MAX) return flat; + const lower = flat.toLowerCase(); + let idx = -1; + for (const t of query.toLowerCase().split(/\s+/).filter(Boolean)) { + const i = lower.indexOf(t); + if (i !== -1 && (idx === -1 || i < idx)) idx = i; + } + if (idx <= 40) return flat.slice(0, MAX) + "…"; + const start = idx - 40; + const end = Math.min(flat.length, start + MAX); + return "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""); } // NIP-50 search over the forum relay: thread OPs (kind 11) and replies @@ -45,7 +58,7 @@ export async function searchThreads(query: string): Promise { byThread.set(e.id, { threadId: e.id, title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", - snippet: snippetOf(e.content), + snippet: snippetOf(e.content, query), matchKind: "thread", createdAt: e.created_at, }); @@ -55,7 +68,7 @@ export async function searchThreads(query: string): Promise { const r: SearchResult = { threadId: root, title: "", - snippet: snippetOf(e.content), + snippet: snippetOf(e.content, query), matchKind: "reply", createdAt: e.created_at, };