github.com/gemaraproj/gemara@v1.3.0

docs/assets/js/search.js raw

  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, '&lt;')
 29      .replace(/>/g, '&gt;')
 30      .replace(/"/g, '&quot;');
 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 &ldquo;' + escapeHTML(query) + '&rdquo;.</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})();