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