repos

blog.bythewood.me-rust

mirror archived upstream

Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.

axumblogdockermarkdownminijinjarustself-hostedtypstvite

4.4 KB · 128 lines · JavaScript Raw History
  1/**
  2 * Search.js
  3 *
  4 * Provides live search for any "id_search" field using the URL
  5 * /search/live/?q=<query>
  6 *
  7 * Arrow keys navigate results, Enter follows the selected link.
  8 */
  9
 10document.addEventListener("DOMContentLoaded", function () {
 11  const searchEl = document.getElementById("id_search");
 12  if (!searchEl) return;
 13
 14  let activeIndex = -1;
 15
 16  const getItems = () => {
 17    const container = searchEl.parentElement.querySelector("#id_search_results ul");
 18    return container ? Array.from(container.querySelectorAll("a")) : [];
 19  };
 20
 21  const setActive = (index) => {
 22    const items = getItems();
 23    items.forEach((item) => item.classList.remove("active"));
 24    activeIndex = index;
 25    if (index >= 0 && index < items.length) {
 26      items[index].classList.add("active");
 27      items[index].scrollIntoView({ block: "nearest" });
 28    }
 29  };
 30
 31  const clearResults = () => {
 32    const resultsEl = searchEl.parentElement.querySelector("#id_search_results");
 33    if (resultsEl) {
 34      searchEl.parentElement.removeChild(resultsEl);
 35    }
 36    activeIndex = -1;
 37  };
 38
 39  searchEl.addEventListener("keydown", function (e) {
 40    const items = getItems();
 41    if (!items.length) return;
 42
 43    if (e.key === "ArrowDown") {
 44      e.preventDefault();
 45      setActive(activeIndex < items.length - 1 ? activeIndex + 1 : 0);
 46    } else if (e.key === "ArrowUp") {
 47      e.preventDefault();
 48      setActive(activeIndex > 0 ? activeIndex - 1 : items.length - 1);
 49    } else if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
 50      e.preventDefault();
 51      window.location.href = items[activeIndex].href;
 52    }
 53  });
 54
 55  searchEl.addEventListener("keyup", function (e) {
 56    // Ignore navigation keys
 57    if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;
 58
 59    const query = searchEl.value;
 60    if (query.length === 0) {
 61      clearResults();
 62      return;
 63    }
 64
 65    const url = "/search/live/?q=" + encodeURIComponent(query);
 66    fetch(url)
 67      .then((response) => response.json())
 68      .then((data) => {
 69        clearResults();
 70        if (!data.length) return;
 71
 72        const resultsEl = document.createElement("div");
 73        resultsEl.id = "id_search_results";
 74        searchEl.parentElement.appendChild(resultsEl);
 75
 76        const resultsDiv = document.createElement("ul");
 77        resultsDiv.classList.add("list-group", "rounded-bottom", "position-absolute", "mt-2");
 78        resultsDiv.style.zIndex = "1100";
 79        resultsDiv.style.width = searchEl.offsetWidth + "px";
 80        resultsDiv.style.backgroundColor = "#13120e";
 81        resultsDiv.style.border = "1px solid rgba(107,158,120,0.15)";
 82        resultsEl.appendChild(resultsDiv);
 83
 84        // Build result rows from text nodes: a query with regex
 85        // metacharacters (e.g. "c++") must not throw, and post text must
 86        // never be parsed as HTML.
 87        const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 88        const appendHighlighted = (parent, text, strongClass) => {
 89          const re = new RegExp(escapeRegExp(query), "gi");
 90          let last = 0;
 91          let m;
 92          while ((m = re.exec(text)) !== null) {
 93            parent.appendChild(document.createTextNode(text.slice(last, m.index)));
 94            const strong = document.createElement("strong");
 95            if (strongClass) strong.className = strongClass;
 96            strong.textContent = m[0];
 97            parent.appendChild(strong);
 98            last = m.index + m[0].length;
 99          }
100          parent.appendChild(document.createTextNode(text.slice(last)));
101        };
102
103        data.forEach((result) => {
104          const a = document.createElement("a");
105          a.classList.add("list-group-item", "list-group-item-action");
106          const titleSpan = document.createElement("span");
107          titleSpan.className = "fw-bold";
108          appendHighlighted(titleSpan, result.title, "");
109          const descSpan = document.createElement("span");
110          descSpan.className = "text-muted";
111          appendHighlighted(descSpan, result.description, "fw-bolder");
112          a.appendChild(titleSpan);
113          a.appendChild(document.createElement("br"));
114          a.appendChild(descSpan);
115          a.href = result.url;
116          resultsDiv.appendChild(a);
117        });
118      });
119  });
120
121  searchEl.addEventListener("blur", function (e) {
122    const searchResults = searchEl.parentElement.querySelector("#id_search_results");
123    if (searchResults && !searchResults.contains(e.relatedTarget)) {
124      clearResults();
125    }
126  });
127});