search highlight quote fix
This commit is contained in:
+59
-26
@@ -37,6 +37,18 @@ export function encodeTextFragment(text: string): string {
|
|||||||
return `${prefixWords}§${suffixWords}§${len}`;
|
return `${prefixWords}§${suffixWords}§${len}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map typographical (curly) quotes to ASCII so matching is resilient to
|
||||||
|
// publisher-style differences. Typesense often indexes a straight-quote form
|
||||||
|
// of the text while the rendered DOM uses curly quotes (or vice versa). Applied
|
||||||
|
// to both sides of every match so prefix/suffix and DOM text agree regardless
|
||||||
|
// of which form either one carries. Length-preserving (1 char → 1 char), so
|
||||||
|
// it does not disturb the `fragment.length` window used for disambiguation.
|
||||||
|
function normalizeQuotes(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/[“”„‟″«»]/g, '"')
|
||||||
|
.replace(/[‘’‚‛′]/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a text fragment from URL params.
|
* Decode a text fragment from URL params.
|
||||||
* Supports `§` (current), `|` (v2 legacy), and `,` (v1 legacy) delimiters.
|
* Supports `§` (current), `|` (v2 legacy), and `,` (v1 legacy) delimiters.
|
||||||
@@ -91,9 +103,11 @@ export function extractMatchedText(
|
|||||||
// Build a single string with paragraph breaks
|
// Build a single string with paragraph breaks
|
||||||
const fullText = plainParagraphs.join(" ");
|
const fullText = plainParagraphs.join(" ");
|
||||||
const normalized = fullText.replace(/\s+/g, " ");
|
const normalized = fullText.replace(/\s+/g, " ");
|
||||||
const normalizedLower = normalized.toLowerCase();
|
const normalizedLower = normalizeQuotes(normalized.toLowerCase());
|
||||||
|
|
||||||
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
|
const normalizedPrefix = normalizeQuotes(
|
||||||
|
fragment.prefix.replace(/\s+/g, " ").toLowerCase(),
|
||||||
|
);
|
||||||
let searchFrom = 0;
|
let searchFrom = 0;
|
||||||
let matchStart = -1;
|
let matchStart = -1;
|
||||||
|
|
||||||
@@ -102,9 +116,9 @@ export function extractMatchedText(
|
|||||||
if (idx === -1) break;
|
if (idx === -1) break;
|
||||||
|
|
||||||
if (fragment.suffix) {
|
if (fragment.suffix) {
|
||||||
const normalizedSuffix = fragment.suffix
|
const normalizedSuffix = normalizeQuotes(
|
||||||
.replace(/\s+/g, " ")
|
fragment.suffix.replace(/\s+/g, " ").toLowerCase(),
|
||||||
.toLowerCase();
|
);
|
||||||
const expectedEnd = idx + fragment.length;
|
const expectedEnd = idx + fragment.length;
|
||||||
const suffixSearchStart = Math.max(
|
const suffixSearchStart = Math.max(
|
||||||
idx,
|
idx,
|
||||||
@@ -173,17 +187,11 @@ export function extractMatchedTextFromSnippet(
|
|||||||
const lastEnd = (last.index ?? 0) + last[0].length;
|
const lastEnd = (last.index ?? 0) + last[0].length;
|
||||||
|
|
||||||
const span = snippetHtml.slice(firstStart, lastEnd);
|
const span = snippetHtml.slice(firstStart, lastEnd);
|
||||||
const plain = span
|
const plain = normalizeQuotes(
|
||||||
.replace(/<[^>]*>/g, "")
|
decodeSnippetEntities(span.replace(/<[^>]*>/g, ""))
|
||||||
.replace(/ /gi, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/&/gi, "&")
|
.trim(),
|
||||||
.replace(/</gi, "<")
|
);
|
||||||
.replace(/>/gi, ">")
|
|
||||||
.replace(/"/gi, '"')
|
|
||||||
.replace(/'/gi, "'")
|
|
||||||
.replace(/&[a-z]+;/gi, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (!plain) return null;
|
if (!plain) return null;
|
||||||
|
|
||||||
@@ -191,17 +199,36 @@ export function extractMatchedTextFromSnippet(
|
|||||||
// (e.g. snippet has marks at far ends of a long passage), fall back to
|
// (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.
|
// just the first marked term — that's still a real match in the doc.
|
||||||
if (plain.length > 200) {
|
if (plain.length > 200) {
|
||||||
const firstPlain = first[1]
|
const firstPlain = normalizeQuotes(
|
||||||
.replace(/<[^>]*>/g, "")
|
decodeSnippetEntities(first[1].replace(/<[^>]*>/g, ""))
|
||||||
.replace(/&[a-z]+;/gi, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.trim(),
|
||||||
.trim();
|
);
|
||||||
return firstPlain || null;
|
return firstPlain || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return plain;
|
return plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode the HTML entities Typesense commonly emits in match snippets.
|
||||||
|
// Curly-quote entities (“ etc.) must be decoded explicitly — if they
|
||||||
|
// fall through to the catch-all that maps unknown entities to a space, the
|
||||||
|
// resulting `?hl=` fragment loses characters that are present in the DOM,
|
||||||
|
// and findAndHighlightText fails to locate the highlight.
|
||||||
|
function decodeSnippetEntities(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/&(?:quot|#34|#x22);/gi, '"')
|
||||||
|
.replace(/&(?:apos|#39|#x27);/gi, "'")
|
||||||
|
.replace(/&(?:ldquo|rdquo|bdquo|#8220|#8221|#8222);/gi, '"')
|
||||||
|
.replace(/&(?:lsquo|rsquo|sbquo|#8216|#8217|#8218);/gi, "'")
|
||||||
|
.replace(/&[a-z]+\d*;/gi, " ")
|
||||||
|
.replace(/&#x?[0-9a-f]+;/gi, " ");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a result URL with `?hl=` appended so the destination page highlights
|
* Build a result URL with `?hl=` appended so the destination page highlights
|
||||||
* the matched phrase. Preserves any other query params already on the URL.
|
* the matched phrase. Preserves any other query params already on the URL.
|
||||||
@@ -370,10 +397,16 @@ export function findAndHighlightText(fragment: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedFullLower = collapsedNorm.toLowerCase();
|
// Quote-normalize for matching so the prefix/suffix and the DOM text agree
|
||||||
|
// even when one side uses straight ASCII quotes and the other uses curly
|
||||||
|
// typographical quotes. Length-preserving, so collapsedPosMap indices stay
|
||||||
|
// valid against rawText.
|
||||||
|
const normalizedFullLower = normalizeQuotes(collapsedNorm.toLowerCase());
|
||||||
|
|
||||||
// Search for the prefix in the full text (case-insensitive)
|
// Search for the prefix in the full text (case-insensitive)
|
||||||
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
|
const normalizedPrefix = normalizeQuotes(
|
||||||
|
fragment.prefix.replace(/\s+/g, " ").toLowerCase(),
|
||||||
|
);
|
||||||
let searchFrom = 0;
|
let searchFrom = 0;
|
||||||
let matchStartNorm = -1;
|
let matchStartNorm = -1;
|
||||||
|
|
||||||
@@ -383,9 +416,9 @@ export function findAndHighlightText(fragment: {
|
|||||||
|
|
||||||
// If we have a suffix, verify it appears near the expected length
|
// If we have a suffix, verify it appears near the expected length
|
||||||
if (fragment.suffix) {
|
if (fragment.suffix) {
|
||||||
const normalizedSuffix = fragment.suffix
|
const normalizedSuffix = normalizeQuotes(
|
||||||
.replace(/\s+/g, " ")
|
fragment.suffix.replace(/\s+/g, " ").toLowerCase(),
|
||||||
.toLowerCase();
|
);
|
||||||
// Search for suffix near the expected end position in the normalized string.
|
// Search for suffix near the expected end position in the normalized string.
|
||||||
// Use a wider tolerance window (±50) since normalization can shift positions.
|
// Use a wider tolerance window (±50) since normalization can shift positions.
|
||||||
const expectedEndNorm = idx + fragment.length;
|
const expectedEndNorm = idx + fragment.length;
|
||||||
|
|||||||
Reference in New Issue
Block a user