1// SPDX-License-Identifier: Apache-2.0
2
3(function () {
4 const input = document.getElementById('search-input');
5 const results = document.getElementById('search-results');
6 if (!input || !results) return;
7
8 const indexUrl = input.dataset.index || '/search.json';
9 let index = null;
10 let pending = null;
11
12 function loadIndex() {
13 if (index) return Promise.resolve();
14 if (pending) return pending;
15 pending = fetch(indexUrl)
16 .then(function (r) { return r.json(); })
17 .then(function (data) { index = data; })
18 .catch(function (err) {
19 console.error('Search index failed to load', err);
20 index = [];
21 });
22 return pending;
23 }
24
25 function escapeHTML(s) {
26 return String(s)
27 .replace(/&/g, '&')
28 .replace(/</g, '<')
29 .replace(/>/g, '>')
30 .replace(/"/g, '"');
31 }
32
33 function search(query) {
34 if (!index || !query) return [];
35 const q = query.toLowerCase();
36 const matches = [];
37 for (let i = 0; i < index.length; i++) {
38 const entry = index[i];
39 const title = (entry.title || '').toLowerCase();
40 const content = (entry.content || '').toLowerCase();
41 const titleMatch = title.indexOf(q) !== -1;
42 const contentMatch = content.indexOf(q) !== -1;
43 if (!titleMatch && !contentMatch) continue;
44 matches.push({
45 entry: entry,
46 score: (titleMatch ? 10 : 0) + (contentMatch ? 1 : 0),
47 });
48 }
49 matches.sort(function (a, b) { return b.score - a.score; });
50 return matches.slice(0, 10).map(function (m) { return m.entry; });
51 }
52
53 function render(matches, query) {
54 if (!query) {
55 results.innerHTML = '';
56 results.hidden = true;
57 return;
58 }
59 if (matches.length === 0) {
60 results.innerHTML = '<li class="search-empty">No matches for “' + escapeHTML(query) + '”.</li>';
61 results.hidden = false;
62 return;
63 }
64 results.innerHTML = matches
65 .map(function (m) {
66 return '<li><a href="' + escapeHTML(m.url) + '">' + escapeHTML(m.title) + '</a></li>';
67 })
68 .join('');
69 results.hidden = false;
70 }
71
72 input.addEventListener('focus', function () {
73 loadIndex().then(function () {
74 const q = input.value.trim();
75 if (q) render(search(q), q);
76 });
77 });
78
79 input.addEventListener('input', function () {
80 loadIndex().then(function () {
81 const q = input.value.trim();
82 render(search(q), q);
83 });
84 });
85
86 // Hide results when focus leaves the search area, but allow link clicks first.
87 input.addEventListener('blur', function () {
88 setTimeout(function () {
89 if (!results.contains(document.activeElement)) {
90 results.hidden = true;
91 }
92 }, 150);
93 });
94
95 input.addEventListener('keydown', function (e) {
96 if (e.key === 'Escape') {
97 input.value = '';
98 results.hidden = true;
99 input.blur();
100 }
101 });
102})();