PageSourceSearch

https://plurality.net/assets/js/search.js?v=6532876

js plurality.net collected 2026-09-24 18:30:26 UTC 30,974 bytes, 887 lines download raw bytes

1(function () {
2  var overlay = document.getElementById('search-overlay');
3  var container = document.getElementById('search-container');
4  if (!overlay || !container) return;
5
6  var instance = null;
7  var suggestions = [];
8  var dropdown = null;
9  var activeIdx = -1;
10  var input = null;
11  var suppressHide = false;
12
13  // Build suggestions array from datalist, then remove it
14  var datalist = document.getElementById('search-suggestions');
15  if (datalist) {
16    var opts = datalist.querySelectorAll('option');
17    for (var i = 0; i < opts.length; i++) {
18      if (opts[i].value) suggestions.push(opts[i].value);
19    }
20    datalist.remove();
21  }
22
23  var pageLang = document.documentElement.dataset.lang || document.documentElement.lang || 'en';
24  var isLatinQuery = function (q) { return /^[\x20-\x7E]+$/.test(q); };
25  var isLatinTerm = function (t) { return /^[\x20-\x7E]/.test(t); };
26
27  // Detect whether Fuse.js should be used (zh/ja pages with Fuse loaded)
28  var useFuse = typeof Fuse !== 'undefined' && (pageLang === 'zh' || pageLang === 'ja');
29
30  // Fuse.js state
31  var fuseIndex = null;       // Fuse instance for full-text (searches subsections)
32  var fuseFlat = null;        // flattened subsection array (source of truth for exact scan)
33  var fuseSuggestions = null;  // Fuse instance for autocomplete terms
34  var fuseLoading = false;
35  var fuseLoaded = false;
36  var fuseSearchInput = null;
37  var fuseResultsEl = null;
38  var fuseMessageEl = null;
39  var fuseDebounceTimer = null;
40  var enFlat = null;          // en edition flattened subsections (lazy-loaded)
41  var enFailed = false;
42  var enToken = 0;             // stale-input guard for async en fallback
43  var enPromise = null;        // cached in-flight en-index fetch promise
44
45  var SEARCH_SUBMIT_SVG = '\u2728';
46
47  function getSearchInput() {
48    return (
49      fuseSearchInput ||
50      input ||
51      (container && container.querySelector('.pagefind-ui__search-input'))
52    );
53  }
54
55  function createSearchSubmitButton() {
56    var btn = document.createElement('button');
57    btn.type = 'button';
58    btn.className = 'plurality-search__submit';
59    btn.setAttribute('aria-label', (t && t.search_label) || 'Search');
60    btn.innerHTML = SEARCH_SUBMIT_SVG;
61    btn.addEventListener('click', function () {
62      submitSearch();
63    });
64    return btn;
65  }
66
67  function submitSearch() {
68    var inp = getSearchInput();
69    if (!inp) return;
70    var q = inp.value.trim();
71    if (dropdown) dropdown.hidden = true;
72    activeIdx = -1;
73    inp.removeAttribute('aria-activedescendant');
74    if (!q) {
75      if (useFuse && fuseResultsEl && fuseMessageEl) {
76        fuseResultsEl.innerHTML = '';
77        fuseMessageEl.textContent = '';
78      }
79      return;
80    }
81    if (window.PluralityBookAsk && typeof window.PluralityBookAsk.runAsk === 'function') {
82      window.PluralityBookAsk.runAsk(q).then(function () {
83        window.dispatchEvent(
84          new CustomEvent('plurality-search-after-ask', { detail: { query: q } }),
85        );
86      });
87    } else {
88      runKeywordSearchAfterAsk(q);
89    }
90  }
91
92  function wrapSearchFormRow(form) {
93    if (!form || form.querySelector('.plurality-search__row')) return;
94    var inp = form.querySelector('.pagefind-ui__search-input, input[type="search"], input[type="text"]');
95    if (!inp) return;
96    var row = document.createElement('div');
97    row.className = 'plurality-search__row';
98    var wrap = document.createElement('div');
99    wrap.className = 'plurality-search__input-wrap';
100    form.insertBefore(row, inp);
101    wrap.appendChild(inp);
102    row.appendChild(wrap);
103    row.appendChild(createSearchSubmitButton());
104    var clearBtn = form.querySelector('.pagefind-ui__search-clear');
105    if (clearBtn) clearBtn.remove();
106  }
107
108
109  var uiTranslations = {
110    en: {
111      placeholder: "Search the book\u2026",
112      zero_results: "No results for [SEARCH_TERM]",
113      exact_summary: "[EXACT] exact · [NEAR] near matches for [SEARCH_TERM]",
114      exact_only: "[EXACT] exact matches for [SEARCH_TERM]",
115      exact_badge: "Exact",
116      en_fallback: "[COUNT] exact matches in the English edition"
117    },
118    zh: {
119      placeholder: "\u641c\u5c0b\u672c\u66f8\u2026",
120      zero_results: "\u627e\u4e0d\u5230 [SEARCH_TERM] \u7684\u76f8\u95dc\u7d50\u679c",
121      many_results: "\u627e\u5230 [COUNT] \u500b [SEARCH_TERM] \u7684\u76f8\u95dc\u7d50\u679c",
122      one_result: "\u627e\u5230 [COUNT] \u500b [SEARCH_TERM] \u7684\u76f8\u95dc\u7d50\u679c",
123      search_label: "\u641c\u5c0b",
124      clear_search: "\u6e05\u9664",
125      load_more: "\u8f09\u5165\u66f4\u591a",
126      searching: "\u641c\u5c0b [SEARCH_TERM] \u4e2d\u2026",
127      exact_summary: "「[SEARCH_TERM]」完全符合 [EXACT] 筆・相近結果 [NEAR] 筆",
128      exact_only: "「[SEARCH_TERM]」完全符合 [EXACT] 筆",
129      exact_badge: "完全符合",
130      en_fallback: "英文版完全符合 [COUNT] 筆"
131    },
132    ja: {
133      placeholder: "\u672c\u3092\u691c\u7d22\u2026",
134      zero_results: "[SEARCH_TERM] に近い検索結果はありません",
135      many_results: "[SEARCH_TERM] に近い検索結果 [COUNT] 件",
136      one_result: "[SEARCH_TERM] に近い検索結果 [COUNT] 件",
137      search_label: "\u691c\u7d22",
138      clear_search: "\u30af\u30ea\u30a2",
139      load_more: "\u3082\u3063\u3068\u898b\u308b",
140      searching: "[SEARCH_TERM] \u3092\u691c\u7d22\u4e2d\u2026",
141      exact_summary: "「[SEARCH_TERM]」完全一致 [EXACT] 件・近い結果 [NEAR] 件",
142      exact_only: "「[SEARCH_TERM]」完全一致 [EXACT] 件",
143      exact_badge: "完全一致",
144      en_fallback: "英語版での完全一致 [COUNT] 件"
145    },
146    de: {
147      placeholder: "Buch durchsuchen\u2026",
148      zero_results: "Keine Ergebnisse f\u00fcr [SEARCH_TERM]",
149      many_results: "[COUNT] Ergebnisse f\u00fcr [SEARCH_TERM]",
150      one_result: "[COUNT] Ergebnis f\u00fcr [SEARCH_TERM]",
151      search_label: "Suche",
152      clear_search: "L\u00f6schen",
153      load_more: "Mehr laden",
154      searching: "Suche nach [SEARCH_TERM]\u2026"
155    },
156    th: {
157      placeholder: "\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e43\u0e19\u0e2b\u0e19\u0e31\u0e07\u0e2a\u0e37\u0e2d\u2026",
158      zero_results: "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e1c\u0e25\u0e25\u0e31\u0e1e\u0e18\u0e4c\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a [SEARCH_TERM]",
159      many_results: "\u0e1e\u0e1a [COUNT] \u0e1c\u0e25\u0e25\u0e31\u0e1e\u0e18\u0e4c\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a [SEARCH_TERM]",
160      one_result: "\u0e1e\u0e1a [COUNT] \u0e1c\u0e25\u0e25\u0e31\u0e1e\u0e18\u0e4c\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a [SEARCH_TERM]",
161      search_label: "\u0e04\u0e49\u0e19\u0e2b\u0e32",
162      clear_search: "\u0e25\u0e49\u0e32\u0e07",
163      load_more: "\u0e42\u0e2b\u0e25\u0e14\u0e40\u0e1e\u0e34\u0e48\u0e21\u0e40\u0e15\u0e34\u0e21",
164      searching: "\u0e01\u0e33\u0e25\u0e31\u0e07\u0e04\u0e49\u0e19\u0e2b\u0e32 [SEARCH_TERM]\u2026"
165    },
166    el: {
167      placeholder: "\u0391\u03bd\u03b1\u03b6\u03ae\u03c4\u03b7\u03c3\u03b7 \u03c3\u03c4\u03bf \u03b2\u03b9\u03b2\u03bb\u03af\u03bf\u2026",
168      zero_results: "\u0394\u03b5\u03bd \u03b2\u03c1\u03ad\u03b8\u03b7\u03ba\u03b1\u03bd \u03b1\u03c0\u03bf\u03c4\u03b5\u03bb\u03ad\u03c3\u03bc\u03b1\u03c4\u03b1 \u03b3\u03b9\u03b1 [SEARCH_TERM]",
169      many_results: "\u0392\u03c1\u03ad\u03b8\u03b7\u03ba\u03b1\u03bd [COUNT] \u03b1\u03c0\u03bf\u03c4\u03b5\u03bb\u03ad\u03c3\u03bc\u03b1\u03c4\u03b1 \u03b3\u03b9\u03b1 [SEARCH_TERM]",
170      one_result: "\u0392\u03c1\u03ad\u03b8\u03b7\u03ba\u03b5 [COUNT] \u03b1\u03c0\u03bf\u03c4\u03ad\u03bb\u03b5\u03c3\u03bc\u03b1 \u03b3\u03b9\u03b1 [SEARCH_TERM]",
171      search_label: "\u0391\u03bd\u03b1\u03b6\u03ae\u03c4\u03b7\u03c3\u03b7",
172      clear_search: "\u039a\u03b1\u03b8\u03b1\u03c1\u03b9\u03c3\u03bc\u03cc\u03c2",
173      load_more: "\u03a0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b1",
174      searching: "\u0391\u03bd\u03b1\u03b6\u03ae\u03c4\u03b7\u03c3\u03b7 [SEARCH_TERM]\u2026"
175    }
176  };
177
178  var t = uiTranslations[pageLang] || uiTranslations.en;
179
180  // ── Matching helpers (used for non-Fuse autocomplete) ──
181
182  function matchTerm(term, query) {
183    var q = query.toLowerCase();
184    var tl = term.toLowerCase();
185    if (!isLatinQuery(q)) {
186      return tl.indexOf(q) !== -1;
187    }
188    var words = tl.split(/[\s/(-]+/);
189    for (var i = 0; i < words.length; i++) {
190      if (words[i].indexOf(q) === 0) return true;
191    }
192    return false;
193  }
194
195  function sortMatches(matches) {
196    if (pageLang === 'en') return matches;
197    return matches.sort(function (a, b) {
198      var aLatin = isLatinTerm(a);
199      var bLatin = isLatinTerm(b);
200      if (aLatin === bLatin) return 0;
201      return aLatin ? 1 : -1;
202    });
203  }
204
205  // ── Autocomplete dropdown ──
206
207  function positionDropdown() {
208    if (!dropdown || !input) return;
209    var rect = input.getBoundingClientRect();
210    var overlayRect = overlay.getBoundingClientRect();
211    dropdown.style.top = (rect.bottom - overlayRect.top + 4) + 'px';
212    dropdown.style.left = (rect.left - overlayRect.left) + 'px';
213    dropdown.style.width = rect.width + 'px';
214  }
215
216  function showSuggestions(query) {
217    if (!dropdown) return;
218    activeIdx = -1;
219    dropdown.innerHTML = '';
220
221    if (!query || query.length < 1) {
222      dropdown.hidden = true;
223      if (input) input.removeAttribute('aria-activedescendant');
224      return;
225    }
226
227    var matches;
228    if (useFuse && fuseSuggestions) {
229      var fuseResults = fuseSuggestions.search(query, { limit: 20 });
230      var raw = [];
231      for (var i = 0; i < fuseResults.length; i++) {
232        raw.push(fuseResults[i].item);
233      }
234      matches = sortMatches(raw).slice(0, 8);
235    } else {
236      var raw = [];
237      for (var i = 0; i < suggestions.length && raw.length < 20; i++) {
238        if (matchTerm(suggestions[i], query)) raw.push(suggestions[i]);
239      }
240      matches = sortMatches(raw).slice(0, 8);
241    }
242
243    if (matches.length === 0) {
244      dropdown.hidden = true;
245      if (input) input.removeAttribute('aria-activedescendant');
246      return;
247    }
248
249    for (var j = 0; j < matches.length; j++) {
250      var li = document.createElement('li');
251      li.className = 'search-suggest__item';
252      li.setAttribute('role', 'option');
253      li.id = 'suggest-' + j;
254      li.textContent = matches[j];
255      li.addEventListener('mousedown', (function (val) {
256        return function (e) {
257          e.preventDefault();
258          e.stopPropagation();
259          suppressHide = true;
260          selectSuggestion(val);
261        };
262      })(matches[j]));
263      dropdown.appendChild(li);
264    }
265
266    positionDropdown();
267    dropdown.hidden = false;
268  }
269
270  function selectSuggestion(value) {
271    if (!input) return;
272    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value);
273    input.dispatchEvent(new Event('input', { bubbles: true }));
274    dropdown.hidden = true;
275    activeIdx = -1;
276    input.removeAttribute('aria-activedescendant');
277    input.focus();
278    suppressHide = false;
279  }
280
281  function handleKeydown(e) {
282    if (!dropdown || dropdown.hidden) return;
283    var items = dropdown.querySelectorAll('.search-suggest__item');
284    if (!items.length) return;
285
286    if (e.key === 'ArrowDown') {
287      e.preventDefault();
288      activeIdx = Math.min(activeIdx + 1, items.length - 1);
289      updateActive(items);
290    } else if (e.key === 'ArrowUp') {
291      e.preventDefault();
292      activeIdx = Math.max(activeIdx - 1, -1);
293      updateActive(items);
294    } else if (e.key === 'Enter' && activeIdx >= 0) {
295      e.preventDefault();
296      selectSuggestion(items[activeIdx].textContent);
297    } else if (e.key === 'Enter') {
298      e.preventDefault();
299      submitSearch();
300    }
301  }
302
303  function updateActive(items) {
304    for (var i = 0; i < items.length; i++) {
305      items[i].classList.toggle('search-suggest__item--active', i === activeIdx);
306    }
307    if (input) {
308      if (activeIdx >= 0) {
309        input.setAttribute('aria-activedescendant', 'suggest-' + activeIdx);
310        items[activeIdx].scrollIntoView({ block: 'nearest' });
311      } else {
312        input.removeAttribute('aria-activedescendant');
313      }
314    }
315  }
316
317  function initDropdown() {
318    dropdown = document.createElement('ul');
319    dropdown.className = 'search-suggest';
320    dropdown.setAttribute('role', 'listbox');
321    dropdown.id = 'search-suggest-list';
322    dropdown.hidden = true;
323    overlay.appendChild(dropdown);
324  }
325
326  // ── Fuse.js helpers ──
327
328  function escapeHtml(str) {
329    return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
330  }
331
332  // Strip CJK punctuation for normalized matching
333  // Handles ・、,。;:!?「」『』()【】…—~ etc.
334  var cjkPunct = /[・\u30FB、,。;:!?「」『』()〔〕【】…—~·\u00B7]/g;
335
336  // Build a position map: for each char in the normalized string,
337  // store its original index in the source string
338  function buildNormMap(str) {
339    var norm = '';
340    var map = [];
341    for (var i = 0; i < str.length; i++) {
342      if (!cjkPunct.test(str[i])) {
343        map.push(i);
344        norm += str[i];
345      }
346      cjkPunct.lastIndex = 0; // reset regex state
347    }
348    return { norm: norm, map: map };
349  }
350
351  function normalizeForExact(s) {
352    var out = String(s || '').toLowerCase().replace(cjkPunct, '');
353    cjkPunct.lastIndex = 0;
354    return out;
355  }
356
357  function scanExact(flatArr, query) {
358    var nq = normalizeForExact(query);
359    if (!nq) return [];
360    var headingHits = [];
361    var contentHits = [];
362    for (var i = 0; i < flatArr.length; i++) {
363      var item = flatArr[i];
364      if (!item.heading) continue; // matches render-skip for intro/endorsement blobs
365      if (normalizeForExact(item.heading).indexOf(nq) !== -1 || normalizeForExact(item.chapterTitle).indexOf(nq) !== -1) {
366        headingHits.push(i);
367      } else if (normalizeForExact(item.content).indexOf(nq) !== -1) {
368        contentHits.push(i);
369      }
370    }
371    return headingHits.concat(contentHits);
372  }
373
374  function buildExcerpt(content, query) {
375    if (!content) return '';
376    var ctxChars = 40;
377
378    // 1. Try exact substring match first
379    var lowerContent = content.toLowerCase();
380    var lowerQuery = query.toLowerCase();
381    var idx = lowerContent.indexOf(lowerQuery);
382
383    if (idx !== -1) {
384      var start = Math.max(0, idx - ctxChars);
385      var end = Math.min(content.length, idx + query.length + ctxChars);
386      return (start > 0 ? '\u2026' : '') +
387        escapeHtml(content.substring(start, idx)) +
388        '<mark>' + escapeHtml(content.substring(idx, idx + query.length)) + '</mark>' +
389        escapeHtml(content.substring(idx + query.length, end)) +
390        (end < content.length ? '\u2026' : '');
391    }
392
393    // 2. Try normalized match (strip CJK punctuation from both sides)
394    var info = buildNormMap(lowerContent);
395    var normQuery = lowerQuery.replace(cjkPunct, '');
396    cjkPunct.lastIndex = 0;
397    var normIdx = info.norm.indexOf(normQuery);
398
399    if (normIdx !== -1 && normQuery.length > 0) {
400      // Map back to original content positions
401      var origStart = info.map[normIdx];
402      var origEnd = info.map[normIdx + normQuery.length - 1] + 1;
403      var winStart = Math.max(0, origStart - ctxChars);
404      var winEnd = Math.min(content.length, origEnd + ctxChars);
405      return (winStart > 0 ? '\u2026' : '') +
406        escapeHtml(content.substring(winStart, origStart)) +
407        '<mark>' + escapeHtml(content.substring(origStart, origEnd)) + '</mark>' +
408        escapeHtml(content.substring(origEnd, winEnd)) +
409        (winEnd < content.length ? '\u2026' : '');
410    }
411
412    // 3. Fallback: show beginning of content
413    var slice = content.substring(0, 100);
414    return escapeHtml(slice) + (content.length > 100 ? '\u2026' : '');
415  }
416
417  function formatMessage(template, term, counts) {
418    var count = (counts && counts.count) || 0;
419    var exact = (counts && counts.exact) || 0;
420    var near = (counts && counts.near) || 0;
421    return template
422      .replace('[COUNT]', count)
423      .replace('[EXACT]', exact)
424      .replace('[NEAR]', near)
425      .replace('[SEARCH_TERM]', term);
426  }
427
428  var pendingKeywordAfterAsk = null;
429
430  function runKeywordSearchAfterAsk(q) {
431    if (!q) return;
432    if (useFuse) {
433      if (fuseSearchInput) {
434        Object.getOwnPropertyDescriptor(
435          HTMLInputElement.prototype,
436          'value',
437        ).set.call(fuseSearchInput, q);
438      }
439      if (fuseIndex && fuseResultsEl) {
440        renderFuseResults(q);
441      } else {
442        pendingKeywordAfterAsk = q;
443      }
444      return;
445    }
446    var pfInput = container.querySelector('input');
447    if (pfInput) {
448      Object.getOwnPropertyDescriptor(
449        HTMLInputElement.prototype,
450        'value',
451      ).set.call(pfInput, q);
452      pfInput.dispatchEvent(new Event('input', { bubbles: true }));
453    }
454  }
455
456  window.addEventListener('plurality-search-after-ask', function (ev) {
457    var q = ev.detail && ev.detail.query;
458    runKeywordSearchAfterAsk(q);
459  });
460  // Render one chapter group's HTML (reused by renderFuseResults and en fallback)
461  function renderGroupHtml(ch, query) {
462    var html = '';
463    html += '<li class="pagefind-ui__result">';
464    html += '<div class="pagefind-ui__result-inner">';
465
466    // Main chapter title
467    html += '<p class="pagefind-ui__result-title">';
468    html += '<a class="pagefind-ui__result-link" href="' + escapeHtml(ch.url) + '">';
469    html += escapeHtml(ch.title);
470    html += '</a>';
471    html += '</p>';
472
473    // Sub-results (subsection anchors)
474    for (var s = 0; s < ch.subResults.length; s++) {
475      var sub = ch.subResults[s];
476      if (!sub.heading) continue;
476 // skip intro sections without headings
477
478      var subUrl = ch.url + '#' + sub.anchor;
479      var subExcerpt = buildExcerpt(sub.content, query);
480
481      html += '<div class="pagefind-ui__result-nested">';
482      html += '<p class="pagefind-ui__result-title">';
483      html += '<a class="pagefind-ui__result-link" href="' + escapeHtml(subUrl) + '">';
484      html += escapeHtml(sub.heading);
485      html += '</a>';
486      if (sub.exact) {
487        html += '<span class="plurality-search__badge">' + escapeHtml(t.exact_badge) + '</span>';
488      }
489      html += '</p>';
490      html += '<p class="pagefind-ui__result-excerpt">' + subExcerpt + '</p>';
491      html += '</div>';
492    }
493
494    // Section tag (matches PageFind's metadata tags)
495    html += '<ul class="pagefind-ui__result-tags">';
496    html += '<li class="pagefind-ui__result-tag">Section: ' + escapeHtml(ch.section) + '</li>';
497    html += '</ul>';
498
499    html += '</div>';
500    html += '</li>';
501    return html;
502  }
503
504  // Search the flat subsection index: exact substring hits first, then Fuse fuzzy results
505  function renderFuseResults(query) {
506    if (!fuseIndex || !fuseResultsEl || !fuseMessageEl) return;
507    enToken++; // invalidate any pending en-fallback callback
508
509    if (!query || !query.trim()) {
510      fuseMessageEl.textContent = '';
511      fuseResultsEl.innerHTML = '';
512      return;
513    }
514
515    var exactIdx = scanExact(fuseFlat, query);
516    var exactSet = {};
517    for (var e = 0; e < exactIdx.length; e++) exactSet[exactIdx[e]] = true;
518
519    var fuzzyResults = fuseIndex.search(query, { limit: 40 });
520    var fuzzy = [];
521    for (var f = 0; f < fuzzyResults.length; f++) {
522      if (exactSet[fuzzyResults[f].refIndex]) continue;
523      fuzzy.push(fuzzyResults[f]);
524    }
525
526    // Build one ordered list: exact items first, then fuzzy
527    var ordered = [];
528    for (var ei = 0; ei < exactIdx.length; ei++) {
529      ordered.push({ item: fuseFlat[exactIdx[ei]], exact: true });
530    }
531    for (var fi = 0; fi < fuzzy.length; fi++) {
532      ordered.push({ item: fuzzy[fi].item, exact: false });
533    }
534
535    // Group results by chapter URL (first-seen group order)
536    var grouped = [];
537    var chapterMap = {};
538    for (var i = 0; i < ordered.length; i++) {
539      var item = ordered[i].item;
540      var key = item.chapterUrl;
541      if (!chapterMap[key]) {
542        chapterMap[key] = {
543          title: item.chapterTitle,
544          section: item.chapterSection,
545          url: item.chapterUrl,
546          subResults: [],
547        };
548        grouped.push(chapterMap[key]);
549      }
550      // Max 3 sub-results per chapter (matches PageFind behavior)
551      if (chapterMap[key].subResults.length < 3) {
552        chapterMap[key].subResults.push({
553          heading: item.heading,
554          anchor: item.anchor,
555          content: item.content,
556          exact: ordered[i].exact,
557        });
558      }
559    }
560
561    // Limit to 10 chapter-level results
562    grouped = grouped.slice(0, 10);
563
564    // Counts (subsection-level, pre-cap)
565    var EXACT = exactIdx.length;
566    var NEAR = fuzzy.length;
567    var count = grouped.length;
568
569    var msgTemplate;
570    if (EXACT > 0 && NEAR > 0) {
571      msgTemplate = t.exact_summary || uiTranslations.en.exact_summary;
572    } else if (EXACT > 0 && NEAR === 0) {
573      msgTemplate = t.exact_only || uiTranslations.en.exact_only;
574    } else if (count === 0) {
575      msgTemplate = t.zero_results || 'No results for [SEARCH_TERM]';
576    } else if (count === 1) {
577      msgTemplate = t.one_result || t.many_results || '[COUNT] results for [SEARCH_TERM]';
578    } else {
579      msgTemplate = t.many_results || '[COUNT] results for [SEARCH_TERM]';
580    }
581    fuseMessageEl.textContent = formatMessage(msgTemplate, query, { count: count, exact: EXACT, near: NEAR });
582
583    // Render results
584    var html = '';
585    for (var g = 0; g < grouped.length; g++) {
586      html += renderGroupHtml(grouped[g], query);
587    }
588    fuseResultsEl.innerHTML = html;
589
590    // English exact-fallback tier: Latin queries with zero local exact hits
591    if (exactIdx.length === 0 && isLatinQuery(query) && query.length >= 3 && pageLang !== 'en') {
592      var token = ++enToken;
593      ensureEnIndex().then(function (flatEn) {
594        if (token !== enToken || !flatEn) return;
595        var enHits = scanExact(flatEn, query);
596        if (!enHits.length) return;
597        var enGrouped = [];
598        var enChapterMap = {};
599        for (var ei = 0; ei < enHits.length; ei++) {
600          var enItem = flatEn[enHits[ei]];
601          var enKey = enItem.chapterUrl;
602          if (!enChapterMap[enKey]) {
603            enChapterMap[enKey] = {
604              title: enItem.chapterTitle,
605              section: enItem.chapterSection,
606              url: enItem.chapterUrl,
607              subResults: [],
608            };
609            enGrouped.push(enChapterMap[enKey]);
610          }
611          if (enChapterMap[enKey].subResults.length < 3) {
612            enChapterMap[enKey].subResults.push({
613              heading: enItem.heading,
614              anchor: enItem.anchor,
615              content: enItem.content,
616              exact: true,
617            });
618          }
619        }
620        enGrouped = enGrouped.slice(0, 5);
621        var enHtml = '<li class="plurality-search__en-note">' +
622          escapeHtml(formatMessage(t.en_fallback || uiTranslations.en.en_fallback, query, { count: enHits.length })) +
623          '</li>';
624        for (var eg = 0; eg < enGrouped.length; eg++) {
625          enHtml += renderGroupHtml(enGrouped[eg], query);
626        }
627        fuseResultsEl.innerHTML = fuseResultsEl.innerHTML + enHtml;
628      });
629    }
630  }
631
632  function flattenChapters(data) {
633    var flat = [];
634    for (var c = 0; c < data.length; c++) {
635      var ch = data[c];
636      var subs = ch.subsections || [];
637      for (var s = 0; s < subs.length; s++) {
638        flat.push({
639          chapterTitle: ch.title,
640          chapterSection: ch.section,
641          chapterUrl: ch.url,
642          heading: subs[s].heading,
643          anchor: subs[s].anchor,
644          content: subs[s].content,
645        });
646      }
647    }
648    return flat;
649  }
650
651  function ensureEnIndex() {
652    if (enFlat) return Promise.resolve(enFlat);
653    if (enFailed) return Promise.resolve(null);
654    if (enPromise) return enPromise;
655    enPromise = fetch('/search-index.json')
656      .then(function (res) {
657        if (!res.ok) throw new Error('HTTP ' + res.status);
658        return res.json();
659      })
660      .then(function (data) {
661        enFlat = flattenChapters(data);
662        return enFlat;
663      })
664      .catch(function () {
665        enFailed = true;
666        return null;
667      });
668    return enPromise;
669  }
670
671  function loadFuseIndex() {
672    if (fuseLoaded || fuseLoading) return;
673    fuseLoading = true;
674    if (fuseMessageEl) {
675      fuseMessageEl.textContent = formatMessage(t.searching || 'Loading\u2026', '', null);
676    }
677    fetch('/' + pageLang + '/search-index.json')
678      .then(function (res) {
679        if (!res.ok) throw new Error('HTTP ' + res.status);
680        return res.json();
681      })
682      .then(function (data) {
683        fuseFlat = flattenChapters(data);
684
685        fuseIndex = new Fuse(fuseFlat, {
686          keys: [
687            { name: 'heading', weight: 2 },
688            { name: 'content', weight: 1 },
689            { name: 'chapterTitle', weight: 1.5 },
690          ],
691          includeScore: true,
692          includeMatches: true,
693          ignoreLocation: true,
694          threshold: 0.3,
695          minMatchCharLength: 1,
696          findAllMatches: true,
697        });
698        fuseLoaded = true;
699        fuseLoading = false;
700        if (fuseMessageEl) fuseMessageEl.textContent = '';
701        if (pendingKeywordAfterAsk) {
702          var pending = pendingKeywordAfterAsk;
703          pendingKeywordAfterAsk = null;
704          runKeywordSearchAfterAsk(pending);
705        } else if (fuseSearchInput && fuseSearchInput.value.trim()) {
706          renderFuseResults(fuseSearchInput.value.trim());
707        }
708      })
709      .catch(function () {
710        fuseLoading = false;
711        useFuse = false;
712        initPagefind();
713      });
714  }
715
716  function buildFuseUI() {
717    container.innerHTML = '';
718
719    var wrapper = document.createElement('div');
720    wrapper.className = 'pagefind-ui pagefind-ui--fuse';
721
722    var form = document.createElement('form');
723    form.className = 'pagefind-ui__form';
724    form.setAttribute('role', 'search');
725    form.addEventListener('submit', function (e) { e.preventDefault(); submitSearch(); });
726
727    fuseSearchInput = document.createElement('input');
728    fuseSearchInput.className = 'pagefind-ui__search-input';
729    fuseSearchInput.type = 'search';
730    fuseSearchInput.setAttribute('enterkeyhint', 'search');
731    fuseSearchInput.placeholder = t.placeholder || 'Search\u2026';
732
733    form.appendChild(fuseSearchInput);
734    wrapSearchFormRow(form);
735
736    var drawer = document.createElement('div');
737    drawer.className = 'pagefind-ui__drawer';
738
739    fuseMessageEl = document.createElement('div');
740    fuseMessageEl.className = 'pagefind-ui__message';
741
742    fuseResultsEl = document.createElement('ol');
743    fuseResultsEl.className = 'pagefind-ui__results';
744
745    drawer.appendChild(fuseMessageEl);
746    drawer.appendChild(fuseResultsEl);
747
748    wrapper.appendChild(form);
749    wrapper.appendChild(drawer);
750    container.appendChild(wrapper);
751
752    // Input handler with debounce for full-text, immediate for autocomplete
753    fuseSearchInput.addEventListener('input', function () {
754      var val = fuseSearchInput.value;
755      showSuggestions(val);
756      clearTimeout(fuseDebounceTimer);
757      fuseDebounceTimer = setTimeout(function () {
758        renderFuseResults(val.trim());
759      }, 200);
760    });
761
762    input = fuseSearchInput;
763
764    fuseSearchInput.addEventListener('keydown', handleKeydown);
765    fuseSearchInput.addEventListener('focus', function () {
766      if (fuseSearchInput.value.length >= 1) showSuggestions(fuseSearchInput.value);
767    });
768    fuseSearchInput.addEventListener('blur', function () {
769      if (suppressHide) return;
770      setTimeout(function () {
771        if (dropdown) dropdown.hidden = true;
772      }, 200);
773    });
774  }
775
776  function initPagefind() {
777    if (instance) return;
778    var opts = {
779      element: container,
780      showSubResults: true,
781      showImages: false,
782      translations: t
783    };
784    // Pre-filter to current language so the filter panel doesn't show
785    opts.mergeFilter = {};
786    opts.mergeFilter.lang = [pageLang];
787    instance = new PagefindUI(opts);
788  }
789
790  function initFuseSuggestions() {
791    if (fuseSuggestions || !suggestions.length) return;
792    fuseSuggestions = new Fuse(suggestions, {
793      includeScore: true,
794      ignoreLocation: true,
795      threshold: 0.3,
796      minMatchCharLength: 1,
797    });
798  }
799
800  // ── Open / Close ──
801
802  function open() {
803    overlay.classList.add('active');
804    overlay.setAttribute('aria-hidden', 'false');
805    document.body.style.overflow = 'hidden';
806
807    if (useFuse) {
808      if (!fuseSearchInput) {
809        buildFuseUI();
810        initFuseSuggestions();
811        if (!dropdown && suggestions.length) {
812          initDropdown();
813        }
814        if (input && suggestions.length) {
815          input.setAttribute('role', 'combobox');
816          input.setAttribute('aria-autocomplete', 'list');
817          input.setAttribute('aria-controls', 'search-suggest-list');
818          input.setAttribute('autocomplete', 'off');
819        }
820        loadFuseIndex();
821      }
822      setTimeout(function () {
823        if (fuseSearchInput) fuseSearchInput.focus();
824      }, 50);
825    } else {
826      if (!instance) {
827        initPagefind();
828      }
829      setTimeout(function () {
830        input = container.querySelector('input');
831        if (input && suggestions.length && !dropdown) {
832          initDropdown();
833
834          input.setAttribute('role', 'combobox');
835          input.setAttribute('aria-autocomplete', 'list');
836          input.setAttribute('aria-controls', 'search-suggest-list');
837          input.setAttribute('autocomplete', 'off');
838
839          input.addEventListener('input', function () {
840            showSuggestions(input.value);
841          });
842          input.addEventListener('keydown', handleKeydown);
843          input.addEventListener('focus', function () {
844            if (input.value.length >= 1) showSuggestions(input.value);
845          });
846          input.addEventListener('blur', function () {
847            if (suppressHide) return;
848            setTimeout(function () {
849              if (dropdown) dropdown.hidden = true;
850            }, 200);
851          });
852        }
853        var pfForm = container.querySelector('.pagefind-ui__form');
854        if (pfForm) wrapSearchFormRow(pfForm);
855        input = getSearchInput();
856        if (input) input.focus();
857      }, 100);
858    }
859  }
860
861  function close() {
862    overlay.classList.remove('active');
863    overlay.setAttribute('aria-hidden', 'true');
864    document.body.style.overflow = '';
865    if (dropdown) dropdown.hidden = true;
866  }
867
868  document.querySelectorAll('.search-toggle').forEach(function (btn) {
869    btn.addEventListener('click', open);
870  });
871
872  overlay.addEventListener('click', function (e) {
873    if (e.target === overlay) close();
874  });
875
876  window.PluralitySearch = { submit: submitSearch };
877
878  document.addEventListener('keydown', function (e) {
879    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
880      e.preventDefault();
881      if (overlay.classList.contains('active')) close(); else open();
882    }
883    if (e.key === 'Escape' && overlay.classList.contains('active')) {
884      close();
885    }
886  });
887})();

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.