1use 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#[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 pub fn has_tabs(&self) -> bool {
30 self.tabs.len() > 1
31 }
32
33 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#[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#[derive(Debug, Clone, PartialEq)]
53pub struct ApiEndpointEntry {
54 pub prefix: String,
56 pub slug: String,
58 pub title: String,
60 pub method: HttpMethod,
62}
63
64#[derive(PartialEq)]
73pub struct SearchEntry {
74 pub path: String,
76 pub anchor: String,
79 pub title: String,
81 pub heading: String,
83 pub description: String,
85 pub body: String,
87 pub breadcrumb: String,
89 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
130struct Section {
132 heading: String,
134 anchor: String,
136 body: String,
138}
139
140fn 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(_) => {} }
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
189fn 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
209pub struct DocsRegistry {
214 pub nav: NavConfig,
216 parsed_docs: HashMap<&'static str, ParsedDoc>,
218 search_index: Vec<SearchEntry>,
220 openapi_specs: Vec<(String, OpenApiSpec)>,
222 api_sidebar_entries: Vec<(ApiTag, Vec<ApiEndpointEntry>)>,
224 api_operation_index: HashMap<String, (usize, usize)>,
226 pub default_path: String,
228 pub api_group_name: String,
230 pub theme: Option<ThemeConfig>,
232 #[cfg(feature = "highlight")]
234 pub code_theme: CodeThemeConfig,
235}
236
237impl DocsRegistry {
238 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 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 #[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 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 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 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 pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
332 self.parsed_docs.get(path)
333 }
334
335 pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
337 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 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 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 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 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 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 pub fn get_all_paths(&self) -> Vec<&str> {
411 self.parsed_docs.keys().copied().collect()
412 }
413
414 pub fn get_api_operation(&self, path: &str) -> Option<&ApiOperation> {
422 self.get_api_operation_with_spec(path).map(|(op, _)| op)
423 }
424
425 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 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 pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
446 self.openapi_specs.first().map(|(_, spec)| spec)
447 }
448
449 pub fn get_first_api_prefix(&self) -> Option<&str> {
451 self.openapi_specs.first().map(|(p, _)| p.as_str())
452 }
453
454 pub fn get_api_sidebar_entries(&self) -> &[(ApiTag, Vec<ApiEndpointEntry>)] {
456 &self.api_sidebar_entries
457 }
458
459 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 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 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 pub fn tab_for_path(&self, path: &str) -> Option<String> {
527 for group in &self.nav.groups {
529 if group.pages.iter().any(|p| p == path) {
530 return group.tab.clone();
531 }
532 }
533
534 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 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 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 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 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 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 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 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 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 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(§ion.body);
712 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 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 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 assert_eq!(sections[0].heading, "");
903 assert_eq!(sections[0].anchor, "");
904 assert!(sections[0].body.contains("Intro text."));
905
906 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 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 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 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 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 assert!(
1036 reg.get_doc_content("getting-started/intro")
1037 .unwrap()
1038 .contains("Welcome.")
1039 );
1040 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&b</loc>"),
1117 "got: {xml}"
1118 );
1119 assert!(
1120 !xml.contains("a&b"),
1121 "bare `&` in <loc> breaks XML parsing: {xml}"
1122 );
1123 }
1124}