feat: add search result highlight

This commit is contained in:
gosangam
2026-05-11 01:01:46 +05:30
parent 0106d8cee2
commit 730efb1bb3
2 changed files with 104 additions and 2 deletions
+95
View File
@@ -135,6 +135,101 @@ export function extractMatchedText(
return matched;
}
/**
* Extract the most relevant snippet of text from a search result snippet HTML
* (which contains `<mark>...</mark>` tags around the parts Typesense matched).
*
* Returns the continuous span from the first `<mark>` to the last `<mark>`,
* with all HTML tags stripped — i.e. the actual text the user searched for as
* it appears in the source document. Returns `null` if there are no marks or
* the result would be empty.
*
* Used to build a `?hl=` URL when navigating from a search result, so the
* destination page highlights the matched phrase.
*
* Examples:
* "...just <mark>surrender unto</mark> Me..." → "surrender unto"
* "<mark>letter</mark> to <mark>Tusta</mark> <mark>Krsna</mark>" → "letter to Tusta Krsna"
*/
export function extractMatchedTextFromSnippet(
snippetHtml: string,
): string | null {
if (!snippetHtml) return null;
const matches = Array.from(
snippetHtml.matchAll(/<mark[^>]*>([\s\S]*?)<\/mark>/gi),
);
if (matches.length === 0) return null;
const first = matches[0];
const last = matches[matches.length - 1];
const firstStart = first.index ?? 0;
const lastEnd = (last.index ?? 0) + last[0].length;
const span = snippetHtml.slice(firstStart, lastEnd);
const plain = span
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&[a-z]+;/gi, " ")
.replace(/\s+/g, " ")
.trim();
if (!plain) return null;
// If the span between first and last mark is unreasonably long
// (e.g. snippet has marks at far ends of a long passage), fall back to
// just the first marked term — that's still a real match in the doc.
if (plain.length > 200) {
const firstPlain = first[1]
.replace(/<[^>]*>/g, "")
.replace(/&[a-z]+;/gi, " ")
.replace(/\s+/g, " ")
.trim();
return firstPlain || null;
}
return plain;
}
/**
* Build a result URL with `?hl=` appended so the destination page highlights
* the matched phrase. Preserves any other query params already on the URL.
*
* Returns the original URL unchanged if no usable highlight text could be
* extracted from the snippet.
*/
export function buildSearchResultUrl(
resultUrl: string,
snippetHtml: string,
): string {
const matched = extractMatchedTextFromSnippet(snippetHtml);
if (!matched) return resultUrl;
const fragment = encodeTextFragment(matched);
if (!fragment) return resultUrl;
// resultUrl is a relative path like "/books/bg/18/66"; we just need to
// splice in the `hl` param without disturbing any existing query string.
const [path, queryAndHash = ""] = resultUrl.split(/(?=[?#])/, 2);
const hashIdx = queryAndHash.indexOf("#");
const existingQuery =
hashIdx >= 0 ? queryAndHash.slice(0, hashIdx) : queryAndHash;
const hash = hashIdx >= 0 ? queryAndHash.slice(hashIdx) : "";
const params = new URLSearchParams(
existingQuery.startsWith("?") ? existingQuery.slice(1) : existingQuery,
);
params.set("hl", fragment);
return `${path}?${params.toString()}${hash}`;
}
/**
* Build the full URL with highlight fragment for the selected text.
*/