1#[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#[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 pub fn has_tabs(&self) -> bool {
28 self.tabs.len() > 1
29 }
30
31 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#[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#[derive(Debug, Clone, PartialEq)]
51pub struct ApiEndpointEntry {
52 pub prefix: String,
54 pub slug: String,
56 pub title: String,
58 pub method: HttpMethod,
60}
61
62#[derive(PartialEq)]
71pub struct SearchEntry {
72 pub path: String,
74 pub anchor: String,
77 pub title: String,
79 pub heading: String,
81 pub description: String,
83 pub body: String,
85 pub breadcrumb: String,
87 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
128struct Section {
130 heading: String,
132 anchor: String,
134 body: String,
136}
137
138fn 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(_) => {} }
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
187fn 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
207pub struct DocsRegistry {
212 pub nav: NavConfig,
214 parsed_docs: HashMap<&'static str, ParsedDoc>,
216 search_index: Vec<SearchEntry>,
218 openapi_specs: Vec<(String, OpenApiSpec)>,
220 api_sidebar_entries: Vec<(ApiTag, Vec<ApiEndpointEntry>)>,
222 api_operation_index: HashMap<String, (usize, usize)>,
224 pub default_path: String,
226 pub api_group_name: String,
228 pub theme: Option<ThemeConfig>,
230 #[cfg(feature = "highlight")]
232 pub code_theme: CodeThemeConfig,
233}
234
235impl DocsRegistry {
236 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 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 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 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 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 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 pub fn get_parsed_doc(&self, path: &str) -> Option<&ParsedDoc> {
326 self.parsed_docs.get(path)
327 }
328
329 pub fn get_sidebar_title(&self, path: &str) -> Option<String> {
331 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 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 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 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 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 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 pub fn get_all_paths(&self) -> Vec<&str> {
405 self.parsed_docs.keys().copied().collect()
406 }
407
408 pub fn get_api_operation(&self, path: &str) -> Option<&ApiOperation> {
416 self.get_api_operation_with_spec(path).map(|(op, _)| op)
417 }
418
419 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 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 pub fn get_first_api_spec(&self) -> Option<&OpenApiSpec> {
440 self.openapi_specs.first().map(|(_, spec)| spec)
441 }
442
443 pub fn get_first_api_prefix(&self) -> Option<&str> {
445 self.openapi_specs.first().map(|(p, _)| p.as_str())
446 }
447
448 pub fn get_api_sidebar_entries(&self) -> &[(ApiTag, Vec<ApiEndpointEntry>)] {
450 &self.api_sidebar_entries
451 }
452
453 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 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 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 pub fn tab_for_path(&self, path: &str) -> Option<String> {
521 for group in &self.nav.groups {
523 if group.pages.iter().any(|p| p == path) {
524 return group.tab.clone();
525 }
526 }
527
528 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 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 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 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 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 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 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 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 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 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(§ion.body);
705 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 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 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 assert_eq!(sections[0].heading, "");
891 assert_eq!(sections[0].anchor, "");
892 assert!(sections[0].body.contains("Intro text."));
893
894 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 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 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 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 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 assert!(
1022 reg.get_doc_content("getting-started/intro")
1023 .unwrap()
1024 .contains("Welcome.")
1025 );
1026 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}