From 730efb1bb324f150b0cc2e18617f2118be8ab5ff Mon Sep 17 00:00:00 2001 From: gosangam Date: Mon, 11 May 2026 01:01:46 +0530 Subject: [PATCH] feat: add search result highlight --- app/search/search-content.tsx | 11 +++- lib/text-highlight.ts | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/app/search/search-content.tsx b/app/search/search-content.tsx index ce574c75..af224b1d 100644 --- a/app/search/search-content.tsx +++ b/app/search/search-content.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; +import { buildSearchResultUrl } from "@/lib/text-highlight"; import { Search, Loader2, @@ -422,7 +423,8 @@ function SearchInner() {

{effectiveData.total.toLocaleString()}{" "} - {effectiveData.total === 1 ? "result " : "results "} for “ + {effectiveData.total === 1 ? "result " : "results "} for + “ {effectiveData.query}”

{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. */