Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
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});