1/** 2 * Episode page search deep-link highlighter (Alpine.js component). 3 * 4 * Expects URL params: 5 * q â original search query 6 * snippet â matched passage text from semantic/keyword search 7 * 8 * Finds the best matching block in the episode body, highlights it, and 9 * scrolls it into view. 10 */ 11function episodeSearchHighlight() { 12 return { 13 query: '', 14 snippet: '', 15 found: false, 16 matchLabel: '', 17 18 init() { 19 const params = new URLSearchParams(window.location.search); 20 this.query = (params.get('q') || '').trim(); 21 this.snippet = (params.get('snippet') || '').trim(); 22 if (!this.query && !this.snippet) { 23 return; 24 } 25 this.$nextTick(() => { 26 // Allow StreamField / fonts to settle before measuring layout. 27 requestAnimationFrame(() => this.highlight()); 28 }); 29 }, 30 31 normalize(value) { 32 return (value || '') 33 .replace(/[\u2018\u2019]/g, "'") 34 .replace(/[\u201C\u201D]/g, '"') 35 .replace(/[\u2013\u2014]/g, '-') 36 .replace(/\s+/g, ' ') 37 .trim() 38 .toLowerCase(); 39 }, 40 41 candidateBlocks(root) { 42 return Array.from( 43 root.querySelectorAll( 44 'p, li, blockquote, h1, h2, h3, h4, h5, h6, .text-component__block' 45 ) 46 ).filter((el) => (el.textContent || '').trim().length > 0); 47 }, 48 49 findBestBlock(root) { 50 const snippetNorm = this.normalize(this.snippet); 51 const queryNorm = this.normalize(this.query); 52 const blocks = this.candidateBlocks(root); 53 if (!blocks.length) { 54 return null; 55 } 56 57 if (snippetNorm) { 58 // Exact / prefix match against the returned passage. 59 const prefixes = [ 60 snippetNorm, 61 snippetNorm.slice(0, 160), 62 snippetNorm.slice(0, 100), 63 snippetNorm.slice(0, 60), 64 ].filter((p, idx, arr) => p && p.length >= 24 && arr.indexOf(p) === idx); 65 66 for (const prefix of prefixes) { 67 for (const el of blocks) { 68 if (this.normalize(el.textContent).includes(prefix)) { 69 return el; 70 } 71 } 72 } 73 } 74 75 // Fall back: score blocks by query-term overlap. 76 const terms = queryNorm 77 .split(/[^a-z0-9']+/) 78 .filter((t) => t.length > 2); 79 if (!terms.length) { 80 return null; 81 } 82 83 let best = null; 84 let bestScore = 0; 85 for (const el of blocks) { 86 const hay = this.normalize(el.textContent); 87 let score = 0; 88 for (const term of terms) { 89 if (hay.includes(term)) { 90 score += term.length; 91 } 92 } 93 if (score > bestScore) { 94 bestScore = score; 95 best = el; 96 } 97 } 98 return bestScore > 0 ? best : null; 99 }, 100 101 escapeRegExp(value) { 102 return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 103 }, 104 105 highlightTermsInElement(el) { 106 const terms = this.normalize(this.query) 107 .split(/[^a-z0-9']+/) 108 .filter((t) => t.length > 2); 109 if (!terms.length) { 110 return; 111 } 112 113 const pattern = new RegExp(`\\b(${terms.map((t) => this.escapeRegExp(t)).join('|')})\\b`, 'gi'); 114 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); 115 const textNodes = []; 116 while (walker.nextNode()) { 117 textNodes.push(walker.currentNode); 118 } 119 120 textNodes.forEach((node) => { 121 const text = node.nodeValue; 122 if (!text || !pattern.test(text)) { 123 return; 124 } 125 pattern.lastIndex = 0; 126 const frag = document.createDocumentFragment(); 127 let lastIndex = 0; 128 let match; 129 while ((match = pattern.exec(text)) !== null) { 130 if (match.index > lastIndex) { 131 frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); 132 } 133 const mark = document.createElement('mark'); 134 mark.className = 'episode-search-mark'; 135 mark.textContent = match[0]; 136 frag.appendChild(mark); 137 lastIndex = match.index + match[0].length; 138 } 139 if (lastIndex < text.length) { 140 frag.appendChild(document.createTextNode(text.slice(lastIndex))); 141 } 142 node.parentNode.replaceChild(frag, node); 143 }); 144 }, 145 146 highlight() { 147 const root = this.$refs.content; 148 if (!root) { 149 return; 150 } 151 152 const block = this.findBestBlock(root); 153 if (!block) { 154 this.found = false;
155 this.matchLabel = this.query 156 ? `No on-page passage match for â${this.query}â.` 157 : ''; 158 return; 159 } 160 161 block.classList.add('episode-search-hit'); 162 block.setAttribute('id', 'search-match'); 163 this.highlightTermsInElement(block); 164 165 this.found = true; 166 this.matchLabel = this.query 167 ? `Showing the passage that matched â${this.query}â.` 168 : 'Showing the matched passage.'; 169 170 // Offset for sticky header if present. 171 const header = document.querySelector('.f-header'); 172 const offset = header ? header.getBoundingClientRect().height + 16 : 80; 173 const top = block.getBoundingClientRect().top + window.scrollY - offset; 174 window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); 175 }, 176 177 dismiss() { 178 const url = new URL(window.location.href); 179 url.searchParams.delete('q'); 180 url.searchParams.delete('snippet'); 181 window.history.replaceState({}, '', url.pathname + url.search + url.hash); 182 this.query = ''; 183 this.snippet = ''; 184 this.found = false; 185 }, 186 }; 187}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.