Highlight the search terms in the results

This commit is contained in:
dtonon 2026-06-11 20:03:31 +01:00
parent 540ba10654
commit d836ee588f
2 changed files with 48 additions and 6 deletions

View file

@ -4,6 +4,7 @@
let query = $state(""); let query = $state("");
let results = $state<SearchResult[]>([]); let results = $state<SearchResult[]>([]);
let resultsQuery = $state(""); // The query that produced the current results
let open = $state(false); let open = $state(false);
let searching = $state(false); let searching = $state(false);
let activeIndex = $state(-1); let activeIndex = $state(-1);
@ -29,6 +30,7 @@
const r = await searchThreads(q); const r = await searchThreads(q);
if (id !== seq) return; if (id !== seq) return;
results = r; results = r;
resultsQuery = q;
} finally { } finally {
if (id === seq) searching = false; 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 <mark> 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() { function onFocus() {
if (query.trim().length >= 2) open = true; if (query.trim().length >= 2) open = true;
} }
@ -133,6 +154,14 @@
No results No results
</div> </div>
{/if} {/if}
{#snippet marked(text: string)}
{#each highlight(text) as p}
{#if p.hit}<mark
class="bg-secondary/40 dark:bg-secondary/30 rounded-sm text-inherit"
>{p.text}</mark
>{:else}{p.text}{/if}
{/each}
{/snippet}
{#each results as r, i (r.threadId)} {#each results as r, i (r.threadId)}
<a <a
id="search-result-{i}" id="search-result-{i}"
@ -151,7 +180,7 @@
<span <span
class="block truncate font-medium text-neutral-800 dark:text-neutral-200" class="block truncate font-medium text-neutral-800 dark:text-neutral-200"
> >
{r.title} {@render marked(r.title)}
{#if r.matchKind === "reply"} {#if r.matchKind === "reply"}
<span <span
class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500" class="ml-1 text-xs font-normal text-neutral-400 dark:text-neutral-500"
@ -163,7 +192,7 @@
<span <span
class="block truncate text-sm text-neutral-500 dark:text-neutral-400" class="block truncate text-sm text-neutral-500 dark:text-neutral-400"
> >
{r.snippet} {@render marked(r.snippet)}
</span> </span>
{/if} {/if}
</a> </a>

View file

@ -12,9 +12,22 @@ export type SearchResult = {
createdAt: number; 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(); 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 // NIP-50 search over the forum relay: thread OPs (kind 11) and replies
@ -45,7 +58,7 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
byThread.set(e.id, { byThread.set(e.id, {
threadId: e.id, threadId: e.id,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
snippet: snippetOf(e.content), snippet: snippetOf(e.content, query),
matchKind: "thread", matchKind: "thread",
createdAt: e.created_at, createdAt: e.created_at,
}); });
@ -55,7 +68,7 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
const r: SearchResult = { const r: SearchResult = {
threadId: root, threadId: root,
title: "", title: "",
snippet: snippetOf(e.content), snippet: snippetOf(e.content, query),
matchKind: "reply", matchKind: "reply",
createdAt: e.created_at, createdAt: e.created_at,
}; };