PageSourceSearch

https://www.turnkeylinux.org/js/site.js

js turnkeylinux.org collected 2026-10-01 08:42:08 UTC 27,819 bytes, 543 lines download raw bytes

1(() => {
2  const i18nNode = document.getElementById('site-i18n');
3  const i18n = i18nNode ? JSON.parse(i18nNode.textContent) : {};
4  const message = (key, fallback, values = {}) => Object.entries(values).reduce(
5    (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
6    i18n[key] || fallback,
7  );
8  const menuButton = document.querySelector('.menu-toggle');
9  const menu = document.querySelector('.main-menu');
10  if (menuButton && menu) {
11    const setMenuOpen = (open, returnFocus = false) => {
12      menu.classList.toggle('open', open);
13      menuButton.setAttribute('aria-expanded', String(open));
14      menuButton.textContent = open ? message('closeMenu', 'Close') : message('menu', 'Menu');
15      if (returnFocus) menuButton.focus();
16    };
17    menuButton.addEventListener('click', () => {
18      setMenuOpen(!menu.classList.contains('open'));
19    });
20    menu.querySelectorAll('a').forEach((link) => link.addEventListener('click', () => setMenuOpen(false)));
21    document.addEventListener('keydown', (event) => {
22      if (event.key === 'Escape' && menu.classList.contains('open')) setMenuOpen(false, true);
23    });
24    document.addEventListener('click', (event) => {
25      if (menu.classList.contains('open') && !event.target.closest('.site-header')) setMenuOpen(false);
26    });
27    window.matchMedia('(min-width: 901px)').addEventListener('change', (event) => {
28      if (event.matches) setMenuOpen(false);
29    });
30  }
31
32  const headerSearch = document.querySelector('[data-header-search]');
33  const headerSearchToggle = headerSearch?.querySelector('.header-search-toggle');
34  const headerSearchInput = headerSearch?.querySelector('input[type="search"]');
35  if (headerSearch && headerSearchToggle && headerSearchInput) {
36    const setHeaderSearchOpen = (open, returnFocus = false) => {
37      headerSearch.classList.toggle('is-open', open);
38      headerSearchToggle.setAttribute('aria-expanded', String(open));
39      headerSearchToggle.setAttribute('aria-label', open ? message('closeSearch', 'Close website search') : message('openSearch', 'Open website search'));
40      if (open) {
41        menu?.classList.remove('open');
42        menuButton?.setAttribute('aria-expanded', 'false');
43        if (menuButton) menuButton.textContent = message('menu', 'Menu');
44        headerSearchInput.focus();
45      }
46      else if (returnFocus) headerSearchToggle.focus();
47    };
48    headerSearch.addEventListener('click', (event) => {
49      const toggleClicked = event.target.closest?.('.header-search-toggle');
50      const compactFormClicked = event.target === headerSearch && !headerSearch.classList.contains('is-open');
51      if (!toggleClicked && !compactFormClicked) return;
52      event.preventDefault();
53      const open = !headerSearch.classList.contains('is-open');
54      setHeaderSearchOpen(open, !open);
55    });
56    headerSearchInput.addEventListener('focus', () => setHeaderSearchOpen(true));
57    document.addEventListener('keydown', (event) => {
58      if (event.key === 'Escape' && headerSearch.classList.contains('is-open')) setHeaderSearchOpen(false, true);
59    });
60    document.addEventListener('click', (event) => {
61      if (headerSearch.classList.contains('is-open') && !event.target.closest('[data-header-search]') && !headerSearchInput.value) {
62        setHeaderSearchOpen(false);
63      }
64    });
65    menuButton?.addEventListener('click', () => {
66      if (menu?.classList.contains('open')) setHeaderSearchOpen(false);
67    });
68  }
69
70  const terminalDemo = document.querySelector('[data-terminal-demo]');
71  const terminalOutput = terminalDemo?.querySelector('[data-terminal-output]');
72  if (terminalDemo && terminalOutput) {
73    const steps = [
74      ['Generating unique server encryption keys', 'Generated unique server encryption keys'],
75      ['Installing the latest security updates', 'Installed the latest security updates'],
76      ['Starting MariaDB database', 'Started MariaDB database'],
77      ['Starting Apache web server', 'Started Apache web server'],
78      ['Starting Webmin management console', 'Started Webmin management console'],
79      ['Starting SSH Secure Shell', 'Started SSH Secure Shell'],
80      ['Starting WordPress', 'Started WordPress'],
81    ];
82    const frames = ['[  |  ]', '[  /  ]', '[  -  ]', '[  \\  ]'];
83    let stepIndex = 0;
84    let frameIndex = 0;
85    let timer = 0;
86    let visible = false;
87    let complete = false;
88
89    const line = (status, message, className = 'terminal-line') => {
90      const row = document.createElement('span');
91      row.className = className;
92      if (status) {
93        const marker = document.createElement('b');
94        marker.textContent = status;
95        row.append(marker);
96      }
97      row.append(document.createTextNode(`${status ? ' ' : ''}${message}`));
98      return row;
99    };
100    const render = (showCurrent = true) => {
101      const fragment = document.createDocumentFragment();
102      steps.slice(0, stepIndex).forEach((step) => fragment.append(line('[  OK  ]', step[1])));
103      if (showCurrent && stepIndex < steps.length) fragment.append(line(frames[frameIndex % frames.length], steps[stepIndex][0]));
104      if (stepIndex >= steps.length) {
105        fragment.append(line('', 'Server ready  https://wordpress.local', 'terminal-ready'));
106        fragment.append(line('', 'What do you want to customize?', 'terminal-follow-up'));
107      }
108      terminalOutput.replaceChildren(fragment);
109    };
110    const pause = () => {
111      window.clearTimeout(timer);
112      timer = 0;
113      if (!complete) terminalDemo.dataset.animationState = 'paused';
114    };
115    const tick = () => {
116      if (!visible || document.hidden || complete) return;
117      terminalDemo.dataset.animationState = 'running';
118      render();
119      frameIndex += 1;
120      if (frameIndex < frames.length * 2) {
121        timer = window.setTimeout(tick, 115);
122        return;
123      }
124      stepIndex += 1;
125      frameIndex = 0;
126      if (stepIndex >= steps.length) {
127        complete = true;
128        terminalDemo.dataset.animationState = 'complete';
129        render(false);
130        return;
131      }
132      timer = window.setTimeout(tick, 220);
133    };
134    const resume = () => {
135      if (complete || timer || !visible || document.hidden) return;
136      tick();
137    };
138    const finishWithoutMotion = () => {
139      stepIndex = steps.length;
140      complete = true;
141      terminalDemo.dataset.animationState = 'complete';
142      render(false);
143    };
144
145    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || !('IntersectionObserver' in window)) {
146      finishWithoutMotion();
147    } else {
148      terminalDemo.dataset.animationState = 'paused';
149      render();
150      const observer = new IntersectionObserver(([entry]) => {
151        visible = entry.isIntersecting && entry.intersectionRatio >= 0.55;
152        if (visible) resume();
153        else pause();
154      }, { threshold: [0, 0.55] });
155      observer.observe(terminalDemo);
156      document.addEventListener('visibilitychange', () => {
157        if (document.hidden) pause();
158        else resume();
159      });
160    }
161  }
162
163  document.querySelectorAll('[data-scroll-hint]').forEach((wrapper) => {
164    const scroller = wrapper.querySelector('.app-category-tabs');
165    if (!scroller) return;
166    const updateHint = () => {
167      const scrollable = scroller.scrollWidth > scroller.clientWidth + 2;
168      wrapper.classList.toggle('is-scrollable', scrollable);
169      wrapper.classList.toggle('is-at-end', !scrollable || scroller.scrollLeft + scroller.clientWidth >= scroller.scrollWidth - 2);
170    };
171    scroller.addEventListener('scroll', updateHint, { passive: true });
172    window.addEventListener('resize', updateHint, { passive: true });
173    scroller.querySelectorAll('button').forEach((button) => button.addEventListener('focus', () => {
174      button.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
175    }));
176    updateHint();
177  });
178
179  document.querySelectorAll('.content-appliance .prose .screenshots img:not([alt])').forEach((image) => {
180    image.alt = '';
181  });
182
183  document.querySelectorAll('.prose pre').forEach((block) => {
184    if (block.scrollWidth > block.clientWidth) block.tabIndex = 0;
185  });
186
187  const attachFilter = (inputId, containerId) => {
188    const input = document.getElementById(inputId);
189    const container = document.getElementById(containerId);
190    if (!input || !container) return;
191    const items = [...container.children];
192    input.addEventListener('input', () => {
193      const query = input.value.trim().toLowerCase();
194      items.forEach((item) => {
195        item.hidden = query !== '' && !(item.dataset.filterText || item.textContent).toLowerCase().includes(query);
196      });
197    });
198  };
199  attachFilter('app-filter', 'app-grid');
200
201  const docFilter = document.getElementById('doc-filter');
202  const docGrid = document.getElementById('doc-grid');
203  const docStatus = document.getElementById('doc-filter-status');
204  if (docFilter && docGrid && docStatus) {
205    const journeys = [...docGrid.querySelectorAll('[data-doc-journey]')];
206    const items = [...document.querySelectorAll('[data-doc-item]')];
207    const completeLibrary = document.getElementById('docs-complete-library');
208    const legacyLibrary = document.querySelector('.legacy-documentation details');
209    const directories = document.querySelector('.docs-directories');
210    const empty = document.getElementById('docs-empty');
211    const applyDocFilter = () => {
212      const words = docFilter.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
213      const filtering = words.length > 0;
214      let itemMatches = 0;
215      items.forEach((item) => {
216        const text = item.dataset.docTitle || item.textContent.toLowerCase();
217        const visible = !filtering || words.every((word) => text.includes(word));
218        item.hidden = !visible;
219        if (visible && filtering) itemMatches += 1;
220      });
221      let journeyMatches = 0;
222      journeys.forEach((journey) => {
223        const visible = !filtering || words.every((word) => journey.textContent.toLowerCase().includes(word));
224        journey.hidden = !visible;
225        if (visible && filtering) journeyMatches += 1;
226      });
227      if (completeLibrary && filtering) completeLibrary.open = true;
228      if (legacyLibrary && filtering) legacyLibrary.open = [...legacyLibrary.querySelectorAll('[data-doc-item]')].some((item) => !item.hidden);
229      if (directories) directories.hidden = filtering;
230      if (empty) empty.hidden = !filtering || itemMatches + journeyMatches > 0;
231      docStatus.textContent = filtering
232        ? message('docsMatching', '{count} matching guides', { count: itemMatches })
233        : message('docsCurrent', '{count} current guides', { count: docGrid.dataset.currentCount });
234    };
235    docFilter.addEventListener('input', applyDocFilter);
236  }
237
238  const collectionFilter = document.getElementById('doc-collection-filter');
239  const collectionList = document.getElementById('doc-collection-list');
240  const collectionEmpty = document.getElementById('doc-collection-empty');
241  if (collectionFilter && collectionList) {
242    const items = [...collectionList.children];
243    collectionFilter.addEventListener('input', () => {
244      const words = collectionFilter.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
245      let visible = 0;
246      items.forEach((item) => {
247        item.hidden = !words.every((word) => (item.dataset.filterText || item.textContent).includes(word));
248        if (!item.hidden) visible += 1;
249      });
250      if (collectionEmpty) collectionEmpty.hidden = visible !== 0;
251    });
252  }
253
254  const appSort = document.getElementById('app-sort');
255  const appGrid = document.getElementById('app-grid');
256  if (appSort && appGrid) {
257    const cards = [...appGrid.children];
258    const number = (card, name) => Number(card.dataset[name] || 0);
259    appSort.addEventListener('change', () => {
260      cards.sort((left, right) => {
261        if (appSort.value === 'title') return left.dataset.appTitle.localeCompare(right.dataset.appTitle);
262        if (appSort.value === 'newest') return number(right, 'appDate') - number(left, 'appDate');
263        return number(left, 'appRank') - number(right, 'appRank');
264      }
264).forEach((card) => appGrid.append(card));
265    });
266  }
267
268  const homeSearch = document.getElementById('home-app-filter');
269  const homeForm = document.getElementById('instant-app-search');
270  const homeGrid = document.getElementById('home-app-grid');
271  const homeStatus = document.getElementById('home-app-status');
272  const homeCategoryLink = document.getElementById('home-category-link');
273  const homeLoadMore = document.getElementById('home-app-load-more');
274  const homeTabs = [...document.querySelectorAll('[data-home-category]')];
275  if (homeSearch && homeForm && homeGrid && homeStatus && homeCategoryLink && homeLoadMore && homeTabs.length) {
276    const cards = [...homeGrid.children];
277    let activeTerm = homeTabs[0].dataset.homeCategory || '';
278    let activeTitle = homeTabs[0].textContent.trim();
279    let visibleBatches = 1;
280    const batchSize = () => window.innerWidth <= 580 ? 9 : window.innerWidth <= 900 ? 12 : 25;
281
282    const applyHomeFilter = (reset = false) => {
283      if (reset) visibleBatches = 1;
284      const words = homeSearch.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
285      const matching = cards.filter((card) => {
286        const categoryMatch = !activeTerm || (card.dataset.termIds || '').includes(`,${activeTerm},`);
287        const text = (card.dataset.filterText || card.textContent).toLowerCase();
288        return categoryMatch && words.every((word) => text.includes(word));
289      });
290      const limit = Math.min(matching.length, batchSize() * visibleBatches);
291      const visible = new Set(matching.slice(0, limit));
292      cards.forEach((card) => { card.hidden = !visible.has(card); });
293      const statusKey = words.length ? 'appsShowingMatching' : activeTerm ? 'appsShowingCategory' : 'appsShowing';
294      homeStatus.textContent = message(statusKey, 'Showing {shown} of {total} apps', {
295        shown: limit,
296        total: matching.length,
297        category: activeTitle,
298      });
299      const remaining = matching.length - limit;
300      homeLoadMore.hidden = remaining <= 0;
301      homeLoadMore.textContent = remaining > batchSize()
302        ? message('appsShowMore', 'Show {count} more apps', { count: batchSize() })
303        : message('appsShowMore', 'Show {count} more apps', { count: remaining });
304      return { matching, limit };
305    };
306
307    homeTabs.forEach((tab) => tab.addEventListener('click', () => {
308      activeTerm = tab.dataset.homeCategory || '';
309      activeTitle = tab.textContent.trim();
310      homeTabs.forEach((item) => item.setAttribute('aria-pressed', String(item === tab)));
311      homeCategoryLink.href = tab.dataset.categoryPath || '/apps/';
312      homeCategoryLink.textContent = activeTerm ? message('appsBrowseCategory', 'Browse all {category} apps', { category: activeTitle }) : message('appsBrowseAll', 'Browse all apps');
313      applyHomeFilter(true);
314    }));
315    homeSearch.addEventListener('input', () => applyHomeFilter(true));
316    homeForm.addEventListener('submit', (event) => { event.preventDefault(); applyHomeFilter(true); });
317    homeLoadMore.addEventListener('click', () => {
318      const previousLimit = batchSize() * visibleBatches;
319      visibleBatches += 1;
320      const { matching } = applyHomeFilter();
321      const firstRevealed = matching[previousLimit]?.querySelector('a[href]') || matching[previousLimit];
322      firstRevealed?.focus();
323      firstRevealed?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
324    });
325    window.addEventListener('resize', () => applyHomeFilter(), { passive: true });
326    applyHomeFilter();
327  }
328
329  const screenshotFilter = document.getElementById('screenshot-filter');
330  const screenshotGrid = document.getElementById('screenshot-grid');
331  const screenshotStatus = document.getElementById('screenshot-status');
332  const screenshotLoadMore = document.getElementById('screenshot-load-more');
333  const screenshotTabs = [...document.querySelectorAll('[data-screenshot-category]')];
334  if (screenshotFilter && screenshotGrid && screenshotStatus && screenshotLoadMore && screenshotTabs.length) {
335    const cards = [...screenshotGrid.children];
336    let activeTerm = screenshotTabs[0].dataset.screenshotCategory || '';
337    let visibleBatches = 1;
338    const batchSize = () => window.innerWidth <= 580 ? 12 : window.innerWidth <= 900 ? 18 : 24;
339    const applyScreenshotFilter = (reset = false) => {
340      if (reset) visibleBatches = 1;
341      const words = screenshotFilter.value.trim().toLowerCase().split(/\s+/).filter(
341Boolean);
342      const matching = cards.filter((card) => {
343        const categoryMatch = !activeTerm || (card.dataset.termIds || '').includes(`,${activeTerm},`);
344        const text = (card.dataset.filterText || card.textContent).toLowerCase();
345        return categoryMatch && words.every((word) => text.includes(word));
346      });
347      const shown = Math.min(batchSize() * visibleBatches, matching.length);
348      const visible = new Set(matching.slice(0, shown));
349      cards.forEach((card) => { card.hidden = !visible.has(card); });
350      screenshotStatus.textContent = message('screenshotsShowing', 'Showing {shown} of {total} screenshots', { shown, total: matching.length });
351      screenshotLoadMore.hidden = shown >= matching.length;
352      const remaining = matching.length - shown;
353      screenshotLoadMore.textContent = remaining > batchSize()
354        ? message('screenshotsShowMore', 'Show {count} more screenshots', { count: batchSize() })
355        : message('screenshotsShowMore', 'Show {count} more screenshots', { count: remaining });
356      return { matching, shown };
357    };
358    screenshotTabs.forEach((tab) => tab.addEventListener('click', () => {
359      activeTerm = tab.dataset.screenshotCategory || '';
360      screenshotTabs.forEach((item) => item.setAttribute('aria-pressed', String(item === tab)));
361      applyScreenshotFilter(true);
362    }));
363    screenshotFilter.addEventListener('input', () => applyScreenshotFilter(true));
364    screenshotLoadMore.addEventListener('click', () => {
365      const previousShown = batchSize() * visibleBatches;
366      visibleBatches += 1;
367      const { matching } = applyScreenshotFilter();
368      const firstRevealed = matching[previousShown]?.querySelector('a[href]') || matching[previousShown];
369      firstRevealed?.focus();
370      firstRevealed?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
371    });
372    window.addEventListener('resize', () => applyScreenshotFilter(), { passive: true });
373    applyScreenshotFilter(true);
374  }
375
376  const faqFilter = document.getElementById('faq-filter');
377  const faqTopicFilter = document.getElementById('faq-topic-filter');
378  const faqGroups = document.getElementById('faq-groups');
379  const faqStatus = document.getElementById('faq-filter-status');
380  const faqEmpty = document.getElementById('faq-empty');
381  if (faqFilter && faqTopicFilter && faqGroups && faqStatus && faqEmpty) {
382    const groups = [...faqGroups.querySelectorAll('[data-faq-group]')];
383    const items = [...faqGroups.querySelectorAll('[data-faq-item]')];
384    const applyFaqFilter = () => {
385      const words = faqFilter.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
386      const topic = faqTopicFilter.value;
387      let matches = 0;
388      items.forEach((item) => {
389        const text = (item.dataset.filterText || item.textContent).toLowerCase();
390        const visible = (!topic || item.dataset.faqTopic === topic) && words.every((word) => text.includes(word));
391        item.hidden = !visible;
392        if (visible) matches += 1;
393      });
394      groups.forEach((group) => {
395        group.hidden = ![...group.querySelectorAll('[data-faq-item]')].some((item) => !item.hidden);
396      });
397      const filtered = words.length > 0 || topic !== '';
398      faqStatus.textContent = filtered
399        ? message('faqMatching', '{count} matching answers', { count: matches })
400        : message('faqStatus', '{answers} answers in {topics} topics', { answers: faqGroups.dataset.answerCount, topics: faqGroups.dataset.topicCount });
401      faqEmpty.hidden = matches !== 0;
402    };
403    faqFilter.addEventListener('input', applyFaqFilter);
404    faqTopicFilter.addEventListener('change', applyFaqFilter);
405  }
406
407  const blogFilter = document.getElementById('blog-filter');
408  const blogTopicFilter = document.getElementById('blog-topic-filter');
409  const blogYearFilter = document.getElementById('blog-year-filter');
410  const blogArchive = document.getElementById('blog-archive');
411  const blogStatus = document.getElementById('blog-filter-status');
412  const blogEmpty = document.getElementById('blog-empty');
413  if (blogFilter && blogTopicFilter && blogYearFilter && blogArchive && blogStatus && blogEmpty) {
414    const groups = [...blogArchive.querySelectorAll('[data-blog-year-group]')];
415    const items = [...blogArchive.querySelectorAll('[data-blog-item]')];
416    const applyBlogFilter = () => {
417      const words = blogFilter.value.trim().toLowerCase().split(/\s+/).filter(
417Boolean);
418      const topic = blogTopicFilter.value;
419      const year = blogYearFilter.value;
420      let matches = 0;
421      items.forEach((item) => {
422        const text = (item.dataset.filterText || item.textContent).toLowerCase();
423        const topicMatch = !topic || (item.dataset.termIds || '').includes(`,${topic},`);
424        const visible = topicMatch && (!year || item.dataset.blogYear === year) && words.every((word) => text.includes(word));
425        item.hidden = !visible;
426        if (visible) matches += 1;
427      });
428      const filtered = words.length > 0 || topic !== '' || year !== '';
429      groups.forEach((group) => {
430        const visible = [...group.querySelectorAll('[data-blog-item]')].some((item) => !item.hidden);
431        group.hidden = !visible;
432        if (visible && filtered) group.querySelector('details').open = true;
433      });
434      blogStatus.textContent = filtered
435        ? message('blogMatching', '{count} matching articles', { count: matches })
436        : message('blogStatus', '{articles} articles across {years} years', { articles: blogArchive.dataset.articleCount, years: blogArchive.dataset.yearCount });
437      blogEmpty.hidden = matches !== 0;
438    };
439    blogFilter.addEventListener('input', applyBlogFilter);
440    blogTopicFilter.addEventListener('change', applyBlogFilter);
441    blogYearFilter.addEventListener('change', applyBlogFilter);
442  }
443
444  const form = document.querySelector('.site-search');
445  if (!form) return;
446  const input = document.getElementById('site-search-input');
447  const status = document.getElementById('search-status');
448  const results = document.getElementById('search-results');
449  let index = [];
450
451  const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({
452    '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
453  })[char]);
454
455  const decodeHtml = (value) => {
456    const textarea = document.createElement('textarea');
457    textarea.innerHTML = String(value ?? '');
458    return textarea.value;
459  };
460
461  const normalizeText = (value) => decodeHtml(value).replace(/\s+/g, ' ').trim();
462
463  const highlightText = (value, words) => {
464    const source = normalizeText(value);
465    if (!words.length) return escapeHtml(source);
466    const escapedWords = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
467    const pattern = new RegExp(`(${escapedWords.join('|')})`, 'gi');
468    return source.split(pattern).map((part, index) => index % 2 ? `<mark>${escapeHtml(part)}</mark>` : escapeHtml(part)).join('');
469  };
470
471  const search = async (query) => {
472    query = query.trim().toLowerCase();
473    if (!query) {
474      status.textContent = '';
475      results.replaceChildren();
476      return;
477    }
478    if (!index.length) {
479      status.textContent = message('searchLoading', 'Loading search index...');
480      index = await fetch(i18n.searchIndex || '/search-index.json').then((response) => {
481        if (!response.ok) throw new Error(message('searchError', 'Unable to load search index'));
482        return response.json();
483      });
484    }
485    const words = query.split(/\s+/).filter(Boolean);
486    const matches = index.filter((item) => {
487      const haystack = `${item.title} ${item.text} ${item.type}`.toLowerCase();
488      return words.every((word) => haystack.includes(word));
489    }).map((item) => {
490      const title = item.title.toLowerCase();
491      const titleScore = title === query ? 0 : title.startsWith(query) ? 1 : words.every((word) => title.includes(word)) ? 2 : 3;
492      return { ...item, titleScore };
493    }).sort((left, right) => Number(left.priority) - Number(right.priority)
494      || left.titleScore - right.titleScore
495      || left.title.localeCompare(right.title));
496    const grouped = [];
497    for (const item of matches) {
498      let group = grouped.at(-1);
499      if (!group || group.priority !== item.priority || group.label !== item.typeLabel) {
500        group = { priority: item.priority, label: item.typeLabel, items: [], visible: 5 };
501        grouped.push(group);
502      }
503      group.items.push(item);
504    }
505    const renderResults = () => {
506      const showing = grouped.reduce((total, group) => total + Math.min(group.visible, group.items.length), 0);
507      status.textContent = showing < matches.length
508        ? message('searchResultsShowing', '{count} results, showing {shown}', { count: matches.length, shown: showing })
509        : message('searchResults', '{count} results', { count: matches.length });
510      results.innerHTML = grouped.map((group, groupIndex) => {
511        const visibleItems = group.items.slice(0, group.visible);
512        const remaining = group.items.length - visibleItems.length;
513        const cards = visibleItems.map((item) => `<article class="search-result"><span class="content-type-badge">${highlightText(item.typeLabel, words)}</span><h3><a href="${encodeURI(item.url)}">${highlightText(item.title, words)}</a></h3><p>${highlightText(item.text, words)}</p></article>`).join('');
514        const more = remaining > 0 ? `<button class="search-more button secondary" type="button" data-search-more="${groupIndex}">${escapeHtml(message('searchMore', 'Show more {type} results', { type: group.label.toLowerCase() }))} <span aria-hidden="true">↓</span></button>` : '';
515        return `<section class="search-result-group" data-search-priority="${group.priority}" aria-labelledby="search-group-${groupIndex}"><h2 id="search-group-${groupIndex}" class="result-heading" tabindex="-1">${escapeHtml(group.label)} <small>${visibleItems.length} of ${group.items.length}</small></h2><div>${cards}</div>${more}</section>`;
516      }).join('');
517      results.querySelectorAll('[data-search-more]').forEach((button) => button.addEventListener('click', () => {
518        const groupIndex = Number(button.dataset.searchMore);
519        const group = grouped[groupIndex];
520        if (!group) return;
521        const previousVisible = Math.min(group.visible, group.items.length);
522        group.visible = Math.min(group.visible + 10, group.items.length);
523        renderResults();
524        const firstRevealed = results.querySelector(`.search-result-group:nth-of-type(${groupIndex + 1}) .search-result:nth-child(${previousVisible + 1}) a`);
525        firstRevealed?.focus();
526        firstRevealed?.scrollIntoView({ block: 'center', inline: '
526nearest', behavior: 'instant' });
527      }));
528    };
529    renderResults();
530  };
531
532  form.addEventListener('submit', (event) => {
533    event.preventDefault();
534    const query = input.value;
535    history.replaceState(null, '', query ? `?q=${encodeURIComponent(query)}` : location.pathname);
536    search(query).catch((error) => { status.textContent = error.message; });
537  });
538  const initial = new URLSearchParams(location.search).get('q') || '';
539  if (initial) {
540    input.value = initial;
541    search(initial).catch((error) => { status.textContent = error.message; });
542  }
543})();

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.