feat: add search result highlight
This commit is contained in:
@@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buildSearchResultUrl } from "@/lib/text-highlight";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -422,7 +423,8 @@ function SearchInner() {
|
|||||||
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
|
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{effectiveData.total.toLocaleString()}{" "}
|
{effectiveData.total.toLocaleString()}{" "}
|
||||||
{effectiveData.total === 1 ? "result " : "results "} for “
|
{effectiveData.total === 1 ? "result " : "results "} for
|
||||||
|
“
|
||||||
{effectiveData.query}”
|
{effectiveData.query}”
|
||||||
</p>
|
</p>
|
||||||
{effectiveData.total_pages > 1 && (
|
{effectiveData.total_pages > 1 && (
|
||||||
@@ -551,9 +553,14 @@ function ResultCard({ result }: { result: SearchResult }) {
|
|||||||
};
|
};
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
// Append `?hl=<matched-text>` 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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={result.url}
|
href={href}
|
||||||
className="block group"
|
className="block group"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -135,6 +135,101 @@ export function extractMatchedText(
|
|||||||
return matched;
|
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.
|
* Build the full URL with highlight fragment for the selected text.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user