1use crate::config::{DocsConfig, ThemeConfig};
6use dioxus_mdx::{
7 ApiOperation, ApiTag, HttpMethod, OpenApiSpec, ParsedDoc, parse_document, parse_openapi,
8};
9use serde::Deserialize;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Deserialize)]
14pub struct NavConfig {
15 #[serde(default)]
16 pub tabs: Vec<String>,
17 pub groups: Vec<NavGroup>,
18}
19
20impl NavConfig {
21 pub fn has_tabs(&self) -> bool {
23 self.tabs.len() > 1
24 }
25
26 pub fn groups_for_tab(&self, tab: &str) -> Vec<&NavGroup> {
28 self.groups
29 .iter()
30 .filter(|g| g.tab.as_deref() == Some(tab))
31 .collect()
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Deserialize)]
37pub struct NavGroup {
38 pub group: String,
39 #[serde(default)]
40 pub tab: Option<String>,
41 pub pages: Vec<String>,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct ApiEndpointEntry {
47 pub slug: String,
49 pub title: String,
51 pub method: HttpMethod,
53}
54
55#[derive(PartialEq)]
57pub struct SearchEntry {
58 pub path: String,
59 pub title: String,
60 pub description: String,
61 pub content_preview: String,
62 pub breadcrumb: String,
63 pub api_method: Option<HttpMethod>,
64}
65
66pub struct DocsRegistry {
71 pub nav: NavConfig,
73 parsed_docs: HashMap<&'static str, ParsedDoc>,
75 search_index: Vec<SearchEntry>,
77 openapi_specs: Vec<(String, OpenApiSpec)>,
79 pub default_path: String,
81 pub api_group_name: String,
83 pub theme: Option<ThemeConfig>,
85}
86
87impl DocsRegistry {
88 pub(crate) fn from_config(config: DocsConfig) -> Self {
90 let nav: NavConfig =
91 serde_json::from_str(config.nav_json()).expect("Failed to parse _nav.json");
92
93 let parsed_docs: HashMap<&'static str, ParsedDoc> = config
95 .content_map()
96 .iter()
97 .map(|(&path, &content)| (path, parse_document(content)))
98 .collect();
99
100 let openapi_specs: Vec<(String, OpenApiSpec)> = config
102 .openapi_specs()
103 .iter()
104 .map(|(prefix, yaml)| {
105 let spec = parse_openapi(yaml)
106 .unwrap_or_else(|_| panic!("Failed to parse OpenAPI spec for {prefix}"));
107 (prefix.clone(), spec)
108 })
109 .collect();
110
111 let default_path = config
113 .default_path_value()
114 .map(String::from)
115 .unwrap_or_else(|| {
116 nav.groups
117 .first()
118 .and_then(|g| g.pages.first())
119 .cloned()
120 .unwrap_or_default()
121 });
122
123 let api_group_name = config
124 .api_group_name_value()
125 .map(String::from)
126 .unwrap_or_else(|| "API Reference".to_string());
127
128 let theme = config.theme_config().cloned();
129
130 if !openapi_specs.is_empty() && !nav.groups.iter().any(|g| g.group == api_group_name) {
132 eprintln!(
133 "dioxus-docs-kit warning: OpenAPI specs registered but no nav group \
134 matches api_group_name \"{api_group_name}\". API endpoints won't appear \
135 in the sidebar. Add a group with `\"group\": \"{api_group_name}\"` to \
136 _nav.json, or call .with_api_group_name(\"<your group name>\") on DocsConfig."
137 );
138 }
139
140 let search_index =
142 Self::build_search_index(&nav, &parsed_docs, &openapi_specs, &api_group_name);
143
144 Self {
145 nav,
146 parsed_docs,
147 search_index,
148 openapi_specs,
149 default_path,
150 api_group_name,
151 theme,
152 }
153 }
154
155 pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
157 self.parsed_docs.get(path)
158 }
159
160 pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
162 if let Some(op) = self.get_api_operation(path) {
164 return op
165 .summary
166 .clone()
167 .or_else(|| Some(op.slug().replace('-', " ")));
168 }
169
170 self.get_parsed_doc(path).and_then(|doc| {
171 doc.frontmatter.sidebar_title.clone().or_else(|| {
172 if doc.frontmatter.title.is_empty() {
173 None
174 } else {
175 Some(doc.frontmatter.title.clone())
176 }
177 })
178 })
179 }
180
181 pub fn get_doc_title(&self, path: &str) -> Option<String> {
183 self.get_parsed_doc(path).and_then(|doc| {
184 if doc.frontmatter.title.is_empty() {
185 None
186 } else {
187 Some(doc.frontmatter.title.clone())
188 }
189 })
190 }
191
192 pub fn get_doc_icon(&self, path: &str) -> Option<String> {
194 self.get_parsed_doc(path)
195 .and_then(|doc| doc.frontmatter.icon.clone())
196 }
197
198 pub fn get_doc_content(&self, path: &str) -> Option<&str> {
200 self.parsed_docs
201 .get(path)
202 .map(|doc| doc.raw_markdown.as_str())
203 }
204
205 pub fn get_all_paths(&self) -> Vec<&str> {
207 self.parsed_docs.keys().copied().collect()
208 }
209
210 pub fn get_api_operation(&self, path: &str) -> Option<&ApiOperation> {
218 for (prefix, spec) in &self.openapi_specs {
219 if let Some(slug) = path.strip_prefix(&format!("{prefix}/"))
220 && let Some(op) = spec.operations.iter().find(|op| op.slug() == slug)
221 {
222 return Some(op);
223 }
224 }
225 None
226 }
227
228 pub fn get_api_spec(&self, prefix: &str) -> Option<&OpenApiSpec> {
230 self.openapi_specs
231 .iter()
232 .find(|(p, _)| p == prefix)
233 .map(|(_, spec)| spec)
234 }
235
236 pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
238 self.openapi_specs.first().map(|(_, spec)| spec)
239 }
240
241 pub fn get_first_api_prefix(&self) -> Option<&str> {
243 self.openapi_specs.first().map(|(p, _)| p.as_str())
244 }
245
246 pub fn get_api_sidebar_entries(&self) -> Vec<(ApiTag, Vec<ApiEndpointEntry>)> {
248 let mut all_groups: Vec<(ApiTag, Vec<ApiEndpointEntry>)> = Vec::new();
249
250 for (_prefix, spec) in &self.openapi_specs {
251 for tag in &spec.tags {
252 let entries: Vec<ApiEndpointEntry> = spec
253 .operations
254 .iter()
255 .filter(|op| op.tags.contains(&tag.name))
256 .map(|op| ApiEndpointEntry {
257 slug: op.slug(),
258 title: op
259 .summary
260 .clone()
261 .unwrap_or_else(|| op.slug().replace('-', " ")),
262 method: op.method,
263 })
264 .collect();
265
266 if !entries.is_empty() {
267 all_groups.push((tag.clone(), entries));
268 }
269 }
270
271 let tagged_ids: Vec<_> = spec.tags.iter().map(|t| t.name.as_str()).collect();
273 let untagged: Vec<ApiEndpointEntry> = spec
274 .operations
275 .iter()
276 .filter(|op| {
277 op.tags.is_empty() || op.tags.iter().all(|t| !tagged_ids.contains(&t.as_str()))
278 })
279 .map(|op| ApiEndpointEntry {
280 slug: op.slug(),
281 title: op
282 .summary
283 .clone()
284 .unwrap_or_else(|| op.slug().replace('-', " ")),
285 method: op.method,
286 })
287 .collect();
288
289 if !untagged.is_empty() {
290 all_groups.push((
291 ApiTag {
292 name: "Other".to_string(),
293 description: None,
294 },
295 untagged,
296 ));
297 }
298 }
299
300 all_groups
301 }
302
303 pub fn get_api_endpoint_paths(&self) -> Vec<String> {
305 let mut paths = Vec::new();
306 for (prefix, spec) in &self.openapi_specs {
307 for op in &spec.operations {
308 paths.push(format!("{prefix}/{}", op.slug()));
309 }
310 }
311 paths
312 }
313
314 pub fn tab_for_path(&self, path: &str) -> Option<String> {
316 for group in &self.nav.groups {
318 if group.pages.iter().any(|p| p == path) {
319 return group.tab.clone();
320 }
321 }
322
323 for (prefix, _) in &self.openapi_specs {
325 if path.starts_with(&format!("{prefix}/")) {
326 for group in &self.nav.groups {
327 if group.group == self.api_group_name {
328 return group.tab.clone();
329 }
330 }
331 }
332 }
333
334 None
335 }
336
337 pub fn generate_llms_txt(
343 &self,
344 site_title: &str,
345 site_description: &str,
346 base_url: &str,
347 ) -> String {
348 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
349
350 for group in &self.nav.groups {
351 for page in &group.pages {
352 if let Some(doc) = self.get_parsed_doc(page) {
353 let title = if doc.frontmatter.title.is_empty() {
354 page.split('/').next_back().unwrap_or(page).to_string()
355 } else {
356 doc.frontmatter.title.clone()
357 };
358 let desc = doc.frontmatter.description.as_deref().unwrap_or("");
359 let url = format!("{base_url}/docs/{page}");
360 if desc.is_empty() {
361 out.push_str(&format!("- [{title}]({url})\n"));
362 } else {
363 out.push_str(&format!("- [{title}]({url}): {desc}\n"));
364 }
365 }
366 }
367 }
368
369 out
370 }
371
372 pub fn generate_llms_full_txt(
374 &self,
375 site_title: &str,
376 site_description: &str,
377 base_url: &str,
378 ) -> String {
379 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
380
381 for group in &self.nav.groups {
382 for page in &group.pages {
383 if let Some(doc) = self.get_parsed_doc(page) {
384 let title = if doc.frontmatter.title.is_empty() {
385 page.split('/').next_back().unwrap_or(page).to_string()
386 } else {
387 doc.frontmatter.title.clone()
388 };
389 let url = format!("{base_url}/docs/{page}");
390 out.push_str(&format!("---\n\n## [{title}]({url})\n\n"));
391 out.push_str(&doc.raw_markdown);
392 out.push_str("\n\n");
393 }
394 }
395 }
396
397 out
398 }
399
400 pub fn search_docs(&self, query: &str) -> Vec<&SearchEntry> {
408 let query = query.trim();
409 if query.is_empty() {
410 return Vec::new();
411 }
412 let q = query.to_lowercase();
413
414 let mut title_matches: Vec<&SearchEntry> = Vec::new();
415 let mut desc_matches: Vec<&SearchEntry> = Vec::new();
416 let mut content_matches: Vec<&SearchEntry> = Vec::new();
417
418 for entry in &self.search_index {
419 if entry.title.to_lowercase().contains(&q) {
420 title_matches.push(entry);
421 } else if entry.description.to_lowercase().contains(&q) {
422 desc_matches.push(entry);
423 } else if entry.content_preview.to_lowercase().contains(&q) {
424 content_matches.push(entry);
425 }
426 }
427
428 title_matches.extend(desc_matches);
429 title_matches.extend(content_matches);
430 title_matches
431 }
432
433 fn build_search_index(
435 nav: &NavConfig,
436 parsed_docs: &HashMap<&'static str, ParsedDoc>,
437 openapi_specs: &[(String, OpenApiSpec)],
438 api_group_name: &str,
439 ) -> Vec<SearchEntry> {
440 let mut entries = Vec::new();
441
442 for group in &nav.groups {
444 for page in &group.pages {
445 if let Some(doc) = parsed_docs.get(page.as_str()) {
446 let title = if doc.frontmatter.title.is_empty() {
447 page.split('/')
448 .next_back()
449 .unwrap_or(page)
450 .replace('-', " ")
451 } else {
452 doc.frontmatter.title.clone()
453 };
454 let description = doc.frontmatter.description.clone().unwrap_or_default();
455 let preview: String = doc.raw_markdown.chars().take(200).collect();
456
457 entries.push(SearchEntry {
458 path: page.clone(),
459 title,
460 description,
461 content_preview: preview,
462 breadcrumb: group.group.clone(),
463 api_method: None,
464 });
465 }
466 }
467 }
468
469 for (prefix, spec) in openapi_specs {
471 for op in &spec.operations {
472 let title = op
473 .summary
474 .clone()
475 .unwrap_or_else(|| op.slug().replace('-', " "));
476 let description = op.description.clone().unwrap_or_default();
477 let tag = op
478 .tags
479 .first()
480 .cloned()
481 .unwrap_or_else(|| "Other".to_string());
482
483 entries.push(SearchEntry {
484 path: format!("{prefix}/{}", op.slug()),
485 title,
486 description: description.clone(),
487 content_preview: description,
488 breadcrumb: format!("{api_group_name} > {tag}"),
489 api_method: Some(op.method),
490 });
491 }
492 }
493
494 entries
495 }
496}