Skip to main content

dioxus_docs_kit/
registry.rs

1//! Documentation content registry.
2//!
3//! Holds parsed docs, nav config, search index, and OpenAPI specs.
4
5use 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/// Navigation configuration for the documentation sidebar.
13#[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    /// Whether the nav config has multiple tabs.
22    pub fn has_tabs(&self) -> bool {
23        self.tabs.len() > 1
24    }
25
26    /// Get groups belonging to a specific tab.
27    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/// A group of navigation items in the sidebar.
36#[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/// A sidebar entry for an API endpoint.
45#[derive(Debug, Clone, PartialEq)]
46pub struct ApiEndpointEntry {
47    /// URL slug (e.g. "list-pets").
48    pub slug: String,
49    /// Display title (summary or fallback).
50    pub title: String,
51    /// HTTP method.
52    pub method: HttpMethod,
53}
54
55/// A searchable entry in the documentation.
56#[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
66/// Central documentation registry holding all parsed content.
67///
68/// Created via [`DocsConfig`] builder and typically stored in a `Lazy<DocsRegistry>` static.
69/// Provide it to UI components via `use_context_provider(|| &*DOCS as &'static DocsRegistry)`.
70pub struct DocsRegistry {
71    /// Navigation configuration.
72    pub nav: NavConfig,
73    /// Pre-parsed documentation pages.
74    parsed_docs: HashMap<&'static str, ParsedDoc>,
75    /// Prebuilt search index.
76    search_index: Vec<SearchEntry>,
77    /// OpenAPI specs keyed by URL prefix.
78    openapi_specs: Vec<(String, OpenApiSpec)>,
79    /// Default page path for redirects.
80    pub default_path: String,
81    /// Display name for the API Reference sidebar group.
82    pub api_group_name: String,
83    /// Optional theme configuration.
84    pub theme: Option<ThemeConfig>,
85}
86
87impl DocsRegistry {
88    /// Build a registry from a [`DocsConfig`].
89    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        // Parse all documents
94        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        // Parse OpenAPI specs
101        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        // Determine default path
112        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        // Warn if OpenAPI specs were registered but no nav group matches api_group_name
131        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        // Build search index
141        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    /// Get a pre-parsed document by path.
156    pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
157        self.parsed_docs.get(path)
158    }
159
160    /// Get the sidebar title for a documentation path.
161    pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
162        // Check if this is an API endpoint
163        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    /// Get the document title from frontmatter.
182    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    /// Get the icon for a documentation path from frontmatter.
193    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    /// Get raw documentation content by path.
199    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    /// Get all available documentation paths.
206    pub fn get_all_paths(&self) -> Vec<&str> {
207        self.parsed_docs.keys().copied().collect()
208    }
209
210    // ========================================================================
211    // OpenAPI methods
212    // ========================================================================
213
214    /// Look up an API operation by its slug across all registered specs.
215    ///
216    /// The `path` is the full docs path, e.g. "api-reference/list-pets".
217    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    /// Get the OpenAPI spec that owns a given path prefix.
229    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    /// Get the first OpenAPI spec (convenience for single-spec setups).
237    pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
238        self.openapi_specs.first().map(|(_, spec)| spec)
239    }
240
241    /// Get the prefix of the first OpenAPI spec.
242    pub fn get_first_api_prefix(&self) -> Option<&str> {
243        self.openapi_specs.first().map(|(p, _)| p.as_str())
244    }
245
246    /// Get API endpoint sidebar entries grouped by tag.
247    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            // Untagged operations
272            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    /// Get all API endpoint paths for navigation ordering.
304    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    /// Determine which tab a given page path belongs to.
315    pub fn tab_for_path(&self, path: &str) -> Option<String> {
316        // Check static pages in nav groups
317        for group in &self.nav.groups {
318            if group.pages.iter().any(|p| p == path) {
319                return group.tab.clone();
320            }
321        }
322
323        // Check dynamic API endpoint pages
324        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    // ========================================================================
338    // LLMs.txt
339    // ========================================================================
340
341    /// Generate an `llms.txt` index listing all doc pages with titles and descriptions.
342    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    /// Generate an `llms-full.txt` with the full MDX content of every doc page.
373    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    // ========================================================================
401    // Search
402    // ========================================================================
403
404    /// Search documentation by query string.
405    ///
406    /// Returns matching entries with title matches first, then description, then content.
407    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    /// Build the search index from parsed docs and OpenAPI specs.
434    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        // Index documentation pages from nav config
443        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        // Index API operations
470        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}