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};
11use dioxus_mdx::{
12 ApiOperation, ApiTag, HttpMethod, OpenApiSpec, ParsedDoc, parse_document, parse_openapi,
13 slugify,
14};
15use serde::Deserialize;
16use std::collections::HashMap;
17
18#[derive(Debug, Clone, Deserialize)]
20pub struct NavConfig {
21 #[serde(default)]
22 pub tabs: Vec<String>,
23 pub groups: Vec<NavGroup>,
24}
25
26impl NavConfig {
27 pub fn has_tabs(&self) -> bool {
29 self.tabs.len() > 1
30 }
31
32 pub fn groups_for_tab(&self, tab: &str) -> Vec<&NavGroup> {
34 self.groups
35 .iter()
36 .filter(|g| g.tab.as_deref() == Some(tab))
37 .collect()
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
43pub struct NavGroup {
44 pub group: String,
45 #[serde(default)]
46 pub tab: Option<String>,
47 pub pages: Vec<String>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct ApiEndpointEntry {
53 pub prefix: String,
55 pub slug: String,
57 pub title: String,
59 pub method: HttpMethod,
61}
62
63#[derive(PartialEq)]
72pub struct SearchEntry {
73 pub path: String,
75 pub anchor: String,
78 pub title: String,
80 pub heading: String,
82 pub description: String,
84 pub body: String,
86 pub breadcrumb: String,
88 pub api_method: Option<HttpMethod>,
90 pub(crate) title_lower: String,
91 pub(crate) heading_lower: String,
92 pub(crate) description_lower: String,
93 pub(crate) body_lower: String,
94}
95
96impl SearchEntry {
97 #[allow(clippy::too_many_arguments)]
98 fn new(
99 path: String,
100 anchor: String,
101 title: String,
102 heading: String,
103 description: String,
104 body: String,
105 breadcrumb: String,
106 api_method: Option<HttpMethod>,
107 ) -> Self {
108 let title_lower = search_lower(&title);
109 let heading_lower = search_lower(&heading);
110 let description_lower = search_lower(&description);
111 let body_lower = search_lower(&body);
112 Self {
113 path,
114 anchor,
115 title,
116 heading,
117 description,
118 body,
119 breadcrumb,
120 api_method,
121 title_lower,
122 heading_lower,
123 description_lower,
124 body_lower,
125 }
126 }
127}
128
129struct Section {
131 heading: String,
133 anchor: String,
135 body: String,
137}
138
139fn split_into_sections(raw: &str) -> Vec<Section> {
146 let mut sections = Vec::new();
147 let mut heading = String::new();
148 let mut anchor = String::new();
149 let mut body = String::new();
150 let mut fence: Option<char> = None;
151
152 for line in raw.lines() {
153 let trimmed = line.trim_start();
154 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
155 let marker = if trimmed.starts_with("```") { '`' } else { '~' };
156 match fence {
157 None => fence = Some(marker),
158 Some(open) if open == marker => fence = None,
159 Some(_) => {} }
161 body.push_str(line);
162 body.push('\n');
163 continue;
164 }
165 if fence.is_none()
166 && let Some(text) = parse_atx_heading(trimmed)
167 {
168 sections.push(Section {
169 heading: std::mem::take(&mut heading),
170 anchor: std::mem::take(&mut anchor),
171 body: std::mem::take(&mut body),
172 });
173 anchor = slugify(text);
174 heading = text.to_string();
175 continue;
176 }
177 body.push_str(line);
178 body.push('\n');
179 }
180 sections.push(Section {
181 heading,
182 anchor,
183 body,
184 });
185 sections
186}
187
188fn parse_atx_heading(line: &str) -> Option<&str> {
193 let hashes = line.bytes().take_while(|&b| b == b'#').count();
194 if !(2..=4).contains(&hashes) {
195 return None;
196 }
197 let rest = &line[hashes..];
198 if !rest.starts_with([' ', '\t']) {
199 return None;
200 }
201 let text = rest.trim();
202 if text.is_empty() {
203 return None;
204 }
205 Some(text)
206}
207
208pub struct DocsRegistry {
213 pub nav: NavConfig,
215 parsed_docs: HashMap<&'static str, ParsedDoc>,
217 search_index: Vec<SearchEntry>,
219 openapi_specs: Vec<(String, OpenApiSpec)>,
221 api_sidebar_entries: Vec<(ApiTag, Vec<ApiEndpointEntry>)>,
223 api_operation_index: HashMap<String, (usize, usize)>,
225 pub default_path: String,
227 pub api_group_name: String,
229 pub theme: Option<ThemeConfig>,
231 #[cfg(feature = "highlight")]
233 pub code_theme: CodeThemeConfig,
234}
235
236impl DocsRegistry {
237 pub(crate) fn try_from_config(config: DocsConfig) -> Result<Self, DocsKitError> {
239 let nav: NavConfig =
240 serde_json::from_str(config.nav_json()).map_err(DocsKitError::NavParse)?;
241
242 let parsed_docs: HashMap<&'static str, ParsedDoc> = config
244 .content_map()
245 .iter()
246 .map(|(&path, &content)| (path, parse_document(content)))
247 .collect();
248
249 let openapi_specs: Vec<(String, OpenApiSpec)> = config
251 .openapi_specs()
252 .iter()
253 .map(|(prefix, yaml)| {
254 parse_openapi(yaml)
255 .map(|spec| (prefix.clone(), spec))
256 .map_err(|error| DocsKitError::OpenApi {
257 prefix: prefix.clone(),
258 error,
259 })
260 })
261 .collect::<Result<_, _>>()?;
262
263 let default_path = config
265 .default_path_value()
266 .map(String::from)
267 .unwrap_or_else(|| {
268 nav.groups
269 .first()
270 .and_then(|g| g.pages.first())
271 .cloned()
272 .unwrap_or_default()
273 });
274
275 let api_group_name = config
276 .api_group_name_value()
277 .map(String::from)
278 .unwrap_or_else(|| "API Reference".to_string());
279
280 let theme = config.theme_config().cloned();
281 #[cfg(feature = "highlight")]
282 let code_theme = config.code_theme_value();
283
284 if !openapi_specs.is_empty() && !nav.groups.iter().any(|g| g.group == api_group_name) {
286 tracing::warn!(
287 "dioxus-docs-kit: OpenAPI specs registered but no nav group \
288 matches api_group_name \"{api_group_name}\". API endpoints won't appear \
289 in the sidebar. Add a group with `\"group\": \"{api_group_name}\"` to \
290 _nav.json, or call .with_api_group_name(\"<your group name>\") on DocsConfig."
291 );
292 }
293
294 let search_index =
296 Self::build_search_index(&nav, &parsed_docs, &openapi_specs, &api_group_name);
297
298 let api_sidebar_entries = Self::build_api_sidebar_entries(&openapi_specs);
299
300 let api_operation_index = openapi_specs
301 .iter()
302 .enumerate()
303 .flat_map(|(spec_idx, (prefix, spec))| {
304 spec.operations.iter().enumerate().map(move |(op_idx, op)| {
305 (format!("{prefix}/{}", op.slug()), (spec_idx, op_idx))
306 })
307 })
308 .collect();
309
310 Ok(Self {
311 nav,
312 parsed_docs,
313 search_index,
314 openapi_specs,
315 api_sidebar_entries,
316 api_operation_index,
317 default_path,
318 api_group_name,
319 theme,
320 #[cfg(feature = "highlight")]
321 code_theme,
322 })
323 }
324
325 pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
327 self.parsed_docs.get(path)
328 }
329
330 pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
332 if let Some(op) = self.get_api_operation(path) {
334 return op
335 .summary
336 .clone()
337 .or_else(|| Some(op.slug().replace('-', " ")));
338 }
339
340 self.get_parsed_doc(path).and_then(|doc| {
341 doc.frontmatter.sidebar_title.clone().or_else(|| {
342 if doc.frontmatter.title.is_empty() {
343 None
344 } else {
345 Some(doc.frontmatter.title.clone())
346 }
347 })
348 })
349 }
350
351 pub fn get_doc_title(&self, path: &str) -> Option<String> {
353 self.get_parsed_doc(path).and_then(|doc| {
354 if doc.frontmatter.title.is_empty() {
355 None
356 } else {
357 Some(doc.frontmatter.title.clone())
358 }
359 })
360 }
361
362 pub fn get_page_title(&self, path: &str) -> Option<String> {
369 if let Some(op) = self.get_api_operation(path) {
370 return op
371 .summary
372 .clone()
373 .or_else(|| Some(op.slug().replace('-', " ")));
374 }
375 self.get_doc_title(path)
376 }
377
378 pub fn get_page_description(&self, path: &str) -> Option<String> {
384 if let Some(op) = self.get_api_operation(path) {
385 return op.description.clone();
386 }
387 self.get_parsed_doc(path)
388 .and_then(|doc| doc.frontmatter.description.clone())
389 }
390
391 pub fn get_doc_icon(&self, path: &str) -> Option<String> {
393 self.get_parsed_doc(path)
394 .and_then(|doc| doc.frontmatter.icon.clone())
395 }
396
397 pub fn get_doc_content(&self, path: &str) -> Option<&str> {
399 self.parsed_docs
400 .get(path)
401 .map(|doc| doc.raw_markdown.as_str())
402 }
403
404 pub fn get_all_paths(&self) -> Vec<&str> {
406 self.parsed_docs.keys().copied().collect()
407 }
408
409 pub fn get_api_operation(&self, path: &str) -> Option<&ApiOperation> {
417 self.get_api_operation_with_spec(path).map(|(op, _)| op)
418 }
419
420 pub fn get_api_operation_with_spec(&self, path: &str) -> Option<(&ApiOperation, &OpenApiSpec)> {
426 let &(spec_idx, op_idx) = self.api_operation_index.get(path)?;
427 let (_, spec) = &self.openapi_specs[spec_idx];
428 Some((&spec.operations[op_idx], spec))
429 }
430
431 pub fn get_api_spec(&self, prefix: &str) -> Option<&OpenApiSpec> {
433 self.openapi_specs
434 .iter()
435 .find(|(p, _)| p == prefix)
436 .map(|(_, spec)| spec)
437 }
438
439 pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
441 self.openapi_specs.first().map(|(_, spec)| spec)
442 }
443
444 pub fn get_first_api_prefix(&self) -> Option<&str> {
446 self.openapi_specs.first().map(|(p, _)| p.as_str())
447 }
448
449 pub fn get_api_sidebar_entries(&self) -> &[(ApiTag, Vec<ApiEndpointEntry>)] {
451 &self.api_sidebar_entries
452 }
453
454 fn build_api_sidebar_entries(
456 openapi_specs: &[(String, OpenApiSpec)],
457 ) -> Vec<(ApiTag, Vec<ApiEndpointEntry>)> {
458 let mut all_groups: Vec<(ApiTag, Vec<ApiEndpointEntry>)> = Vec::new();
459
460 let make_entry = |prefix: &str, op: &ApiOperation| ApiEndpointEntry {
461 prefix: prefix.to_string(),
462 slug: op.slug(),
463 title: op
464 .summary
465 .clone()
466 .unwrap_or_else(|| op.slug().replace('-', " ")),
467 method: op.method,
468 };
469
470 for (prefix, spec) in openapi_specs {
471 for tag in &spec.tags {
472 let entries: Vec<ApiEndpointEntry> = spec
473 .operations
474 .iter()
475 .filter(|op| op.tags.contains(&tag.name))
476 .map(|op| make_entry(prefix, op))
477 .collect();
478
479 if !entries.is_empty() {
480 all_groups.push((tag.clone(), entries));
481 }
482 }
483
484 let tagged_ids: Vec<_> = spec.tags.iter().map(|t| t.name.as_str()).collect();
486 let untagged: Vec<ApiEndpointEntry> = spec
487 .operations
488 .iter()
489 .filter(|op| {
490 op.tags.is_empty() || op.tags.iter().all(|t| !tagged_ids.contains(&t.as_str()))
491 })
492 .map(|op| make_entry(prefix, op))
493 .collect();
494
495 if !untagged.is_empty() {
496 all_groups.push((
497 ApiTag {
498 name: "Other".to_string(),
499 description: None,
500 },
501 untagged,
502 ));
503 }
504 }
505
506 all_groups
507 }
508
509 pub fn get_api_endpoint_paths(&self) -> Vec<String> {
511 let mut paths = Vec::new();
512 for (prefix, spec) in &self.openapi_specs {
513 for op in &spec.operations {
514 paths.push(format!("{prefix}/{}", op.slug()));
515 }
516 }
517 paths
518 }
519
520 pub fn tab_for_path(&self, path: &str) -> Option<String> {
522 for group in &self.nav.groups {
524 if group.pages.iter().any(|p| p == path) {
525 return group.tab.clone();
526 }
527 }
528
529 for (prefix, _) in &self.openapi_specs {
531 if path.starts_with(&format!("{prefix}/")) {
532 for group in &self.nav.groups {
533 if group.group == self.api_group_name {
534 return group.tab.clone();
535 }
536 }
537 }
538 }
539
540 None
541 }
542
543 pub fn generate_llms_txt(
553 &self,
554 site_title: &str,
555 site_description: &str,
556 docs_base_url: &str,
557 ) -> String {
558 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
559
560 for group in &self.nav.groups {
561 for page in &group.pages {
562 if let Some(doc) = self.get_parsed_doc(page) {
563 let title = if doc.frontmatter.title.is_empty() {
564 page.split('/').next_back().unwrap_or(page).to_string()
565 } else {
566 doc.frontmatter.title.clone()
567 };
568 let desc = doc.frontmatter.description.as_deref().unwrap_or("");
569 let url = format!("{docs_base_url}/{page}");
570 if desc.is_empty() {
571 out.push_str(&format!("- [{title}]({url})\n"));
572 } else {
573 out.push_str(&format!("- [{title}]({url}): {desc}\n"));
574 }
575 }
576 }
577 }
578
579 out
580 }
581
582 pub fn generate_llms_full_txt(
586 &self,
587 site_title: &str,
588 site_description: &str,
589 docs_base_url: &str,
590 ) -> String {
591 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
592
593 for group in &self.nav.groups {
594 for page in &group.pages {
595 if let Some(doc) = self.get_parsed_doc(page) {
596 let title = if doc.frontmatter.title.is_empty() {
597 page.split('/').next_back().unwrap_or(page).to_string()
598 } else {
599 doc.frontmatter.title.clone()
600 };
601 let url = format!("{docs_base_url}/{page}");
602 out.push_str(&format!("---\n\n## [{title}]({url})\n\n"));
603 out.push_str(&doc.raw_markdown);
604 out.push_str("\n\n");
605 }
606 }
607 }
608
609 out
610 }
611
612 pub fn generate_sitemap(&self, site_url: &str, docs_path: &str) -> String {
618 let mut xml = String::from(
619 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
620 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
621 );
622
623 let index_loc = xml_escape(&format!("{site_url}{docs_path}"));
625 xml.push_str(&format!(
626 "<url>\n<loc>{index_loc}</loc>\n<changefreq>weekly</changefreq>\n<priority>1.0</priority>\n</url>\n"
627 ));
628
629 for group in &self.nav.groups {
631 for page in &group.pages {
632 let loc = xml_escape(&format!("{site_url}{docs_path}/{page}"));
633 xml.push_str(&format!(
634 "<url>\n<loc>{loc}</loc>\n<changefreq>weekly</changefreq>\n<priority>0.7</priority>\n</url>\n"
635 ));
636 }
637 }
638
639 for (prefix, spec) in &self.openapi_specs {
641 for op in &spec.operations {
642 let loc = xml_escape(&format!("{site_url}{docs_path}/{prefix}/{}", op.slug()));
643 xml.push_str(&format!(
644 "<url>\n<loc>{loc}</loc>\n<changefreq>monthly</changefreq>\n<priority>0.5</priority>\n</url>\n"
645 ));
646 }
647 }
648
649 xml.push_str("</urlset>\n");
650 xml
651 }
652
653 pub fn search_docs(&self, query: &str) -> Vec<&SearchEntry> {
663 crate::search::rank(&self.search_index, query, |e, buf| {
664 buf.push(Field::title(&e.title_lower));
665 if !e.heading_lower.is_empty() {
666 buf.push(Field::heading(&e.heading_lower));
667 }
668 if !e.description_lower.is_empty() {
669 buf.push(Field::description(&e.description_lower));
670 }
671 if !e.body_lower.is_empty() {
672 buf.push(Field::body(&e.body_lower));
673 }
674 })
675 }
676
677 fn build_search_index(
682 nav: &NavConfig,
683 parsed_docs: &HashMap<&'static str, ParsedDoc>,
684 openapi_specs: &[(String, OpenApiSpec)],
685 api_group_name: &str,
686 ) -> Vec<SearchEntry> {
687 let mut entries = Vec::new();
688
689 for group in &nav.groups {
691 for page in &group.pages {
692 if let Some(doc) = parsed_docs.get(page.as_str()) {
693 let title = if doc.frontmatter.title.is_empty() {
694 page.split('/')
695 .next_back()
696 .unwrap_or(page)
697 .replace('-', " ")
698 } else {
699 doc.frontmatter.title.clone()
700 };
701 let description = doc.frontmatter.description.clone().unwrap_or_default();
702
703 let sections = split_into_sections(&doc.raw_markdown);
704 let has_headings = sections.iter().any(|s| !s.heading.is_empty());
705 for section in sections {
706 let body = clean_markdown(§ion.body);
707 if section.heading.is_empty() && body.is_empty() && has_headings {
710 continue;
711 }
712 entries.push(SearchEntry::new(
713 page.clone(),
714 section.anchor,
715 title.clone(),
716 section.heading,
717 description.clone(),
718 body,
719 group.group.clone(),
720 None,
721 ));
722 }
723 }
724 }
725 }
726
727 for (prefix, spec) in openapi_specs {
729 for op in &spec.operations {
730 let title = op
731 .summary
732 .clone()
733 .unwrap_or_else(|| op.slug().replace('-', " "));
734 let description = op.description.clone().unwrap_or_default();
735 let tag = op
736 .tags
737 .first()
738 .cloned()
739 .unwrap_or_else(|| "Other".to_string());
740
741 entries.push(SearchEntry::new(
742 format!("{prefix}/{}", op.slug()),
743 String::new(),
744 title,
745 String::new(),
746 description.clone(),
747 clean_markdown(&description),
748 format!("{api_group_name} > {tag}"),
749 Some(op.method),
750 ));
751 }
752 }
753
754 entries
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use crate::error::DocsKitError;
762
763 const NAV: &str = r#"{
764 "tabs": ["Docs", "API Reference"],
765 "groups": [
766 { "group": "Search Fixtures", "tab": "Docs", "pages": ["g/body-doc", "g/desc-doc", "g/title-doc", "g/sections"] },
767 { "group": "Getting Started", "tab": "Docs", "pages": ["getting-started/intro"] },
768 { "group": "API Reference", "tab": "API Reference", "pages": ["api-reference/overview"] }
769 ]
770 }"#;
771
772 const BODY_DOC: &str = "---\ntitle: Body doc\ndescription: nothing here\n---\n\nThe alpha keyword lives in the body.\n";
773 const DESC_DOC: &str = "---\ntitle: Desc doc\ndescription: mentions alpha here\n---\n\nplain\n";
774 const TITLE_DOC: &str = "---\ntitle: Alpha guide\ndescription: plain\n---\n\nplain\n";
775 const INTRO: &str =
776 "---\ntitle: Introduction\ndescription: Getting started guide\n---\n\nWelcome.\n";
777 const OVERVIEW: &str = "---\ntitle: API Overview\n---\n\nEndpoints below.\n";
778 const SECTIONS_DOC: &str = "---\ntitle: Widget Guide\ndescription: All about widgets\n---\n\nIntro paragraph about widgets.\n\n## Installation Steps\n\nRun the installer to set up widgets.\n\n### Advanced Setup\n\nConfigure the widget cache carefully.\n";
780
781 const PETS_SPEC: &str = r#"
782openapi: "3.0.0"
783info:
784 title: Pets API
785 version: "1.0.0"
786tags:
787 - name: pets
788 description: Pet operations
789paths:
790 /pets:
791 get:
792 operationId: listPets
793 summary: List pets
794 tags: [pets]
795 responses:
796 "200":
797 description: OK
798 post:
799 operationId: createPet
800 summary: Create pet
801 tags: [pets]
802 responses:
803 "200":
804 description: OK
805 /misc:
806 get:
807 operationId: miscThing
808 summary: Misc thing
809 responses:
810 "200":
811 description: OK
812"#;
813
814 const ADMIN_SPEC: &str = r#"
815openapi: "3.0.0"
816info:
817 title: Admin API
818 version: "1.0.0"
819paths:
820 /admin/users:
821 get:
822 operationId: listAdminUsers
823 summary: List admin users
824 responses:
825 "200":
826 description: OK
827"#;
828
829 fn content_map() -> HashMap<&'static str, &'static str> {
830 HashMap::from([
831 ("g/body-doc", BODY_DOC),
832 ("g/desc-doc", DESC_DOC),
833 ("g/title-doc", TITLE_DOC),
834 ("g/sections", SECTIONS_DOC),
835 ("getting-started/intro", INTRO),
836 ("api-reference/overview", OVERVIEW),
837 ])
838 }
839
840 fn registry() -> DocsRegistry {
841 DocsConfig::new(NAV, content_map())
842 .with_openapi("api-reference", PETS_SPEC)
843 .with_openapi("admin-api", ADMIN_SPEC)
844 .build()
845 }
846
847 #[test]
848 fn try_build_reports_nav_parse_error_with_detail() {
849 let Err(err) = DocsConfig::new("{ not json", HashMap::new()).try_build() else {
850 panic!("expected nav parse error");
851 };
852 assert!(matches!(err, DocsKitError::NavParse(_)));
853 assert!(err.to_string().contains("_nav.json"));
854 }
855
856 #[test]
857 fn try_build_reports_openapi_error_with_prefix() {
858 let Err(err) = DocsConfig::new(NAV, content_map())
859 .with_openapi("api-reference", "openapi: true")
860 .try_build()
861 else {
862 panic!("expected OpenAPI parse error");
863 };
864 match &err {
865 DocsKitError::OpenApi { prefix, .. } => assert_eq!(prefix, "api-reference"),
866 other => panic!("expected OpenApi error, got {other:?}"),
867 }
868 assert!(err.to_string().contains("api-reference"));
869 }
870
871 #[test]
872 fn search_ranks_title_before_description_before_content() {
873 let reg = registry();
874 let results = reg.search_docs("alpha");
875 let paths: Vec<&str> = results.iter().map(|e| e.path.as_str()).collect();
876 assert_eq!(paths, vec!["g/title-doc", "g/desc-doc", "g/body-doc"]);
877 }
878
879 #[test]
880 fn search_empty_query_returns_nothing() {
881 assert!(registry().search_docs(" ").is_empty());
882 }
883
884 #[test]
885 fn sections_split_on_headings_with_intro_and_slugified_anchors() {
886 let sections = split_into_sections(
887 "Intro text.\n\n## Installation Steps\n\nRun it.\n\n### Advanced Setup\n\nTweak it.\n",
888 );
889 assert_eq!(sections.len(), 3);
890
891 assert_eq!(sections[0].heading, "");
893 assert_eq!(sections[0].anchor, "");
894 assert!(sections[0].body.contains("Intro text."));
895
896 assert_eq!(sections[1].heading, "Installation Steps");
898 assert_eq!(sections[1].anchor, slugify("Installation Steps"));
899 assert_eq!(sections[1].anchor, "installation-steps");
900 assert!(sections[1].body.contains("Run it."));
901
902 assert_eq!(sections[2].heading, "Advanced Setup");
903 assert_eq!(sections[2].anchor, "advanced-setup");
904 }
905
906 #[test]
907 fn sections_skip_headings_inside_code_fences() {
908 let sections = split_into_sections(
909 "Intro.\n\n```md\n## Not A Heading\n```\n\n## Real Heading\n\nBody.\n",
910 );
911 let headings: Vec<&str> = sections.iter().map(|s| s.heading.as_str()).collect();
912 assert_eq!(headings, vec!["", "Real Heading"]);
913 }
914
915 #[test]
916 fn empty_leading_section_kept_only_when_page_has_no_headings() {
917 let sections = split_into_sections("## First\n\nbody\n");
919 assert_eq!(sections[0].heading, "");
920 assert!(sections[0].body.trim().is_empty());
921 }
922
923 #[test]
924 fn search_returns_section_anchor_for_heading_match() {
925 let reg = registry();
926 let hit = reg
927 .search_docs("installation")
928 .into_iter()
929 .find(|e| e.path == "g/sections")
930 .expect("installation heading section");
931 assert_eq!(hit.heading, "Installation Steps");
932 assert_eq!(hit.anchor, "installation-steps");
933 assert!(hit.api_method.is_none());
934 }
935
936 #[test]
937 fn search_multi_term_requires_all_terms_across_the_page() {
938 let reg = registry();
939 let results = reg.search_docs("widget cache");
942 assert!(
943 results.iter().all(|e| e.heading == "Advanced Setup"),
944 "expected only the Advanced Setup section, got: {:?}",
945 results
946 .iter()
947 .map(|e| e.heading.as_str())
948 .collect::<Vec<_>>()
949 );
950 assert!(!results.is_empty());
951 }
952
953 #[test]
954 fn search_index_precomputes_lowercase_fields() {
955 let reg = registry();
956 let entry = reg
957 .search_index
958 .iter()
959 .find(|e| e.title == "Widget Guide")
960 .expect("sectioned doc entry");
961 assert_eq!(entry.title_lower, "widget guide");
962 assert_eq!(entry.description_lower, "all about widgets");
963 assert!(entry.heading_lower == entry.heading.to_lowercase());
964 assert!(entry.body_lower.chars().all(|c| !c.is_uppercase()));
965 }
966
967 #[test]
968 fn api_sidebar_entries_group_by_tag_with_prefix() {
969 let reg = registry();
970 let groups = reg.get_api_sidebar_entries();
971 assert_eq!(groups.len(), 3);
972
973 let (pets_tag, pets_entries) = &groups[0];
974 assert_eq!(pets_tag.name, "pets");
975 let slugs: Vec<&str> = pets_entries.iter().map(|e| e.slug.as_str()).collect();
976 assert_eq!(slugs, vec!["list-pets", "create-pet"]);
977 assert!(pets_entries.iter().all(|e| e.prefix == "api-reference"));
978
979 let (other_tag, other_entries) = &groups[1];
980 assert_eq!(other_tag.name, "Other");
981 assert_eq!(other_entries[0].slug, "misc-thing");
982 assert_eq!(other_entries[0].prefix, "api-reference");
983
984 let (admin_tag, admin_entries) = &groups[2];
986 assert_eq!(admin_tag.name, "Other");
987 assert_eq!(admin_entries[0].slug, "list-admin-users");
988 assert_eq!(admin_entries[0].prefix, "admin-api");
989 }
990
991 #[test]
992 fn operation_lookup_resolves_owning_spec() {
993 let reg = registry();
994
995 let (op, spec) = reg
996 .get_api_operation_with_spec("api-reference/list-pets")
997 .unwrap();
998 assert_eq!(op.summary.as_deref(), Some("List pets"));
999 assert_eq!(spec.info.title, "Pets API");
1000
1001 let (op, spec) = reg
1004 .get_api_operation_with_spec("admin-api/list-admin-users")
1005 .unwrap();
1006 assert_eq!(op.summary.as_deref(), Some("List admin users"));
1007 assert_eq!(spec.info.title, "Admin API");
1008
1009 assert!(
1010 reg.get_api_operation_with_spec("api-reference/nope")
1011 .is_none()
1012 );
1013 assert!(
1014 reg.get_api_operation_with_spec("unknown/list-pets")
1015 .is_none()
1016 );
1017 }
1018
1019 #[test]
1020 fn raw_doc_content_present_for_mdx_absent_for_api() {
1021 let reg = registry();
1022 assert!(
1024 reg.get_doc_content("getting-started/intro")
1025 .unwrap()
1026 .contains("Welcome.")
1027 );
1028 assert!(reg.get_doc_content("api-reference/list-pets").is_none());
1030 assert!(reg.get_doc_content("admin-api/list-admin-users").is_none());
1031 }
1032
1033 #[test]
1034 fn tab_for_path_covers_static_and_api_pages() {
1035 let reg = registry();
1036 assert_eq!(
1037 reg.tab_for_path("getting-started/intro").as_deref(),
1038 Some("Docs")
1039 );
1040 assert_eq!(
1041 reg.tab_for_path("api-reference/list-pets").as_deref(),
1042 Some("API Reference")
1043 );
1044 assert_eq!(
1045 reg.tab_for_path("admin-api/list-admin-users").as_deref(),
1046 Some("API Reference")
1047 );
1048 assert_eq!(reg.tab_for_path("nope/nothing"), None);
1049 }
1050
1051 #[test]
1052 fn llms_txt_lists_pages_under_docs_base_url() {
1053 let out = registry().generate_llms_txt("My Site", "My docs", "https://example.com/docs");
1054 assert!(out.starts_with("# My Site\n\n> My docs\n"));
1055 assert!(out.contains(
1056 "- [Introduction](https://example.com/docs/getting-started/intro): Getting started guide\n"
1057 ));
1058 }
1059
1060 #[test]
1061 fn sitemap_includes_index_pages_and_api_endpoints() {
1062 let out = registry().generate_sitemap("https://example.com", "/docs");
1063 assert!(out.contains("<loc>https://example.com/docs</loc>"));
1064 assert!(out.contains("<loc>https://example.com/docs/getting-started/intro</loc>"));
1065 assert!(out.contains("<loc>https://example.com/docs/api-reference/list-pets</loc>"));
1066 assert!(out.contains("<loc>https://example.com/docs/admin-api/list-admin-users</loc>"));
1067 }
1068
1069 #[test]
1070 fn default_path_falls_back_to_first_nav_page() {
1071 assert_eq!(registry().default_path, "g/body-doc");
1072 }
1073
1074 #[test]
1075 fn sidebar_title_resolves_api_summaries_and_frontmatter() {
1076 let reg = registry();
1077 assert_eq!(
1078 reg.get_sidebar_title("api-reference/list-pets").as_deref(),
1079 Some("List pets")
1080 );
1081 assert_eq!(
1082 reg.get_sidebar_title("getting-started/intro").as_deref(),
1083 Some("Introduction")
1084 );
1085 }
1086
1087 #[test]
1088 fn sitemap_escapes_ampersand_in_loc() {
1089 let nav = r#"{
1090 "groups": [
1091 { "group": "G", "pages": ["guides/a&b"] }
1092 ]
1093 }"#;
1094 let mut content_map = HashMap::new();
1095 content_map.insert("guides/a&b", "---\ntitle: A and B\n---\n\nbody\n");
1096 let registry = DocsConfig::new(nav, content_map).build();
1097
1098 let xml = registry.generate_sitemap("https://example.com", "/docs");
1099
1100 assert!(
1101 xml.contains("<loc>https://example.com/docs/guides/a&b</loc>"),
1102 "got: {xml}"
1103 );
1104 assert!(
1105 !xml.contains("a&b"),
1106 "bare `&` in <loc> breaks XML parsing: {xml}"
1107 );
1108 }
1109}