Skip to content

Commit 622e778

Browse files
committed
Add search to specification (fixes #774)
- Enable Zola's built-in search index in config.toml - Add elasticlunr-based search.js for client-side search - Add search input to spec sidebar with keyboard shortcut (Cmd/Ctrl+K) - Add CSS styles for search UI with light/dark mode support The search indexes all spec pages and shows results in a dropdown with titles and body snippets.
1 parent 2403d6f commit 622e778

File tree

6 files changed

+296
-1
lines changed

6 files changed

+296
-1
lines changed

website/config.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ compile_sass = false
66
minify_html = true
77
generate_feeds = true
88
feed_filenames = ["rss.xml"]
9+
build_search_index = true
10+
11+
[search]
12+
index_format = "elasticlunr_json"
13+
include_title = true
14+
include_description = true
15+
include_path = true
16+
include_content = true
917

1018
[markdown]
1119
highlight_code = true

website/css/input.css

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,3 +885,164 @@ code {
885885
.dark .preview-notice strong {
886886
color: var(--color-accent-dark);
887887
}
888+
889+
/* ========================================
890+
Specification Search
891+
======================================== */
892+
893+
.spec-search {
894+
padding: 0 1rem 1rem;
895+
position: relative;
896+
}
897+
898+
.spec-search-input-wrapper {
899+
position: relative;
900+
display: flex;
901+
align-items: center;
902+
}
903+
904+
#spec-search-input {
905+
width: 100%;
906+
padding: 0.5rem 3rem 0.5rem 0.75rem;
907+
font-size: 0.875rem;
908+
border: 1px solid var(--color-border);
909+
border-radius: 0.375rem;
910+
background-color: var(--color-surface);
911+
color: var(--color-fg);
912+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
913+
}
914+
915+
.dark #spec-search-input {
916+
border-color: var(--color-border-dark);
917+
background-color: var(--color-surface-dark);
918+
color: var(--color-fg-dark);
919+
}
920+
921+
#spec-search-input::placeholder {
922+
color: var(--color-muted);
923+
}
924+
925+
.dark #spec-search-input::placeholder {
926+
color: var(--color-muted-dark);
927+
}
928+
929+
#spec-search-input:focus {
930+
outline: none;
931+
border-color: var(--color-accent);
932+
box-shadow: 0 0 0 2px color-mix(in oklch, var(--color-accent) 20%, transparent);
933+
}
934+
935+
.dark #spec-search-input:focus {
936+
border-color: var(--color-accent-dark);
937+
box-shadow: 0 0 0 2px color-mix(in oklch, var(--color-accent-dark) 20%, transparent);
938+
}
939+
940+
.spec-search-shortcut {
941+
position: absolute;
942+
right: 0.5rem;
943+
top: 50%;
944+
transform: translateY(-50%);
945+
display: flex;
946+
align-items: center;
947+
gap: 0.125rem;
948+
padding: 0.125rem 0.375rem;
949+
font-size: 0.6875rem;
950+
font-family: var(--font-sans);
951+
font-weight: 500;
952+
color: var(--color-muted);
953+
background-color: var(--color-code-bg);
954+
border: 1px solid var(--color-border);
955+
border-radius: 0.25rem;
956+
pointer-events: none;
957+
}
958+
959+
.dark .spec-search-shortcut {
960+
color: var(--color-muted-dark);
961+
background-color: var(--color-code-bg-dark);
962+
border-color: var(--color-border-dark);
963+
}
964+
965+
#spec-search-input:focus + .spec-search-shortcut {
966+
opacity: 0;
967+
}
968+
969+
.spec-search-results {
970+
display: none;
971+
position: absolute;
972+
top: 100%;
973+
left: 1rem;
974+
right: 1rem;
975+
max-height: 400px;
976+
overflow-y: auto;
977+
background-color: var(--color-surface);
978+
border: 1px solid var(--color-border);
979+
border-radius: 0.375rem;
980+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
981+
z-index: 100;
982+
}
983+
984+
.dark .spec-search-results {
985+
background-color: var(--color-surface-dark);
986+
border-color: var(--color-border-dark);
987+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
988+
}
989+
990+
.search-result-item {
991+
display: block;
992+
padding: 0.75rem 1rem;
993+
text-decoration: none;
994+
border-bottom: 1px solid var(--color-border);
995+
transition: background-color 0.15s ease;
996+
}
997+
998+
.dark .search-result-item {
999+
border-bottom-color: var(--color-border-dark);
1000+
}
1001+
1002+
.search-result-item:last-child {
1003+
border-bottom: none;
1004+
}
1005+
1006+
.search-result-item:hover {
1007+
background-color: var(--color-code-bg);
1008+
}
1009+
1010+
.dark .search-result-item:hover {
1011+
background-color: var(--color-code-bg-dark);
1012+
}
1013+
1014+
.search-result-title {
1015+
font-weight: 500;
1016+
color: var(--color-fg);
1017+
margin-bottom: 0.25rem;
1018+
}
1019+
1020+
.dark .search-result-title {
1021+
color: var(--color-fg-dark);
1022+
}
1023+
1024+
.search-result-body {
1025+
font-size: 0.8125rem;
1026+
color: var(--color-muted);
1027+
line-height: 1.4;
1028+
overflow: hidden;
1029+
text-overflow: ellipsis;
1030+
display: -webkit-box;
1031+
-webkit-line-clamp: 2;
1032+
-webkit-box-orient: vertical;
1033+
}
1034+
1035+
.dark .search-result-body {
1036+
color: var(--color-muted-dark);
1037+
}
1038+
1039+
.search-no-results {
1040+
padding: 1rem;
1041+
text-align: center;
1042+
color: var(--color-muted);
1043+
font-size: 0.875rem;
1044+
}
1045+
1046+
.dark .search-no-results {
1047+
color: var(--color-muted-dark);
1048+
}

website/static/search.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
(function() {
2+
function initSearch() {
3+
const searchInput = document.getElementById('spec-search-input');
4+
const searchResults = document.getElementById('spec-search-results');
5+
let searchIndex = null;
6+
7+
if (!searchInput || !searchResults) return;
8+
9+
const shortcutMod = document.querySelector('.spec-search-shortcut-mod');
10+
if (shortcutMod) {
11+
shortcutMod.textContent = navigator.platform.indexOf('Mac') > -1 ? '⌘' : 'Ctrl+';
12+
}
13+
14+
fetch('/search_index.en.json')
15+
.then(response => response.json())
16+
.then(data => {
17+
searchIndex = elasticlunr.Index.load(data);
18+
})
19+
.catch(err => {
20+
console.error('Failed to load search index:', err);
21+
});
22+
23+
function search(query) {
24+
if (!searchIndex || !query || query.length < 2) {
25+
return [];
26+
}
27+
28+
const results = searchIndex.search(query, {
29+
fields: {
30+
title: { boost: 3 },
31+
body: { boost: 1 }
32+
},
33+
bool: "OR",
34+
expand: true
35+
});
36+
37+
return results.slice(0, 10).map(result => {
38+
const doc = searchIndex.documentStore.getDoc(result.ref);
39+
return {
40+
title: doc.title,
41+
path: result.ref,
42+
body: doc.body ? doc.body.substring(0, 150) + '...' : ''
43+
};
44+
});
45+
}
46+
47+
function escapeHtml(text) {
48+
const div = document.createElement('div');
49+
div.textContent = text;
50+
return div.innerHTML;
51+
}
52+
53+
function renderResults(results) {
54+
if (results.length === 0) {
55+
searchResults.innerHTML = '<div class="search-no-results">No results found</div>';
56+
searchResults.style.display = 'block';
57+
return;
58+
}
59+
60+
searchResults.innerHTML = results.map(result =>
61+
'<a href="' + result.path + '" class="search-result-item">' +
62+
'<div class="search-result-title">' + escapeHtml(result.title) + '</div>' +
63+
'<div class="search-result-body">' + escapeHtml(result.body) + '</div>' +
64+
'</a>'
65+
).join('');
66+
searchResults.style.display = 'block';
67+
}
68+
69+
let debounceTimer;
70+
searchInput.addEventListener('input', e => {
71+
clearTimeout(debounceTimer);
72+
const query = e.target.value.trim();
73+
74+
if (query.length < 2) {
75+
searchResults.style.display = 'none';
76+
return;
77+
}
78+
79+
debounceTimer = setTimeout(() => {
80+
renderResults(search(query));
81+
}, 150);
82+
});
83+
84+
searchInput.addEventListener('focus', () => {
85+
if (searchInput.value.trim().length >= 2) {
86+
searchResults.style.display = 'block';
87+
}
88+
});
89+
90+
document.addEventListener('click', e => {
91+
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
92+
searchResults.style.display = 'none';
93+
}
94+
});
95+
96+
searchInput.addEventListener('keydown', e => {
97+
if (e.key === 'Escape') {
98+
searchResults.style.display = 'none';
99+
searchInput.blur();
100+
}
101+
});
102+
103+
document.addEventListener('keydown', e => {
104+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
105+
e.preventDefault();
106+
searchInput.focus();
107+
searchInput.select();
108+
}
109+
});
110+
}
111+
112+
if (document.readyState === 'loading') {
113+
document.addEventListener('DOMContentLoaded', initSearch);
114+
} else {
115+
initSearch();
116+
}
117+
})();

website/static/style.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

website/templates/base.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,7 @@
127127
});
128128
});
129129
</script>
130+
<script src="https://cdnjs.cloudflare.com/ajax/libs/elasticlunr/0.9.5/elasticlunr.min.js"></script>
131+
<script src="{{ get_url(path='search.js') }}"></script>
130132
</body>
131133
</html>

website/templates/spec/base.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
<div class="spec-sidebar-header">
1111
<a href="{{ get_url(path='spec') }}" class="spec-title">Rue Specification</a>
1212
</div>
13+
<div class="spec-search">
14+
<div class="spec-search-input-wrapper">
15+
<input type="text" id="spec-search-input" placeholder="Search..." autocomplete="off">
16+
<kbd class="spec-search-shortcut"><span class="spec-search-shortcut-mod"></span>K</kbd>
17+
</div>
18+
<div id="spec-search-results" class="spec-search-results"></div>
19+
</div>
1320
<nav class="spec-nav">
1421
{% set spec_section = get_section(path="spec/_index.md") %}
1522
{% set current = page.permalink | default(value=section.permalink) | default(value="") %}

0 commit comments

Comments
 (0)