{effectiveData.total_pages > 1 && (
@@ -551,9 +553,14 @@ function ResultCard({ result }: { result: SearchResult }) {
};
const Icon = config.icon;
+ // Append `?hl=` so the destination page highlights the snippet
+ // text Typesense matched. Falls back to the original URL if no marks were
+ // present in the snippet.
+ const href = buildSearchResultUrl(result.url, result.snippet);
+
return (
{
try {
diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts
index 7c2d6d2d..44714b66 100644
--- a/lib/text-highlight.ts
+++ b/lib/text-highlight.ts
@@ -135,6 +135,101 @@ export function extractMatchedText(
return matched;
}
+/**
+ * Extract the most relevant snippet of text from a search result snippet HTML
+ * (which contains `...` tags around the parts Typesense matched).
+ *
+ * Returns the continuous span from the first `` to the last ``,
+ * 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 surrender unto Me..." → "surrender unto"
+ * "letter to Tusta Krsna" → "letter to Tusta Krsna"
+ */
+export function extractMatchedTextFromSnippet(
+ snippetHtml: string,
+): string | null {
+ if (!snippetHtml) return null;
+
+ const matches = Array.from(
+ snippetHtml.matchAll(/]*>([\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.
*/