diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts index cee2c2fe..e0603eec 100644 --- a/lib/text-highlight.ts +++ b/lib/text-highlight.ts @@ -206,28 +206,46 @@ export function extractMatchedTextFromSnippet( const firstStart = first.index ?? 0; const lastEnd = (last.index ?? 0) + last[0].length; - const span = snippetHtml.slice(firstStart, lastEnd); - const plain = normalizeQuotes( - decodeSnippetEntities(span.replace(/<[^>]*>/g, "")) + // Marked-span text — just the tokens Typesense highlighted. + const markedSpan = snippetHtml.slice(firstStart, lastEnd); + const markedPlain = normalizeQuotes( + decodeSnippetEntities(markedSpan.replace(/<[^>]*>/g, "")) .replace(/\s+/g, " ") .trim(), ); - if (!plain) return null; + // Full-snippet text — includes Typesense's surrounding context. Typesense + // snippets are direct slices of the source field, so the surrounding text + // exists verbatim in the destination page. Using it as the prefix makes + // single-token matches (e.g. `Therefore material senses...`) + // land on the *correct* occurrence on the destination page rather than the + // first instance of that token alphabetically. + const fullPlain = normalizeQuotes( + decodeSnippetEntities(snippetHtml.replace(/<[^>]*>/g, "")) + .replace(/\s+/g, " ") + // strip leading/trailing ellipsis Typesense sometimes adds + .replace(/^(?:…|\.{3,})\s*/, "") + .replace(/\s*(?:…|\.{3,})$/, "") + .trim(), + ); - // 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 = normalizeQuotes( - decodeSnippetEntities(first[1].replace(/<[^>]*>/g, "")) - .replace(/\s+/g, " ") - .trim(), - ); - return firstPlain || null; + // Pick the best candidate: + // - if the marked span alone is already ≥6 words, it's a multi-token phrase + // and is specific enough — use it. + // - otherwise prefer the full snippet (more context = more uniquely placed), + // capped at 200 chars to keep URLs sane. + // - fall back to marked span if the full snippet is empty / unusable. + const markedWordCount = markedPlain + ? markedPlain.split(/\s+/).filter(Boolean).length + : 0; + + if (markedWordCount >= 6) { + return markedPlain || null; } - - return plain; + if (fullPlain && fullPlain.length <= 200) { + return fullPlain; + } + return markedPlain || null; } // Decode the HTML entities Typesense commonly emits in match snippets.