PageSourceSearch

https://futureupodcast.com/static/js/episode-search-highlight.js

js futureupodcast.com collected 2026-10-02 05:05:52 UTC 5,588 bytes, 187 lines download raw bytes

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.