// Shared client-side news search over /search.json (no dependencies, no modules). // Used by the header search (field + mobile reveal) and /search (full results). // Focused-empty state per Baymard autocomplete guidance: recent searches (local // only — localStorage, no network) plus the newest articles, never a void. (function () { const RKEY = 'ctao-recent-searches'; function recents() { try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch { return []; } } function remember(q) { q = (q || '').trim(); if (q.length < 2) return; try { const r = [q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 4); localStorage.setItem(RKEY, JSON.stringify(r)); } catch { /* private mode — recents are a nicety */ } } window.newsSearch = function (opts) { const input = document.getElementById(opts.input); const list = document.getElementById(opts.list); const status = document.getElementById(opts.status); // Null-safe: a page may render only some search surfaces — bail silently // (returning a no-op) so one missing input can't kill later inits. if (!input || !list || !status) return function () {}; let index = null; let timer; async function ensureIndex() { if (!index) index = await (await fetch('/search.json')).json(); return index; } // First title match wrapped in (Starlight search pattern) — built // from text nodes, never innerHTML. function highlight(text, q) { const i = text.toLowerCase().indexOf(q); if (i < 0) return [text]; const mark = document.createElement('mark'); mark.textContent = text.slice(i, i + q.length); return [text.slice(0, i), mark, text.slice(i + q.length)]; } function label(text) { const li = document.createElement('li'); li.className = 'suggest-label'; li.setAttribute('aria-hidden', 'true'); // the status line announces it li.textContent = text; return li; } function row(children, href, q) { const li = document.createElement('li'); const a = document.createElement('a'); a.href = href; if (q) a.dataset.q = q; a.append(...children); li.append(a); return li; } // Focused-empty state: up to 4 recent queries, topped up to 5 rows with // the newest articles from the same index. async function idle() { if (input.value.trim().length >= 2) return; list.innerHTML = ''; const r = recents(); if (r.length) { list.append(label('Recent')); for (const term of r) list.append(row([term], '/search?q=' + encodeURIComponent(term), term)); } const fresh = (await ensureIndex()).slice(0, Math.max(1, 5 - r.length)); if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list list.append(label('Latest news')); for (const p of fresh) { const li = row([p.title], '/news/' + encodeURIComponent(p.slug)); const small = document.createElement('small'); small.textContent = p.date; li.append(small); list.append(li); } status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news'); } async function run() { const q = input.value.trim().toLowerCase(); if (q.length < 2) { list.innerHTML = ''; idle(); return; } list.innerHTML = ''; const idx = await ensureIndex(); const hits = idx .filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q)) .slice(0, opts.limit); const unit = opts.unit || 'result'; status.textContent = hits.length ? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”' : 'No results for “' + input.value.trim() + '” — try a shorter or different term.'; for (const p of hits) { const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug)); const small = document.createElement('small'); small.textContent = p.date; li.append(small); list.append(li); } } input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); }); input.addEventListener('focus', () => { if (input.value.trim().length < 2) idle(); }); // Remember successful queries: Enter (form submit) and suggestion clicks if (input.form) input.form.addEventListener('submit', () => remember(input.value)); list.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a'); remember((a && a.dataset.q) || input.value); }); return run; // callers may run() immediately (e.g. /search?q=…) }; })();