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        // Build search index
131        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    /// Get a pre-parsed document by path.
146    pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
147        self.parsed_docs.get(path)
148    }
149
150    /// Get the sidebar title for a documentation path.
151    pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
152        // Check if this is an API endpoint
153        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    /// Get the document title from frontmatter.
172    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    /// Get the icon for a documentation path from frontmatter.
183    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    /// Get raw documentation content by path.
189    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    /// Get all available documentation paths.
196    pub fn get_all_paths(&self) -> Vec<&str> {
197        self.parsed_docs.keys().copied().collect()
198    }
199
200    // ========================================================================
201    // OpenAPI methods
202    // ========================================================================
203
204    /// Look up an API operation by its slug across all registered specs.
205    ///
206    /// The `path` is the full docs path, e.g. "api-reference/list-pets".
207    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    /// Get the OpenAPI spec that owns a given path prefix.
219    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    /// Get the first OpenAPI spec (convenience for single-spec setups).
227    pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
228        self.openapi_specs.first().map(|(_, spec)| spec)
229    }
230
231    /// Get the prefix of the first OpenAPI spec.
232    pub fn get_first_api_prefix(&self) -> Option<&str> {
233        self.openapi_specs.first().map(|(p, _)| p.as_str())
234    }
235
236    /// Get API endpoint sidebar entries grouped by tag.
237    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            // Untagged operations
262            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    /// Get all API endpoint paths for navigation ordering.
294    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    /// Determine which tab a given page path belongs to.
305    pub fn tab_for_path(&self, path: &str) -> Option<String> {
306        // Check static pages in nav groups
307        for group in &self.nav.groups {
308            if group.pages.iter().any(|p| p == path) {
309                return group.tab.clone();
310            }
311        }
312
313        // Check dynamic API endpoint pages
314        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    // ========================================================================
328    // LLMs.txt
329    // ========================================================================
330
331    /// Generate an `llms.txt` index listing all doc pages with titles and descriptions.
332    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    /// Generate an `llms-full.txt` with the full MDX content of every doc page.
363    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    // ========================================================================
391    // Search
392    // ========================================================================
393
394    /// Search documentation by query string.
395    ///
396    /// Returns matching entries with title matches first, then description, then content.
397    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    /// Build the search index from parsed docs and OpenAPI specs.
424    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        // Index documentation pages from nav config
433        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        // Index API operations
460        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}