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 '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' 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.