diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts index cacc44ad..9d03a56c 100644 --- a/lib/text-highlight.ts +++ b/lib/text-highlight.ts @@ -49,6 +49,26 @@ function normalizeQuotes(s: string): string { .replace(/[‘’‚‛′]/g, "'"); } +// Strip IAST / Vedic diacritics for matching. Decomposes via NFD then drops +// codepoints in the Combining Diacritical Marks block. Necessary because +// Typesense returns snippets from the `_ascii` shadow field (e.g. "Krsna") +// while the rendered DOM uses the original IAST form ("Kṛṣṇa") — without +// this normalization, a search-result link to KB 1.34 with prefix "Krsna is +// so beautiful" never matches the "Kṛṣṇa is so beautiful" in the DOM. +// +// Length-preserving for NFC input (one NFC char → one ASCII char): NFD +// expands an accented char into base + combining mark, the regex deletes +// only the combining mark, and the base is left. So position maps built +// from the original collapsed string remain valid after this transform. +function stripDiacritics(s: string): string { + return s.normalize("NFD").replace(/[̀-ͯ]/g, ""); +} + +// Combined normalization used on both sides of every match. +function normalizeForMatch(s: string): string { + return stripDiacritics(normalizeQuotes(s.toLowerCase())); +} + /** * Decode a text fragment from URL params. * Supports `§` (current), `|` (v2 legacy), and `,` (v1 legacy) delimiters. @@ -103,10 +123,10 @@ export function extractMatchedText( // Build a single string with paragraph breaks const fullText = plainParagraphs.join(" "); const normalized = fullText.replace(/\s+/g, " "); - const normalizedLower = normalizeQuotes(normalized.toLowerCase()); + const normalizedLower = normalizeForMatch(normalized); - const normalizedPrefix = normalizeQuotes( - fragment.prefix.replace(/\s+/g, " ").toLowerCase(), + const normalizedPrefix = normalizeForMatch( + fragment.prefix.replace(/\s+/g, " "), ); let searchFrom = 0; let matchStart = -1; @@ -116,8 +136,8 @@ export function extractMatchedText( if (idx === -1) break; if (fragment.suffix) { - const normalizedSuffix = normalizeQuotes( - fragment.suffix.replace(/\s+/g, " ").toLowerCase(), + const normalizedSuffix = normalizeForMatch( + fragment.suffix.replace(/\s+/g, " "), ); const expectedEnd = idx + fragment.length; const suffixSearchStart = Math.max( @@ -397,15 +417,15 @@ export function findAndHighlightText(fragment: { } } - // 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()); + // Normalize for matching so prefix/suffix and DOM text agree on quote style + // and diacritics (search snippets come from the `_ascii` shadow field, the + // DOM renders the original IAST form). Length-preserving for NFC input, so + // collapsedPosMap indices remain valid against rawText. + const normalizedFullLower = normalizeForMatch(collapsedNorm); // Search for the prefix in the full text (case-insensitive) - const normalizedPrefix = normalizeQuotes( - fragment.prefix.replace(/\s+/g, " ").toLowerCase(), + const normalizedPrefix = normalizeForMatch( + fragment.prefix.replace(/\s+/g, " "), ); let searchFrom = 0; let matchStartNorm = -1; @@ -416,8 +436,8 @@ export function findAndHighlightText(fragment: { // If we have a suffix, verify it appears near the expected length if (fragment.suffix) { - const normalizedSuffix = normalizeQuotes( - fragment.suffix.replace(/\s+/g, " ").toLowerCase(), + const normalizedSuffix = normalizeForMatch( + fragment.suffix.replace(/\s+/g, " "), ); // Search for suffix near the expected end position in the normalized string. // Use a wider tolerance window (±50) since normalization can shift positions.