Skip to content

Commit 267a5fb

Browse files
committed
feat: search
1 parent ce04b6a commit 267a5fb

File tree

18 files changed

+1177
-12
lines changed

18 files changed

+1177
-12
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ jobs:
4343
ruby-version: ruby-3.4.2
4444
bundler-cache: true
4545

46+
- uses: denoland/setup-deno@v2
47+
with:
48+
deno-version: v2.x
49+
4650
- name: Run tests
4751
env:
4852
RAILS_ENV: test

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@
1010
/website/tmp/
1111

1212
node_modules/
13+
/website/app/assets/builds/*
14+
!/website/app/assets/builds/.keep

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ HotDocs is a set of optimized Rails components & tools for writing docs:
2222
| Styled components you can customize ||||
2323
| Markdown (with syntax highlight & themes) | 🚀 | 👍 | 🚀 |
2424
| Static export | 🔜 🚀 | 👍 | 🚀 |
25-
| Search | 🔜 ✅ | 🔌 | 🔌 |
25+
| Search | | 🔌 | 🔌 |
2626
| Light / Dark | 🔜 ✅ | 🔌 ||
2727
| Open source ||||
2828
| Free ||||
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
import lunr from "lunr";
3+
4+
export default class extends Controller {
5+
static targets = ["search", "dialog", "results", "resultTemplate", "data"];
6+
7+
connect() {
8+
this._allowOpening();
9+
}
10+
11+
disconnect() {
12+
document.removeEventListener("keydown", this.keydownOpen);
13+
document.removeEventListener("click", this.clickClose, { once: true });
14+
}
15+
16+
open() {
17+
if (this.searchTarget.open) return;
18+
this._allowClosing();
19+
this._initSearch();
20+
this.searchTarget.showModal();
21+
}
22+
23+
search = debounce(this._search, 200);
24+
25+
_allowOpening() {
26+
this.keydownOpen = (event) => {
27+
if (this.searchTarget.open || event.key !== "/") return;
28+
event.preventDefault();
29+
this.open();
30+
};
31+
32+
document.addEventListener("keydown", this.keydownOpen);
33+
}
34+
35+
_allowClosing() {
36+
this.clickClose = (event) => {
37+
if (this.dialogTarget.contains(event.target)) return;
38+
this.searchTarget.close();
39+
};
40+
41+
document.addEventListener("click", this.clickClose, { once: true });
42+
}
43+
44+
_initSearch() {
45+
if (this.documents) {
46+
this.searchTarget.classList.add("loaded");
47+
return;
48+
}
49+
this._createSearchIndex();
50+
this.searchTarget.classList.add("loaded");
51+
}
52+
53+
_createSearchIndex() {
54+
const documents = this._getDocuments();
55+
if (documents.length === 0) return;
56+
this.documents = documents;
57+
this.searchIndex = lunr(function () {
58+
this.ref("title");
59+
this.field("title", { boost: 5 });
60+
this.field("text");
61+
this.metadataWhitelist = ["position"];
62+
documents.forEach(function (doc) {
63+
this.add(doc);
64+
}, this);
65+
});
66+
}
67+
68+
_getDocuments() {
69+
const searchData = JSON.parse(this.dataTarget.textContent);
70+
if (searchData.length === 0) {
71+
console.warn(
72+
[
73+
"The search data is not present in the HTML.",
74+
"If you are in development, run `bundle exec rails hotdocs:index`.",
75+
"If you are in production, assets compilation should have taken care of it.",
76+
].join(" ")
77+
);
78+
}
79+
return searchData.map((data) => {
80+
const div = document.createElement("div");
81+
div.innerHTML = data.html;
82+
return { ...data, text: div.innerText };
83+
});
84+
}
85+
86+
_search(event) {
87+
if (!this.searchIndex) return;
88+
const query = event.target.value;
89+
const results = this.searchIndex.search(query).slice(0, 10);
90+
this._displayResults(results);
91+
}
92+
93+
_displayResults(results) {
94+
this.resultsTarget.innerHTML = null;
95+
96+
results.forEach((result) => {
97+
const matches = Object.keys(result.matchData.metadata);
98+
const excerpt = this._withExcerpt(matches, result)[0];
99+
if (!excerpt) return;
100+
this.resultsTarget.appendChild(this._createResultElement(excerpt));
101+
});
102+
}
103+
104+
_withExcerpt(matches, result) {
105+
return matches.flatMap((match) => {
106+
return Object.keys(result.matchData.metadata[match]).map((key) => {
107+
const position = result.matchData.metadata[match][key].position[0];
108+
const [sliceStart, sliceLength] = key === "text" ? position : [0, 0];
109+
const doc = this.documents.find((doc) => doc.title === result.ref);
110+
const excerpt = this._excerpt(doc.text, sliceStart, sliceLength);
111+
return { ...doc, excerpt };
112+
});
113+
});
114+
}
115+
116+
_excerpt(doc, sliceStart, sliceLength) {
117+
const startPos = Math.max(sliceStart - 80, 0);
118+
const endPos = Math.min(sliceStart + sliceLength + 80, doc.length);
119+
return [
120+
startPos > 0 ? "..." : "",
121+
doc.slice(startPos, sliceStart),
122+
"<strong>" +
123+
escapeHtmlEntities(doc.slice(sliceStart, sliceStart + sliceLength)) +
124+
"</strong>",
125+
doc.slice(sliceStart + sliceLength, endPos),
126+
endPos < doc.length ? "..." : "",
127+
].join("");
128+
}
129+
130+
_createResultElement(excerpt) {
131+
const clone = this.resultTemplateTarget.content.cloneNode(true);
132+
const li = clone.querySelector("li");
133+
li.querySelector("h1").innerHTML = `${excerpt.parent} > ${excerpt.title}`;
134+
li.querySelector("a").innerHTML = excerpt.excerpt;
135+
li.querySelector("a").href = excerpt.url;
136+
return clone;
137+
}
138+
}
139+
140+
function debounce(func, wait) {
141+
let timeoutId;
142+
143+
return function (...args) {
144+
clearTimeout(timeoutId);
145+
timeoutId = setTimeout(() => func.apply(this, args), wait);
146+
};
147+
}
148+
149+
function escapeHtmlEntities(string) {
150+
return String(string)
151+
.replace(/&/g, "&amp;")
152+
.replace(/</g, "&lt;")
153+
.replace(/>/g, "&gt;")
154+
.replace(/"/g, "&quot;");
155+
}

app/assets/stylesheets/hotdocs/application.css

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,127 @@ body {
246246
font-weight: bold;
247247
}
248248

249+
/* CSS: SEARCH */
250+
251+
:root {
252+
--search-background-color: #f5f6f7;
253+
--search-button-background-color: #e9e9e9;
254+
--search-excerpt-background-color: white;
255+
--search-excerpt-border-color: #d7d7d7;
256+
--search-text-color: var(--text-color);
257+
}
258+
259+
[data-theme=dark]:root {
260+
--search-background-color: #242526;
261+
--search-button-background-color: #1b1b1b;
262+
--search-excerpt-background-color: #1b1b1b;
263+
--search-excerpt-border-color: #535353;
264+
}
265+
266+
.search-button {
267+
align-items: center;
268+
background-color: var(--search-background-color);
269+
border: solid 1px transparent;
270+
border-radius: 99999px;
271+
display: flex;
272+
gap: 0.5ch;
273+
padding: 0.5rem 0.5rem;
274+
275+
@media (min-width: 40rem) {
276+
padding: 0.25rem 0.5rem;
277+
}
278+
279+
&:hover {
280+
background: none;
281+
border: solid 1px var(--nav-link-color);
282+
}
283+
}
284+
285+
.search-button__icon {
286+
height: 1.2rem;
287+
width: 1.2rem;
288+
}
289+
290+
.search-button__label {
291+
display: none;
292+
293+
@media (min-width: 40rem) {
294+
display: initial;
295+
}
296+
}
297+
298+
body:has(.search:open), body:has(.search[open]) {
299+
overflow: hidden;
300+
}
301+
302+
.search {
303+
background-color: #000000dd;
304+
bottom: 0;
305+
color: var(--search-text-color);
306+
height: 100vh;
307+
left: 0;
308+
max-height: 100vh;
309+
max-width: 100vw;
310+
padding-inline: 1rem;
311+
position: fixed;
312+
right: 0;
313+
top: 0;
314+
width: 100vw;
315+
}
316+
317+
::backdrop {
318+
display: none;
319+
}
320+
321+
.search__dialog {
322+
overflow: auto;
323+
background-color: var(--search-background-color);
324+
border-radius: 0.375rem;
325+
max-height: calc(100vh - 120px);
326+
margin: 60px auto auto;
327+
max-width: 560px;
328+
padding: 1rem;
329+
width: auto;
330+
}
331+
332+
.search__input {
333+
background-color: var(--search-excerpt-background-color);
334+
border: 1px solid #808080;
335+
border-radius: 0.2rem;
336+
padding: 0.3rem 0.5rem;
337+
width: 100%;
338+
339+
&:focus-visible {
340+
outline: solid 2px #0077ff;
341+
}
342+
}
343+
344+
.search__result {
345+
margin-top: 1.5rem;
346+
347+
&:first-child {
348+
margin-top: 1rem;
349+
}
350+
}
351+
352+
.search.loaded .search__result--loading {
353+
display: none;
354+
}
355+
356+
.search__result-excerpt {
357+
background-color: var(--search-excerpt-background-color);
358+
border: 1px solid var(--search-excerpt-border-color);
359+
border-radius: 0.2rem;
360+
box-shadow: 0 1px 3px 0 #0000001a;
361+
display: block;
362+
margin-top: 0.2rem;
363+
padding: 0.3rem 0.5rem;
364+
365+
&:hover {
366+
outline: solid 2px #0077ff;
367+
}
368+
}
369+
249370
/* CSS: MENU */
250371

251372
:root {

app/views/layouts/hotdocs/application.html.erb

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,30 @@
99
<%= stylesheet_link_tag "hotdocs/application", media: "all", "data-turbo-track": "reload" %>
1010
</head>
1111

12-
<body>
12+
<body data-controller="search">
13+
<dialog data-search-target="search" class="search">
14+
<div data-search-target="dialog" class="search__dialog">
15+
<input autofocus data-action="input->search#search" type="text" class="search__input"></input>
16+
17+
<template data-search-target="resultTemplate">
18+
<li class="search__result">
19+
<h1></h1>
20+
<a href="#" class="search__result-excerpt"></a>
21+
</li>
22+
</template>
23+
24+
<ul data-search-target="results">
25+
<li class="search__result search__result--loading">
26+
Loading index...
27+
</li>
28+
</ul>
29+
</div>
30+
31+
<script type="application/json" data-search-target="data">
32+
<%= raw(Rails.application.assets.resolver.read("search_data.json")&.force_encoding("UTF-8") || [].to_json) %>
33+
</script>
34+
</dialog>
35+
1336
<nav class="nav" data-controller="sidenav" data-sidenav-open-class-value="sidenav--open" data-sidenav-main-menu-class-value="sidenav__sections--main">
1437
<div class="nav__section">
1538
<button class="nav__toggle" type="button" aria-label="Toggle navigation" aria-expanded="false" data-action="click->sidenav#open">
@@ -39,6 +62,14 @@
3962
<%= item %>
4063
<% end %>
4164
</div>
65+
66+
<button type="button" data-action="click->search#open:stop" class="search-button">
67+
<svg class="search-button__icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
68+
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
69+
</svg>
70+
71+
<span class="search-button__label">Type / to search</span>
72+
</button>
4273
</div>
4374

4475
<div class="sidenav-backdrop"></div>

lib/hotdocs/markdown.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
class MarkdownHandler
44
def self.prepare(engine)
55
# Install npm packages
6+
# `capture3` raises if deno is not available
67
Open3.capture3("deno --allow-read --allow-env --node-modules-dir=auto #{engine.root.join("lib/hotdocs/markdown.mjs")}", stdin_data: "")
7-
rescue
8-
Rails.logger.info("deno not found: Could not install npm packages.")
98
end
109

1110
def initialize(engine)

0 commit comments

Comments
 (0)