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::components::seo::xml_escape;
6#[cfg(feature = "highlight")]
7use crate::config::CodeThemeConfig;
8use crate::config::{DocsConfig, ThemeConfig};
9use crate::error::DocsKitError;
10use crate::search::{Field, clean_markdown, search_lower};
11use dioxus_mdx::{
12    ApiOperation, ApiTag, HttpMethod, OpenApiSpec, ParsedDoc, parse_document, parse_openapi,
13    slugify,
14};
15use serde::Deserialize;
16use std::collections::HashMap;
17
18/// Navigation configuration for the documentation sidebar.
19#[derive(Debug, Clone, Deserialize)]
20pub struct NavConfig {
21    #[serde(default)]
22    pub tabs: Vec<String>,
23    pub groups: Vec<NavGroup>,
24}
25
26impl NavConfig {
27    /// Whether the nav config has multiple tabs.
28    pub fn has_tabs(&self) -> bool {
29        self.tabs.len() > 1
30    }
31
32    /// Get groups belonging to a specific tab.
33    pub fn groups_for_tab(&self, tab: &str) -> Vec<&NavGroup> {
34        self.groups
35            .iter()
36            .filter(|g| g.tab.as_deref() == Some(tab))
37            .collect()
38    }
39}
40
41/// A group of navigation items in the sidebar.
42#[derive(Debug, Clone, PartialEq, Deserialize)]
43pub struct NavGroup {
44    pub group: String,
45    #[serde(default)]
46    pub tab: Option<String>,
47    pub pages: Vec<String>,
48}
49
50/// A sidebar entry for an API endpoint.
51#[derive(Debug, Clone, PartialEq)]
52pub struct ApiEndpointEntry {
53    /// URL prefix of the spec that owns this endpoint (e.g. "api-reference").
54    pub prefix: String,
55    /// URL slug (e.g. "list-pets").
56    pub slug: String,
57    /// Display title (summary or fallback).
58    pub title: String,
59    /// HTTP method.
60    pub method: HttpMethod,
61}
62
63/// A searchable entry in the documentation.
64///
65/// One entry per document *section* (split on h2–h4 headings): content before
66/// the first heading is a page-level "intro" entry with an empty `heading` /
67/// `anchor`, and each heading starts a new entry whose `anchor` deep-links to
68/// the rendered heading id. OpenAPI operations are indexed as single page-level
69/// entries. The `*_lower` fields are lowercased once at build time so search
70/// never re-lowercases per keystroke.
71#[derive(PartialEq)]
72pub struct SearchEntry {
73    /// Content path of the owning page (e.g. "getting-started/introduction").
74    pub path: String,
75    /// Anchor id of the section heading, empty for page-level / intro entries.
76    /// Matches the rendered heading `id` (`dioxus_mdx::slugify`).
77    pub anchor: String,
78    /// Page title (frontmatter title or humanised slug / API summary).
79    pub title: String,
80    /// Section heading text, empty for page-level / intro entries.
81    pub heading: String,
82    /// Page/operation description.
83    pub description: String,
84    /// Cleaned section body text used for matching and snippet extraction.
85    pub body: String,
86    /// Sidebar breadcrumb (nav group, or API group + tag).
87    pub breadcrumb: String,
88    /// HTTP method for API operation entries (drives the result badge).
89    pub api_method: Option<HttpMethod>,
90    pub(crate) title_lower: String,
91    pub(crate) heading_lower: String,
92    pub(crate) description_lower: String,
93    pub(crate) body_lower: String,
94}
95
96impl SearchEntry {
97    #[allow(clippy::too_many_arguments)]
98    fn new(
99        path: String,
100        anchor: String,
101        title: String,
102        heading: String,
103        description: String,
104        body: String,
105        breadcrumb: String,
106        api_method: Option<HttpMethod>,
107    ) -> Self {
108        let title_lower = search_lower(&title);
109        let heading_lower = search_lower(&heading);
110        let description_lower = search_lower(&description);
111        let body_lower = search_lower(&body);
112        Self {
113            path,
114            anchor,
115            title,
116            heading,
117            description,
118            body,
119            breadcrumb,
120            api_method,
121            title_lower,
122            heading_lower,
123            description_lower,
124            body_lower,
125        }
126    }
127}
128
129/// A single documentation section produced by [`split_into_sections`].
130struct Section {
131    /// Heading text, empty for the leading intro section.
132    heading: String,
133    /// Anchor id (`slugify(heading)`), empty for the intro section.
134    anchor: String,
135    /// Raw markdown body for this section (before [`clean_markdown`]).
136    body: String,
137}
138
139/// Split reconstructed markdown into sections on its h2–h4 ATX headings.
140///
141/// The slice before the first heading is always returned first as the intro
142/// section (empty `heading`/`anchor`). Fenced code blocks are skipped so a `##`
143/// inside code is not mistaken for a heading. Anchors use the same
144/// `dioxus_mdx::slugify` the renderer applies to heading ids.
145fn split_into_sections(raw: &str) -> Vec<Section> {
146    let mut sections = Vec::new();
147    let mut heading = String::new();
148    let mut anchor = String::new();
149    let mut body = String::new();
150    let mut fence: Option<char> = None;
151
152    for line in raw.lines() {
153        let trimmed = line.trim_start();
154        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
155            let marker = if trimmed.starts_with("```") { '`' } else { '~' };
156            match fence {
157                None => fence = Some(marker),
158                Some(open) if open == marker => fence = None,
159                Some(_) => {} // the other marker inside a fence is literal content
160            }
161            body.push_str(line);
162            body.push('\n');
163            continue;
164        }
165        if fence.is_none()
166            && let Some(text) = parse_atx_heading(trimmed)
167        {
168            sections.push(Section {
169                heading: std::mem::take(&mut heading),
170                anchor: std::mem::take(&mut anchor),
171                body: std::mem::take(&mut body),
172            });
173            anchor = slugify(text);
174            heading = text.to_string();
175            continue;
176        }
177        body.push_str(line);
178        body.push('\n');
179    }
180    sections.push(Section {
181        heading,
182        anchor,
183        body,
184    });
185    sections
186}
187
188/// Return the heading text of an h2–h4 ATX heading line, or `None`.
189///
190/// Mirrors the TOC heading regex (`^#{2,4}\s+\S`): 2–4 leading `#`, at least one
191/// space/tab, then non-empty text (h1/h5/h6 are ignored).
192fn parse_atx_heading(line: &str) -> Option<&str> {
193    let hashes = line.bytes().take_while(|&b| b == b'#').count();
194    if !(2..=4).contains(&hashes) {
195        return None;
196    }
197    let rest = &line[hashes..];
198    if !rest.starts_with([' ', '\t']) {
199        return None;
200    }
201    let text = rest.trim();
202    if text.is_empty() {
203        return None;
204    }
205    Some(text)
206}
207
208/// Central documentation registry holding all parsed content.
209///
210/// Created via [`DocsConfig`] builder and typically stored in a `Lazy<DocsRegistry>` static.
211/// Provide it to UI components via `use_context_provider(|| &*DOCS as &'static DocsRegistry)`.
212pub struct DocsRegistry {
213    /// Navigation configuration.
214    pub nav: NavConfig,
215    /// Pre-parsed documentation pages.
216    parsed_docs: HashMap<&'static str, ParsedDoc>,
217    /// Prebuilt search index.
218    search_index: Vec<SearchEntry>,
219    /// OpenAPI specs keyed by URL prefix.
220    openapi_specs: Vec<(String, OpenApiSpec)>,
221    /// Precomputed API sidebar entries grouped by tag.
222    api_sidebar_entries: Vec<(ApiTag, Vec<ApiEndpointEntry>)>,
223    /// Full docs path ("prefix/slug") → (spec index, operation index).
224    api_operation_index: HashMap<String, (usize, usize)>,
225    /// Default page path for redirects.
226    pub default_path: String,
227    /// Display name for the API Reference sidebar group.
228    pub api_group_name: String,
229    /// Optional theme configuration.
230    pub theme: Option<ThemeConfig>,
231    /// Syntax-highlighting theme for code blocks.
232    #[cfg(feature = "highlight")]
233    pub code_theme: CodeThemeConfig,
234}
235
236impl DocsRegistry {
237    /// Build a registry from a [`DocsConfig`].
238    pub(crate) fn try_from_config(config: DocsConfig) -> Result<Self, DocsKitError> {
239        let nav: NavConfig =
240            serde_json::from_str(config.nav_json()).map_err(DocsKitError::NavParse)?;
241
242        // Parse all documents
243        let parsed_docs: HashMap<&'static str, ParsedDoc> = config
244            .content_map()
245            .iter()
246            .map(|(&path, &content)| (path, parse_document(content)))
247            .collect();
248
249        // Parse OpenAPI specs
250        let openapi_specs: Vec<(String, OpenApiSpec)> = config
251            .openapi_specs()
252            .iter()
253            .map(|(prefix, yaml)| {
254                parse_openapi(yaml)
255                    .map(|spec| (prefix.clone(), spec))
256                    .map_err(|error| DocsKitError::OpenApi {
257                        prefix: prefix.clone(),
258                        error,
259                    })
260            })
261            .collect::<Result<_, _>>()?;
262
263        // Determine default path
264        let default_path = config
265            .default_path_value()
266            .map(String::from)
267            .unwrap_or_else(|| {
268                nav.groups
269                    .first()
270                    .and_then(|g| g.pages.first())
271                    .cloned()
272                    .unwrap_or_default()
273            });
274
275        let api_group_name = config
276            .api_group_name_value()
277            .map(String::from)
278            .unwrap_or_else(|| "API Reference".to_string());
279
280        let theme = config.theme_config().cloned();
281        #[cfg(feature = "highlight")]
282        let code_theme = config.code_theme_value();
283
284        // Warn if OpenAPI specs were registered but no nav group matches api_group_name
285        if !openapi_specs.is_empty() && !nav.groups.iter().any(|g| g.group == api_group_name) {
286            tracing::warn!(
287                "dioxus-docs-kit: OpenAPI specs registered but no nav group \
288                 matches api_group_name \"{api_group_name}\". API endpoints won't appear \
289                 in the sidebar. Add a group with `\"group\": \"{api_group_name}\"` to \
290                 _nav.json, or call .with_api_group_name(\"<your group name>\") on DocsConfig."
291            );
292        }
293
294        // Build search index
295        let search_index =
296            Self::build_search_index(&nav, &parsed_docs, &openapi_specs, &api_group_name);
297
298        let api_sidebar_entries = Self::build_api_sidebar_entries(&openapi_specs);
299
300        let api_operation_index = openapi_specs
301            .iter()
302            .enumerate()
303            .flat_map(|(spec_idx, (prefix, spec))| {
304                spec.operations.iter().enumerate().map(move |(op_idx, op)| {
305                    (format!("{prefix}/{}", op.slug()), (spec_idx, op_idx))
306                })
307            })
308            .collect();
309
310        Ok(Self {
311            nav,
312            parsed_docs,
313            search_index,
314            openapi_specs,
315            api_sidebar_entries,
316            api_operation_index,
317            default_path,
318            api_group_name,
319            theme,
320            #[cfg(feature = "highlight")]
321            code_theme,
322        })
323    }
324
325    /// Get a pre-parsed document by path.
326    pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
327        self.parsed_docs.get(path)
328    }
329
330    /// Get the sidebar title for a documentation path.
331    pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
332        // Check if this is an API endpoint
333        if let Some(op) = self.get_api_operation(path) {
334            return op
335                .summary
336                .clone()
337                .or_else(|| Some(op.slug().replace('-', " ")));
338        }
339
340        self.get_parsed_doc(path).and_then(|doc| {
341            doc.frontmatter.sidebar_title.clone().or_else(|| {
342                if doc.frontmatter.title.is_empty() {
343                    None
344                } else {
345                    Some(doc.frontmatter.title.clone())
346                }
347            })
348        })
349    }
350
351    /// Get the document title from frontmatter.
352    pub fn get_doc_title(&self, path: &str) -> Option<String> {
353        self.get_parsed_doc(path).and_then(|doc| {
354            if doc.frontmatter.title.is_empty() {
355                None
356            } else {
357                Some(doc.frontmatter.title.clone())
358            }
359        })
360    }
361
362    /// Get the page title for the document `<head>`.
363    ///
364    /// Resolves MDX frontmatter titles *and* OpenAPI operation pages (which are
365    /// not in `parsed_docs`): for an endpoint path it returns the operation
366    /// summary, falling back to a humanised slug. Branding/suffixes are left to
367    /// the caller.
368    pub fn get_page_title(&self, path: &str) -> Option<String> {
369        if let Some(op) = self.get_api_operation(path) {
370            return op
371                .summary
372                .clone()
373                .or_else(|| Some(op.slug().replace('-', " ")));
374        }
375        self.get_doc_title(path)
376    }
377
378    /// Get the meta description for the document `<head>`.
379    ///
380    /// Resolves MDX frontmatter descriptions *and* OpenAPI operation
381    /// descriptions, so endpoint pages ship a unique `<meta name="description">`
382    /// instead of falling through to a generic default.
383    pub fn get_page_description(&self, path: &str) -> Option<String> {
384        if let Some(op) = self.get_api_operation(path) {
385            return op.description.clone();
386        }
387        self.get_parsed_doc(path)
388            .and_then(|doc| doc.frontmatter.description.clone())
389    }
390
391    /// Get the icon for a documentation path from frontmatter.
392    pub fn get_doc_icon(&self, path: &str) -> Option<String> {
393        self.get_parsed_doc(path)
394            .and_then(|doc| doc.frontmatter.icon.clone())
395    }
396
397    /// Get raw documentation content by path.
398    pub fn get_doc_content(&self, path: &str) -> Option<&str> {
399        self.parsed_docs
400            .get(path)
401            .map(|doc| doc.raw_markdown.as_str())
402    }
403
404    /// Get all available documentation paths.
405    pub fn get_all_paths(&self) -> Vec<&str> {
406        self.parsed_docs.keys().copied().collect()
407    }
408
409    // ========================================================================
410    // OpenAPI methods
411    // ========================================================================
412
413    /// Look up an API operation by its slug across all registered specs.
414    ///
415    /// The `path` is the full docs path, e.g. "api-reference/list-pets".
416    pub fn get_api_operation(&self, path: &str) -> Option<&ApiOperation> {
417        self.get_api_operation_with_spec(path).map(|(op, _)| op)
418    }
419
420    /// Look up an API operation together with the spec that owns it.
421    ///
422    /// Use this instead of pairing [`Self::get_api_operation`] with
423    /// [`Self::get_first_api_spec`] — with multiple registered specs, the
424    /// first spec is not necessarily the one the operation belongs to.
425    pub fn get_api_operation_with_spec(&self, path: &str) -> Option<(&ApiOperation, &OpenApiSpec)> {
426        let &(spec_idx, op_idx) = self.api_operation_index.get(path)?;
427        let (_, spec) = &self.openapi_specs[spec_idx];
428        Some((&spec.operations[op_idx], spec))
429    }
430
431    /// Get the OpenAPI spec that owns a given path prefix.
432    pub fn get_api_spec(&self, prefix: &str) -> Option<&OpenApiSpec> {
433        self.openapi_specs
434            .iter()
435            .find(|(p, _)| p == prefix)
436            .map(|(_, spec)| spec)
437    }
438
439    /// Get the first OpenAPI spec (convenience for single-spec setups).
440    pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
441        self.openapi_specs.first().map(|(_, spec)| spec)
442    }
443
444    /// Get the prefix of the first OpenAPI spec.
445    pub fn get_first_api_prefix(&self) -> Option<&str> {
446        self.openapi_specs.first().map(|(p, _)| p.as_str())
447    }
448
449    /// Get API endpoint sidebar entries grouped by tag (precomputed).
450    pub fn get_api_sidebar_entries(&self) -> &[(ApiTag, Vec<ApiEndpointEntry>)] {
451        &self.api_sidebar_entries
452    }
453
454    /// Build API endpoint sidebar entries grouped by tag.
455    fn build_api_sidebar_entries(
456        openapi_specs: &[(String, OpenApiSpec)],
457    ) -> Vec<(ApiTag, Vec<ApiEndpointEntry>)> {
458        let mut all_groups: Vec<(ApiTag, Vec<ApiEndpointEntry>)> = Vec::new();
459
460        let make_entry = |prefix: &str, op: &ApiOperation| ApiEndpointEntry {
461            prefix: prefix.to_string(),
462            slug: op.slug(),
463            title: op
464                .summary
465                .clone()
466                .unwrap_or_else(|| op.slug().replace('-', " ")),
467            method: op.method,
468        };
469
470        for (prefix, spec) in openapi_specs {
471            for tag in &spec.tags {
472                let entries: Vec<ApiEndpointEntry> = spec
473                    .operations
474                    .iter()
475                    .filter(|op| op.tags.contains(&tag.name))
476                    .map(|op| make_entry(prefix, op))
477                    .collect();
478
479                if !entries.is_empty() {
480                    all_groups.push((tag.clone(), entries));
481                }
482            }
483
484            // Untagged operations
485            let tagged_ids: Vec<_> = spec.tags.iter().map(|t| t.name.as_str()).collect();
486            let untagged: Vec<ApiEndpointEntry> = spec
487                .operations
488                .iter()
489                .filter(|op| {
490                    op.tags.is_empty() || op.tags.iter().all(|t| !tagged_ids.contains(&t.as_str()))
491                })
492                .map(|op| make_entry(prefix, op))
493                .collect();
494
495            if !untagged.is_empty() {
496                all_groups.push((
497                    ApiTag {
498                        name: "Other".to_string(),
499                        description: None,
500                    },
501                    untagged,
502                ));
503            }
504        }
505
506        all_groups
507    }
508
509    /// Get all API endpoint paths for navigation ordering.
510    pub fn get_api_endpoint_paths(&self) -> Vec<String> {
511        let mut paths = Vec::new();
512        for (prefix, spec) in &self.openapi_specs {
513            for op in &spec.operations {
514                paths.push(format!("{prefix}/{}", op.slug()));
515            }
516        }
517        paths
518    }
519
520    /// Determine which tab a given page path belongs to.
521    pub fn tab_for_path(&self, path: &str) -> Option<String> {
522        // Check static pages in nav groups
523        for group in &self.nav.groups {
524            if group.pages.iter().any(|p| p == path) {
525                return group.tab.clone();
526            }
527        }
528
529        // Check dynamic API endpoint pages
530        for (prefix, _) in &self.openapi_specs {
531            if path.starts_with(&format!("{prefix}/")) {
532                for group in &self.nav.groups {
533                    if group.group == self.api_group_name {
534                        return group.tab.clone();
535                    }
536                }
537            }
538        }
539
540        None
541    }
542
543    // ========================================================================
544    // LLMs.txt
545    // ========================================================================
546
547    /// Generate an `llms.txt` index listing all doc pages with titles and descriptions.
548    ///
549    /// `docs_base_url` is the absolute URL of the docs root, e.g.
550    /// `"https://example.com/docs"`; page URLs are formed as
551    /// `<docs_base_url>/<page>`.
552    pub fn generate_llms_txt(
553        &self,
554        site_title: &str,
555        site_description: &str,
556        docs_base_url: &str,
557    ) -> String {
558        let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
559
560        for group in &self.nav.groups {
561            for page in &group.pages {
562                if let Some(doc) = self.get_parsed_doc(page) {
563                    let title = if doc.frontmatter.title.is_empty() {
564                        page.split('/').next_back().unwrap_or(page).to_string()
565                    } else {
566                        doc.frontmatter.title.clone()
567                    };
568                    let desc = doc.frontmatter.description.as_deref().unwrap_or("");
569                    let url = format!("{docs_base_url}/{page}");
570                    if desc.is_empty() {
571                        out.push_str(&format!("- [{title}]({url})\n"));
572                    } else {
573                        out.push_str(&format!("- [{title}]({url}): {desc}\n"));
574                    }
575                }
576            }
577        }
578
579        out
580    }
581
582    /// Generate an `llms-full.txt` with the full MDX content of every doc page.
583    ///
584    /// See [`Self::generate_llms_txt`] for the `docs_base_url` format.
585    pub fn generate_llms_full_txt(
586        &self,
587        site_title: &str,
588        site_description: &str,
589        docs_base_url: &str,
590    ) -> String {
591        let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
592
593        for group in &self.nav.groups {
594            for page in &group.pages {
595                if let Some(doc) = self.get_parsed_doc(page) {
596                    let title = if doc.frontmatter.title.is_empty() {
597                        page.split('/').next_back().unwrap_or(page).to_string()
598                    } else {
599                        doc.frontmatter.title.clone()
600                    };
601                    let url = format!("{docs_base_url}/{page}");
602                    out.push_str(&format!("---\n\n## [{title}]({url})\n\n"));
603                    out.push_str(&doc.raw_markdown);
604                    out.push_str("\n\n");
605                }
606            }
607        }
608
609        out
610    }
611
612    // ========================================================================
613    // Sitemap
614    // ========================================================================
615
616    /// Generate a sitemap.xml for all documentation pages.
617    pub fn generate_sitemap(&self, site_url: &str, docs_path: &str) -> String {
618        let mut xml = String::from(
619            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
620             <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
621        );
622
623        // Docs index
624        let index_loc = xml_escape(&format!("{site_url}{docs_path}"));
625        xml.push_str(&format!(
626            "<url>\n<loc>{index_loc}</loc>\n<changefreq>weekly</changefreq>\n<priority>1.0</priority>\n</url>\n"
627        ));
628
629        // Individual doc pages
630        for group in &self.nav.groups {
631            for page in &group.pages {
632                let loc = xml_escape(&format!("{site_url}{docs_path}/{page}"));
633                xml.push_str(&format!(
634                    "<url>\n<loc>{loc}</loc>\n<changefreq>weekly</changefreq>\n<priority>0.7</priority>\n</url>\n"
635                ));
636            }
637        }
638
639        // API endpoint pages
640        for (prefix, spec) in &self.openapi_specs {
641            for op in &spec.operations {
642                let loc = xml_escape(&format!("{site_url}{docs_path}/{prefix}/{}", op.slug()));
643                xml.push_str(&format!(
644                    "<url>\n<loc>{loc}</loc>\n<changefreq>monthly</changefreq>\n<priority>0.5</priority>\n</url>\n"
645                ));
646            }
647        }
648
649        xml.push_str("</urlset>\n");
650        xml
651    }
652
653    // ========================================================================
654    // Search
655    // ========================================================================
656
657    /// Search documentation by query string.
658    ///
659    /// Splits the query on whitespace and returns section-level entries whose
660    /// fields all match every term (multi-term AND), ranked title > heading >
661    /// description > body with a word-boundary bonus. See [`crate::search`].
662    pub fn search_docs(&self, query: &str) -> Vec<&SearchEntry> {
663        crate::search::rank(&self.search_index, query, |e, buf| {
664            buf.push(Field::title(&e.title_lower));
665            if !e.heading_lower.is_empty() {
666                buf.push(Field::heading(&e.heading_lower));
667            }
668            if !e.description_lower.is_empty() {
669                buf.push(Field::description(&e.description_lower));
670            }
671            if !e.body_lower.is_empty() {
672                buf.push(Field::body(&e.body_lower));
673            }
674        })
675    }
676
677    /// Build the search index from parsed docs and OpenAPI specs.
678    ///
679    /// Docs are indexed per section (split on h2–h4 headings); OpenAPI
680    /// operations stay page-level.
681    fn build_search_index(
682        nav: &NavConfig,
683        parsed_docs: &HashMap<&'static str, ParsedDoc>,
684        openapi_specs: &[(String, OpenApiSpec)],
685        api_group_name: &str,
686    ) -> Vec<SearchEntry> {
687        let mut entries = Vec::new();
688
689        // Index documentation pages from nav config, one entry per section.
690        for group in &nav.groups {
691            for page in &group.pages {
692                if let Some(doc) = parsed_docs.get(page.as_str()) {
693                    let title = if doc.frontmatter.title.is_empty() {
694                        page.split('/')
695                            .next_back()
696                            .unwrap_or(page)
697                            .replace('-', " ")
698                    } else {
699                        doc.frontmatter.title.clone()
700                    };
701                    let description = doc.frontmatter.description.clone().unwrap_or_default();
702
703                    let sections = split_into_sections(&doc.raw_markdown);
704                    let has_headings = sections.iter().any(|s| !s.heading.is_empty());
705                    for section in sections {
706                        let body = clean_markdown(&section.body);
707                        // Drop an empty intro once real sections exist; keep it
708                        // for a heading-less page so it stays findable by title.
709                        if section.heading.is_empty() && body.is_empty() && has_headings {
710                            continue;
711                        }
712                        entries.push(SearchEntry::new(
713                            page.clone(),
714                            section.anchor,
715                            title.clone(),
716                            section.heading,
717                            description.clone(),
718                            body,
719                            group.group.clone(),
720                            None,
721                        ));
722                    }
723                }
724            }
725        }
726
727        // Index API operations (page-level).
728        for (prefix, spec) in openapi_specs {
729            for op in &spec.operations {
730                let title = op
731                    .summary
732                    .clone()
733                    .unwrap_or_else(|| op.slug().replace('-', " "));
734                let description = op.description.clone().unwrap_or_default();
735                let tag = op
736                    .tags
737                    .first()
738                    .cloned()
739                    .unwrap_or_else(|| "Other".to_string());
740
741                entries.push(SearchEntry::new(
742                    format!("{prefix}/{}", op.slug()),
743                    String::new(),
744                    title,
745                    String::new(),
746                    description.clone(),
747                    clean_markdown(&description),
748                    format!("{api_group_name} > {tag}"),
749                    Some(op.method),
750                ));
751            }
752        }
753
754        entries
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use crate::error::DocsKitError;
762
763    const NAV: &str = r#"{
764        "tabs": ["Docs", "API Reference"],
765        "groups": [
766            { "group": "Search Fixtures", "tab": "Docs", "pages": ["g/body-doc", "g/desc-doc", "g/title-doc", "g/sections"] },
767            { "group": "Getting Started", "tab": "Docs", "pages": ["getting-started/intro"] },
768            { "group": "API Reference", "tab": "API Reference", "pages": ["api-reference/overview"] }
769        ]
770    }"#;
771
772    const BODY_DOC: &str = "---\ntitle: Body doc\ndescription: nothing here\n---\n\nThe alpha keyword lives in the body.\n";
773    const DESC_DOC: &str = "---\ntitle: Desc doc\ndescription: mentions alpha here\n---\n\nplain\n";
774    const TITLE_DOC: &str = "---\ntitle: Alpha guide\ndescription: plain\n---\n\nplain\n";
775    const INTRO: &str =
776        "---\ntitle: Introduction\ndescription: Getting started guide\n---\n\nWelcome.\n";
777    const OVERVIEW: &str = "---\ntitle: API Overview\n---\n\nEndpoints below.\n";
778    // Multi-section fixture: intro text, then an h2 with a nested h3.
779    const SECTIONS_DOC: &str = "---\ntitle: Widget Guide\ndescription: All about widgets\n---\n\nIntro paragraph about widgets.\n\n## Installation Steps\n\nRun the installer to set up widgets.\n\n### Advanced Setup\n\nConfigure the widget cache carefully.\n";
780
781    const PETS_SPEC: &str = r#"
782openapi: "3.0.0"
783info:
784  title: Pets API
785  version: "1.0.0"
786tags:
787  - name: pets
788    description: Pet operations
789paths:
790  /pets:
791    get:
792      operationId: listPets
793      summary: List pets
794      tags: [pets]
795      responses:
796        "200":
797          description: OK
798    post:
799      operationId: createPet
800      summary: Create pet
801      tags: [pets]
802      responses:
803        "200":
804          description: OK
805  /misc:
806    get:
807      operationId: miscThing
808      summary: Misc thing
809      responses:
810        "200":
811          description: OK
812"#;
813
814    const ADMIN_SPEC: &str = r#"
815openapi: "3.0.0"
816info:
817  title: Admin API
818  version: "1.0.0"
819paths:
820  /admin/users:
821    get:
822      operationId: listAdminUsers
823      summary: List admin users
824      responses:
825        "200":
826          description: OK
827"#;
828
829    fn content_map() -> HashMap<&'static str, &'static str> {
830        HashMap::from([
831            ("g/body-doc", BODY_DOC),
832            ("g/desc-doc", DESC_DOC),
833            ("g/title-doc", TITLE_DOC),
834            ("g/sections", SECTIONS_DOC),
835            ("getting-started/intro", INTRO),
836            ("api-reference/overview", OVERVIEW),
837        ])
838    }
839
840    fn registry() -> DocsRegistry {
841        DocsConfig::new(NAV, content_map())
842            .with_openapi("api-reference", PETS_SPEC)
843            .with_openapi("admin-api", ADMIN_SPEC)
844            .build()
845    }
846
847    #[test]
848    fn try_build_reports_nav_parse_error_with_detail() {
849        let Err(err) = DocsConfig::new("{ not json", HashMap::new()).try_build() else {
850            panic!("expected nav parse error");
851        };
852        assert!(matches!(err, DocsKitError::NavParse(_)));
853        assert!(err.to_string().contains("_nav.json"));
854    }
855
856    #[test]
857    fn try_build_reports_openapi_error_with_prefix() {
858        let Err(err) = DocsConfig::new(NAV, content_map())
859            .with_openapi("api-reference", "openapi: true")
860            .try_build()
861        else {
862            panic!("expected OpenAPI parse error");
863        };
864        match &err {
865            DocsKitError::OpenApi { prefix, .. } => assert_eq!(prefix, "api-reference"),
866            other => panic!("expected OpenApi error, got {other:?}"),
867        }
868        assert!(err.to_string().contains("api-reference"));
869    }
870
871    #[test]
872    fn search_ranks_title_before_description_before_content() {
873        let reg = registry();
874        let results = reg.search_docs("alpha");
875        let paths: Vec<&str> = results.iter().map(|e| e.path.as_str()).collect();
876        assert_eq!(paths, vec!["g/title-doc", "g/desc-doc", "g/body-doc"]);
877    }
878
879    #[test]
880    fn search_empty_query_returns_nothing() {
881        assert!(registry().search_docs("  ").is_empty());
882    }
883
884    #[test]
885    fn sections_split_on_headings_with_intro_and_slugified_anchors() {
886        let sections = split_into_sections(
887            "Intro text.\n\n## Installation Steps\n\nRun it.\n\n### Advanced Setup\n\nTweak it.\n",
888        );
889        assert_eq!(sections.len(), 3);
890
891        // Intro section carries no heading/anchor.
892        assert_eq!(sections[0].heading, "");
893        assert_eq!(sections[0].anchor, "");
894        assert!(sections[0].body.contains("Intro text."));
895
896        // Heading sections carry slugify anchors that match the renderer's ids.
897        assert_eq!(sections[1].heading, "Installation Steps");
898        assert_eq!(sections[1].anchor, slugify("Installation Steps"));
899        assert_eq!(sections[1].anchor, "installation-steps");
900        assert!(sections[1].body.contains("Run it."));
901
902        assert_eq!(sections[2].heading, "Advanced Setup");
903        assert_eq!(sections[2].anchor, "advanced-setup");
904    }
905
906    #[test]
907    fn sections_skip_headings_inside_code_fences() {
908        let sections = split_into_sections(
909            "Intro.\n\n```md\n## Not A Heading\n```\n\n## Real Heading\n\nBody.\n",
910        );
911        let headings: Vec<&str> = sections.iter().map(|s| s.heading.as_str()).collect();
912        assert_eq!(headings, vec!["", "Real Heading"]);
913    }
914
915    #[test]
916    fn empty_leading_section_kept_only_when_page_has_no_headings() {
917        // A page whose body starts straight with a heading has no intro text.
918        let sections = split_into_sections("## First\n\nbody\n");
919        assert_eq!(sections[0].heading, "");
920        assert!(sections[0].body.trim().is_empty());
921    }
922
923    #[test]
924    fn search_returns_section_anchor_for_heading_match() {
925        let reg = registry();
926        let hit = reg
927            .search_docs("installation")
928            .into_iter()
929            .find(|e| e.path == "g/sections")
930            .expect("installation heading section");
931        assert_eq!(hit.heading, "Installation Steps");
932        assert_eq!(hit.anchor, "installation-steps");
933        assert!(hit.api_method.is_none());
934    }
935
936    #[test]
937    fn search_multi_term_requires_all_terms_across_the_page() {
938        let reg = registry();
939        // "cache" only appears in the "Advanced Setup" section body; "widget"
940        // is everywhere on the page. AND matching narrows to that section.
941        let results = reg.search_docs("widget cache");
942        assert!(
943            results.iter().all(|e| e.heading == "Advanced Setup"),
944            "expected only the Advanced Setup section, got: {:?}",
945            results
946                .iter()
947                .map(|e| e.heading.as_str())
948                .collect::<Vec<_>>()
949        );
950        assert!(!results.is_empty());
951    }
952
953    #[test]
954    fn search_index_precomputes_lowercase_fields() {
955        let reg = registry();
956        let entry = reg
957            .search_index
958            .iter()
959            .find(|e| e.title == "Widget Guide")
960            .expect("sectioned doc entry");
961        assert_eq!(entry.title_lower, "widget guide");
962        assert_eq!(entry.description_lower, "all about widgets");
963        assert!(entry.heading_lower == entry.heading.to_lowercase());
964        assert!(entry.body_lower.chars().all(|c| !c.is_uppercase()));
965    }
966
967    #[test]
968    fn api_sidebar_entries_group_by_tag_with_prefix() {
969        let reg = registry();
970        let groups = reg.get_api_sidebar_entries();
971        assert_eq!(groups.len(), 3);
972
973        let (pets_tag, pets_entries) = &groups[0];
974        assert_eq!(pets_tag.name, "pets");
975        let slugs: Vec<&str> = pets_entries.iter().map(|e| e.slug.as_str()).collect();
976        assert_eq!(slugs, vec!["list-pets", "create-pet"]);
977        assert!(pets_entries.iter().all(|e| e.prefix == "api-reference"));
978
979        let (other_tag, other_entries) = &groups[1];
980        assert_eq!(other_tag.name, "Other");
981        assert_eq!(other_entries[0].slug, "misc-thing");
982        assert_eq!(other_entries[0].prefix, "api-reference");
983
984        // The second spec's untagged operation carries its own prefix.
985        let (admin_tag, admin_entries) = &groups[2];
986        assert_eq!(admin_tag.name, "Other");
987        assert_eq!(admin_entries[0].slug, "list-admin-users");
988        assert_eq!(admin_entries[0].prefix, "admin-api");
989    }
990
991    #[test]
992    fn operation_lookup_resolves_owning_spec() {
993        let reg = registry();
994
995        let (op, spec) = reg
996            .get_api_operation_with_spec("api-reference/list-pets")
997            .unwrap();
998        assert_eq!(op.summary.as_deref(), Some("List pets"));
999        assert_eq!(spec.info.title, "Pets API");
1000
1001        // Multi-spec regression: the second spec's operations must resolve to
1002        // the second spec, not the first.
1003        let (op, spec) = reg
1004            .get_api_operation_with_spec("admin-api/list-admin-users")
1005            .unwrap();
1006        assert_eq!(op.summary.as_deref(), Some("List admin users"));
1007        assert_eq!(spec.info.title, "Admin API");
1008
1009        assert!(
1010            reg.get_api_operation_with_spec("api-reference/nope")
1011                .is_none()
1012        );
1013        assert!(
1014            reg.get_api_operation_with_spec("unknown/list-pets")
1015                .is_none()
1016        );
1017    }
1018
1019    #[test]
1020    fn raw_doc_content_present_for_mdx_absent_for_api() {
1021        let reg = registry();
1022        // MDX pages expose their raw Markdown source (drives the copy-page button).
1023        assert!(
1024            reg.get_doc_content("getting-started/intro")
1025                .unwrap()
1026                .contains("Welcome.")
1027        );
1028        // OpenAPI endpoint paths have no Markdown source, so no button renders.
1029        assert!(reg.get_doc_content("api-reference/list-pets").is_none());
1030        assert!(reg.get_doc_content("admin-api/list-admin-users").is_none());
1031    }
1032
1033    #[test]
1034    fn tab_for_path_covers_static_and_api_pages() {
1035        let reg = registry();
1036        assert_eq!(
1037            reg.tab_for_path("getting-started/intro").as_deref(),
1038            Some("Docs")
1039        );
1040        assert_eq!(
1041            reg.tab_for_path("api-reference/list-pets").as_deref(),
1042            Some("API Reference")
1043        );
1044        assert_eq!(
1045            reg.tab_for_path("admin-api/list-admin-users").as_deref(),
1046            Some("API Reference")
1047        );
1048        assert_eq!(reg.tab_for_path("nope/nothing"), None);
1049    }
1050
1051    #[test]
1052    fn llms_txt_lists_pages_under_docs_base_url() {
1053        let out = registry().generate_llms_txt("My Site", "My docs", "https://example.com/docs");
1054        assert!(out.starts_with("# My Site\n\n> My docs\n"));
1055        assert!(out.contains(
1056            "- [Introduction](https://example.com/docs/getting-started/intro): Getting started guide\n"
1057        ));
1058    }
1059
1060    #[test]
1061    fn sitemap_includes_index_pages_and_api_endpoints() {
1062        let out = registry().generate_sitemap("https://example.com", "/docs");
1063        assert!(out.contains("<loc>https://example.com/docs</loc>"));
1064        assert!(out.contains("<loc>https://example.com/docs/getting-started/intro</loc>"));
1065        assert!(out.contains("<loc>https://example.com/docs/api-reference/list-pets</loc>"));
1066        assert!(out.contains("<loc>https://example.com/docs/admin-api/list-admin-users</loc>"));
1067    }
1068
1069    #[test]
1070    fn default_path_falls_back_to_first_nav_page() {
1071        assert_eq!(registry().default_path, "g/body-doc");
1072    }
1073
1074    #[test]
1075    fn sidebar_title_resolves_api_summaries_and_frontmatter() {
1076        let reg = registry();
1077        assert_eq!(
1078            reg.get_sidebar_title("api-reference/list-pets").as_deref(),
1079            Some("List pets")
1080        );
1081        assert_eq!(
1082            reg.get_sidebar_title("getting-started/intro").as_deref(),
1083            Some("Introduction")
1084        );
1085    }
1086
1087    #[test]
1088    fn sitemap_escapes_ampersand_in_loc() {
1089        let nav = r#"{
1090            "groups": [
1091                { "group": "G", "pages": ["guides/a&b"] }
1092            ]
1093        }"#;
1094        let mut content_map = HashMap::new();
1095        content_map.insert("guides/a&b", "---\ntitle: A and B\n---\n\nbody\n");
1096        let registry = DocsConfig::new(nav, content_map).build();
1097
1098        let xml = registry.generate_sitemap("https://example.com", "/docs");
1099
1100        assert!(
1101            xml.contains("<loc>https://example.com/docs/guides/a&amp;b</loc>"),
1102            "got: {xml}"
1103        );
1104        assert!(
1105            !xml.contains("a&b"),
1106            "bare `&` in <loc> breaks XML parsing: {xml}"
1107        );
1108    }
1109}