use dioxus::prelude::*;
use dioxus_mdx::HttpMethod;
use super::search_shell::{SearchHit, SearchModalShell};
use crate::DocsContext;
use crate::registry::DocsRegistry;
use crate::search::{MAX_RESULTS, SNIPPET_WINDOW, build_snippet, split_terms};
fn docs_hits(registry: &'static DocsRegistry, query: &str, limit: usize) -> Vec<SearchHit> {
let terms = split_terms(query);
registry
.search_docs(query)
.into_iter()
.take(limit)
.map(|entry| {
let target = if entry.anchor.is_empty() {
entry.path.clone()
} else {
format!("{}#{}", entry.path, entry.anchor)
};
let (title, context) = if entry.heading.is_empty() {
(entry.title.clone(), None)
} else {
(entry.heading.clone(), Some(entry.title.clone()))
};
let snippet_src = if entry.body.is_empty() {
&entry.description
} else {
&entry.body
};
SearchHit {
target,
title,
context,
badge: entry.api_method.map(method_badge),
meta: entry.breadcrumb.clone(),
tags: Vec::new(),
snippet: build_snippet(snippet_src, &terms, SNIPPET_WINDOW),
}
})
.collect()
}
#[component]
pub fn SearchModal() -> Element {
let ctx = use_context::<DocsContext>();
let registry = use_context::<&'static DocsRegistry>();
let search = use_callback(move |query: String| docs_hits(registry, &query, MAX_RESULTS));
let on_select = use_callback(move |target: String| {
let mut parts = target.splitn(2, '#');
let path = parts.next().unwrap_or_default().to_string();
let anchor = parts.next().unwrap_or_default().to_string();
(ctx.navigate)(path);
#[cfg(target_arch = "wasm32")]
if !anchor.is_empty() {
scroll_to_anchor(anchor);
}
#[cfg(not(target_arch = "wasm32"))]
let _ = anchor;
});
rsx! {
SearchModalShell {
placeholder: "Search documentation...",
search,
on_select,
}
}
}
#[cfg(target_arch = "wasm32")]
fn scroll_to_anchor(anchor: String) {
spawn(async move {
let js = format!(
r#"
(function() {{
const id = {};
let attempts = 0;
function tryScroll() {{
const el = document.getElementById(id);
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
history.replaceState(null, '', '#' + id);
return;
}}
if (attempts++ < 20) {{
requestAnimationFrame(tryScroll);
}}
}}
requestAnimationFrame(tryScroll);
}})();
"#,
serde_json::to_string(&anchor).unwrap_or_default()
);
let _ = document::eval(&js);
});
}
fn method_badge(method: HttpMethod) -> (&'static str, &'static str) {
match method {
HttpMethod::Get => ("GET", "badge-soft badge-success"),
HttpMethod::Post => ("POST", "badge-soft badge-primary"),
HttpMethod::Put => ("PUT", "badge-soft badge-warning"),
HttpMethod::Delete => ("DEL", "badge-soft badge-error"),
HttpMethod::Patch => ("PATCH", "badge-soft badge-info"),
_ => ("???", "badge-soft badge-ghost"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DocsConfig;
use std::collections::HashMap;
fn wide_registry(pages: usize) -> &'static DocsRegistry {
let mut nav_pages: Vec<String> = Vec::new();
let mut map: HashMap<&'static str, &'static str> = HashMap::new();
for i in 0..pages {
let path: &'static str = Box::leak(format!("g/page-{i}").into_boxed_str());
let body: &'static str = Box::leak(
format!("---\ntitle: Widget {i}\n---\n\nThe widget keyword appears here.\n")
.into_boxed_str(),
);
nav_pages.push(format!("\"{path}\""));
map.insert(path, body);
}
let nav: &'static str = Box::leak(
format!(
r#"{{ "groups": [ {{ "group": "G", "pages": [{}] }} ] }}"#,
nav_pages.join(",")
)
.into_boxed_str(),
);
Box::leak(Box::new(DocsConfig::new(nav, map).build()))
}
#[test]
fn docs_hits_caps_rendered_results() {
let registry = wide_registry(40);
assert!(
registry.search_docs("widget").len() > 25,
"fixture should match more than the cap"
);
assert_eq!(docs_hits(registry, "widget", 25).len(), 25);
}
#[test]
fn docs_hits_returns_every_match_below_the_cap() {
let registry = wide_registry(3);
assert_eq!(docs_hits(registry, "widget", 25).len(), 3);
}
#[test]
fn docs_hits_fully_builds_every_returned_row() {
let registry = wide_registry(40);
let hits = docs_hits(registry, "widget", 5);
assert_eq!(hits.len(), 5);
assert!(hits.iter().all(|h| !h.snippet.is_empty()));
}
#[test]
fn docs_hits_empty_query_yields_nothing() {
let registry = wide_registry(3);
assert!(docs_hits(registry, "", 25).is_empty());
}
}