// --------------------------------------------------------------------------- // 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}`; } // 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, "'"); } // 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. * 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 = normalizeForMatch(normalized); const normalizedPrefix = normalizeForMatch( fragment.prefix.replace(/\s+/g, " "), ); 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 = normalizeForMatch( fragment.suffix.replace(/\s+/g, " "), ); 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; // 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(), ); // 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(), ); // 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; } if (fullPlain && fullPlain.length <= 200) { return fullPlain; } return markedPlain || null; } // 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 * 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(); } /** * Append (or replace) the `?hl=…` fragment on a path-only URL. * * Unlike `buildHighlightUrl`, this works without `window` — useful for * server-rendered links and for rebuilding bookmark/post source URLs at * render time (e.g. a bookmark saved by an older client without an `?hl=` * still scrolls to its text when you click it, because we synthesise the * fragment from the stored excerpt/caption). * * Accepts: * path — same-origin path, may already include a `?query` (e.g. * `/books/bg/1/1` or `/books/bg/1/1?foo=bar`). * text — the text we want to highlight on the destination page. * * Returns the path with `hl` set to the encoded fragment. If `text` * produces no fragment (e.g. empty string) returns the input unchanged. */ export function appendHighlight(path: string, text: string): string { if (!text || !path) return path; const fragment = encodeTextFragment(text); if (!fragment) return path; const hashIdx = path.indexOf("#"); const hash = hashIdx >= 0 ? path.slice(hashIdx) : ""; const noHash = hashIdx >= 0 ? path.slice(0, hashIdx) : path; const qIdx = noHash.indexOf("?"); const head = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash; const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : ""; const params = new URLSearchParams(query); params.delete("hl"); params.set("hl", fragment); return `${head}?${params.toString()}${hash}`; } /** * Returns true if `path` already carries an `?hl=` query param. * Cheap string check — avoids a URL parse on the render hot path. */ export function hasHighlight(path: string): boolean { if (!path) return false; return /[?&]hl=/.test(path); } /** * 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(); } // HTML block-level tags. When concatenating text nodes for matching, we // insert a synthetic space at boundaries between *different* block-level // ancestors so cross-paragraph selections still match (React often // renders adjacent

s with no whitespace between them). // // Crucially, crossing an *inline* boundary (e.g.

In Caitanya // (...) it

) must NOT insert a space — otherwise the prefix // "In Caitanya (X) it" gets reconstructed as "In Caitanya ( X ) it" and // the match fails on every passage with italicised Sanskrit terms. const BLOCK_TAGS = new Set([ "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI", "UL", "OL", "BLOCKQUOTE", "SECTION", "ARTICLE", "HEADER", "FOOTER", "NAV", "ASIDE", "MAIN", "FIGURE", "FIGCAPTION", "PRE", "TR", "TD", "TH", "TABLE", "ADDRESS", "HR", "DL", "DT", "DD", "BODY", "HTML", ]); function nearestBlockAncestor(el: Element | null): Element | null { let cur: Element | null = el; while (cur) { if (BLOCK_TAGS.has(cur.tagName)) return cur; cur = cur.parentElement; } return null; } /** * Find a DOM Range matching a decoded text fragment WITHOUT modifying the * DOM. Returns null when no match is found. Used by the bookmark-indicator * overlay: we need each bookmark's bounding rect to anchor a floating * icon, but we don't want a second wrapping pass on top of whatever * findAndHighlightText (`?hl=`) might already have done on the same page. * * Shares the same prefix+suffix+length normalisation that * findAndHighlightText uses so a bookmark's saved fragment matches the * same DOM text the toolbar selected. */ export function findTextRange(fragment: { prefix: string; suffix: string; length: number; }): Range | null { const contentAreas = document.querySelectorAll( "main, [data-slot=card-content], article, [data-content-area]", ); if (contentAreas.length === 0) return null; 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); } } }); // Same segment/normalisation pipeline as findAndHighlightText; see comments // there. We trade a little duplication for guaranteed parity in matching. const segments: { node: Text; start: number; end: number }[] = []; let rawText = ""; let normText = ""; const posMap: number[] = []; let prevParent: Element | null = null; let prevBlock: Element | null = null; for (const node of allTextNodes) { const text = node.textContent || ""; const parent = node.parentElement; const block = nearestBlockAncestor(parent); if ( prevParent && prevBlock !== block && normText.length > 0 && !normText.endsWith(" ") && !text.startsWith(" ") ) { posMap.push(rawText.length); normText += " "; } prevParent = parent; prevBlock = block; 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; } 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 = normalizeForMatch(collapsedNorm); const normalizedPrefix = normalizeForMatch( fragment.prefix.replace(/\s+/g, " "), ); let searchFrom = 0; let matchStartNorm = -1; while (searchFrom < normalizedFullLower.length) { const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom); if (idx === -1) break; if (fragment.suffix) { const normalizedSuffix = normalizeForMatch( fragment.suffix.replace(/\s+/g, " "), ); 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 null; const matchEndNorm = Math.min( matchStartNorm + fragment.length, collapsedNorm.length, ); let matchStart = collapsedPosMap[matchStartNorm] ?? 0; let matchEnd = matchEndNorm < collapsedPosMap.length ? collapsedPosMap[matchEndNorm] : rawText.length; // Defensive raw-text alignment — same logic as the other matchers. // Use fragment.length for range fragments so the selection isn't // collapsed to just the prefix. const rawLower = rawText.toLowerCase(); const prefixLower = fragment.prefix.toLowerCase(); const directIdx = rawLower.indexOf(prefixLower); if (directIdx >= 0) { matchStart = directIdx; const endByLength = fragment.suffix && fragment.length > fragment.prefix.length ? directIdx + fragment.length : directIdx + fragment.prefix.length; matchEnd = Math.min(endByLength, rawText.length); } // Map raw-text offsets back to (textNode, offsetInNode) anchors for the Range. let startAnchor: { node: Text; offset: number } | null = null; let endAnchor: { node: Text; offset: number } | null = null; for (const seg of segments) { if ( !startAnchor && matchStart >= seg.start && matchStart <= seg.end ) { startAnchor = { node: seg.node, offset: matchStart - seg.start }; } if (matchEnd >= seg.start && matchEnd <= seg.end) { endAnchor = { node: seg.node, offset: matchEnd - seg.start }; } if (startAnchor && endAnchor) break; } if (!startAnchor || !endAnchor) return null; try { const range = document.createRange(); range.setStart(startAnchor.node, startAnchor.offset); range.setEnd(endAnchor.node, endAnchor.offset); return range; } catch { return null; } } /** * 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; let prevBlock: Element | null = null; for (const node of allTextNodes) { const text = node.textContent || ""; const parent = node.parentElement; const block = nearestBlockAncestor(parent); // Insert a space in normText only when crossing a *block-level* // boundary (so cross-paragraph searches still work), but NOT when // crossing inline boundaries like , , — otherwise a // selection like "(Madhya-līlā) it" gets reconstructed as // "( Madhya-līlā ) it" and the prefix match fails. rawText stays // exact so DOM offsets remain accurate for highlighting. if ( prevParent && prevBlock !== block && normText.length > 0 && !normText.endsWith(" ") && !text.startsWith(" ") ) { posMap.push(rawText.length); // the synthetic space maps to current raw position normText += " "; } prevParent = parent; prevBlock = block; 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; } } // 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 = normalizeForMatch( fragment.prefix.replace(/\s+/g, " "), ); 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 = 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. 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, ); let matchStart = collapsedPosMap[matchStartNorm] ?? 0; let matchEnd = matchEndNorm < collapsedPosMap.length ? collapsedPosMap[matchEndNorm] : rawText.length; // Defensive raw-text alignment — same logic as markFragmentAsBookmark. // Prefer the raw-text index when the prefix is plain enough to match // directly, since collapsedPosMap can drift on certain inputs. // // The matchEnd choice depends on the fragment shape (see encoder): // • prefix-only (no suffix) → the prefix IS the full selection, so // matchEnd = directIdx + prefix.length. // • prefix + suffix → the selection spans from prefix-start to // suffix-end, and `fragment.length` is that full span. We must use // it; collapsing to just prefix.length here was the bug that made // long highlights show only the first 4 words. const rawLower = rawText.toLowerCase(); const prefixLower = fragment.prefix.toLowerCase(); const directIdx = rawLower.indexOf(prefixLower); if (directIdx >= 0) { matchStart = directIdx; const endByLength = fragment.suffix && fragment.length > fragment.prefix.length ? directIdx + fragment.length : directIdx + fragment.prefix.length; matchEnd = Math.min(endByLength, 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; } // --------------------------------------------------------------------------- // Persistent bookmark marks // --------------------------------------------------------------------------- /** * Wrap the bookmarked passage in a `` with a * violet wash so the user sees their saved passage every time they visit * the page, until they remove the bookmark. * * Distinct from `findAndHighlightText` in three ways: * • Persistent — does NOT scroll into view (only the floating icon * handles that affordance) and uses a different colour from `?hl=` * (amber) so transient share-link landings stay visually distinct. * • Idempotent — if a mark for this bookmark id already exists, we * return the existing nodes without re-wrapping. * • Returns the created (or existing) `` elements so the caller * can measure their bounding rect to anchor a floating indicator. * * The matching pipeline mirrors `findAndHighlightText`; we intentionally * inline the duplicated logic for now (clear-and-obvious beats clever * abstraction while we iterate on this feature). */ export function markFragmentAsBookmark( fragment: { prefix: string; suffix: string; length: number }, bookmarkId: string, ): HTMLElement[] { // Idempotency — bail if marks for this bookmark are already in the DOM. const existing = Array.from( document.querySelectorAll( `[data-bookmark-id="${CSS.escape(bookmarkId)}"]`, ), ); if (existing.length > 0) return existing; const contentAreas = document.querySelectorAll( "main, [data-slot=card-content], article, [data-content-area]", ); if (contentAreas.length === 0) return []; 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); } } }); // Same segment/normalisation pipeline as findAndHighlightText; see the // BLOCK_TAGS comment above for the inline-vs-block boundary rule. const segments: { node: Text; start: number; end: number }[] = []; let rawText = ""; let normText = ""; const posMap: number[] = []; let prevParent: Element | null = null; let prevBlock: Element | null = null; for (const node of allTextNodes) { const text = node.textContent || ""; const parent = node.parentElement; const block = nearestBlockAncestor(parent); if ( prevParent && prevBlock !== block && normText.length > 0 && !normText.endsWith(" ") && !text.startsWith(" ") ) { posMap.push(rawText.length); normText += " "; } prevParent = parent; prevBlock = block; 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; } 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 = normalizeForMatch(collapsedNorm); const normalizedPrefix = normalizeForMatch( fragment.prefix.replace(/\s+/g, " "), ); let searchFrom = 0; let matchStartNorm = -1; while (searchFrom < normalizedFullLower.length) { const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom); if (idx === -1) break; if (fragment.suffix) { const normalizedSuffix = normalizeForMatch( fragment.suffix.replace(/\s+/g, " "), ); 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 []; const matchEndNorm = Math.min( matchStartNorm + fragment.length, collapsedNorm.length, ); let matchStart = collapsedPosMap[matchStartNorm] ?? 0; let matchEnd = matchEndNorm < collapsedPosMap.length ? collapsedPosMap[matchEndNorm] : rawText.length; // Defensive: cross-check the position against a direct case-insensitive // search in rawText. If the prefix is plain enough to find directly, // prefer the raw-text index — collapsedPosMap can drift by one when the // normalization pipeline produces different code-unit counts than the // original (NFD decomposition, Unicode lowercase widening, etc.), and // that one-character shift is what dropped the leading character of // matches in the wild. // // For range fragments (prefix + suffix), `fragment.length` is the full // span — using prefix.length here would shrink the highlight to just // the first few words. const rawLower = rawText.toLowerCase(); const prefixLower = fragment.prefix.toLowerCase(); const directIdx = rawLower.indexOf(prefixLower); if (directIdx >= 0) { matchStart = directIdx; const endByLength = fragment.suffix && fragment.length > fragment.prefix.length ? directIdx + fragment.length : directIdx + fragment.prefix.length; matchEnd = Math.min(endByLength, rawText.length); } // Tailwind classes — violet wash + slight rounded corners. Picked to be // visually distinct from the amber used by ?hl= so a user landing on a // share-link sees the transient amber on TOP of any persistent violet // bookmark mark (two colours = clearly two concepts). const MARK_CLASS = "bg-violet-300/45 dark:bg-violet-400/30 text-foreground rounded-sm px-0.5"; const created: HTMLElement[] = []; 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; 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 = MARK_CLASS; mark.setAttribute("data-bookmark-id", bookmarkId); mark.textContent = matched; frag.appendChild(mark); if (after) frag.appendChild(document.createTextNode(after)); parent.replaceChild(frag, node); created.push(mark); } return created; } /** * Unwrap bookmark marks. Pass an id to remove a single bookmark's marks; * pass nothing to remove every bookmark mark in the document (used when * the PageBookmarks overlay unmounts on route change). */ export function removeBookmarkMarks(bookmarkId?: string): void { const selector = bookmarkId ? `[data-bookmark-id="${CSS.escape(bookmarkId)}"]` : `[data-bookmark-id]`; document.querySelectorAll(selector).forEach((mark) => { const parent = mark.parentNode; if (!parent) return; parent.replaceChild( document.createTextNode(mark.textContent ?? ""), mark, ); // Merge adjacent text nodes so subsequent matchers see a clean tree. parent.normalize(); }); }