// --------------------------------------------------------------------------- // 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) * * This keeps URLs short while being specific enough to locate the text. */ export function encodeTextFragment(text: string): string { const words = text.split(/\s+/).filter(Boolean); if (words.length === 0) return ""; const prefixWords = words.slice(0, 4).join(" "); const suffixWords = words.length > 6 ? words.slice(-4).join(" ") : ""; const len = text.length; const parts = [prefixWords]; if (suffixWords) parts.push(suffixWords); parts.push(String(len)); return parts.join(","); } /** * Decode a text fragment from URL params. * Returns { prefix, suffix, length } or null if invalid. */ export function decodeTextFragment( hl: string, ): { prefix: string; suffix: string; length: number } | null { const parts = hl.split(","); if (parts.length < 2) return null; const lengthStr = parts[parts.length - 1]; 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(","); // in case prefix itself had commas 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 to get plain text const plainParagraphs = paragraphs.map((p) => p.replace(/<[^>]*>/g, "").replace(/&[^;]+;/g, " "), ); // Build a single string with paragraph breaks const fullText = plainParagraphs.join(" "); const normalized = fullText.replace(/\s+/g, " "); const normalizedPrefix = fragment.prefix.replace(/\s+/g, " "); let searchFrom = 0; let matchStart = -1; while (searchFrom < normalized.length) { const idx = normalized.indexOf(normalizedPrefix, searchFrom); if (idx === -1) break; if (fragment.suffix) { const normalizedSuffix = fragment.suffix.replace(/\s+/g, " "); const expectedEnd = idx + fragment.length; const suffixSearchStart = Math.max( idx, expectedEnd - normalizedSuffix.length - 20, ); const suffixSearchEnd = Math.min(normalized.length, expectedEnd + 20); const searchWindow = normalized.slice(suffixSearchStart, suffixSearchEnd); if (searchWindow.includes(normalizedSuffix)) { matchStart = idx; break; } } else { matchStart = idx; break; } searchFrom = idx + 1; } if (matchStart === -1) return null; const matchEnd = Math.min(matchStart + fragment.length, normalized.length); const matched = normalized.slice(matchStart, matchEnd).trim(); if (matched.length === 0) return null; return matched; } /** * 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(); } /** * 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 with node boundaries const segments: { node: Text; start: number; end: number }[] = []; let fullText = ""; for (const node of allTextNodes) { const text = node.textContent || ""; segments.push({ node, start: fullText.length, end: fullText.length + text.length, }); fullText += text; } // Normalize whitespace for matching const normalizedFull = fullText.replace(/\s+/g, " "); // Search for the prefix in the full text const normalizedPrefix = fragment.prefix.replace(/\s+/g, " "); let searchFrom = 0; let matchStart = -1; while (searchFrom < normalizedFull.length) { const idx = normalizedFull.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, " "); const expectedEnd = idx + fragment.length; const suffixSearchStart = Math.max( idx, expectedEnd - normalizedSuffix.length - 20, ); const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20); const searchWindow = normalizedFull.slice( suffixSearchStart, suffixSearchEnd, ); if (searchWindow.includes(normalizedSuffix)) { matchStart = idx; break; } } else { // No suffix — just match prefix + approximate length matchStart = idx; break; } searchFrom = idx + 1; } if (matchStart === -1) return false; const matchEnd = Math.min( matchStart + fragment.length, normalizedFull.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; }