Skip to content

Commit 485a535

Browse files
committed
feat: Implement search functionality with a modal interface, including search index generation and styling updates for improved user experience
1 parent 01685c4 commit 485a535

File tree

7 files changed

+395
-3
lines changed

7 files changed

+395
-3
lines changed

scripts/build_docs_site.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,68 @@ def scoped(style: str, theme: str) -> str:
239239
path.write_text("\n".join(chunks) + "\n", encoding="utf-8")
240240

241241

242+
def md_body_to_search_text(body: str, max_len: int = 16000) -> str:
243+
t = body
244+
t = re.sub(r"(?i)^\[TOC\]\s*\n*", "", t)
245+
t = re.sub(r"<[^>]+>", " ", t)
246+
t = re.sub(r"`+", " ", t)
247+
t = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1 ", t)
248+
t = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1 ", t)
249+
t = re.sub(r"^#{1,6}\s*(\S.*?)\s*$", r"\1 ", t, flags=re.M)
250+
t = re.sub(r"[*_~>|\\]+", " ", t)
251+
t = re.sub(r"\s+", " ", t).strip()
252+
return t[:max_len]
253+
254+
255+
def build_search_entries() -> list[dict[str, str]]:
256+
records: list[dict[str, str]] = []
257+
for _group_label, items in NAV:
258+
for title, src in items:
259+
if src == "__api__":
260+
records.append(
261+
{
262+
"title": title,
263+
"href": site_href("api/index.html"),
264+
"text": (
265+
f"{title} Python API reference pdoc modules "
266+
"classes functions yokedcache"
267+
),
268+
}
269+
)
270+
continue
271+
md_path = PAGES_DIR / src
272+
if not md_path.is_file():
273+
raise FileNotFoundError(str(md_path))
274+
raw = md_path.read_text(encoding="utf-8")
275+
_meta, body = parse_frontmatter(raw)
276+
plain = md_body_to_search_text(body)
277+
records.append(
278+
{
279+
"title": title,
280+
"href": site_href(md_to_html_path(src)),
281+
"text": f"{title} {plain}",
282+
}
283+
)
284+
records.append(
285+
{
286+
"title": "Changelog",
287+
"href": site_href("changelog.html"),
288+
"text": "changelog release history versions fixes features migrations",
289+
}
290+
)
291+
records.append(
292+
{
293+
"title": "Home",
294+
"href": site_href("index.html"),
295+
"text": (
296+
"YokedCache async cache redis memcached memory disk sqlite "
297+
"python fastapi starlette tag invalidation middleware metrics"
298+
),
299+
}
300+
)
301+
return records
302+
303+
242304
def nav_context(current_out: str) -> list[dict]:
243305
groups = []
244306
for label, items in NAV:
@@ -275,6 +337,15 @@ def _skip_root_html(src: str, names: list[str]) -> set[str]:
275337
shutil.copytree(STATIC_DIR, OUT, dirs_exist_ok=True, ignore=_skip_root_html)
276338
shutil.copytree(ASSETS_DIR, OUT / "assets")
277339
write_syntax_highlight_css(OUT / "assets" / "syntax-highlight.css")
340+
try:
341+
search_entries = build_search_entries()
342+
except FileNotFoundError as e:
343+
print(f"Search index: missing {e}", file=sys.stderr)
344+
return 1
345+
(OUT / "assets" / "search-index.json").write_text(
346+
json.dumps(search_entries, ensure_ascii=False, separators=(",", ":")),
347+
encoding="utf-8",
348+
)
278349

279350
(OUT / ".nojekyll").write_text("", encoding="utf-8")
280351

@@ -342,6 +413,7 @@ def _skip_root_html(src: str, names: list[str]) -> set[str]:
342413
asset_style_href=site_href("assets/style.css"),
343414
asset_syntax_href=site_href("assets/syntax-highlight.css"),
344415
asset_script_href=site_href("assets/app.js"),
416+
search_index_href=site_href("assets/search-index.json"),
345417
home_href=site_href("index.html"),
346418
changelog_href=site_href("changelog.html"),
347419
api_href=site_href("api/index.html"),
@@ -365,6 +437,7 @@ def _skip_root_html(src: str, names: list[str]) -> set[str]:
365437
"maintainer_same_as": MAINTAINER_SAME_AS,
366438
"home_canonical": home_canonical,
367439
"changelog_canonical": f"{SITE_URL}/changelog.html",
440+
"search_index_href": site_href("assets/search-index.json"),
368441
}
369442
for standalone in ("index.html.jinja2", "changelog.html.jinja2"):
370443
stpl = env.get_template(standalone)

site-src/assets/app.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ sidebar?.querySelectorAll(".sidebar-link").forEach((link) => {
5656
});
5757

5858
document.addEventListener("keydown", (e) => {
59+
if (e.key === "Escape" && document.getElementById("searchModal")?.open) {
60+
return;
61+
}
5962
if (e.key === "Escape" && drawerOpen()) {
6063
setDrawerOpen(false);
6164
menuBtn?.focus();
@@ -106,3 +109,142 @@ document.querySelectorAll(".code-block pre code").forEach((code) => {
106109
label.textContent = m[1];
107110
wrap.insertBefore(label, wrap.firstChild);
108111
});
112+
113+
(function initDocSearch() {
114+
const indexUrl = html.getAttribute("data-search-index");
115+
const modal = document.getElementById("searchModal");
116+
const openBtn = document.getElementById("searchOpenBtn");
117+
const closeBtn = document.getElementById("searchCloseBtn");
118+
const input = document.getElementById("searchInput");
119+
const resultsEl = document.getElementById("searchResults");
120+
const emptyHint = document.getElementById("searchEmptyHint");
121+
const kbdHint = document.getElementById("searchKbdHint");
122+
123+
if (kbdHint) {
124+
const mac = /Mac|iPhone|iPad|iPod/i.test(
125+
navigator.platform || navigator.userAgentData?.platform || ""
126+
);
127+
kbdHint.textContent = mac ? "⌘K" : "Ctrl+K";
128+
}
129+
130+
if (!indexUrl || !modal || !input || !resultsEl || typeof Fuse === "undefined") {
131+
return;
132+
}
133+
134+
let fuse = null;
135+
let loadError = null;
136+
137+
function ensureFuse() {
138+
if (fuse || loadError) return Promise.resolve();
139+
return fetch(indexUrl)
140+
.then((r) => {
141+
if (!r.ok) throw new Error("HTTP " + r.status);
142+
return r.json();
143+
})
144+
.then((records) => {
145+
fuse = new Fuse(records, {
146+
keys: [
147+
{ name: "title", weight: 0.42 },
148+
{ name: "text", weight: 0.58 },
149+
],
150+
threshold: 0.38,
151+
ignoreLocation: true,
152+
minMatchCharLength: 2,
153+
includeScore: true,
154+
});
155+
})
156+
.catch((err) => {
157+
loadError = err;
158+
});
159+
}
160+
161+
function renderResults(q) {
162+
resultsEl.innerHTML = "";
163+
if (loadError) {
164+
emptyHint.textContent = "Could not load search index.";
165+
emptyHint.hidden = false;
166+
return;
167+
}
168+
if (!fuse) {
169+
emptyHint.textContent = "Loading…";
170+
emptyHint.hidden = false;
171+
return;
172+
}
173+
const query = q.trim();
174+
if (query.length < 2) {
175+
emptyHint.textContent = "Type at least 2 characters. Fuzzy matching handles typos.";
176+
emptyHint.hidden = false;
177+
return;
178+
}
179+
emptyHint.hidden = true;
180+
const hits = fuse.search(query, { limit: 14 });
181+
if (!hits.length) {
182+
emptyHint.textContent = "No matches. Try different wording.";
183+
emptyHint.hidden = false;
184+
return;
185+
}
186+
const frag = document.createDocumentFragment();
187+
hits.forEach(({ item }) => {
188+
const li = document.createElement("li");
189+
li.setAttribute("role", "option");
190+
const a = document.createElement("a");
191+
a.href = item.href;
192+
a.className = "search-result-link";
193+
const t = document.createElement("span");
194+
t.className = "search-result-title";
195+
t.textContent = item.title;
196+
a.appendChild(t);
197+
li.appendChild(a);
198+
frag.appendChild(li);
199+
});
200+
resultsEl.appendChild(frag);
201+
}
202+
203+
let debounceTimer = null;
204+
function scheduleSearch() {
205+
clearTimeout(debounceTimer);
206+
debounceTimer = setTimeout(() => renderResults(input.value), 60);
207+
}
208+
209+
function openSearch() {
210+
ensureFuse().then(() => {
211+
modal.showModal();
212+
input.focus();
213+
if (loadError) {
214+
renderResults("");
215+
return;
216+
}
217+
input.select();
218+
renderResults(input.value);
219+
});
220+
}
221+
222+
function closeSearch() {
223+
modal.close();
224+
}
225+
226+
openBtn?.addEventListener("click", () => openSearch());
227+
closeBtn?.addEventListener("click", () => closeSearch());
228+
input?.addEventListener("input", scheduleSearch);
229+
230+
modal.addEventListener("close", () => {
231+
input.value = "";
232+
resultsEl.innerHTML = "";
233+
emptyHint.textContent = "Type to search. Fuzzy matching handles typos.";
234+
emptyHint.hidden = false;
235+
});
236+
237+
document.addEventListener("keydown", (e) => {
238+
if (!(e.metaKey || e.ctrlKey) || (e.key !== "k" && e.key !== "K")) return;
239+
const tag = e.target?.tagName;
240+
if (tag === "INPUT" || tag === "TEXTAREA" || e.target?.isContentEditable) {
241+
if (e.target !== input) return;
242+
}
243+
e.preventDefault();
244+
if (modal.open) {
245+
closeSearch();
246+
} else {
247+
openSearch();
248+
}
249+
});
250+
})();

0 commit comments

Comments
 (0)