1import {findEntries, loadReusableSearchArchive, publications, resultPage, searchEndpoints} from './research-search.js?v=3'; 2import {SNAPSHOT_MAX_AGE_MS} from './research-cache.js?v=1'; 3import {setCardImage} from './image-delivery.js?v=1'; 4 5export function initializeResearchSearch(document, window, loadArchive = loadReusableSearchArchive) { 6 const controls = document.querySelector('[data-research-controls]'); 7 const stream = document.querySelector('[data-research-stream]'); 8 if (!controls || !stream) return; 9 10 const search = controls.querySelector('[data-research-search]'); 11 const publication = controls.querySelector('[data-research-publication]'); 12 const author = controls.querySelector('[data-research-author]'); 13 const clear = document.querySelector('[data-research-clear]'); 14 const status = document.querySelector('[data-research-filter-status]'); 15 const empty = document.querySelector('[data-research-filter-empty]'); 16 const results = document.querySelector('[data-research-results]'); 17 const cards = document.querySelector('[data-research-result-cards]'); 18 const template = document.querySelector('[data-research-result-template]'); 19 const browsePagination = document.querySelector('[data-research-browse-pagination]'); 20 const pagination = document.querySelector('[data-research-result-pagination]'); 21 const previous = pagination.querySelector('[data-search-previous]'); 22 const next = pagination.querySelector('[data-search-next]'); 23 const pageLabel = pagination.querySelector('[data-search-page]'); 24 const retry = document.querySelector('[data-research-retry]'); 25 const heading = document.querySelector('[data-research-heading]'); 26 let archive; 27 let pending; 28 let failed = false; 29 let page = 1; 30 let debounce; 31 let dateFormatter; 32 let requestController; 33 let savedExpiry; 34 let warmupTimer; 35 let warmupController; 36 let warmed = false; 37 const params = new URLSearchParams(window.location.search); 38 search.value = params.get('q') || ''; 39 publication.value = publications[params.get('publication')] ? params.get('publication') : ''; 40 const requestedAuthor = params.get('author') || ''; 41 if (requestedAuthor) addAuthor(requestedAuthor); 42 author.value = requestedAuthor; 43 page = Number.parseInt(params.get('resultPage'), 10) || 1; 44 45 function active() { return Boolean(search.value.trim() || publication.value || author.value); } 46 function addAuthor(name) { 47 const option = document.createElement('option'); 48 option.value = name; 49 option.textContent = name || 'Any'; 50 author.appendChild(option); 51 } 52 function populateAuthors() { 53 const selected = author.value; 54 const names = [...new Set(archive.items.flatMap(item => item.authors))].sort((a, b) => a.localeCompare(b)); 55 if (selected && !names.includes(selected)) names.push(selected); 56 author.replaceChildren(); 57 ['', ...names].forEach(addAuthor); 58 author.value = selected; 59 } 60 function updateUrl() { 61 const url = new URL(window.location.href); 62 for (const [key, value] of Object.entries({q: search.value.trim(), publication: publication.value, 63 author: author.value, resultPage: active() && page > 1 ? String(page) : ''})) { 64 if (value) url.searchParams.set(key, value); 65 else url.searchParams.delete(key); 66 } 67 window.history.replaceState(null, '', url); 68 } 69 function resultCard(item) { 70 const card = template.content.firstElementChild.cloneNode(true); 71 card.setAttribute('data-research-source', item.source); 72 const link = card.querySelector('a'); 73 link.href = item.link; 74 if (item.source !== 'cs') { link.target = '_blank'; link.rel = 'noopener noreferrer'; } 75 card.querySelector('[data-result-source]').textContent = publications[item.source].name; 76 card.querySelector('h3').textContent = item.title; 77 card.querySelector('[data-result-authors]').textContent = item.authors.join(', '); 78 card.querySelector('[data-result-description]').textContent = item.description; 79 const date = card.querySelector('time'); 80 date.dateTime = item.publishedAt; 81 dateFormatter ||= new Intl.DateTimeFormat('en-US', {month:'short',day:'numeric',year:'numeric',timeZone:'UTC'}); 82 date.textContent = dateFormatter.format(new Date(item.publishedAt)); 83 card.querySelector('[data-result-cta]').textContent = item.source === 'cs' 84 ? 'Read research' : 'Read on Substack (opens in a new tab)'; 85 const image = card.querySelector('img'); 86 image.hidden = !item.image; 87 if (item.image) setCardImage(image, item.image); 88 card.classList.toggle('has-image', Boolean(item.image)); 89 return card; 90 } 91 function savedNotice() { 92 return failed ? 'Showing saved results. Updates could not be verified; retry to check for changes.' 93 : 'Showing saved results while checking every archive page for updatesâ¦'; 94 } 95 function receiveArchive(value) { 96 clearTimeout(savedExpiry); 97 archive = value; 98 populateAuthors(); 99 if (archive.saved && typeof window.setTimeout === 'function') { 100 savedExpiry = window.setTimeout(() => { 101 if (!archive?.saved) return; 102 archive = undefined; 103 failed = !pending; 104 render(); 105 }, Math.max(0, archive.verifiedAt + SNAPSHOT_MAX_AGE_MS - Date.now())); 106 } 107 } 108 function render() { 109 if (archive?.saved && Date.now() - archive.verifiedAt >= SNAPSHOT_MAX_AGE_MS) { 110 archive = undefined; 111 failed = !pending; 112 } 113 const searching = active(); 114 stream.hidden = searching; 115 browsePagination.hidden = searching; 116 results.hidden = !searching; 117 clear.hidden = !searching; 118 retry.hidden = !failed; 119 empty.hidden = true; 120 pagination.hidden = true; 121 heading.textContent = searching ? 'Search results' : 'Across four publications'; 122 results.setAttribute('aria-busy', String(searching && Boolean(pending))); 123 if (!searching) { 124 const items = [...stream.querySelectorAll(':scope > [data-research-item]')];
125 items.forEach((item, index) => item.classList.toggle('research-featured', index === 0)); 126 status.textContent = archive?.saved ? `${savedNotice()} You can keep browsing below.` 127 : failed ? 'Full archive search is unavailable. You can still browse the latest research below.' 128 : archive ? `Search ${archive.items.length.toLocaleString()} research posts across all four publications. Browsing newest first.` 129 : pending ? 'Preparing search across all four publications⦠You can keep browsing below.' 130 : 'Search all research across four publications by title, author, or topic.'; 131 return; 132 } 133 if (!archive) { 134 cards.replaceChildren(); 135 status.textContent = failed ? 'Could not load the complete archive. Retry search or clear the filters to keep browsing.' 136 : 'Searching the full archive across all four publicationsâ¦'; 137 return; 138 } 139 const matches = findEntries(archive.items, {query: search.value, publication: publication.value, author: author.value}); 140 const slice = resultPage(matches, page); 141 page = slice.page; 142 cards.replaceChildren(...slice.items.map(resultCard)); 143 empty.hidden = matches.length > 0; 144 status.textContent = `${slice.total.toLocaleString()} ${slice.total === 1 ? 'result' : 'results'} ${archive.saved ? 'in the saved archive' : 'across the full archive'}${search.value.trim() ? ` for â${search.value.trim()}â` : ''}. ` + 145 (slice.total ? `Showing ${slice.start}â${slice.end} · Newest first.` : 'Try another search or clear the filters.') + 146 (archive.saved ? ` ${savedNotice()}` : archive.stale ? ' Using the last saved publication archive; recent updates may be delayed.' : ''); 147 pagination.hidden = slice.pages <= 1; 148 previous.disabled = page === 1; 149 next.disabled = page === slice.pages; 150 pageLabel.textContent = `Page ${page} of ${slice.pages}`; 151 } 152 function ensureArchive({force = false, refresh = false} = {}) { 153 clearTimeout(warmupTimer); 154 if ((archive && !force && !refresh) || pending) return pending; 155 failed = false; 156 const controller = requestController = new AbortController(); 157 pending = loadArchive(window.location.origin, searchEndpoints[window.location.hostname], { 158 force, signal: controller.signal, 159 onSaved(value) { receiveArchive(value); render(); } 160 }) 161 .then(receiveArchive) 162 .catch(() => { if (!controller.signal.aborted) failed = true; }) 163 .finally(() => { pending = null; render(); updateUrl(); }); 164 render(); 165 return pending; 166 } 167 function change() { 168 clearTimeout(debounce); 169 page = 1; 170 if (active() && !archive && !pending) ensureArchive(); 171 else render(); 172 updateUrl(); 173 } 174 search.addEventListener('input', () => { 175 clearTimeout(debounce); 176 page = 1; 177 if (active()) debounce = setTimeout(change, 180); 178 else change(); 179 }); 180 search.addEventListener('focus', ensureArchive); 181 publication.addEventListener('focus', ensureArchive); 182 author.addEventListener('focus', ensureArchive); 183 publication.addEventListener('change', change); 184 author.addEventListener('change', change); 185 retry.addEventListener('click', () => ensureArchive({force: true})); 186 controls.addEventListener('pointerenter', () => { 187 const connection = window.navigator?.connection; 188 if (warmed || archive || pending || connection?.saveData || ['slow-2g', '2g'].includes(connection?.effectiveType)) return; 189 clearTimeout(warmupTimer); 190 warmupTimer = setTimeout(() => { 191 if (archive || pending || document.hidden) return; 192 const connection = window.navigator?.connection; 193 if (connection?.saveData || ['slow-2g', '2g'].includes(connection?.effectiveType)) return; 194 warmed = true; 195 warmupController = new AbortController(); 196 loadArchive(window.location.origin, searchEndpoints[window.location.hostname], {signal: warmupController.signal}).catch(() => {}); 197 }, 120); 198 }); 199 controls.addEventListener('pointerleave', () => clearTimeout(warmupTimer)); 200 clear.addEventListener('click', () => { 201 clearTimeout(debounce); 202 search.value = ''; publication.value = ''; author.value = ''; 203 change(); search.focus(); 204 }); 205 function movePage(delta) { 206 page += delta; 207 render(); updateUrl(); 208 heading.focus({preventScroll: true}); 209 heading.scrollIntoView({block: 'start'}); 210 } 211 previous.addEventListener('click', () => movePage(-1)); 212 next.addEventListener('click', () => movePage(1)); 213 document.addEventListener('keydown', event => { 214 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { 215 event.preventDefault(); search.focus(); 216 } 217 }); 218 new window.MutationObserver(() => { if (!active()) render(); }).observe(stream, {childList: true}); 219 window.addEventListener?.('pagehide', () => { 220 clearTimeout(warmupTimer); 221 clearTimeout(savedExpiry); 222 requestController?.abort(); 223 warmupController?.abort(); 224 }); 225 window.addEventListener?.('pageshow', event => { 226 if (!event.persisted || (!archive && !active())) return; 227 if (archive) receiveArchive({...archive, saved: true}); 228 const refresh = () => ensureArchive({refresh: true}); 229 if (pending) pending.finally(refresh); 230 else refresh(); 231 }); 232 controls.hidden = false;
233 status.hidden = false; 234 render(); 235 if (active()) ensureArchive(); 236} 237 238if (typeof document !== 'undefined') initializeResearchSearch(document, window);
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.