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