Skip to content

Commit 10a58dd

Browse files
authored
Merge pull request #3 from LuaLS/search
Add site search
2 parents 4b51183 + d705e6a commit 10a58dd

File tree

6 files changed

+320
-2
lines changed

6 files changed

+320
-2
lines changed

package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"autoprefixer": "^10.4.14",
1717
"axios": "^1.4.0",
1818
"dayjs": "^1.11.9",
19+
"fuse.js": "^6.6.2",
1920
"highlight.js": "^11.8.0",
2021
"jsdom": "^22.1.0",
2122
"mermaid": "^10.2.4",

src/components/common/Search.astro

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
---
2+
type searchItem = {
3+
text: string;
4+
href: string;
5+
};
6+
7+
const getContentStaticURL = (path: string, target?: string): string | null => {
8+
const rgx = /\/src\/.+?\/(.+)\..*?/g;
9+
10+
const result = rgx.exec(path);
11+
if (result === null) return null;
12+
13+
if (target) {
14+
return `/${result[1]}#${target}`;
15+
} else {
16+
return `/${result[1]}`;
17+
}
18+
};
19+
20+
const searchTags: searchItem[] = [];
21+
22+
const pages = await Astro.glob("../../content/**/*.mdx");
23+
24+
for (const page of pages) {
25+
const frontmatter = page.frontmatter;
26+
const headings = page.getHeadings();
27+
28+
const href = getContentStaticURL(page.file);
29+
if (!href) {
30+
throw new Error(`Failed to get static url of ${page.url}`);
31+
}
32+
33+
// Add article title to search options
34+
searchTags.push({ text: page.frontmatter.title, href });
35+
36+
// Add custom article tags to search options
37+
for (const tag of frontmatter.tags ?? []) {
38+
searchTags.push({ text: tag, href });
39+
}
40+
41+
// Add article headings to search options
42+
for (const heading of headings) {
43+
const href = getContentStaticURL(page.file, heading.slug);
44+
if (!href) {
45+
throw new Error(`Failed to get static url of ${page.url}`);
46+
}
47+
searchTags.push({ text: heading.text, href });
48+
}
49+
}
50+
---
51+
52+
<dialog id="site-search">
53+
<input type="search" placeholder="Search..." />
54+
55+
<div id="site-search-suggestions" tabindex="-1">
56+
{
57+
searchTags.map((entry) => (
58+
<a href={entry.href}>
59+
<span class="term">{entry.text}</span>
60+
<span class="page">{entry.href.substring(1)}</span>
61+
</a>
62+
))
63+
}
64+
</div>
65+
</dialog>
66+
67+
<script>
68+
import Fuse from "fuse.js";
69+
70+
const assertElement = <T extends Element>(
71+
selector: string,
72+
parent: Document | Element = document
73+
): T => {
74+
const element = parent.querySelector<T>(selector);
75+
76+
if (element === null) {
77+
throw new Error(`Could not find element: ${selector}`);
78+
}
79+
80+
return element;
81+
};
82+
83+
const searchIcon = assertElement<HTMLSpanElement>(
84+
"header button.site-search-icon"
85+
);
86+
const searchDialog = assertElement<HTMLDialogElement>("dialog#site-search");
87+
const searchInput = assertElement<HTMLInputElement>("input[type='search']");
88+
const optionsContainer = assertElement<HTMLDivElement>(
89+
"div#site-search-suggestions"
90+
);
91+
92+
// Get array of option elements
93+
const options = Array.from(optionsContainer.children) as HTMLDivElement[];
94+
95+
// Override scroll behavior so that the suggestions can be navigated with the arrow keys
96+
for (const option of options) {
97+
option.addEventListener("keydown", (e) => {
98+
if (e.key === "ArrowDown") {
99+
if (option.nextSibling) {
100+
(option.nextSibling as HTMLDivElement).focus();
101+
}
102+
e.preventDefault();
103+
} else if (e.key === "ArrowUp") {
104+
if (option.previousSibling) {
105+
(option.previousSibling as HTMLDivElement).focus();
106+
} else {
107+
searchInput.focus();
108+
}
109+
e.preventDefault();
110+
}
111+
});
112+
option.addEventListener("click", () => {
113+
searchDialog.close();
114+
});
115+
}
116+
117+
const searchItems = options.map((el) => {
118+
const text = el.querySelector(".term");
119+
const page = el.querySelector(".page");
120+
121+
if (!text) console.warn("Could not find .term for:", el);
122+
if (!page) console.warn("Could not find .page for:", el);
123+
124+
return {
125+
text: text?.textContent?.toLowerCase() ?? "",
126+
page: page?.textContent?.toLowerCase() ?? "",
127+
};
128+
});
129+
const haystack = new Fuse(searchItems, {
130+
keys: [{ name: "text", weight: 3 }, "page"],
131+
includeScore: true,
132+
});
133+
134+
const updateSuggestions = () => {
135+
const input = searchInput.value.trim().toLowerCase();
136+
137+
const results = haystack.search(input, { limit: 30 });
138+
results.sort((a, b) => (b?.score ?? 1) - (a?.score ?? 1));
139+
140+
// Hide all options
141+
options.forEach((el) => el.classList.remove("match"));
142+
143+
for (const result of results) {
144+
const element = options[result.refIndex];
145+
146+
element.title = result.score;
147+
148+
if (result.score && result.score <= 0.1) {
149+
element.classList.add("match");
150+
optionsContainer.insertBefore(element, optionsContainer.firstChild);
151+
}
152+
}
153+
};
154+
155+
const open = () => {
156+
searchDialog.showModal();
157+
searchInput.focus();
158+
};
159+
160+
// Show when search icon is clicked
161+
searchIcon.addEventListener("click", open);
162+
document.addEventListener("keydown", (e) => {
163+
if (e.key !== "/") return;
164+
open();
165+
e.preventDefault();
166+
});
167+
// Close when background is clicked
168+
searchDialog.addEventListener("click", (e) => {
169+
console.log(e);
170+
if (e.target !== searchDialog) return;
171+
searchDialog.close();
172+
});
173+
// Close when esc pressed
174+
document.addEventListener("keydown", (e) => {
175+
if (!searchDialog.classList.contains("show") || e.key !== "Escape") return;
176+
searchDialog.close();
177+
});
178+
// Update suggestions on text entry
179+
searchInput.addEventListener("keyup", (e) => {
180+
switch (e.key) {
181+
case "ArrowDown":
182+
assertElement<HTMLDivElement>(".match", optionsContainer).focus();
183+
break;
184+
case "Enter":
185+
const firstOption =
186+
optionsContainer.firstChild as HTMLAnchorElement | null;
187+
if (firstOption !== null) {
188+
location.assign(firstOption.href);
189+
searchDialog.close();
190+
break;
191+
}
192+
default:
193+
updateSuggestions();
194+
break;
195+
}
196+
});
197+
</script>
198+
199+
<style lang="scss">
200+
dialog#site-search {
201+
display: none;
202+
width: 60%;
203+
height: 60%;
204+
background: none;
205+
border: none;
206+
grid-template-columns: 1fr;
207+
grid-template-rows: 2.5em 4fr;
208+
align-items: center;
209+
justify-content: center;
210+
gap: 1em;
211+
212+
&::backdrop {
213+
background-color: #000000aa;
214+
}
215+
216+
&[open] {
217+
display: grid;
218+
}
219+
}
220+
221+
input[type="search"] {
222+
width: 100%;
223+
font-size: 1.5em;
224+
margin: auto;
225+
border-radius: 0.5rem;
226+
padding: 0.25rem 0.5rem;
227+
border: none;
228+
background: #0f2e4b;
229+
color: white;
230+
231+
&:focus+div#site-search-suggestions > a:first-child() {
232+
color: white;
233+
}
234+
}
235+
236+
div#site-search-suggestions {
237+
align-self: flex-start;
238+
overflow-y: auto;
239+
max-height: 100%;
240+
background-color: #081f34;
241+
border-radius: 0.5em;
242+
243+
& > a {
244+
display: none;
245+
font-size: 1em;
246+
padding: 0.25em 0.5em;
247+
box-sizing: border-box;
248+
249+
&:focus {
250+
outline: none;
251+
color: white;
252+
border-radius: unset;
253+
}
254+
255+
&.match {
256+
display: flex;
257+
justify-content: space-between;
258+
}
259+
span {
260+
text-overflow: ellipsis;
261+
overflow: hidden;
262+
width: 100%;
263+
white-space: nowrap;
264+
265+
&.term {
266+
flex: 1 1;
267+
}
268+
269+
&.page {
270+
flex: 1 2;
271+
text-align: right;
272+
opacity: 0.5;
273+
}
274+
}
275+
}
276+
}
277+
278+
@media screen and (max-width: 1000px) {
279+
dialog#site-search {
280+
width: 100%;
281+
}
282+
}
283+
284+
@media screen and (max-width: 600px) {
285+
div#site-search-suggestions {
286+
& > a {
287+
span {
288+
&.page {
289+
font-size: 0.8em;
290+
}
291+
}
292+
}
293+
}
294+
}
295+
</style>

src/components/layout/Header.astro

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import ExternalLink from "../common/ExternalLink.astro";
33
import Icon from "../common/Icon.astro";
44
import Tooltip from "../common/Tooltip.astro";
5+
56
export interface Props {
67
class?: string;
78
}
@@ -23,6 +24,9 @@ const { class: className } = Astro.props;
2324
</Tooltip>
2425
</a>
2526
</div>
27+
<button class="site-search-icon">
28+
<Icon name="search" group="solid" />
29+
</button>
2630
<span class="github">
2731
<ExternalLink
2832
url="https://github.com/luals/lua-language-server"
@@ -57,7 +61,7 @@ const { class: className } = Astro.props;
5761
nav {
5862
width: 100%;
5963
display: grid;
60-
grid-template-columns: repeat(3, 2em);
64+
grid-template-columns: repeat(4, 2em);
6165
justify-content: space-between;
6266
align-items: center;
6367
justify-items: center;
@@ -74,6 +78,13 @@ const { class: className } = Astro.props;
7478
color: white;
7579
}
7680

81+
button {
82+
border: none;
83+
background: none;
84+
color: white;
85+
font-size: 1em;
86+
}
87+
7788
.github {
7889
width: fit-content;
7990
font-size: 1.2em;

src/content/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const wikiArticleCollection = defineCollection({
55
schema: z.object({
66
title: z.string(),
77
description: z.string(),
8+
tags: z.string().optional(),
89
["getting-started"]: z.boolean().optional(),
910
incomplete: z.boolean().optional(),
1011
lastModified: z.string().optional(),

src/layouts/Main.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Layout from "./Default.astro";
44
// Components
55
import Header from "../components/layout/Header.astro";
66
import Footer from "../components/layout/Footer.astro";
7+
import Search from "../components/common/Search.astro";
78
89
export interface Props {
910
title: string;
@@ -15,5 +16,5 @@ export interface Props {
1516
<Header />
1617
<slot />
1718
<Footer />
19+
<Search />
1820
</Layout>
19-

0 commit comments

Comments
 (0)