PageSourceSearch

https://broadbandswitch.uk/assets/js/site-search.js

js broadbandswitch.uk collected 2026-10-04 08:38:39 UTC 48,442 bytes, 826 lines download raw bytes

1/*!
2 * BroadbandSwitch.uk  |  site-search.js  v2  (29 September 2026)
3 * ----------------------------------------------------------------
4 * One file, two jobs, same engine.
5 *
6 *   1.  Header overlay on every page.  Opens from any [data-site-search-open]
7 *       trigger, from any header link to /sitemap.html, from the "/" key and
8 *       from Ctrl/Cmd+K.  Ranked results as you type.
9 *   2.  On /sitemap.html it drives the on-page search: filters the server-rendered
10 *       list, adds section filters, an A to Z for the provider directory, and reads
11 *       ?q= from the URL.
12 *
13 * New in v2
14 *   - Typo tolerance.  A word the site never uses is corrected outright
15 *     ("brodband speed tset" shows results for "broadband speed test", with a
16 *     link to search as typed).  A plausible word with a better neighbour is
17 *     offered as "Did you mean ...?".  Provider names are corrected as a phrase
18 *     ("virgin meida" -> "virgin media").
19 *   - Intent cards.  A postcode opens the comparison tool for that postcode; a
20 *     provider name shows its deals page, directory profile and head-to-heads; a
21 *     town shows its local deals page.
22 *   - Rare words outrank common ones ("hyperoptic deals" ranks Hyperoptic pages
23 *     above generic deals pages).
24 *   - Stop words are ignored ("cancel my contract").
25 *   - Recent searches (this browser only) and popular searches on an empty box.
26 *   - Section filters, counts and an A to Z on the browse page.
27 *   - Styles for the new pieces are injected by this file, so a cached copy of
28 *     site-search.css can never leave them unstyled.
29 *
30 * Progressive enhancement throughout.  If the index cannot load, every entry
31 * point still lands on /sitemap.html?q=... and the static list is fully usable
32 * with a plain substring filter.
33 *
34 * No dependencies.  Safe to load with defer.
35 */
36(function () {
37  'use strict';
38
39  var INDEX_URL = '/search-index.json';
40  var SEARCH_PAGE = '/sitemap.html';
41  var CACHE_KEY = 'bsu:search-index:v2';
42  var RECENT_KEY = 'bsu:search-recent:v1';
43  var MAX_RESULTS = 60;
44  var MAX_OVERLAY = 8;
45  var POPULAR = ['BT vs Sky', 'social tariff', 'speed test', 'exit fees', 'full fibre', 'router login', 'cancel contract early', 'price rises 2026'];
46
47  var index = null;      // prepared index
48  var loading = null;
49  var overlay = null;
50  var els = {};
51  var activeIndex = -1;
52  var lastFocus = null;
53  var results = [];
54
55  var reduceMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false;
56
57  /* ---------------------------------------------------------------- *
58   * Language helpers
59   * ---------------------------------------------------------------- */
60
61  /* Customer language to site language.  Each key expands into extra tokens that also count as a match. */
62  var SYN = {
63    change: ['switch'], changing: ['switch'], swap: ['switch'], swapping: ['switch'], move: ['switch', 'moving'],
64    leave: ['switch', 'exit', 'leave'], leaving: ['switch', 'exit'], cancel: ['exit', 'cancel', 'cooling', 'early'],
65    cancelling: ['exit', 'cooling', 'early'], quit: ['switch', 'exit'], internet: ['broadband'],
66    wifi: ['wi-fi', 'wifi', 'router'], 'wi-fi': ['wifi', 'router'], cheap: ['cheapest', 'under', 'value', 'cheap'],
67    affordable: ['cheapest', 'under', 'social'], budget: ['cheapest', 'under'], price: ['cost', 'price', 'rise'],
68    prices: ['cost', 'price', 'rises'], bill: ['cost', 'price', 'bill'], bills: ['cost', 'price', 'rises'],
69    supplier: ['provider'], company: ['provider'], isp: ['provider', 'providers', 'isp'], fast: ['speed', 'gigabit', 'fast'],
70    quick: ['speed'], slow: ['speed', 'slow', 'slower'], elderly: ['pensioners', 'older'], oap: ['pensioners'],
71    retired: ['pensioners'], tenant: ['renters', 'rented', 'renting'], renting: ['renters', 'rented'], rent: ['renters', 'rented'],
72    television: ['tv'], feedback: ['contact', 'corrections'], complaint: ['complaint', 'complaints', 'compensation', 'ombudsman'],
73    complain: ['complaint', 'complaints', 'compensation'], help: ['help', 'contact', 'guide'], deal: ['deals'], offer: ['deal
73s'],
74    offers: ['deals'], fibre: ['fibre', 'fttp', 'full'], fiber: ['fibre', 'fttp'], gig: ['gigabit'], speeds: ['speed'],
75    drops: ['drop', 'dropping', 'drops'], dropping: ['drop', 'drops'], buffering: ['bufferbloat', 'slow', 'speed'],
76    lag: ['latency', 'ping'], gaming: ['gaming', 'latency', 'ping'], kids: ['children', 'parental', 'online safety'],
77    children: ['kids', 'parental'], student: ['students'], pensioner: ['pensioners'], landlord: ['renters', 'rented', 'tenant'],
78    ots: ['one touch switch'], pstn: ['landline', 'switch-off'], router: ['router', 'wi-fi']
79  };
80  var STOP = {};
81  'a an the my me i is it to for in of on and or do can how what why with at be am are was get got from about into vs v your you we our this that does did should will would need'.split(' ').forEach(function (w) { STOP[w] = true; });
82
83  function norm(s) {
84    return String(s || '').toLowerCase()
85      .normalize('NFD').replace(/[̀-ͯ]/g, '')
86      .replace(/[^a-z0-9£%+ -]+/g, ' ').replace(/-/g, ' ')
87      .replace(/\s+/g, ' ').trim();
88  }
89  function toksAll(s) { return norm(s).split(' ').filter(Boolean); }
90  function toks(s) {
91    var t = toksAll(s), k = t.filter(function (w) { return !STOP[w]; });
92    return k.length ? k : t;
93  }
94  function expand(t) {
95    var out = [t];
96    (SYN[t] || []).forEach(function (x) { x.split(' ').forEach(function (y) { out.push(y); }); });
97    return out;
98  }
99  function escapeHtml(s) {
100    return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; });
101  }
102  function trim(s, n) { s = String(s); return s.length <= n ? s : s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…'; }
103  function highlight(text, q) {
104    var safe = escapeHtml(text);
105    var t = toks(q).filter(function (x) { return x.length > 1; });
106    if (!t.length) return safe;
107    try {
108      var rx = new RegExp('(' + t.map(function (x) { return x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ')', 'gi');
109      return safe.replace(rx, '<mark>$1</mark>');
110    } catch (e) { return safe; }
111  }
112
113  /* ---------------------------------------------------------------- *
114   * Index loading and preparation
115   * ---------------------------------------------------------------- */
116
117  function loadIndex() {
118    if (index) return Promise.resolve(index);
119    if (loading) return loading;
120    try {
121      var cached = sessionStorage.getItem(CACHE_KEY);
122      if (cached) { index = prepare(JSON.parse(cached)); return Promise.resolve(index); }
123    } catch (e) { /* storage unavailable */ }
124    loading = fetch(INDEX_URL, { credentials: 'same-origin' })
125      .then(function (r) { if (!r.ok) throw new Error('index ' + r.status); return r.json(); })
126      .then(function (data) {
127        try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)); } catch (e) { /* ignore */ }
128        index = prepare(data);
129        return index;
130      })
131      .catch(function () { index = null; loading = null; return null; });
132    return loading;
133  }
134
135  function prepare(data) {
136    if (!data || !data.pages) return null;
137    var vocab = {};
138    var providers = [];
139    data.pages.forEach(function (p, i) {
140      p.i = i;
141      var slug = p.u.replace(/[/.]/g, ' ').replace(/-/g, ' ');
142      p._t = norm(p.t);
143      p._h = norm([p.t, p.d || '', p.h || '', p.k || '', p.s, slug, (p.a || []).join(' ')].join(' '));
144      p._tt = {};
145      toksAll(p.t).forEach(function (w) { p._tt[w] = true; });
146      var seen = {};
147      toksAll(p._h).forEach(function (w) { if (w.length > 2 && !seen[w]) { seen[w] = true; vocab[w] = (vocab[w] || 0) + 1; } });
148      if (p.s === 'Provider directory') providers.push({ name: p.t.split(':')[0].trim(), norm: norm(p.t.split(':')[0]), page: p });
149    });
150    data._vocab = vocab;
151    data._vocabList = Object.keys(vocab).map(function (w) { return { w: w, f: vocab[w], l: w.length }; });
152    data._providers = providers;
153    data._deals = data.pages.filter(function (p) { return p.s === 'Providers & head-to-heads' && /-broadband-deals\.html$/.test(p.u) && !/-vs-/.test(p.u); });
154    data._h2h = data.pages.filter(function (p) { return /-vs-/.test(p.u); });
155    data._brands = data._deals.map(function (d) {
156      var name = d.t.replace(/\s+broadband deals.*$/i, '').replace(/\s+deals.*$/i, '').replace(/:.*$/, '').trim();
157      return { name: name, norm: norm(name) };
158    });
159    data._brands.forEach(function (b) {
160      var f = b.norm.split(' ')[0];
161      b.unique = data._brands.filter(function (x) { return x.norm.split(' ')[0] === f; }).length === 1 && f.length >= 2;
162    });
163    data._locations = data.pages.filter(function (p) { return p.s === 'Locations' && /-broadband-deals\.html$/.test(p.u); })
164      .map(function (p) { return { town: norm(p.t.replace(/broadband deals.*/i, '').trim()), page: p }; });
165    return data;
166  }
167
168  /* ---------------------------------------------------------------- *
169   * Typo tolerance: bounded Damerau-Levenshtein against the site vocabulary
170   * ---------------------------------------------------------------- */
171
172  function dl(a, b, max) {
173    var la = a.length, lb = b.length;
174    if (Math.abs(la - lb) > max) return max + 1;
175    var d = [], i, j;
176    for (i = 0; i <= la; i++) { d[i] = [i]; }
177    for (j = 1; j <= lb; j++) d[0][j] = j;
178    for (i = 1; i <= la; i++) {
179      var rowMin = Infinity;
180      for (j = 1; j <= lb; j++) {
181        var cost = a[i - 1] === b[j - 1] ? 0 : 1;
182        var v = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
183        if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, d[i - 2][j - 2] + 1);
184        d[i][j] = v; if (v < rowMin) rowMin = v;
185      }
186      if (rowMin > max) return max + 1;
187    }
188    return d[la][lb];
189  }
190  function maxEdits(w) { return w.length <= 3 ? 0 : w.length <= 5 ? 1 : w.length <= 9 ? 2 : 3; }
191  function known(t) {
192    if (!index) return true;
193    if (index._vocab[t]) return true;
194    if (t.length >= 4) { for (var i = 0; i < index._vocabList.length; i++) if (index._vocabList[i].w.indexOf(t) === 0) return true; }
195    return false;
196  }
197  function correctToken(t) {
198    if (!index || /^\d/.test(t) || t.length < 3) return null;
199    var max = maxEdits(t);
200    if (!max) return null;
201    var best = null;
202    for (var i = 0; i < index._vocabList.length; i++) {
203      var v = index._vocabList[i];
204      if (Math.abs(v.l - t.length) > max) continue;
205      var e = dl(t, v.w, max);
206      if (e > max) continue;
207      var score = e * 10 - Math.log(v.f + 1) - (v.w[0] === t[0] ? 2 : 0);
208      if (!best || score < best.score) best = { w: v.w, e: e, score: score };
209    }
210    return best ? best.w : null;
211  }
212  function correctPhrase(q) {
213    if (!index) return null;
214    var nq = norm(q);
215    if (nq.length < 4) return null;
216    var best = null, max = maxEdits(nq);
217    for (var i = 0; i < index._providers.length; i++) {
218      var pr = index._providers[i];
219      if (Math.abs(pr.norm.length - nq.length) > max + 1) continue;
220      var e = dl(nq, pr.norm, max + 1);
221      if (e <= max + 1 && e > 0 && (!best || e < best.e)) best = { w: pr.norm, e: e };
222    }
223    return best ? best.w : null;
224  }
225  function suggest(q) {
226    var t = toks(q);
227    if (!t.length) return null;
228    var fixed = t.map(function (w) { return known(w) ? w : (correctToken(w) || w); });
229    if (fixed.join(' ') !== t.join(' ')) return fixed.join(' ');
230    var pc = correctPhrase(q);
231    if (pc && pc !== norm(q)) return pc;
232    return null;
233  }
234
235  /* ---------------------------------------------------------------- *
236   * Ranking
237   * ---------------------------------------------------------------- */
238
239  function rarity(t) {
240    var df = index._vocab[t] || 0;
241    return df ? 1 + Math.max(0, 2.6 - Math.log(df + 1) / Math.LN10) : 1.6;
242  }
243  function titleHasPrefix(p, a) {
244    if (a.length <= 3) return false;
245    for (var w in p._tt) if (w.indexOf(a) === 0) return true;
246    return false;
247  }
248  function search(rawQuery) {
249    if (!index) return [];
250    var nq = norm(rawQuery);
251    if (!nq) return [];
252    var tokens = toks(rawQuery);
253    var out = [];
254    for (var i = 0; i < index.pages.length; i++) {
255      var p = index.pages[i];
256      var score = 0;
257      if (p._t === nq) score += 1000;
258      else if (p._t.indexOf(nq) === 0) score += 400;
259      else if (p._t.indexOf(nq) !== -1) score += 250;
260      if (p.a && p.a.some(function (a) { return norm(a).indexOf(nq) !== -1; })) score += 220;
261      var inTitle = 0, inHay = 0, tW = 0, hW = 0;
262      for (var t = 0; t < tokens.length; t++) {
263        var alts = expand(tokens[t]);
264        var r = rarity(tokens[t]);
265        var hitT = false, hitP = false, hitH = false;
266        for (var k = 0; k < alts.length; k++) {
267          if (p._tt[alts[k]]) hitT = true;
268          else if (!hitT && titleHasPrefix(p, alts[k])) hitP = true;
269          if (p._h.indexOf(alts[k]) !== -1) hitH = true;
270        }
271        if (hitT) { inTitle++; tW += r; } else if (hitP) { inTitle += 0.7; tW += r * 0.7; }
272        if (hitH) { inHay++; hW += r; }
273      }
274      if (inHay === 0 && score === 0) continue;
275      score += (inTitle >= tokens.length ? 160 : 0) + tW * 40;
276      score += (inHay >= tokens.length ? 60 : 0) + hW * 12;
277      if (inHay < tokens.length && tokens.length > 1 && inTitle < 1) continue; /* every word must land somewhere */
278      score += (p.w || 1) * 10;
279      if (p.s === 'Provider directory') score -= 25; /* profiles rank below guides unless named */
280      out.push({ p: p, score: score });
281    }
282    out.sort(function (a, b) {
283      if (b.score !== a.score) return b.score - a.score;
284      if ((b.p.w || 1) !== (a.p.w || 1)) return (b.p.w || 1) - (a.p.w || 1);
285      return a.p.t.localeCompare(b.p.t, 'en-GB');
286    });
287    return out.map(function (r) { return r.p; });
288  }
289
290  /* resolve: returns {results, shownQuery, dym, mode} where mode is exact | auto | suggest */
291  function resolve(rawQ) {
292    var q = rawQ.trim();
293    if (!q) return { results: [], shownQuery: '', dym: null, mode: 'exact' };
294    var primary = search(q);
295    var alt = suggest(q);
296    if (alt) {
297      var altRes = search(alt);
298      var unknown = toks(q).some(function (w) { return !known(w) && !/^\d/.test(w) && w.length > 2; });
299      if (altRes.length && (unknown || primary.length < 3)) return { results: altRes, shownQuery: alt, dym: q, mode: 'auto' };
300      if (altRes.length > primary.length) return { results: primary, shownQuery: q, dym: alt, mode: 'suggest' };
301    }
302    return { results: primary, shownQuery: q, dym: null, mode: 'exact' };
303  }
304
305  /* ---------------------------------------------------------------- *
306   * Intent cards: postcode, provider, town
307   * ---------------------------------------------------------------- */
308
309  var POSTCODE = /^[a-z]{1,2}\d[a-z\d]?\s*\d?[a-z]{0,2}$/i;
310  var ICONS = {
311    postcode: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 11-8 11S4 16 4 10a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>',
312    provider: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M8 15h4"/></svg>',
313    town: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6"/></svg>'
314  };
315  function intents(q) {
316    if (!index) return [];
317    var cards = [];
318    var raw = q.trim();
319    var nq = norm(q);
320    if (POSTCODE.test(raw) && raw.replace(/\s/g, '').length >= 5) {
321      var pc = raw.toUpperCase().replace(/\s+/g, ' ');
322      cards.push({ kind: 'postcode', title: 'Compare live deals at ' + pc, sub: 'Every provider and price available at your address, cheapest first.', href: '/compare/?postcode=' + encodeURIComponent(pc), cta: 'Check this postcode' });
323      return cards;
324    }
325    var loc = null;
326    for (var i = 0; i < index._locations.length; i++) {
327      var l = index._locations[i];
328      if (l.town === nq || (nq.length >= 4 && l.town.indexOf(nq) === 0 && l.town.split(' ')[0] === nq)) { loc = l; break; }
329    }
330    if (loc) {
331      cards.push({ kind: 'town', title: 'Broadband deals in ' + loc.page.t.replace(/ broadband deals.*$/i, ''), sub: 'Providers, speeds and prices available locally, then check your exact postcode.', href: loc.page.u, cta: 'See local deals' });
332      return cards;
333    }
334    var core = nq.replace(/\b(deals?|broadband|review|reviews|offers?|prices?|internet|fibre)\b/g, ' ').replace(/\s+/g, ' ').trim();
335    var brand = null;
336    if (core) {
337      for (var b = 0; b < index._brands.length; b++) {
338        var br = index._brands[b];
339        if (br.norm === core || br.norm === core + ' broadband' || (core.length >= 3 && br.norm.split(' ')[0] === core && br.unique)) { brand = br; break; }
340      }
341    }
342    var pr = null;
343    for (var j = 0; j < index._providers.length; j++) {
344      var x = index._providers[j];
345      if (x.norm === nq || x.norm === core) { pr = x; break; }
346    }
347    if (!pr && brand) {
348      for (var k = 0; k < index._providers.length; k++) {
349        var y = index._providers[k];
350        if (y.norm === brand.norm || y.norm.split(' ')[0] === brand.norm.split(' ')[0]) { pr = y; break; }
351      }
352    }
353    if (pr || brand) {
354      var name = pr ? pr.name : brand.name;
355      var first = norm(name).split(' ')[0];
356      var deal = null;
357      for (var d = 0; d < index._deals.length; d++) { var dd = index._deals[d]; if (norm(dd.t).indexOf(first) === 0 || norm(dd.u).indexOf('/' + first + ' ') !== -1 || dd.u.indexOf('/' + first + '-') === 0) { deal = dd; break; } }
358      var h2h = index._h2h.filter(function (h) { return norm(h.t).split(' ').indexOf(first) !== -1; }).slice(0, 3);
359      var profile = pr ? pr.page : null;
360      var links = [];
361      if (deal && profile) links.push({ t: 'Directory profile', u: profile.u });
362      h2h.forEach(function (h) { links.push({ t: h.t.replace(/[:.].*$/, '').trim(), u: h.u }); });
363      cards.push({ kind: 'provider', title: name, sub: deal ? "This month's deals, the real 24-month cost and the directory profile." : 'Directory profile: contact details, coverage, verification and Trustpilot rating.', href: deal ? deal.u : (profile ? profile.u : '/broadband-providers.php'), cta: deal ? 'See deals' : 'Open profile', links: links });
364    }
365    return cards;
366  }
367  function renderCards(cards) {
368    return cards.map(function (c) {
369      return '<div class="bsu-card"><div class="bsu-card__ic">' + ICONS[c.kind] + '</div><div class="bsu-card__body"><b>' + escapeHtml(c.title) + '</b><span>' + escapeHtml(c.sub) + '</span>' +
370        (c.links && c.links.length ? '<div class="bsu-card__links">' + c.links.map(function (l) { return '<a href="' + escapeHtml(l.u) + '">' + escapeHtml(l.t) + '</a>'; }).join('') + '</div>' : '') +
371        '</div><a class="bsu-card__go" href="' + escapeHtml(c.href) + '">' + escapeHtml(c.cta) + '</a></div>';
372    }).join('');
373  }
374
375  /* ---------------------------------------------------------------- *
376   * Recent searches (per browser), analytics
377   * ---------------------------------------------------------------- */
378
379  function getRecent() { try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch (e) { return []; } }
380  function addRecent(q) { try { var r = getRecent().filter(function (x) { return x !== q; }); r.unshift(q); localStorage.setItem(RECENT_KEY, JSON.stringify(r.slice(0, 6))); } catch (e) { /* ignore */ } }
381  function track(event, q, extra) {
382    try {
383      var payload = { event: event, search_term: q };
384      if (extra) for (var k in extra) payload[k] = extra[k];
385      if (window.dataLayer) window.dataLayer.push(payload);
386      else if (typeof window.gtag === 'function') window.gtag('event', event, payload);
387    } catch (e) { /* analytics must never break search */ }
388  }
389
390  /* ---------------------------------------------------------------- *
391   * Styles for the v2 pieces (injected so a cached stylesheet cannot strand them)
392   * ---------------------------------------------------------------- */
393
394  function injectStyles() {
395    if (document.getElementById('bsu-search-v2-css')) return;
396    var css = [
397      '.bsu-search__dym,.sm-notice{display:flex;gap:.5rem;align-items:flex-start;margin:.625rem 1rem 0;padding:.625rem .75rem;border-radius:10px;background:#fef3e2;color:#1c1524;font-size:.875rem;line-height:1.45}',
398      '.sm-notice{margin:.75rem 0 0}',
399      '.bsu-search__dym[hidden],.sm-notice[hidden]{display:none}',
400      '.bsu-search__dym svg,.sm-notice svg{color:#b45309;flex:0 0 auto;margin-top:.1rem}',
401      '.bsu-search__dym a,.sm-notice a{color:var(--bsu-brand,var(--brand,#E50571));font-weight:600}',
402      '.sm-notice .sm-notice__sub{display:block;color:#5d5566;font-size:.8125rem;margin-top:.15rem}',
403      '.bsu-search__intent{margin:.625rem 1rem 0;display:grid;gap:.5rem}',
404      '.sm-intent{margin:.75rem 0 0;display:grid;gap:.625rem}',
405      '.bsu-card{border:1px solid var(--bsu-line,#e6e0ea);border-left:4px solid var(--bsu-brand,var(--brand,#E50571));border-radius:12px;padding:.75rem .875rem;background:var(--bsu-surface,#fff);display:flex;gap:.75rem;
405align-items:center;flex-wrap:wrap}',
406      '.bsu-card__ic{width:38px;height:38px;border-radius:10px;background:#fde7f1;display:grid;place-items:center;color:#b0045a;flex:0 0 auto}',
407      '.bsu-card__body{flex:1 1 200px;min-width:0}.bsu-card__body b{display:block;font-weight:600}.bsu-card__body span{color:#5d5566;font-size:.8125rem;display:block}',
408      '.bsu-card__links{display:flex;gap:.75rem;flex-wrap:wrap;font-size:.8125rem;margin-top:.25rem}.bsu-card__links a{color:var(--bsu-brand,var(--brand,#E50571));font-weight:600}',
409      '.bsu-card__go{background:var(--bsu-brand,var(--brand,#E50571));color:#fff !important;border-radius:999px;padding:.5rem 1rem;font-weight:600;font-size:.8125rem;text-decoration:none;white-space:nowrap}',
410      '.bsu-card__go:hover{background:#b0045a}',
411      '.bsu-search__start{padding:.25rem 1rem .875rem}.bsu-search__start h4{margin:.75rem 0 .375rem;font-size:.6875rem;letter-spacing:.09em;text-transform:uppercase;color:#8e8497;font-weight:600}',
412      '.bsu-chips{display:flex;flex-wrap:wrap;gap:.5rem}.bsu-chip{border:1px solid var(--bsu-line,#e6e0ea);background:var(--bsu-surface,#fff);border-radius:999px;padding:.3rem .75rem;font:inherit;font-size:.8125rem;color:#1c1524;cursor:pointer}.bsu-chip:hover{border-color:var(--bsu-brand,#E50571);color:#b0045a}',
413      '.sm-filters{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0 .25rem}.sm-filters[hidden]{display:none}',
414      '.sm-filters button{border:1px solid var(--bsu-line,#e6e0ea);background:var(--bsu-surface,#fff);border-radius:999px;padding:.3rem .75rem;font:inherit;font-size:.8125rem;color:#5d5566;cursor:pointer;display:inline-flex;gap:.4rem;align-items:center}',
415      '.sm-filters button b{font-weight:500;color:#8e8497;font-variant-numeric:tabular-nums}',
416      '.sm-filters button[aria-pressed="true"]{background:#2b1030;border-color:#2b1030;color:#fff}.sm-filters button[aria-pressed="true"] b{color:rgba(255,255,255,.7)}',
417      '.sm-az{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0 .75rem}.sm-az button{border:0;background:#efeaf2;border-radius:6px;min-width:30px;height:28px;font:inherit;font-size:.8125rem;font-weight:500;color:#5d5566;cursor:pointer;padding:0 .4rem}.sm-az button[aria-pressed="true"]{background:var(--bsu-brand,#E50571);color:#fff}',
418      '.sm-recent{display:flex;flex-wrap:wrap;gap:.5rem .875rem;align-items:center;font-size:.8125rem;color:#5d5566;margin:.5rem 0 0}.sm-recent[hidden]{display:none}.sm-recent button{border:0;background:#efeaf2;border-radius:999px;padding:.25rem .7rem;font:inherit;font-size:.8125rem;color:#1c1524;cursor:pointer}',
419      '.sm-item mark{background:#fde7f1;color:inherit;border-radius:3px;padding:0 1px}',
420      '.sm-top{border:1px solid var(--bsu-line,#e6e0ea);border-radius:12px;padding:.75rem 1rem;background:var(--bsu-surface,#fff)}.sm-top h4{margin:0 0 .4rem;font-size:.6875rem;letter-spacing:.09em;text-transform:uppercase;color:#8e8497;font-weight:600}.sm-top ol{margin:0;padding-left:1.25rem;display:grid;gap:.3rem}.sm-top a{font-weight:600}.sm-top__s{display:block;font-size:.75rem;color:#8e8497}',
421      '.sm-group__title .sm-group__count{font-variant-numeric:tabular-nums}',
422      '.sm-clear{border:0;background:#efeaf2;border-radius:999px;width:28px;height:28px;display:grid;place-items:center;color:#5d5566;cursor:pointer;flex:0 0 auto}.sm-clear[hidden]{display:none}',
423      'input.sm-search__input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;display:none}',
424      '@media (prefers-color-scheme: dark){html[data-theme="dark"] .bsu-search__dym,html[data-theme="dark"] .sm-notice{background:#3a2a14;color:#f4eef7}html[data-theme="dark"] .bsu-card__body span,html[data-theme="dark"] .sm-notice .sm-notice__sub{color:#a99fb2}}'
425    ].join('\n');
426    var st = document.createElement('style');
427    st.id = 'bsu-search-v2-css';
428    st.textContent = css;
429    document.head.appendChild(st);
430  }
431
432  /* ---------------------------------------------------------------- *
433   * Overlay
434   * ---------------------------------------------------------------- */
435
436  var SVG_ICON = '<svg class="bsu-search__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"/><line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
437  var SVG_REDO = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>';
438  var SVG_INFO = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>';
439
440  function buildOverlay() {
441    if (overlay) return overlay;
442    injectStyles();
443    overlay = document.createElement('div');
444    overlay.className = 'bsu-search';
445    overlay.hidden = true;
446    overlay.setAttribute('role', 'dialog');
447    overlay.setAttribute('aria-modal', 'true');
448    overlay.setAttribute('aria-label', 'Search BroadbandSwitch.uk');
449    if (reduceMotion) overlay.setAttribute('data-reduce-motion', 'true');
450    overlay.innerHTML =
451      '<div class="bsu-search__backdrop" data-close></div>' +
452      '<div class="bsu-search__panel">' +
453        '<div class="bsu-search__bar">' + SVG_ICON +
454          '<input class="bsu-search__input" type="search" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" ' +
455            'placeholder="Search a provider, town, deal type or problem" aria-label="Search BroadbandSwitch.uk" ' +
456            'aria-controls="bsu-search-results" aria-expanded="false" role="combobox" aria-autocomplete="list">' +
457          '<button class="bsu-search__close" type="button" data-close aria-label="Close search">Esc</button>' +
458        '</div>' +
459        '<div class="bsu-search__status" role="status" aria-live="polite"></div>' +
460        '<div class="bsu-search__dym" hidden></div>' +
461        '<div class="bsu-search__intent"></div>' +
462        '<div class="bsu-search__start"></div>' +
463        '<ul class="bsu-search__results" id="bsu-search-results" role="listbox" aria-label="Search results"></ul>' +
464        '<div class="bsu-search__foot">' +
465          '<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> to move</span><span><kbd>Enter</kbd> to open</span><span><kbd>Esc</kbd> to close</span>' +
466          '<a class="bsu-search__all" href="' + SEARCH_PAGE + '">Browse every page</a>' +
467        '</div>' +
468      '</div>';
469    document.body.appendChild(overlay);
470    els.input = overlay.querySelector('.bsu-search__input');
471    els.list = overlay.querySelector('.bsu-search__results');
472    els.status = overlay.querySelector('.bsu-search__status');
473    els.dym = overlay.querySelector('.bsu-search__dym');
474    els.intent = overlay.querySelector('.bsu-search__intent');
475    els.start = overlay.querySelector('.bsu-search__start');
476    els.all = overlay.querySelector('.bsu-search__all');
477    overlay.addEventListener('click', function (e) {
478      if (e.target.closest('[data-close]')) { closeOverlay(); return; }
479      var chip = e.target.closest('[data-oq]');
480      if (chip) { e.preventDefault(); els.input.value = chip.getAttribute('data-oq'); render(els.input.value); els.input.focus(); }
481    });
482    els.input.addEventListener('input', function () { render(els.input.value); });
483    els.input.addEventListener('keydown', onKeydown);
484    els.list.addEventListener('mousemove', function (e) { var li = e.target.closest('li[data-i]'); if (li) setActive(Number(li.getAttribute('data-i')), false); });
485    return overlay;
486  }
487
488  function onKeydown(e) {
489    if (e.key === 'Escape') { e.preventDefault(); closeOverlay(); return; }
490    if (e.key === 'ArrowDown') { e.preventDefault(); if (results.length) setActive(activeIndex + 1 >= results.length ? 0 : activeIndex + 1, true); return; }
491    if (e.key === 'ArrowUp') { e.preventDefault(); if (results.length) setActive(activeIndex - 1 < 0 ? results.length - 1 : activeIndex - 1, true); return; }
492    if (e.key === 'Home' && results.length) { e.preventDefault(); setActive(0, true); return; }
493    if (e.key === 'End' && results.length) { e.preventDefault(); setActive(results.length - 1, true); return; }
494    if (e.key === 'Enter') {
495      e.preventDefault();
496      var q = els.input.value.trim();
497      if (activeIndex >= 0 && results[activeIndex]) { addRecent(q); window.location.href = results[activeIndex].u; }
498      else if (q) { addRecent(q); window.location.href = SEARCH_PAGE + '?q=' + encodeURIComponent(q); }
499      return;
500    }
501    if (e.key === 'Tab') trapFocus(e);
502  }
503  function trapFocus(e) {
504    var focusable = overlay.querySelectorAll('input, button, a[href]');
505    if (!focusable.length) return;
506    var first = focusable[0], last = focusable[focusable.length - 1];
507    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
508    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
509  }
510
511  function renderStart() {
512    var rec = getRecent();
513    els.start.hidden = false;
514    els.start.innerHTML =
515      (rec.length ? '<h4>Recent</h4><div class="bsu-chips">' + rec.map(function (x) { return '<button type="button" class="bsu-chip" data-oq="' + escapeHtml(x) + '">' + escapeHtml(x) + '</button>'; }).join('') + '</div>' : '') +
516      '<h4>Popular right now</h4><div class="bsu-chips">' + POPULAR.map(function (x) { return '<button type="button" class="bsu-chip" data-oq="' + escapeHtml(x) + '">' + escapeHtml(x) + '</button>'; }).join('') + '</div>';
517  }
518
519  function render(raw) {
520    var q = raw.trim();
521    results = []; activeIndex = -1;
522    els.dym.hidden = true; els.dym.innerHTML = ''; els.intent.innerHTML = '';
523    if (!q) {
524      els.list.innerHTML = ''; els.status.textContent = '';
525      els.input.setAttribute('aria-expanded', 'false');
526      els.all.textContent = 'Browse every page'; els.all.href = SEARCH_PAGE;
527      renderStart();
528      return;
529    }
530    els.start.hidden = true;
531    if (!index) {
532      els.list.innerHTML = '';
533      els.status.innerHTML = 'Search is still loading.  <a href="' + SEARCH_PAGE + '?q=' + encodeURIComponent(q) + '">Open the full page list</a>.';
534      return;
535    }
536    var r = resolve(q);
537    var shown = r.shownQuery;
538    if (r.mode === 'auto') { els.dym.hidden = false; els.dym.innerHTML = SVG_REDO + '<span>Showing results for <strong>' + escapeHtml(shown) + '</strong>.  <a href="' + SEARCH_PAGE + '?q=' + encodeURIComponent(q) + '&exact=1">Search as typed</a></span>'; }
539    if (r.mode === 'suggest') { els.dym.hidden = false; els.dym.innerHTML = SVG_INFO + '<span>Did you mean <a href="#" data-oq="' + escapeHtml(r.dym) + '">' + escapeHtml(r.dym) + '</a>?</span>'; }
540    var cards = intents(shown);
541    els.intent.innerHTML = renderCards(cards);
542    results = r.results.slice(0, MAX_OVERLAY);
543    els.all.href = SEARCH_PAGE + '?q=' + encodeURIComponent(q);
544    if (!r.results.length) {
545      els.list.innerHTML = '';
546      els.input.setAttribute('aria-expanded', 'false');
547      els.status.innerHTML = cards.length ? '' : 'Nothing matched &ldquo;' + escapeHtml(q) + '&rdquo;.  Try a provider name, your town, a deal type or a speed problem.  Or <a href="/compare/">compare live deals at your postcode</a>.';
548      els.all.textContent = 'Browse every page';
549      if (!cards.length) track('site_search_zero', q, { surface: 'overlay' });
550      return;
551    }
552    els.input.setAttribute('aria-expanded', 'true');
553    els.list.innerHTML = results.map(function (p, i) {
554      return '<li class="bsu-search__result" role="option" id="bsu-r' + i + '" data-i="' + i + '" aria-selected="false">' +
555        '<a href="' + escapeHtml(p.u) + '" tabindex="-1">' +
556          '<span class="bsu-search__t">' + highlight(p.t, shown) + '</span>' +
557          (p.d ? '<span class="bsu-search__d">' + escapeHtml(trim(p.d, 110)) + '</span>' : '') +
558          '<span class="bsu-search__s">' + escapeHtml(p.s) + '</span>' +
559        '</a></li>';
560    }).join('');
561    var total = r.results.length;
562    els.status.textContent = total === 1 ? '1 page matches.' : total.toLocaleString('en-GB') + ' pages match.  Showing the top ' + results.length + '.';
563    els.all.textContent = total > results.length ? 'See all ' + total.toLocaleString('en-GB') + ' results' : 'Browse every page';
564    setActive(cards.length ? -1 : 0, false);
565  }
566
567  function setActive(i, scroll) {
568    var items = els.list.querySelectorAll('li[role="option"]');
569    for (var n = 0; n < items.length; n++) {
570      items[n].setAttribute('aria-selected', n === i ? 'true' : 'false');
571      items[n].classList.toggle('is-active', n === i);
572    }
573    activeIndex = i;
574    if (i >= 0 && items[i]) {
575      els.input.setAttribute('aria-activedescendant', 'bsu-r' + i);
576      if (scroll && items[i].scrollIntoView) items[i].scrollIntoView({ block: 'nearest', behavior: reduceMotion ? 'auto' : 'smooth' });
577    } else {
578      els.input.removeAttribute('aria-activedescendant');
579    }
580  }
581
582  function openOverlay(seed) {
583    buildOverlay();
584    lastFocus = document.activeElement;
585    overlay.hidden = false;
586    document.documentElement.classList.add('bsu-search-open');
587    loadIndex().then(function () { render(els.input.value); });
588    if (seed) els.input.value = seed;
589    els.input.focus(); els.input.select();
590    render(els.input.value);
591  }
592  function closeOverlay() {
593    if (!overlay || overlay.hidden) return;
594    overlay.hidden = true;
595    document.documentElement.classList.remove('bsu-search-open');
596    els.input.value = ''; els.list.innerHTML = ''; els.status.textContent = ''; els.dym.hidden = true; els.intent.innerHTML = '';
597    results = []; activeIndex = -1;
598    if (lastFocus && lastFocus.focus) lastFocus.focus();
599  }
600
601  /* ---------------------------------------------------------------- *
602   * Triggers
603   * ---------------------------------------------------------------- */
604
605  function isSearchPage() { return /\/sitemap\.html$/.test(window.location.pathname); }
606
607  function bindTriggers() {
608    document.addEventListener('click', function (e) {
609      var t = e.target.closest('[data-site-search-open]');
610      if (t) { e.preventDefault(); openOverlay(); return; }
611      var link = e.target.closest('a[href$="/sitemap.html"], a[href="' + SEARCH_PAGE + '"]');
612      if (!link) return;
613      if (isSearchPage()) return;
614      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
615      if (!link.closest('header, .site-header, nav, .topbar, .utility-bar')) return;
616      e.preventDefault();
617      openOverlay();
618    });
619    document.addEventListener('keydown', function (e) {
620      var tag = (e.target.tagName || '').toLowerCase();
621      var typing = tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable;
622      if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (isSearchPage()) focusPageSearch(); else openOverlay(); return; }
623      if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); if (isSearchPage()) focusPageSearch(); else openOverlay(); }
624    });
625    var warmed = false;
626    ['pointerover', 'focusin', 'touchstart'].forEach(function (evt) {
627      document.addEventListener(evt, function (e) {
628        if (warmed) return;
629        if (!e.target.closest || !e.target.closest('[data-site-search-open], a[href$="/sitemap.html"]')) return;
630        warmed = true; loadIndex();
631      }, { passive: true });
632    });
633  }
634
635  /* ---------------------------------------------------------------- *
636   * Search page (/sitemap.html)
637   * ---------------------------------------------------------------- */
638
639  function focusPageSearch() { var input = document.getElementById('q'); if (input) { input.focus(); input.select(); } }
640
641  function ensure(id, cls, after, tag) {
642    var el = document.getElementById(id);
643    if (el) return el;
644    el = document.createElement(tag || 'div');
645    el.id = id; el.className = cls;
646    if (after && after.parentNode) after.parentNode.insertBefore(el, after.nextSibling);
647    return el;
648  }
649
650  function initSearchPage() {
651    var input = document.getElementById('q');
652    if (!input) return;
653    injectStyles();
654    var groups = Array.prototype.slice.call(document.querySelectorAll('[data-search-group]'));
655    var items = Array.prototype.slice.call(document.querySelectorAll('[data-search-item]'));
656    var status = document.getElementById('search-status');
657    var empty = document.getElementById('search-empty');
658    var notice = ensure('search-notice', 'sm-notice', status);
659    var intent = ensure('search-intent', 'sm-intent', notice);
660    var recent = ensure('search-recent', 'sm-recent', intent);
661    var filters = ensure('search-filters', 'sm-filters', recent);
662    var total = items.length;
663    var activeSection = 'all';
664    var azLetter = '';
665    var lastQuery = null;
666    var itemMeta = items.map(function (li) {
667      var a = li.querySelector('a');
668      var desc = li.querySelector('.sm-item__desc');
669      var aText = a ? a.textContent : '', dText = desc ? desc.textContent : '';
670      return { li: li, a: a, desc: desc, aText: aText, dText: dText,
671        title: (li.getAttribute('data-title') || aText), d: (li.getAttribute('data-desc') || dText), s: (li.getAttribute('data-section') || ''),
672        href: a ? a.getAttribute('href') || '' : '' };
673    });
674    itemMeta.forEach(function (m) { m.hay = norm(m.title + ' ' + m.d + ' ' + m.s + ' ' + m.href.replace(/[/.]/g, ' ')); m.tt = norm(m.title); });
675    var groupMeta = groups.map(function (g) { var h = g.querySelector('.sm-group__title'); return { g: g, name: h ? h.firstChild.textContent.trim() : '', count: h ? h.querySelector('.sm-group__count') : null, isDir: /provider directory/i.test(h ? h.textContent : '') }; });
676    var clearBtn = null;
677    var wrap = input.parentNode;
678    if (wrap && !wrap.querySelector('.sm-clear')) {
679      clearBtn = document.createElement('button');
680      clearBtn.type = 'button'; clearBtn.className = 'sm-clear'; clearBtn.setAttribute('aria-label', 'Clear search'); clearBtn.hidden = true;
681      clearBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
682      var hint = wrap.querySelector('.sm-search__hint');
683      wrap.insertBefore(clearBtn, hint || null);
684      clearBtn.addEventListener('click', function () { input.value = ''; apply('', true); input.focus(); });
685    }
686
687    function matchesTokens(m, tokens) {
688      for (var i = 0; i < tokens.length; i++) {
689        var alts = expand(tokens[i]), ok = false;
690        for (var k = 0; k < alts.length; k++) if (m.hay.indexOf(alts[k]) !== -1) { ok = true; break; }
691        if (!ok) return false;
692      }
693      return true;
694    }
695    function setText(m, q) {
696      if (m.a) m.a.innerHTML = q ? highlight(m.aText, q) : escapeHtml(m.aText);
697      if (m.desc) m.desc.innerHTML = q ? highlight(m.dText, q) : escapeHtml(m.dText);
698    }
699    function renderRecent(q) {
700      var rec = getRecent();
701      recent.hidden = !!q || !rec.length;
702      recent.innerHTML = rec.length ? '<span>Recent:</span>' + rec.map(function (x) { return '<button type="button" data-q="' + escapeHtml(x) + '">' + escapeHtml(x) + '</button>'; }).join('') : '';
703    }
704    function renderAz(g) {
705      var existing = g.g.querySelector('.sm-az');
706      if (existing) existing.parentNode.removeChild(existing);
707      var lis = Array.prototype.slice.call(g.g.querySelectorAll('[data-search-item]'));
708      var letters = {};
709      lis.forEach(function (li) { var a = li.querySelector('a'); var L = a ? a.textContent.trim().charAt(0).toUpperCase() : ''; if (L) letters[L] = true; });
710      var keys = Object.keys(letters).sort();
711      if (!keys.length) return;
712      var az = document.createElement('div');
713      az.className = 'sm-az'; az.setAttribute('aria-label', 'Jump to letter');
714      az.innerHTML = keys.map(function (L) { return '<button type="button" data-az="' + L + '" aria-pressed="' + (azLetter === L) + '">' + L + '</button>'; }).join('') + (azLetter ? '<button type="button" data-az="">All</button>' : '');
715      var ul = g.g.querySelector('ul');
716      g.g.insertBefore(az, ul || null);
717    }
718
719    function apply(rawQuery, pushState) {
720      var q = rawQuery.trim();
721      var exact = /(^|&)exact=1(&|$)/.test(window.location.search) && q === (new URLSearchParams(window.location.search).get('q') || '');
722      var shownQuery = q, mode = 'exact', dym = null;
723      if (q && index && !exact) {
724        var r = resolve(q);
725        shownQuery = r.shownQuery; mode = r.mode; dym = r.dym;
726        if (mode === 'suggest') shownQuery = q;
727      }
728      var tokens = toks(shownQuery);
729      var shown = 0, counts = {};
730      itemMeta.forEach(function (m) {
731        var hit = !tokens.length || matchesTokens(m, tokens);
732        if (hit && activeSection !== 'all' && m.s !== activeSection.toLowerCase()) hit = false;
733        m.hit = hit;
734        if (hit) { shown++; counts[m.s] = (counts[m.s] || 0) + 1; }
735      });
736      /* A to Z inside the directory group when browsing */
737      itemMeta.forEach(function (m) {
738        var li = m.li;
739        var azHide = false;
740        if (!q && azLetter && m.li.closest('[data-search-group]') && /provider directory/i.test(m.s)) {
741          azHide = m.aText.trim().charAt(0).toUpperCase() !== azLetter;
742        }
743        li.hidden = !m.hit || azHide;
744        if (lastQuery !== shownQuery) setText(m, m.hit ? shownQuery : '');
745      });
746      lastQuery = shownQuery;
747      groupMeta.forEach(function (g) {
748        var any = g.g.querySelector('[data-search-item]:not([hidden])');
749        g.g.hidden = !any;
750        if (g.count) {
751          var visible = g.g.querySelectorAll('[data-search-item]:not([hidden])').length;
752          var all = g.g.querySelectorAll('[data-search-item]').length;
753          g.count.textContent = q ? visible + ' of ' + all : String(all);
754        }
755        if (g.isDir && !q) renderAz(g); else { var old = g.g.querySelector('.sm-az'); if (old) old.parentNode.removeChild(old); }
756      });
757      /* notice */
758      if (mode === 'auto') notice.innerHTML = SVG_REDO + '<div>Showing results for <a href="#" data-q="' + escapeHtml(shownQuery) + '">' + escapeHtml(shownQuery) + '</a><span class="sm-notice__sub">Search instead for &ldquo;' + escapeHtml(q) + '&rdquo; <a href="' + SEARCH_PAGE + '?q=' + encodeURIComponent(q) + '&exact=1">as typed</a>.</span></div>';
759      else if (mode === 'suggest') notice.innerHTML = SVG_INFO + '<div>Did you mean <a href="#" data-q="' + escapeHtml(dym) + '">' + escapeHtml(dym) + '</a>?</div>';
760      else notice.innerHTML = '';
761      notice.hidden = !notice.innerHTML;
762      /* intent cards, then the five best-ranked matches so the answer is at the top */
763      var topHtml = '';
764      if (q && index) {
765        var ranked = (mode === 'suggest' ? search(q) : search(shownQuery)).slice(0, 5);
766        if (ranked.length > 1 && shown > 5) {
767          topHtml = '<div class="sm-top"><h4>Best matches</h4><ol>' + ranked.map(function (p) {
768            return '<li><a href="' + escapeHtml(p.u) + '">' + highlight(p.t, shownQuery) + '</a><span class="sm-top__s">' + escapeHtml(p.s) + '</span></li>';
769          }).join('') + '</ol></div>';
770        }
771      }
772      intent.innerHTML = (q && index ? renderCards(intents(shownQuery)) : '') + topHtml;
773      /* filters */
774      var secs = [];
775      groupMeta.forEach(function (g) { var n = g.name.toLowerCase(); if (counts[n]) secs.push({ n: n, label: g.name, c: counts[n] }); });
776      if (q && secs.length > 1) {
777        var totalHits = 0; for (var s in counts) totalHits += counts[s];
778        filters.hidden = false;
779        filters.innerHTML = '<button type="button" data-sec="all" aria-pressed="' + (activeSection === 'all') + '">All <b>' + totalHits + '</b></button>' +
780          secs.map(function (x) { return '<button type="button" data-sec="' + escapeHtml(x.label) + '" aria-pressed="' + (activeSection === x.label) + '">' + escapeHtml(x.label) + ' <b>' + x.c + '</b></button>'; }).join('');
781      } else { filters.hidden = true; filters.innerHTML = ''; }
782      if (status) {
783        status.textContent = !q ? 'Showing all ' + total.toLocaleString('en-GB') + ' pages in ' + groups.length + ' sections.'
784          : shown === 1 ? '1 page matches.' : shown.toLocaleString('en-GB') + ' pages match' + (activeSection !== 'all' ? ' in ' + activeSection : '') + '.';
785      }
786      if (empty) {
787        var isZero = !!q && shown === 0 && !intent.innerHTML;
788        empty.hidden = !isZero;
789        if (isZero) { var t = empty.querySelector('[data-empty-term]'); if (t) t.textContent = q; track('site_search_zero', q, { surface: 'page' }); }
790      }
791      if (clearBtn) clearBtn.hidden = !q;
792      renderRecent(q);
793      if (pushState && window.history && window.history.replaceState) {
794        var url = window.location.pathname + (q ? '?q=' + encodeURIComponent(q) : '');
795        window.history.replaceState(null, '', url);
796      }
797      if (pushState && q) { addRecent(q); track('site_search', q, { surface: 'page', results: shown, corrected: mode === 'auto' ? shownQuery : '' }); }
798    }
799
800    var timer = null;
801    input.addEventListener('input', function () { clearTimeout(timer); activeSection = 'all'; azLetter = ''; timer = setTimeout(function () { apply(input.value, true); }, 90); });
802    input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { input.value = ''; apply('', true); } });
803    document.addEventListener('click', function (e) {
804      var dq = e.target.closest('[data-q]');
805      if (dq && !overlay) { e.preventDefault(); input.value = dq.getAttribute('data-q'); activeSection = 'all'; apply(input.value, true); input.focus(); return; }
806      if (dq && overlay && !overlay.contains(dq)) { e.preventDefault(); input.value = dq.getAttribute('data-q'); activeSection = 'all'; apply(input.value, true); input.focus(); return; }
807      var sec = e.target.closest('[data-sec]');
808      if (sec) { activeSection = sec.getAttribute('data-sec'); apply(input.value, false); return; }
809      var az = e.target.closest('[data-az]');
810      if (az) { azLetter = az.getAttribute('data-az'); apply(input.value, false); return; }
811    });
812
813    var params = new URLSearchParams(window.location.search);
814    var seed = params.get('q') || '';
815    if (seed) input.value = seed;
816    apply(seed, false);
817    if (seed) input.focus();
818    /* Once the index has loaded, re-run so typo correction and intent cards apply. */
819    loadIndex().then(function () { apply(input.value, false); });
820  }
821
822  /* ---------------------------------------------------------------- */
823
824  function init() { bindTriggers(); initSearchPage(); }
825  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
826})();

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.