feat: add search result highlight
This commit is contained in:
@@ -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(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user