// --------------------------------------------------------------------------- // 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: 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}`; } /** * 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 (&), decimal ('), hex ('). 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 = normalized.toLowerCase(); const normalizedPrefix = 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 = 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 `...` tags around the parts Typesense matched). * * Returns the continuous span from the first `` to the last ``, * 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 surrender unto Me..." → "surrender unto" * "letter to Tusta Krsna" → "letter to Tusta Krsna" */ export function extractMatchedTextFromSnippet( snippetHtml: string, ): string | null { if (!snippetHtml) return null; const matches = Array.from( snippetHtml.matchAll(/]*>([\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. */ 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/` 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 { 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; } } const normalizedFullLower = collapsedNorm.toLowerCase(); // Search for the prefix in the full text (case-insensitive) const normalizedPrefix = 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 = 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 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 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; }