packages feed

kitchen-sink-0.2.0.0: scaffolding/js/doc-search.js

// Search box of the `documentation` layout: looks the typed words up in the
// headings and text of every documentation page (json/doc-search.json).
(function(){
  const root = document.getElementById("doc-search");
  if (!root) { return; }
  const indexUrl = root.dataset.index;
  const maxResults = 12;

  const input = document.createElement("input");
  input.type = "search";
  input.className = "doc-search-input";
  input.placeholder = "Search the documentation ( / )";
  input.setAttribute("aria-label", "Search the documentation");
  input.autocomplete = "off";
  const results = document.createElement("ul");
  results.className = "doc-search-results";
  results.hidden = true;
  root.appendChild(input);
  root.appendChild(results);

  let entries = null; // null until fetched
  let loading = null;
  let selected = -1;

  const load = () => {
    if (loading === null) {
      loading = fetch(indexUrl)
        .then((res) => res.ok ? res.json() : [])
        .catch(() => [])
        .then((xs) => {
          entries = xs.map((e) => ({
            entry: e,
            title: (e.title ?? "").toLowerCase(),
            heading: (e.heading ?? "").toLowerCase(),
            text: (e.text ?? "").toLowerCase(),
          }));
        });
    }
    return loading;
  };

  // every word must be found; a word found in a heading or a title weighs
  // more than one found in the text only
  const score = (item, words) => {
    let total = 0;
    for (const w of words) {
      let s = 0;
      if (item.heading.includes(w)) { s += 6; }
      if (item.title.includes(w)) { s += item.entry.heading ? 2 : 8; }
      if (item.text.includes(w)) { s += 1; }
      if (s === 0) { return 0; }
      total += s;
    }
    return total;
  };

  const search = (query) => {
    const words = query.toLowerCase().split(/\s+/).filter((w) => w.length > 0);
    if (words.length === 0 || entries === null) { return { words, hits: [] }; }
    const hits = entries
      .map((item, position) => ({ item, position, score: score(item, words) }))
      .filter((hit) => hit.score > 0)
      .sort((a, b) => b.score - a.score || a.position - b.position)
      .slice(0, maxResults)
      .map((hit) => hit.item);
    return { words, hits };
  };

  // the text around the first word found, with the words highlighted
  const snippet = (item, words) => {
    const text = item.entry.text ?? "";
    const firsts = words.map((w) => item.text.indexOf(w)).filter((i) => i >= 0);
    const start = Math.max(0, (firsts.length > 0 ? Math.min(...firsts) : 0) - 40);
    const excerpt = text.slice(start, start + 160);
    const lower = excerpt.toLowerCase();
    const p = document.createElement("span");
    p.className = "doc-search-snippet";
    if (start > 0) { p.append("…"); }
    let pos = 0;
    while (pos < excerpt.length) {
      let next = -1;
      let len = 0;
      for (const w of words) {
        const i = lower.indexOf(w, pos);
        if (i >= 0 && (next < 0 || i < next)) { next = i; len = w.length; }
      }
      if (next < 0) { break; }
      p.append(excerpt.slice(pos, next));
      const mark = document.createElement("mark");
      mark.textContent = excerpt.slice(next, next + len);
      p.append(mark);
      pos = next + len;
    }
    p.append(excerpt.slice(pos));
    if (start + 160 < text.length) { p.append("…"); }
    return p;
  };

  const select = (i) => {
    const items = [...results.querySelectorAll("a")];
    selected = items.length === 0 ? -1 : (i + items.length) % items.length;
    items.forEach((a, j) => a.classList.toggle("doc-search-selected", j === selected));
    if (selected >= 0) { items[selected].scrollIntoView({ block: "nearest" }); }
  };

  const render = () => {
    const { words, hits } = search(input.value);
    results.replaceChildren();
    selected = -1;
    if (words.length === 0) { results.hidden = true; return; }
    if (hits.length === 0) {
      const li = document.createElement("li");
      li.className = "doc-search-empty";
      li.textContent = entries === null ? "Loading…" : "No result";
      results.appendChild(li);
    }
    for (const item of hits) {
      const e = item.entry;
      const li = document.createElement("li");
      const a = document.createElement("a");
      a.href = e.url;
      const title = document.createElement("span");
      title.className = "doc-search-title";
      title.textContent = e.heading ? e.heading : e.title;
      const where = document.createElement("span");
      where.className = "doc-search-page";
      where.textContent = [e.group, e.heading ? e.title : null].filter((x) => x).join(" › ");
      a.append(title, where, snippet(item, words));
      li.appendChild(a);
      results.appendChild(li);
    }
    results.hidden = false;
    if (hits.length > 0) { select(0); }
  };

  const close = () => { results.hidden = true; };

  input.addEventListener("focus", () => { load().then(render); });
  input.addEventListener("input", () => { render(); load().then(render); });
  input.addEventListener("keydown", (ev) => {
    if (ev.key === "ArrowDown") { ev.preventDefault(); select(selected + 1); }
    else if (ev.key === "ArrowUp") { ev.preventDefault(); select(selected - 1); }
    else if (ev.key === "Enter") {
      const items = results.querySelectorAll("a");
      if (selected >= 0 && items[selected]) { ev.preventDefault(); close(); window.location.href = items[selected].href; }
    }
    else if (ev.key === "Escape") { close(); input.blur(); }
  });
  // a result may lead to another heading of the current page
  results.addEventListener("click", (ev) => { if (ev.target.closest("a")) { close(); } });
  document.addEventListener("click", (ev) => { if (!root.contains(ev.target)) { close(); } });
  document.addEventListener("keydown", (ev) => {
    const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(ev.target.tagName) || ev.target.isContentEditable;
    if (ev.key === "/" && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
      ev.preventDefault();
      input.focus();
      input.select();
    }
  });

  // on a narrow screen the list of pages is above the content: start it folded
  const foldNavigation = () => {
    const menu = document.querySelector(".doc-nav-menu");
    if (menu && window.matchMedia("(max-width: 800px)").matches) { menu.open = false; }
  };
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", foldNavigation);
  } else {
    foldNavigation();
  }
})()