Skip to content

Commit c962e61

Browse files
committed
Implement the search bar
1 parent 6676f6c commit c962e61

4 files changed

Lines changed: 276 additions & 2 deletions

File tree

src/assets/styles/docs.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,45 @@ aside > div {
135135
color: var(--sl-color-primary-700);
136136
}
137137

138+
.dei-navigation > li > a > p {
139+
color: var(--sl-color-neutral-500);
140+
font-size: 0.75em;
141+
overflow: clip;
142+
text-overflow: ellipsis;
143+
text-wrap: nowrap;
144+
}
145+
146+
/* Search stuff. */
147+
.dei-searchflex {
148+
display: flex;
149+
align-content: center;
150+
}
151+
152+
.dei-searchflex > sl-spinner {
153+
margin: auto;
154+
font-size: 3em;
155+
--track-width: 4px;
156+
}
157+
158+
.dei-searchflex > p {
159+
color: var(--sl-color-neutral-600);
160+
font-style: italic;
161+
margin: auto;
162+
text-align: center;
163+
}
164+
165+
.dei-searchhighlight {
166+
background-color: var(--sl-color-primary-300);
167+
}
168+
169+
.dei-searcherror {
170+
color: var(--sl-color-danger-700) !important;
171+
}
172+
173+
.dei-searcherror > sl-icon {
174+
font-size: 3em;
175+
}
176+
138177
/* Document render. */
139178
.dei-document {
140179
width: 50%;

src/main.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
import "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/components/input/input.js";
8989
import "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/components/menu-item/menu-item.js";
9090
import "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/components/menu/menu.js";
91+
import "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/components/spinner/spinner.js";
9192
</script>
9293

9394
<!-- Main code -->

src/main.js

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,239 @@ function setTheme(theme) {
107107
}
108108
})();
109109

110+
// Search functionality.
111+
(function() {
112+
// Fetch elements.
113+
const searchInput = document.getElementById("dei-searchinput");
114+
const navMain = document.getElementById("dei-navmain");
115+
const navSearch = document.getElementById("dei-navsearch");
116+
117+
if (!searchInput | !navMain || !navSearch) {
118+
return;
119+
}
120+
121+
// Initialize.
122+
const repo = searchInput.dataset.repo;
123+
124+
let db = null;
125+
let dbFetch = false;
126+
let search = null;
127+
128+
// On input change.
129+
searchInput.addEventListener("sl-input", async () => {
130+
search = searchInput.value.trim();
131+
132+
// Cleared; show main navigation again, hide search results.
133+
if (search === "") {
134+
navMain.style = "";
135+
navSearch.style = "display: none";
136+
return;
137+
}
138+
139+
// Hide main navigation, show search results.
140+
navMain.style = "display: none";
141+
navSearch.style = "";
142+
143+
// Load database if not already loaded.
144+
if (db === null) {
145+
// Don't fetch more than once.
146+
if (dbFetch) {
147+
return;
148+
}
149+
150+
dbFetch = true;
151+
152+
// Display spinner.
153+
navSearch.innerHTML = /* HTML */ `
154+
<div class="dei-searchflex">
155+
<sl-spinner></sl-spinner>
156+
</div>
157+
`;
158+
159+
// Download and parse database.
160+
const response = await fetch(`/${repo}/db.json`);
161+
if (!response.ok) {
162+
// Display error on failure.
163+
navSearch.innerHTML = /* HTML */ `
164+
<div class="dei-searchflex">
165+
<p class="dei-searcherror">
166+
<sl-icon name="exclamation-octagon"></sl-icon><br>
167+
Failed to download database:<br>
168+
${response.status} ${response.statusText}
169+
</p>
170+
</div>
171+
`;
172+
173+
// Allow retrying and give up for this attempt.
174+
dbFetch = false;
175+
return;
176+
}
177+
178+
db = await response.json();
179+
}
180+
181+
// Perform search.
182+
const results = [];
183+
const terms = search.split(/ /g);
184+
185+
for (const entry of db) {
186+
// Initialize result.
187+
let result = {
188+
score: 0,
189+
titleMatches: [],
190+
contentMatches: [],
191+
entry
192+
};
193+
194+
// Look for terms.
195+
let termsMatched = {};
196+
for (let term of terms) {
197+
// Ignore terms shorter than 3 characters.
198+
if (term.length < 3) {
199+
continue;
200+
}
201+
202+
// Ignore casing.
203+
const title = entry.title.toLowerCase();
204+
const content = entry.content.toLowerCase();
205+
206+
term = term.toLowerCase();
207+
208+
// In title.
209+
let match, index = 0;
210+
while ((match = title.indexOf(term, index)) != -1) {
211+
// Increase score.
212+
result.score += 5;
213+
214+
// Add title match.
215+
const titleMatch = entry.title.substring(
216+
match,
217+
match + term.length
218+
);
219+
220+
if (!(titleMatch in result.titleMatches)) {
221+
result.titleMatches.push(titleMatch);
222+
}
223+
224+
// Mark term as matched.
225+
termsMatched[term] = true;
226+
227+
index = match + term.length;
228+
}
229+
230+
// In content.
231+
index = 0;
232+
while ((match = content.indexOf(term, index)) != -1) {
233+
// Increase core.
234+
result.score++;
235+
236+
// Add content match.
237+
const contentMatch = entry.content.substring(
238+
match,
239+
match + term.length
240+
);
241+
242+
if (!(contentMatch in result.contentMatches)) {
243+
result.contentMatches.push(contentMatch);
244+
}
245+
246+
termsMatched[term] = true;
247+
248+
index = match + term.length;
249+
}
250+
}
251+
252+
// Multiply score based on number of unique terms that were matched.
253+
result.score *= Object.entries(termsMatched).length;
254+
255+
// Result has at least some score; push.
256+
if (result.score) {
257+
results.push(result);
258+
}
259+
}
260+
261+
// Sort by score.
262+
results.sort((lhs, rhs) => rhs.score - lhs.score);
263+
264+
// No results.
265+
if (results.length === 0) {
266+
navSearch.innerHTML = /* HTML */ `
267+
<div class="dei-searchflex">
268+
<p>Search found no results</p>
269+
</div>
270+
`;
271+
272+
return;
273+
}
274+
275+
// Otherwise generate result HTML.
276+
navSearch.innerHTML = "";
277+
278+
for (let result of results) {
279+
const entry = result.entry;
280+
281+
let title = entry.title;
282+
let preview = entry.content;
283+
284+
// Mark title matches.
285+
for (const match of result.titleMatches) {
286+
title = title.replaceAll(match, `\x1B\x0B${match}\x1B\x0C`);
287+
}
288+
289+
// Mark content matches.
290+
for (const match of result.contentMatches) {
291+
preview = preview.replaceAll(match, `\x1B\x0B${match}\x1B\x0C`);
292+
}
293+
294+
// Limit preview to three highlighted lines.
295+
let lines = 0;
296+
297+
preview = preview
298+
.split(/\n/g)
299+
.filter((x) => {
300+
if (lines >= 3) {
301+
return false;
302+
}
303+
304+
if (x.includes("\x1B")) {
305+
lines++;
306+
return true;
307+
}
308+
309+
return false;
310+
})
311+
.join("\n");
312+
313+
// Escape content HTML.
314+
preview = preview
315+
.replaceAll("&", "&amp;")
316+
.replaceAll("<", "&lt;")
317+
.replaceAll(">", "&gt;")
318+
.replaceAll("\"", "&quot;")
319+
.replaceAll("'", "&#39;");
320+
321+
// Perform replacements.
322+
title = title
323+
.replaceAll("\x1B\x0B", `<span class="dei-searchhighlight">`)
324+
.replaceAll("\x1B\x0C", `</span>`);
325+
preview = preview
326+
.replaceAll("\x1B\x0B", `<span class="dei-searchhighlight">`)
327+
.replaceAll("\x1B\x0C", `</span>`)
328+
.replaceAll("\n", "<br>");
329+
330+
// Append HTML.
331+
navSearch.innerHTML += /* HTML */ `
332+
<li>
333+
<a href="${entry.href}">
334+
${title}
335+
<p>${preview}</p>
336+
</a>
337+
</li>
338+
`;
339+
}
340+
});
341+
})();
342+
110343
// Scroll to current page in navigation when present.
111344
window.addEventListener("load", () => {
112345
// Fetch current page in navigation; do nothing if there's none.

src/templates/docs.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@
2424
</div>
2525

2626
<!-- Search -->
27-
<sl-input placeholder="Search" clearable pill disabled> <!-- TODO: implement -->
27+
<sl-input id="dei-searchinput" placeholder="Search" data-repo="${DOCS_REPO}" clearable pill>
2828
<sl-icon name="search" slot="prefix"></sl-icon>
2929
</sl-input>
3030

3131
<!-- Navigation -->
32-
<ul class="dei-navigation">
32+
<ul id="dei-navmain" class="dei-navigation">
3333
${DOCS_NAVHTML}
3434
</ul>
35+
<ul id="dei-navsearch" class="dei-navigation" style="display: none"></ul>
3536
</aside>
3637

3738
<sl-button id="dei-openaside" class="dei-openaside" pill>

0 commit comments

Comments
 (0)