dotfiles

My PC Dotfiles & Website

site/static/js/search.js (5146B)


  1(() => {
  2  const input = document.getElementById("search");
  3  if (!input) return;
  4  const container = input.closest(".search-container");
  5  const panel = document.getElementById("search-results");
  6  const items = panel.querySelector(".search-results__items");
  7  const status = panel.querySelector('[role="status"]');
  8  let engine;
  9  let library;
 10  let timer;
 11  let revision = 0;
 12
 13  function loadEngine() {
 14    if (engine) return engine;
 15    if (!library) {
 16      library = new Promise((resolve, reject) => {
 17        if (window.Fuse) return resolve();
 18        const script = document.createElement("script");
 19        script.src = input.dataset.library;
 20        script.onload = resolve;
 21        script.onerror = () => {
 22          script.remove();
 23          library = null;
 24          reject(new Error("Search library unavailable"));
 25        };
 26        document.head.appendChild(script);
 27      });
 28    }
 29    engine = Promise.all([
 30      library,
 31      fetch(input.dataset.index).then((response) => {
 32        if (!response.ok) throw new Error("Search index unavailable");
 33        return response.json();
 34      }),
 35    ])
 36      .then(
 37        ([, index]) =>
 38          new Fuse(index, {
 39            keys: [
 40              { name: "title", weight: 2 },
 41              { name: "body", weight: 1 },
 42            ],
 43            minMatchCharLength: 2,
 44            threshold: 0.3,
 45            ignoreLocation: true,
 46          }),
 47      )
 48      .catch((error) => {
 49        engine = null;
 50        throw error;
 51      });
 52    return engine;
 53  }
 54
 55  function show(visible) {
 56    panel.style.display = visible ? "block" : "none";
 57  }
 58
 59  function close() {
 60    revision++;
 61    clearTimeout(timer);
 62    panel.removeAttribute("aria-busy");
 63    show(false);
 64  }
 65
 66  function teaser(body, terms) {
 67    const lower = body.toLowerCase();
 68    const matches = terms
 69      .map((term) => lower.indexOf(term.toLowerCase()))
 70      .filter((index) => index >= 0);
 71    const start = Math.max(0, (matches.length ? Math.min(...matches) : 0) - 40);
 72    const text =
 73      (start ? "…" : "") +
 74      body.slice(start, start + 200) +
 75      (body.length > start + 200 ? "…" : "");
 76    const paragraph = document.createElement("div");
 77    const pattern = new RegExp(
 78      terms
 79        .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
 80        .join("|"),
 81      "gi",
 82    );
 83    let position = 0;
 84    for (const match of text.matchAll(pattern)) {
 85      paragraph.append(text.slice(position, match.index));
 86      const bold = document.createElement("b");
 87      bold.textContent = match[0];
 88      paragraph.append(bold);
 89      position = match.index + match[0].length;
 90    }
 91    paragraph.append(text.slice(position));
 92    return paragraph;
 93  }
 94
 95  async function search() {
 96    const term = input.value.trim();
 97    const request = ++revision;
 98    items.replaceChildren();
 99    if (!term) return show(false);
100    show(true);
101    status.textContent = "Searching…";
102    panel.setAttribute("aria-busy", "true");
103    try {
104      const fuse = await loadEngine();
105      if (request !== revision) return;
106      const results = fuse.search(term);
107      status.textContent = results.length
108        ? `${results.length} ${results.length === 1 ? "result" : "results"}${results.length > 10 ? " (showing 10)" : ""}`
109        : "No results found.";
110      const fragment = document.createDocumentFragment();
111      for (const { item } of results.slice(0, 10)) {
112        const url = new URL(item.url, location.href);
113        if (url.origin !== location.origin) continue;
114        const row = document.createElement("li");
115        row.className = "search-results__item";
116        const title = document.createElement("h2");
117        title.className = "title";
118        const link = document.createElement("a");
119        link.href = url.href;
120        link.textContent = item.title;
121        title.append(link);
122        row.append(
123          title,
124          teaser((item.body || "").replace(/§/g, ""), term.split(/\s+/)),
125        );
126        const more = document.createElement("a");
127        more.href = url.href;
128        const label = document.createElement("i");
129        label.textContent = "Read More →";
130        more.append(label);
131        row.append(more);
132        fragment.append(row);
133      }
134      items.append(fragment);
135    } catch {
136      if (request === revision)
137        status.textContent = "Search could not load. Try typing again.";
138    } finally {
139      if (request === revision) panel.removeAttribute("aria-busy");
140    }
141  }
142
143  input.addEventListener("focus", () => {
144    loadEngine().catch(() => {});
145    if (input.value.trim()) search();
146  });
147  input.addEventListener("input", () => {
148    revision++;
149    clearTimeout(timer);
150    if (!input.value.trim()) return close();
151    timer = setTimeout(search, 150);
152  });
153  container.addEventListener("keydown", (event) => {
154    if (event.key === "Escape") {
155      input.focus();
156      close();
157    }
158  });
159  document.addEventListener("click", (event) => {
160    if (!container.contains(event.target)) close();
161  });
162  container.addEventListener("focusout", (event) => {
163    if (!container.contains(event.relatedTarget)) close();
164  });
165})();