Files
spsays-training/lib/text-highlight.ts
T
2026-05-24 18:07:52 +05:30

506 lines
17 KiB
TypeScript

// ---------------------------------------------------------------------------
// Text Fragment Highlight — compact URL encoding for selected text
//
// These utilities work in both browser and server environments.
// The DOM-based findAndHighlightText() is browser-only.
// The string-based extractMatchedText() is server-safe (for OG image generation).
// ---------------------------------------------------------------------------
/**
* Encode a text selection into a compact URL-safe fragment.
*
* Format: `?hl=<prefix>§<suffix>§<length>`
* - prefix: first 4 words of the selection
* - suffix: last 4 words of the selection (omitted if same as prefix)
* - length: character count of the full selection (for disambiguation)
*
* Uses `§` (section sign) as delimiter — it never appears in Sanskrit,
* Devanagari, English, or any Vedic text, unlike `,` (commas in prose)
* or `|` (pūrṇa virāma in Sanskrit verses).
*/
export function encodeTextFragment(text: string): string {
const words = text.split(/\s+/).filter(Boolean);
if (words.length === 0) return "";
const len = text.length;
// Short selections (≤8 words): use the full text as prefix, no suffix needed.
// The entire selection is specific enough to locate uniquely.
if (words.length <= 8) {
return `${text.trim()}§${len}`;
}
// Longer selections: first 4 words + last 4 words + length
const prefixWords = words.slice(0, 4).join(" ");
const suffixWords = words.slice(-4).join(" ");
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.
* Supports `§` (current), `|` (v2 legacy), and `,` (v1 legacy) delimiters.
* Returns { prefix, suffix, length } or null if invalid.
*/
export function decodeTextFragment(
hl: string,
): { prefix: string; suffix: string; length: number } | null {
const delimiter = "§";
const parts = hl.split(delimiter);
if (parts.length < 2) return null;
const lengthStr = parts[parts.length - 1].trim();
const length = parseInt(lengthStr, 10);
if (isNaN(length)) return null;
if (parts.length === 2) {
// prefix + length only (short selections)
return { prefix: parts[0], suffix: "", length };
}
// prefix + suffix + length
const prefix = parts.slice(0, -2).join(delimiter);
const suffix = parts[parts.length - 2];
return { prefix, suffix, length };
}
/**
* Server-side: extract the full matched text from an array of paragraph strings
* using a decoded text fragment (prefix, suffix, length).
*
* This is the server equivalent of findAndHighlightText() — instead of
* walking the DOM, it searches a flat array of HTML paragraph strings.
* HTML tags are stripped before matching.
*
* Returns the matched text string, or null if not found.
*/
export function extractMatchedText(
fragment: { prefix: string; suffix: string; length: number },
paragraphs: string[],
): string | null {
// Strip HTML tags and entities to get plain text. The entity regex must be
// strict — `&[^;]+;` would greedily pair a stray `&` (e.g. "24 & 25 Oct")
// with a far-away `;` (e.g. "my beloved son;"), eating everything between
// them. Only match real entities: named (&amp;), decimal (&#39;), hex (&#x27;).
const plainParagraphs = paragraphs.map((p) =>
p
.replace(/<[^>]*>/g, "")
.replace(/&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/g, " "),
);
// Build a single string with paragraph breaks
const fullText = plainParagraphs.join(" ");
const normalized = fullText.replace(/\s+/g, " ");
const normalizedLower = normalizeQuotes(normalized.toLowerCase());
const normalizedPrefix = normalizeQuotes(
fragment.prefix.replace(/\s+/g, " ").toLowerCase(),
);
let searchFrom = 0;
let matchStart = -1;
while (searchFrom < normalizedLower.length) {
const idx = normalizedLower.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break;
if (fragment.suffix) {
const normalizedSuffix = normalizeQuotes(
fragment.suffix.replace(/\s+/g, " ").toLowerCase(),
);
const expectedEnd = idx + fragment.length;
const suffixSearchStart = Math.max(
idx,
expectedEnd - normalizedSuffix.length - 50,
);
const suffixSearchEnd = Math.min(
normalizedLower.length,
expectedEnd + 50,
);
const searchWindow = normalizedLower.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) {
matchStart = idx;
break;
}
} else {
matchStart = idx;
break;
}
searchFrom = idx + 1;
}
if (matchStart === -1) return null;
// Slice from the original (not lowercased) to preserve original casing
const matchEnd = Math.min(matchStart + fragment.length, normalized.length);
const matched = normalized.slice(matchStart, matchEnd).trim();
if (matched.length === 0) return null;
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 = normalizeQuotes(
decodeSnippetEntities(span.replace(/<[^>]*>/g, ""))
.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 = normalizeQuotes(
decodeSnippetEntities(first[1].replace(/<[^>]*>/g, ""))
.replace(/\s+/g, " ")
.trim(),
);
return firstPlain || null;
}
return plain;
}
// Decode the HTML entities Typesense commonly emits in match snippets.
// Curly-quote entities (&ldquo; 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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/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
* 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.
*/
export function buildHighlightUrl(selectedText: string): string {
const fragment = encodeTextFragment(selectedText);
if (!fragment) return window.location.href;
const url = new URL(window.location.href);
// Remove any existing hl param
url.searchParams.delete("hl");
url.searchParams.set("hl", fragment);
return url.toString();
}
/**
* Build a shortened, shareable URL for the current selection. POSTs the
* `?hl=…` URL to the BFF, which stores the mapping in Postgres (Redis-queued)
* and returns a short code. The destination `/share/<code>` page resolves
* back to the same `?hl=…` URL the existing TextHighlighter already knows
* how to process — no change to highlighting behavior.
*
* Falls back to the long URL on any failure so sharing never silently breaks.
*
* `buildHighlightUrl` (sync) is intentionally left in place so other callers
* (e.g. search-result navigation) keep working unchanged.
*/
export async function buildShortHighlightUrl(
selectedText: string,
): Promise<string> {
const longUrl = buildHighlightUrl(selectedText);
let u: URL;
try {
u = new URL(longUrl);
} catch {
return longUrl;
}
const relativePath = u.pathname + u.search + u.hash;
const { createShortLink } = await import("@/lib/share");
const code = await createShortLink(relativePath);
if (!code) return longUrl;
return new URL(`/share/${code}`, u.origin).toString();
}
/**
* Find and highlight text in the DOM that matches a decoded text fragment.
* Returns true if found and highlighted.
*/
export function findAndHighlightText(fragment: {
prefix: string;
suffix: string;
length: number;
}): boolean {
// Walk all text nodes in main/article/card-content areas
const contentAreas = document.querySelectorAll(
"main, [data-slot=card-content], article, [data-content-area]",
);
if (contentAreas.length === 0) return false;
const allTextNodes: Text[] = [];
contentAreas.forEach((area) => {
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && node.textContent.trim().length > 0) {
allTextNodes.push(node);
}
}
});
// Build a concatenated string of all text content.
// We keep two parallel strings:
// rawText — exact concatenation of text nodes (for accurate segment mapping)
// normText — with spaces inserted at element boundaries (for matching)
// And a posMap that maps each normText index back to a rawText index.
const segments: { node: Text; start: number; end: number }[] = [];
let rawText = "";
let normText = "";
const posMap: number[] = []; // posMap[normIdx] = rawIdx
let prevParent: Element | null = null;
for (const node of allTextNodes) {
const text = node.textContent || "";
const parent = node.parentElement;
// Insert a space in normText when crossing element boundaries
// (so cross-paragraph searches work), but NOT in rawText
// (so segment offsets stay accurate for DOM highlighting).
if (
prevParent &&
parent !== prevParent &&
normText.length > 0 &&
!normText.endsWith(" ") &&
!text.startsWith(" ")
) {
posMap.push(rawText.length); // the synthetic space maps to current raw position
normText += " ";
}
prevParent = parent;
const segStart = rawText.length;
segments.push({
node,
start: segStart,
end: segStart + text.length,
});
for (let i = 0; i < text.length; i++) {
posMap.push(rawText.length + i);
normText += text[i];
}
rawText += text;
}
// Collapse whitespace in normText for matching, rebuilding posMap
let collapsedNorm = "";
const collapsedPosMap: number[] = [];
let lastWasSpace = false;
for (let i = 0; i < normText.length; i++) {
const ch = normText[i];
const isSpace = /\s/.test(ch);
if (isSpace) {
if (!lastWasSpace) {
collapsedPosMap.push(posMap[i]);
collapsedNorm += " ";
}
lastWasSpace = true;
} else {
collapsedPosMap.push(posMap[i]);
collapsedNorm += ch;
lastWasSpace = false;
}
}
// 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)
const normalizedPrefix = normalizeQuotes(
fragment.prefix.replace(/\s+/g, " ").toLowerCase(),
);
let searchFrom = 0;
let matchStartNorm = -1;
while (searchFrom < normalizedFullLower.length) {
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break;
// If we have a suffix, verify it appears near the expected length
if (fragment.suffix) {
const normalizedSuffix = normalizeQuotes(
fragment.suffix.replace(/\s+/g, " ").toLowerCase(),
);
// Search for suffix near the expected end position in the normalized string.
// Use a wider tolerance window (±50) since normalization can shift positions.
const expectedEndNorm = idx + fragment.length;
const suffixSearchStart = Math.max(
idx,
expectedEndNorm - normalizedSuffix.length - 50,
);
const suffixSearchEnd = Math.min(
normalizedFullLower.length,
expectedEndNorm + 50,
);
const searchWindow = normalizedFullLower.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) {
matchStartNorm = idx;
break;
}
} else {
matchStartNorm = idx;
break;
}
searchFrom = idx + 1;
}
if (matchStartNorm === -1) return false;
// Map normalized match range back to raw text positions via posMap
const matchEndNorm = Math.min(
matchStartNorm + fragment.length,
collapsedNorm.length,
);
const matchStart = collapsedPosMap[matchStartNorm] ?? 0;
const matchEnd =
matchEndNorm < collapsedPosMap.length
? collapsedPosMap[matchEndNorm]
: rawText.length;
// Map the match range back to DOM text nodes and wrap with <mark>
for (const seg of segments) {
const overlapStart = Math.max(matchStart, seg.start);
const overlapEnd = Math.min(matchEnd, seg.end);
if (overlapStart >= overlapEnd) continue;
const node = seg.node;
const text = node.textContent || "";
const localStart = overlapStart - seg.start;
const localEnd = overlapEnd - seg.start;
// Split the text node and wrap the matching portion in a <mark>
const before = text.slice(0, localStart);
const matched = text.slice(localStart, localEnd);
const after = text.slice(localEnd);
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
if (before) frag.appendChild(document.createTextNode(before));
const mark = document.createElement("mark");
mark.className = "bg-amber-400/30 text-foreground rounded-sm px-0.5";
mark.setAttribute("data-text-highlight", "true");
mark.textContent = matched;
frag.appendChild(mark);
if (after) frag.appendChild(document.createTextNode(after));
parent.replaceChild(frag, node);
}
// Scroll to the first highlight
const firstMark = document.querySelector("[data-text-highlight]");
if (firstMark) {
setTimeout(() => {
firstMark.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
return true;
}