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 let search_index =
132 Self::build_search_index(&nav, &parsed_docs, &openapi_specs, &api_group_name);
133
134 Self {
135 nav,
136 parsed_docs,
137 search_index,
138 openapi_specs,
139 default_path,
140 api_group_name,
141 theme,
142 }
143 }
144
145 pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
147 self.parsed_docs.get(path)
148 }
149
150 pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
152 if let Some(op) = self.get_api_operation(path) {
154 return op
155 .summary
156 .clone()
157 .or_else(|| Some(op.slug().replace('-', " ")));
158 }
159
160 self.get_parsed_doc(path).and_then(|doc| {
161 doc.frontmatter.sidebar_title.clone().or_else(|| {
162 if doc.frontmatter.title.is_empty() {
163 None
164 } else {
165 Some(doc.frontmatter.title.clone())
166 }
167 })
168 })
169 }
170
171 pub fn get_doc_title(&self, path: &str) -> Option<String> {
173 self.get_parsed_doc(path).and_then(|doc| {
174 if doc.frontmatter.title.is_empty() {
175 None
176 } else {
177 Some(doc.frontmatter.title.clone())
178 }
179 })
180 }
181
182 pub fn get_doc_icon(&self, path: &str) -> Option<String> {
184 self.get_parsed_doc(path)
185 .and_then(|doc| doc.frontmatter.icon.clone())
186 }
187
188 pub fn get_doc_content(&self, path: &str) -> Option<&str> {
190 self.parsed_docs
191 .get(path)
192 .map(|doc| doc.raw_markdown.as_str())
193 }
194
195 pub fn get_all_paths(&self) -> Vec<&str> {
197 self.parsed_docs.keys().copied().collect()
198 }
199
200 pub fn get_api_operation(&self, path: &str) -> Option<&ApiOperation> {
208 for (prefix, spec) in &self.openapi_specs {
209 if let Some(slug) = path.strip_prefix(&format!("{prefix}/"))
210 && let Some(op) = spec.operations.iter().find(|op| op.slug() == slug)
211 {
212 return Some(op);
213 }
214 }
215 None
216 }
217
218 pub fn get_api_spec(&self, prefix: &str) -> Option<&OpenApiSpec> {
220 self.openapi_specs
221 .iter()
222 .find(|(p, _)| p == prefix)
223 .map(|(_, spec)| spec)
224 }
225
226 pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
228 self.openapi_specs.first().map(|(_, spec)| spec)
229 }
230
231 pub fn get_first_api_prefix(&self) -> Option<&str> {
233 self.openapi_specs.first().map(|(p, _)| p.as_str())
234 }
235
236 pub fn get_api_sidebar_entries(&self) -> Vec<(ApiTag, Vec<ApiEndpointEntry>)> {
238 let mut all_groups: Vec<(ApiTag, Vec<ApiEndpointEntry>)> = Vec::new();
239
240 for (_prefix, spec) in &self.openapi_specs {
241 for tag in &spec.tags {
242 let entries: Vec<ApiEndpointEntry> = spec
243 .operations
244 .iter()
245 .filter(|op| op.tags.contains(&tag.name))
246 .map(|op| ApiEndpointEntry {
247 slug: op.slug(),
248 title: op
249 .summary
250 .clone()
251 .unwrap_or_else(|| op.slug().replace('-', " ")),
252 method: op.method,
253 })
254 .collect();
255
256 if !entries.is_empty() {
257 all_groups.push((tag.clone(), entries));
258 }
259 }
260
261 let tagged_ids: Vec<_> = spec.tags.iter().map(|t| t.name.as_str()).collect();
263 let untagged: Vec<ApiEndpointEntry> = spec
264 .operations
265 .iter()
266 .filter(|op| {
267 op.tags.is_empty() || op.tags.iter().all(|t| !tagged_ids.contains(&t.as_str()))
268 })
269 .map(|op| ApiEndpointEntry {
270 slug: op.slug(),
271 title: op
272 .summary
273 .clone()
274 .unwrap_or_else(|| op.slug().replace('-', " ")),
275 method: op.method,
276 })
277 .collect();
278
279 if !untagged.is_empty() {
280 all_groups.push((
281 ApiTag {
282 name: "Other".to_string(),
283 description: None,
284 },
285 untagged,
286 ));
287 }
288 }
289
290 all_groups
291 }
292
293 pub fn get_api_endpoint_paths(&self) -> Vec<String> {
295 let mut paths = Vec::new();
296 for (prefix, spec) in &self.openapi_specs {
297 for op in &spec.operations {
298 paths.push(format!("{prefix}/{}", op.slug()));
299 }
300 }
301 paths
302 }
303
304 pub fn tab_for_path(&self, path: &str) -> Option<String> {
306 for group in &self.nav.groups {
308 if group.pages.iter().any(|p| p == path) {
309 return group.tab.clone();
310 }
311 }
312
313 for (prefix, _) in &self.openapi_specs {
315 if path.starts_with(&format!("{prefix}/")) {
316 for group in &self.nav.groups {
317 if group.group == self.api_group_name {
318 return group.tab.clone();
319 }
320 }
321 }
322 }
323
324 None
325 }
326
327 pub fn generate_llms_txt(
333 &self,
334 site_title: &str,
335 site_description: &str,
336 base_url: &str,
337 ) -> String {
338 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
339
340 for group in &self.nav.groups {
341 for page in &group.pages {
342 if let Some(doc) = self.get_parsed_doc(page) {
343 let title = if doc.frontmatter.title.is_empty() {
344 page.split('/').next_back().unwrap_or(page).to_string()
345 } else {
346 doc.frontmatter.title.clone()
347 };
348 let desc = doc.frontmatter.description.as_deref().unwrap_or("");
349 let url = format!("{base_url}/docs/{page}");
350 if desc.is_empty() {
351 out.push_str(&format!("- [{title}]({url})\n"));
352 } else {
353 out.push_str(&format!("- [{title}]({url}): {desc}\n"));
354 }
355 }
356 }
357 }
358
359 out
360 }
361
362 pub fn generate_llms_full_txt(
364 &self,
365 site_title: &str,
366 site_description: &str,
367 base_url: &str,
368 ) -> String {
369 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
370
371 for group in &self.nav.groups {
372 for page in &group.pages {
373 if let Some(doc) = self.get_parsed_doc(page) {
374 let title = if doc.frontmatter.title.is_empty() {
375 page.split('/').next_back().unwrap_or(page).to_string()
376 } else {
377 doc.frontmatter.title.clone()
378 };
379 let url = format!("{base_url}/docs/{page}");
380 out.push_str(&format!("---\n\n## [{title}]({url})\n\n"));
381 out.push_str(&doc.raw_markdown);
382 out.push_str("\n\n");
383 }
384 }
385 }
386
387 out
388 }
389
390 pub fn search_docs(&self, query: &str) -> Vec<&SearchEntry> {
398 let query = query.trim();
399 if query.is_empty() {
400 return Vec::new();
401 }
402 let q = query.to_lowercase();
403
404 let mut title_matches: Vec<&SearchEntry> = Vec::new();
405 let mut desc_matches: Vec<&SearchEntry> = Vec::new();
406 let mut content_matches: Vec<&SearchEntry> = Vec::new();
407
408 for entry in &self.search_index {
409 if entry.title.to_lowercase().contains(&q) {
410 title_matches.push(entry);
411 } else if entry.description.to_lowercase().contains(&q) {
412 desc_matches.push(entry);
413 } else if entry.content_preview.to_lowercase().contains(&q) {
414 content_matches.push(entry);
415 }
416 }
417
418 title_matches.extend(desc_matches);
419 title_matches.extend(content_matches);
420 title_matches
421 }
422
423 fn build_search_index(
425 nav: &NavConfig,
426 parsed_docs: &HashMap<&'static str, ParsedDoc>,
427 openapi_specs: &[(String, OpenApiSpec)],
428 _api_group_name: &str,
429 ) -> Vec<SearchEntry> {
430 let mut entries = Vec::new();
431
432 for group in &nav.groups {
434 for page in &group.pages {
435 if let Some(doc) = parsed_docs.get(page.as_str()) {
436 let title = if doc.frontmatter.title.is_empty() {
437 page.split('/')
438 .next_back()
439 .unwrap_or(page)
440 .replace('-', " ")
441 } else {
442 doc.frontmatter.title.clone()
443 };
444 let description = doc.frontmatter.description.clone().unwrap_or_default();
445 let preview: String = doc.raw_markdown.chars().take(200).collect();
446
447 entries.push(SearchEntry {
448 path: page.clone(),
449 title,
450 description,
451 content_preview: preview,
452 breadcrumb: group.group.clone(),
453 api_method: None,
454 });
455 }
456 }
457 }
458
459 for (prefix, spec) in openapi_specs {
461 for op in &spec.operations {
462 let title = op
463 .summary
464 .clone()
465 .unwrap_or_else(|| op.slug().replace('-', " "));
466 let description = op.description.clone().unwrap_or_default();
467 let tag = op
468 .tags
469 .first()
470 .cloned()
471 .unwrap_or_else(|| "Other".to_string());
472
473 entries.push(SearchEntry {
474 path: format!("{prefix}/{}", op.slug()),
475 title,
476 description: description.clone(),
477 content_preview: description,
478 breadcrumb: format!("API Reference > {tag}"),
479 api_method: Some(op.method),
480 });
481 }
482 }
483
484 entries
485 }
486}