1use std::collections::BTreeMap;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use walkdir::WalkDir;
9
10use rustpress_md::{parse_markdown, Document, MarkdownOptions};
11use rustpress_search::{build_search_index, SearchConfig, SearchPage};
12use rustpress_theme::{
13 render_page, write_theme_assets, LanguageOption, NavItem, PageRender, SiteRender, ThemeConfig,
14 TopNavItem, TopNavLink,
15};
16
17#[derive(Debug, Clone)]
18pub struct BuildOptions {
19 pub config_path: PathBuf,
20}
21
22impl BuildOptions {
23 pub fn new(config_path: impl Into<PathBuf>) -> Self {
24 Self {
25 config_path: config_path.into(),
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BuildResult {
32 pub out_dir: PathBuf,
33 pub page_count: usize,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct Config {
39 pub title: String,
40 pub src_dir: PathBuf,
41 pub out_dir: PathBuf,
42 pub base: String,
43 pub theme: ThemeSection,
44 pub markdown: MarkdownSection,
45 pub search: SearchSection,
46 pub access: AccessSection,
47 pub nav: Vec<NavSection>,
48 pub sidebars: BTreeMap<String, Vec<SidebarSection>>,
49 pub locales: BTreeMap<String, LocaleSection>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(default)]
54pub struct ThemeSection {
55 pub name: String,
56 pub skin: String,
57 pub allow_switch: bool,
58 pub github_url: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct MarkdownSection {
64 pub mermaid: bool,
65 pub code_highlight: bool,
66 pub code_line_numbers: bool,
67 pub heading_anchors: bool,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(default)]
72pub struct SearchSection {
73 pub enabled: bool,
74 pub languages: Vec<String>,
75 pub index_code: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct AccessSection {
81 pub enabled: bool,
82 pub mode: String,
83 pub password: String,
84 pub password_hint: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default)]
89pub struct NavSection {
90 pub text: String,
91 pub link: Option<String>,
92 pub sidebar: Option<String>,
93 pub items: Vec<NavLinkSection>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(default)]
98pub struct NavLinkSection {
99 pub text: String,
100 pub link: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(default)]
105pub struct SidebarSection {
106 pub text: String,
107 pub link: String,
108 pub items: Vec<SidebarLinkSection>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(default)]
113pub struct SidebarLinkSection {
114 pub text: String,
115 pub link: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(default)]
120pub struct LocaleSection {
121 pub label: String,
122 pub lang: String,
123 pub link: String,
124 pub title: Option<String>,
125 pub nav: Vec<NavSection>,
126 pub sidebars: BTreeMap<String, Vec<SidebarSection>>,
127}
128
129impl Default for Config {
130 fn default() -> Self {
131 Self {
132 title: "My Docs".to_string(),
133 src_dir: "docs".into(),
134 out_dir: "dist".into(),
135 base: "/".to_string(),
136 theme: ThemeSection::default(),
137 markdown: MarkdownSection::default(),
138 search: SearchSection::default(),
139 access: AccessSection::default(),
140 nav: Vec::new(),
141 sidebars: BTreeMap::new(),
142 locales: BTreeMap::new(),
143 }
144 }
145}
146
147impl Default for ThemeSection {
148 fn default() -> Self {
149 Self {
150 name: "default".to_string(),
151 skin: "light".to_string(),
152 allow_switch: true,
153 github_url: String::new(),
154 }
155 }
156}
157
158impl Default for MarkdownSection {
159 fn default() -> Self {
160 Self {
161 mermaid: true,
162 code_highlight: true,
163 code_line_numbers: true,
164 heading_anchors: true,
165 }
166 }
167}
168
169impl Default for SearchSection {
170 fn default() -> Self {
171 Self {
172 enabled: true,
173 languages: vec!["zh".to_string(), "en".to_string()],
174 index_code: false,
175 }
176 }
177}
178
179impl Default for AccessSection {
180 fn default() -> Self {
181 Self {
182 enabled: true,
183 mode: "mask".to_string(),
184 password: String::new(),
185 password_hint: "Enter password".to_string(),
186 }
187 }
188}
189
190impl Default for NavSection {
191 fn default() -> Self {
192 Self {
193 text: String::new(),
194 link: None,
195 sidebar: None,
196 items: Vec::new(),
197 }
198 }
199}
200
201impl Default for NavLinkSection {
202 fn default() -> Self {
203 Self {
204 text: String::new(),
205 link: String::new(),
206 }
207 }
208}
209
210impl Default for LocaleSection {
211 fn default() -> Self {
212 Self {
213 label: String::new(),
214 lang: String::new(),
215 link: String::new(),
216 title: None,
217 nav: Vec::new(),
218 sidebars: BTreeMap::new(),
219 }
220 }
221}
222
223impl Default for SidebarSection {
224 fn default() -> Self {
225 Self {
226 text: String::new(),
227 link: String::new(),
228 items: Vec::new(),
229 }
230 }
231}
232
233impl Default for SidebarLinkSection {
234 fn default() -> Self {
235 Self {
236 text: String::new(),
237 link: String::new(),
238 }
239 }
240}
241
242impl Config {
243 pub fn load(path: &Path) -> Result<Self> {
244 let raw = fs::read_to_string(path)
245 .with_context(|| format!("failed to read config {}", path.display()))?;
246 let mut config: Config = toml::from_str(&raw)
247 .with_context(|| format!("failed to parse config {}", path.display()))?;
248 config.normalize()?;
249 Ok(config)
250 }
251
252 fn normalize(&mut self) -> Result<()> {
253 if self.base.is_empty() {
254 self.base = "/".to_string();
255 }
256 if !self.base.starts_with('/') {
257 self.base.insert(0, '/');
258 }
259 if !self.base.ends_with('/') {
260 self.base.push('/');
261 }
262 self.theme.skin = normalize_theme_skin(&self.theme.skin);
263 self.theme.github_url = self.theme.github_url.trim().to_string();
264 self.access.password = self.access.password.trim().to_string();
265 normalize_nav(&mut self.nav, None);
266 normalize_sidebars(&mut self.sidebars, None);
267
268 if !self.locales.is_empty() {
269 if !self.locales.contains_key("root") {
270 anyhow::bail!("locales.root is required when locales are configured");
271 }
272
273 let keys = self.locales.keys().cloned().collect::<Vec<_>>();
274 for key in keys {
275 let locale = self
276 .locales
277 .get_mut(&key)
278 .expect("locale key collected from map");
279 locale.label = locale.label.trim().to_string();
280 if locale.label.is_empty() {
281 locale.label = key.clone();
282 }
283 locale.lang = locale.lang.trim().to_string();
284 if locale.lang.is_empty() {
285 locale.lang = if key == "root" {
286 "en".to_string()
287 } else {
288 key.clone()
289 };
290 }
291 locale.title = locale
292 .title
293 .take()
294 .map(|title| title.trim().to_string())
295 .filter(|title| !title.is_empty());
296 locale.link = if key == "root" {
297 "/".to_string()
298 } else {
299 normalize_locale_prefix(&key, &locale.link)?
300 };
301 }
302
303 for locale in self.locales.values_mut() {
304 let locale_prefix = locale.link.clone();
305 normalize_nav(&mut locale.nav, Some(&locale_prefix));
306 normalize_sidebars(&mut locale.sidebars, Some(&locale_prefix));
307 }
308 }
309 Ok(())
310 }
311}
312
313pub fn init_project(dir: &Path) -> Result<()> {
314 fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
315 let docs_dir = dir.join("docs");
316 let public_dir = dir.join("public");
317 fs::create_dir_all(&docs_dir)
318 .with_context(|| format!("failed to create {}", docs_dir.display()))?;
319 fs::create_dir_all(&public_dir)
320 .with_context(|| format!("failed to create {}", public_dir.display()))?;
321
322 write_new(
323 &dir.join("rustpress.toml"),
324 r#"title = "My Docs"
325src_dir = "docs"
326out_dir = "dist"
327base = "/"
328
329[[nav]]
330text = "Guide"
331link = "/"
332sidebar = "guide"
333
334[[nav.items]]
335text = "Home"
336link = "/"
337
338[[nav.items]]
339text = "Masked Page"
340link = "/private/"
341
342[[sidebars.guide]]
343text = "Guide"
344link = "/"
345
346[[sidebars.guide.items]]
347text = "Home"
348link = "/"
349
350[[sidebars.guide.items]]
351text = "Masked Page"
352link = "/private/"
353
354[theme]
355name = "default"
356skin = "light"
357allow_switch = true
358github_url = ""
359
360[markdown]
361mermaid = true
362code_highlight = true
363code_line_numbers = true
364heading_anchors = true
365
366[search]
367enabled = true
368languages = ["zh", "en"]
369index_code = false
370
371[access]
372enabled = true
373mode = "mask"
374password = "rustpress"
375password_hint = "Enter password"
376"#,
377 )?;
378
379 write_new(
380 &docs_dir.join("index.md"),
381 r#"---
382title: Welcome
383layout: doc
384sidebar: true
385search: true
386access: public
387---
388
389# Welcome
390
391RustPress turns Markdown into a static documentation site.
392
393## Mermaid
394
395```mermaid
396flowchart LR
397 A[Markdown] --> B[RustPress]
398 B --> C[Static HTML]
399```
400
401## Search
402
403Local search indexes English and 中文 content by default.
404"#,
405 )?;
406
407 write_new(
408 &docs_dir.join("private.md"),
409 r#"---
410title: Masked Page
411layout: doc
412sidebar: true
413search: true
414access: masked
415---
416
417# Masked Page
418
419This page demonstrates the front-end password mask. The HTML content is still present in the static output.
420"#,
421 )?;
422
423 write_new(&public_dir.join(".gitkeep"), "")?;
424 Ok(())
425}
426
427pub fn build_site(options: BuildOptions) -> Result<BuildResult> {
428 let config_path = normalize_config_path(&options.config_path)?;
429 let project_root = config_path
430 .parent()
431 .map(Path::to_path_buf)
432 .unwrap_or_else(|| PathBuf::from("."));
433 let config = Config::load(&config_path)?;
434 let src_dir = absolutize(&project_root, &config.src_dir);
435 let out_dir = absolutize(&project_root, &config.out_dir);
436 let public_dir = project_root.join("public");
437
438 if out_dir.exists() {
439 fs::remove_dir_all(&out_dir)
440 .with_context(|| format!("failed to clean {}", out_dir.display()))?;
441 }
442 fs::create_dir_all(&out_dir)
443 .with_context(|| format!("failed to create {}", out_dir.display()))?;
444
445 let pages = read_pages(&src_dir, &config)?;
446 let translations = build_translation_map(&pages);
447 let site = base_site_render(&config);
448
449 write_theme_assets(&out_dir, &site)?;
450 copy_public_assets(&public_dir, &out_dir)?;
451
452 let mut search_pages = Vec::new();
453 for page in &pages {
454 let page_site = site_render_for_page(&config, &pages, &translations, page);
455 let rendered = render_page(
456 &page_site,
457 &PageRender {
458 title: page.document.title.clone(),
459 route: page.route.clone(),
460 html: page.document.html.clone(),
461 markdown_source: page.markdown_source.clone(),
462 headings: page.document.headings.clone(),
463 masked: page.document.frontmatter.access == "masked",
464 search: page.document.frontmatter.search,
465 },
466 );
467 write_page(&out_dir, &page.route, &rendered)?;
468
469 if page.document.frontmatter.search {
470 search_pages.push(SearchPage {
471 title: page.document.title.clone(),
472 url: site_url(&config.base, &page.route),
473 headings: page
474 .document
475 .headings
476 .iter()
477 .map(|heading| heading.text.clone())
478 .collect(),
479 body: page.document.search_text.clone(),
480 });
481 }
482 }
483
484 if config.search.enabled {
485 write_search_index(&out_dir, &config.search, &search_pages)?;
486 }
487
488 Ok(BuildResult {
489 out_dir,
490 page_count: pages.len(),
491 })
492}
493
494#[derive(Debug, Clone)]
495struct Page {
496 route: String,
497 locale_key: String,
498 translation_key: String,
499 markdown_source: String,
500 document: Document,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
504struct PageMetadata {
505 route: String,
506 locale_key: String,
507 translation_key: String,
508}
509
510fn read_pages(src_dir: &Path, config: &Config) -> Result<Vec<Page>> {
511 let mut pages = Vec::new();
512 for entry in WalkDir::new(src_dir).sort_by_file_name() {
513 let entry = entry.with_context(|| format!("failed to scan {}", src_dir.display()))?;
514 if !entry.file_type().is_file() {
515 continue;
516 }
517 if entry.path().extension().and_then(|value| value.to_str()) != Some("md") {
518 continue;
519 }
520
521 let markdown = fs::read_to_string(entry.path())
522 .with_context(|| format!("failed to read {}", entry.path().display()))?;
523 let document = parse_markdown(
524 &markdown,
525 MarkdownOptions {
526 mermaid: config.markdown.mermaid,
527 code_highlight: config.markdown.code_highlight,
528 code_line_numbers: config.markdown.code_line_numbers,
529 heading_anchors: config.markdown.heading_anchors,
530 index_code: config.search.index_code,
531 },
532 )
533 .with_context(|| format!("failed to parse {}", entry.path().display()))?;
534 let metadata = page_metadata_for(src_dir, entry.path(), config)?;
535 pages.push(Page {
536 route: metadata.route,
537 locale_key: metadata.locale_key,
538 translation_key: metadata.translation_key,
539 markdown_source: markdown,
540 document,
541 });
542 }
543 Ok(pages)
544}
545
546fn build_nav(pages: &[Page], config: &Config, page: &Page) -> Vec<NavItem> {
547 if !sidebars_for_locale(config, &page.locale_key).is_empty() {
548 return build_explicit_nav(config, &page.locale_key, &page.route);
549 }
550
551 build_legacy_nav(pages, config, &page.locale_key)
552}
553
554fn build_legacy_nav(pages: &[Page], config: &Config, locale_key: &str) -> Vec<NavItem> {
555 let locale_prefix = home_for_locale(config, locale_key);
556 let group_meta =
557 sidebar_group_meta(nav_sections_for_locale(config, locale_key), &locale_prefix);
558 let mut roots = Vec::new();
559 let mut groups = Vec::<SidebarGroup>::new();
560
561 for page in pages
562 .iter()
563 .filter(|page| page.locale_key == locale_key && page.document.frontmatter.sidebar)
564 {
565 let segments = route_segments(&page.translation_key);
566 let leaf = NavItem {
567 title: page.document.title.clone(),
568 href: page.route.clone(),
569 active_prefix: page.route.clone(),
570 items: Vec::new(),
571 };
572
573 if segments.len() < 2 {
574 roots.push(leaf);
575 continue;
576 }
577
578 let segment = segments[0].to_string();
579 let meta = group_meta.iter().find(|meta| meta.segment == segment);
580 let group_index =
581 if let Some(index) = groups.iter().position(|group| group.segment == segment) {
582 index
583 } else {
584 groups.push(SidebarGroup {
585 segment: segment.clone(),
586 title: meta
587 .map(|meta| meta.title.clone())
588 .unwrap_or_else(|| titleize_segment(&segment)),
589 href: meta
590 .map(|meta| meta.href.clone())
591 .unwrap_or_else(|| page.route.clone()),
592 active_prefix: route_with_prefix(&locale_prefix, &format!("/{segment}/")),
593 order: meta.map(|meta| meta.order).unwrap_or(usize::MAX),
594 item_order: meta.map(|meta| meta.item_order.clone()).unwrap_or_default(),
595 items: Vec::new(),
596 });
597 groups.len() - 1
598 };
599 groups[group_index].items.push(leaf);
600 }
601
602 roots.sort_by(|a, b| {
603 let a_home = a.href == locale_prefix;
604 let b_home = b.href == locale_prefix;
605 b_home.cmp(&a_home).then_with(|| a.href.cmp(&b.href))
606 });
607 groups.sort_by(|a, b| {
608 a.order
609 .cmp(&b.order)
610 .then_with(|| a.title.cmp(&b.title))
611 .then_with(|| a.href.cmp(&b.href))
612 });
613 for group in &mut groups {
614 group.items.sort_by(|a, b| {
615 nav_item_order(&group.item_order, &a.href)
616 .cmp(&nav_item_order(&group.item_order, &b.href))
617 .then_with(|| a.href.cmp(&b.href))
618 });
619 }
620
621 roots.extend(groups.into_iter().map(|group| NavItem {
622 title: group.title,
623 href: group.href,
624 active_prefix: group.active_prefix,
625 items: group.items,
626 }));
627 roots
628}
629
630fn build_explicit_nav(config: &Config, locale_key: &str, route: &str) -> Vec<NavItem> {
631 let nav = nav_sections_for_locale(config, locale_key);
632 let sidebars = sidebars_for_locale(config, locale_key);
633 let Some(sidebar_id) = active_sidebar_id(nav, sidebars, route) else {
634 return Vec::new();
635 };
636
637 sidebars
638 .get(sidebar_id)
639 .map(|sections| sidebar_sections_to_nav_items(sections))
640 .unwrap_or_default()
641}
642
643fn active_sidebar_id<'a>(
644 nav: &'a [NavSection],
645 sidebars: &'a BTreeMap<String, Vec<SidebarSection>>,
646 route: &str,
647) -> Option<&'a str> {
648 nav.iter()
649 .filter_map(|item| item.sidebar.as_deref().map(|sidebar| (item, sidebar)))
650 .find_map(|(item, sidebar)| {
651 let sections = sidebars.get(sidebar)?;
652 if nav_section_matches_route(item, route)
653 || sidebar_sections_match_route(sections, route)
654 {
655 Some(sidebar)
656 } else {
657 None
658 }
659 })
660}
661
662fn nav_section_matches_route(item: &NavSection, route: &str) -> bool {
663 item.link
664 .as_deref()
665 .is_some_and(|href| route_matches_link(route, href))
666 || item
667 .items
668 .iter()
669 .any(|child| route_matches_link(route, &child.link))
670}
671
672fn sidebar_sections_match_route(items: &[SidebarSection], route: &str) -> bool {
673 items.iter().any(|item| {
674 route_matches_link(route, &item.link)
675 || item
676 .items
677 .iter()
678 .any(|child| route_matches_link(route, &child.link))
679 })
680}
681
682fn sidebar_sections_to_nav_items(items: &[SidebarSection]) -> Vec<NavItem> {
683 items
684 .iter()
685 .map(|item| NavItem {
686 title: item.text.clone(),
687 href: item.link.clone(),
688 active_prefix: item.link.clone(),
689 items: item
690 .items
691 .iter()
692 .map(|child| NavItem {
693 title: child.text.clone(),
694 href: child.link.clone(),
695 active_prefix: child.link.clone(),
696 items: Vec::new(),
697 })
698 .collect(),
699 })
700 .collect()
701}
702
703fn route_matches_link(route: &str, href: &str) -> bool {
704 href.starts_with('/') && (route == href || (href != "/" && route.starts_with(href)))
705}
706
707fn build_top_nav(config: &Config, locale_key: &str) -> Vec<TopNavItem> {
708 nav_sections_for_locale(config, locale_key)
709 .iter()
710 .map(|item| TopNavItem {
711 title: item.text.clone(),
712 href: item.link.clone(),
713 items: item
714 .items
715 .iter()
716 .map(|child| TopNavLink {
717 title: child.text.clone(),
718 href: child.link.clone(),
719 })
720 .collect(),
721 })
722 .collect()
723}
724
725#[derive(Debug, Clone)]
726struct SidebarGroup {
727 segment: String,
728 title: String,
729 href: String,
730 active_prefix: String,
731 order: usize,
732 item_order: Vec<String>,
733 items: Vec<NavItem>,
734}
735
736#[derive(Debug, Clone)]
737struct SidebarGroupMeta {
738 segment: String,
739 title: String,
740 href: String,
741 order: usize,
742 item_order: Vec<String>,
743}
744
745fn nav_sections_for_locale<'a>(config: &'a Config, locale_key: &str) -> &'a [NavSection] {
746 config
747 .locales
748 .get(locale_key)
749 .filter(|locale| !locale.nav.is_empty())
750 .map(|locale| locale.nav.as_slice())
751 .unwrap_or(config.nav.as_slice())
752}
753
754fn sidebars_for_locale<'a>(
755 config: &'a Config,
756 locale_key: &str,
757) -> &'a BTreeMap<String, Vec<SidebarSection>> {
758 config
759 .locales
760 .get(locale_key)
761 .filter(|locale| !locale.sidebars.is_empty())
762 .map(|locale| &locale.sidebars)
763 .unwrap_or(&config.sidebars)
764}
765
766fn sidebar_group_meta(nav: &[NavSection], locale_prefix: &str) -> Vec<SidebarGroupMeta> {
767 let mut metas = Vec::new();
768 for item in nav {
769 let href = item
770 .link
771 .as_deref()
772 .or_else(|| item.items.first().map(|child| child.link.as_str()));
773 let Some(href) = href else { continue };
774 let Some(segment) = first_route_segment(href, locale_prefix) else {
775 continue;
776 };
777 if metas
778 .iter()
779 .any(|meta: &SidebarGroupMeta| meta.segment == segment)
780 {
781 continue;
782 }
783 metas.push(SidebarGroupMeta {
784 segment,
785 title: item.text.clone(),
786 href: href.to_string(),
787 order: metas.len(),
788 item_order: item.items.iter().map(|child| child.link.clone()).collect(),
789 });
790 }
791 metas
792}
793
794fn nav_item_order(order: &[String], href: &str) -> usize {
795 order
796 .iter()
797 .position(|item| item == href)
798 .unwrap_or(usize::MAX)
799}
800
801fn build_translation_map(pages: &[Page]) -> BTreeMap<(String, String), String> {
802 pages
803 .iter()
804 .map(|page| {
805 (
806 (page.locale_key.clone(), page.translation_key.clone()),
807 page.route.clone(),
808 )
809 })
810 .collect()
811}
812
813fn base_site_render(config: &Config) -> SiteRender {
814 SiteRender {
815 title: config.title.clone(),
816 lang: default_lang(config),
817 base: config.base.clone(),
818 home_href: "/".to_string(),
819 theme: theme_config(config),
820 search_enabled: config.search.enabled,
821 access_enabled: access_mask_enabled(config),
822 access_password: config.access.password.clone(),
823 password_hint: config.access.password_hint.clone(),
824 top_nav: build_top_nav(config, "root"),
825 nav: Vec::new(),
826 languages: Vec::new(),
827 }
828}
829
830fn site_render_for_page(
831 config: &Config,
832 pages: &[Page],
833 translations: &BTreeMap<(String, String), String>,
834 page: &Page,
835) -> SiteRender {
836 SiteRender {
837 title: title_for_locale(config, &page.locale_key),
838 lang: lang_for_locale(config, &page.locale_key),
839 base: config.base.clone(),
840 home_href: home_for_locale(config, &page.locale_key),
841 theme: theme_config(config),
842 search_enabled: config.search.enabled,
843 access_enabled: access_mask_enabled(config),
844 access_password: config.access.password.clone(),
845 password_hint: config.access.password_hint.clone(),
846 top_nav: build_top_nav(config, &page.locale_key),
847 nav: build_nav(pages, config, page),
848 languages: build_language_options(config, page, translations),
849 }
850}
851
852fn access_mask_enabled(config: &Config) -> bool {
853 config.access.enabled && config.access.mode == "mask" && !config.access.password.is_empty()
854}
855
856fn theme_config(config: &Config) -> ThemeConfig {
857 ThemeConfig {
858 skin: config.theme.skin.clone(),
859 allow_switch: config.theme.allow_switch,
860 github_url: config.theme.github_url.clone(),
861 }
862}
863
864fn title_for_locale(config: &Config, locale_key: &str) -> String {
865 config
866 .locales
867 .get(locale_key)
868 .and_then(|locale| locale.title.as_ref())
869 .cloned()
870 .unwrap_or_else(|| config.title.clone())
871}
872
873fn default_lang(config: &Config) -> String {
874 if config.locales.is_empty() {
875 "en".to_string()
876 } else {
877 lang_for_locale(config, "root")
878 }
879}
880
881fn lang_for_locale(config: &Config, locale_key: &str) -> String {
882 config
883 .locales
884 .get(locale_key)
885 .map(|locale| locale.lang.clone())
886 .unwrap_or_else(|| "en".to_string())
887}
888
889fn home_for_locale(config: &Config, locale_key: &str) -> String {
890 config
891 .locales
892 .get(locale_key)
893 .map(|locale| locale.link.clone())
894 .unwrap_or_else(|| "/".to_string())
895}
896
897fn build_language_options(
898 config: &Config,
899 page: &Page,
900 translations: &BTreeMap<(String, String), String>,
901) -> Vec<LanguageOption> {
902 if config.locales.is_empty() {
903 return Vec::new();
904 }
905
906 locale_keys(config)
907 .into_iter()
908 .filter_map(|locale_key| {
909 let locale = config.locales.get(&locale_key)?;
910 let href = translations
911 .get(&(locale_key.clone(), page.translation_key.clone()))
912 .cloned()
913 .unwrap_or_else(|| locale.link.clone());
914 Some(LanguageOption {
915 label: locale.label.clone(),
916 href,
917 current: locale_key == page.locale_key,
918 })
919 })
920 .collect()
921}
922
923fn locale_keys(config: &Config) -> Vec<String> {
924 let mut keys = Vec::new();
925 if config.locales.contains_key("root") {
926 keys.push("root".to_string());
927 }
928 keys.extend(config.locales.keys().filter(|key| *key != "root").cloned());
929 keys
930}
931
932fn write_search_index(out_dir: &Path, config: &SearchSection, pages: &[SearchPage]) -> Result<()> {
933 let assets_dir = out_dir.join("assets");
934 fs::create_dir_all(&assets_dir)
935 .with_context(|| format!("failed to create {}", assets_dir.display()))?;
936 let index = build_search_index(
937 SearchConfig {
938 languages: config.languages.clone(),
939 },
940 pages,
941 );
942 let json = serde_json::to_vec_pretty(&index)?;
943 fs::write(assets_dir.join("search-index.json"), &json)?;
944
945 let mut compressed = Vec::new();
946 {
947 let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 22);
948 writer.write_all(&json)?;
949 }
950 fs::write(assets_dir.join("search-index.json.br"), compressed)?;
951 fs::write(
952 assets_dir.join("rustpress_search_bg.wasm"),
953 rustpress_search::wasm_placeholder(),
954 )?;
955 Ok(())
956}
957
958fn copy_public_assets(public_dir: &Path, out_dir: &Path) -> Result<()> {
959 if !public_dir.exists() {
960 return Ok(());
961 }
962
963 for entry in WalkDir::new(public_dir) {
964 let entry = entry.with_context(|| format!("failed to scan {}", public_dir.display()))?;
965 if !entry.file_type().is_file() {
966 continue;
967 }
968 let relative = entry.path().strip_prefix(public_dir)?;
969 if relative.file_name().and_then(|value| value.to_str()) == Some(".gitkeep") {
970 continue;
971 }
972 let target = out_dir.join(relative);
973 if let Some(parent) = target.parent() {
974 fs::create_dir_all(parent)
975 .with_context(|| format!("failed to create {}", parent.display()))?;
976 }
977 fs::copy(entry.path(), &target).with_context(|| {
978 format!(
979 "failed to copy {} to {}",
980 entry.path().display(),
981 target.display()
982 )
983 })?;
984 }
985 Ok(())
986}
987
988fn write_page(out_dir: &Path, route: &str, html: &str) -> Result<()> {
989 let path = out_dir.join(route.trim_start_matches('/'));
990 let target = if route.ends_with('/') {
991 path.join("index.html")
992 } else {
993 path
994 };
995 if let Some(parent) = target.parent() {
996 fs::create_dir_all(parent)
997 .with_context(|| format!("failed to create {}", parent.display()))?;
998 }
999 fs::write(&target, html).with_context(|| format!("failed to write {}", target.display()))
1000}
1001
1002fn page_metadata_for(src_dir: &Path, path: &Path, config: &Config) -> Result<PageMetadata> {
1003 if config.locales.is_empty() {
1004 let route = route_for(src_dir, path)?;
1005 return Ok(PageMetadata {
1006 route: route.clone(),
1007 locale_key: "root".to_string(),
1008 translation_key: route,
1009 });
1010 }
1011
1012 let relative = path.strip_prefix(src_dir)?;
1013 if let Some((locale_key, locale_relative)) = locale_relative_path(relative, config) {
1014 let translation_key = route_for_relative(&locale_relative);
1015 let route = route_with_prefix(&config.locales[&locale_key].link, &translation_key);
1016 return Ok(PageMetadata {
1017 route,
1018 locale_key,
1019 translation_key,
1020 });
1021 }
1022
1023 let route = route_for_relative(relative);
1024 Ok(PageMetadata {
1025 route: route.clone(),
1026 locale_key: "root".to_string(),
1027 translation_key: route,
1028 })
1029}
1030
1031fn locale_relative_path(relative: &Path, config: &Config) -> Option<(String, PathBuf)> {
1032 let mut components = relative.components();
1033 let first = components.next()?.as_os_str().to_str()?;
1034 if first == "root" || !config.locales.contains_key(first) {
1035 return None;
1036 }
1037
1038 let mut locale_relative = PathBuf::new();
1039 for component in components {
1040 locale_relative.push(component.as_os_str());
1041 }
1042 Some((first.to_string(), locale_relative))
1043}
1044
1045fn route_for(src_dir: &Path, path: &Path) -> Result<String> {
1046 let relative = path.strip_prefix(src_dir)?;
1047 Ok(route_for_relative(relative))
1048}
1049
1050fn route_for_relative(relative: &Path) -> String {
1051 let without_ext = relative.with_extension("");
1052 if without_ext == Path::new("index") {
1053 return "/".to_string();
1054 }
1055
1056 if without_ext.file_name().and_then(|value| value.to_str()) == Some("index") {
1057 without_ext
1058 .parent()
1059 .map(path_to_route)
1060 .unwrap_or_else(|| "/".to_string())
1061 } else {
1062 path_to_route(&without_ext)
1063 }
1064}
1065
1066fn path_to_route(path: &Path) -> String {
1067 if path.as_os_str().is_empty() {
1068 return "/".to_string();
1069 }
1070
1071 let route = path
1072 .components()
1073 .map(|component| component.as_os_str().to_string_lossy())
1074 .collect::<Vec<_>>()
1075 .join("/");
1076 format!("/{route}/")
1077}
1078
1079fn route_with_prefix(prefix: &str, route: &str) -> String {
1080 if route == "/" {
1081 return prefix.to_string();
1082 }
1083 if prefix == "/" {
1084 route.to_string()
1085 } else {
1086 format!("{}{}", prefix, route.trim_start_matches('/'))
1087 }
1088}
1089
1090fn route_segments(route: &str) -> Vec<&str> {
1091 route
1092 .trim_matches('/')
1093 .split('/')
1094 .filter(|segment| !segment.is_empty())
1095 .collect()
1096}
1097
1098fn first_route_segment(route: &str, locale_prefix: &str) -> Option<String> {
1099 if route.starts_with("http://")
1100 || route.starts_with("https://")
1101 || route.starts_with("mailto:")
1102 || route.starts_with('#')
1103 {
1104 return None;
1105 }
1106
1107 let local_route = if locale_prefix != "/" && route.starts_with(locale_prefix) {
1108 let rest = &route[locale_prefix.len()..];
1109 if rest.is_empty() {
1110 "/".to_string()
1111 } else {
1112 format!("/{rest}")
1113 }
1114 } else {
1115 route.to_string()
1116 };
1117 route_segments(&local_route)
1118 .first()
1119 .map(|segment| (*segment).to_string())
1120}
1121
1122fn titleize_segment(segment: &str) -> String {
1123 segment
1124 .split(['-', '_'])
1125 .filter(|part| !part.is_empty())
1126 .map(|part| {
1127 let mut chars = part.chars();
1128 match chars.next() {
1129 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1130 None => String::new(),
1131 }
1132 })
1133 .collect::<Vec<_>>()
1134 .join(" ")
1135}
1136
1137fn site_url(base: &str, route: &str) -> String {
1138 if route == "/" {
1139 base.to_string()
1140 } else {
1141 format!("{}{}", base, route.trim_start_matches('/'))
1142 }
1143}
1144
1145fn normalize_nav(nav: &mut Vec<NavSection>, locale_prefix: Option<&str>) {
1146 nav.retain(|item| !item.text.trim().is_empty());
1147 for item in nav {
1148 item.text = item.text.trim().to_string();
1149 item.sidebar = item
1150 .sidebar
1151 .take()
1152 .map(|sidebar| sidebar.trim().to_string())
1153 .filter(|sidebar| !sidebar.is_empty());
1154 if item
1155 .link
1156 .as_deref()
1157 .is_some_and(|link| link.trim().is_empty())
1158 {
1159 item.link = None;
1160 }
1161 item.items
1162 .retain(|child| !child.text.trim().is_empty() && !child.link.trim().is_empty());
1163 for child in &mut item.items {
1164 child.text = child.text.trim().to_string();
1165 child.link = normalize_nav_link(&child.link, locale_prefix);
1166 }
1167 if let Some(link) = &mut item.link {
1168 *link = normalize_nav_link(link, locale_prefix);
1169 }
1170 }
1171}
1172
1173fn normalize_sidebars(
1174 sidebars: &mut BTreeMap<String, Vec<SidebarSection>>,
1175 locale_prefix: Option<&str>,
1176) {
1177 sidebars.retain(|id, items| {
1178 if id.trim().is_empty() {
1179 return false;
1180 }
1181
1182 for item in items.iter_mut() {
1183 item.text = item.text.trim().to_string();
1184 item.items
1185 .retain(|child| !child.text.trim().is_empty() && !child.link.trim().is_empty());
1186 for child in &mut item.items {
1187 child.text = child.text.trim().to_string();
1188 child.link = normalize_nav_link(&child.link, locale_prefix);
1189 }
1190
1191 item.link = item.link.trim().to_string();
1192 if item.link.is_empty() {
1193 if let Some(first_child) = item.items.first() {
1194 item.link = first_child.link.clone();
1195 }
1196 } else {
1197 item.link = normalize_nav_link(&item.link, locale_prefix);
1198 }
1199 }
1200
1201 items.retain(|item| !item.text.is_empty() && !item.link.is_empty());
1202
1203 !items.is_empty()
1204 });
1205}
1206
1207fn normalize_nav_link(link: &str, locale_prefix: Option<&str>) -> String {
1208 match locale_prefix {
1209 Some(prefix) => normalize_locale_nav_link(link, prefix),
1210 None => normalize_link(link),
1211 }
1212}
1213
1214fn normalize_locale_nav_link(link: &str, locale_prefix: &str) -> String {
1215 let link = link.trim();
1216 if link.is_empty()
1217 || link.starts_with('/')
1218 || link.starts_with('#')
1219 || link.starts_with("http://")
1220 || link.starts_with("https://")
1221 || link.starts_with("mailto:")
1222 {
1223 link.to_string()
1224 } else {
1225 route_with_prefix(locale_prefix, &normalize_link(link))
1226 }
1227}
1228
1229fn normalize_locale_prefix(key: &str, link: &str) -> Result<String> {
1230 let mut link = if link.trim().is_empty() {
1231 format!("/{key}/")
1232 } else {
1233 normalize_link(link)
1234 };
1235 if !link.starts_with('/') {
1236 anyhow::bail!("locale `{key}` link must be a path");
1237 }
1238 if link != "/" && !link.ends_with('/') {
1239 link.push('/');
1240 }
1241 Ok(link)
1242}
1243
1244fn normalize_theme_skin(skin: &str) -> String {
1245 match skin.trim().to_ascii_lowercase().as_str() {
1246 "dark" => "dark".to_string(),
1247 _ => "light".to_string(),
1248 }
1249}
1250
1251fn normalize_link(link: &str) -> String {
1252 let link = link.trim();
1253 if link.is_empty()
1254 || link.starts_with('/')
1255 || link.starts_with('#')
1256 || link.starts_with("http://")
1257 || link.starts_with("https://")
1258 || link.starts_with("mailto:")
1259 {
1260 link.to_string()
1261 } else {
1262 format!("/{link}")
1263 }
1264}
1265
1266fn normalize_config_path(path: &Path) -> Result<PathBuf> {
1267 if path.exists() {
1268 return Ok(path.to_path_buf());
1269 }
1270 anyhow::bail!("config file does not exist: {}", path.display());
1271}
1272
1273fn absolutize(root: &Path, path: &Path) -> PathBuf {
1274 if path.is_absolute() {
1275 path.to_path_buf()
1276 } else {
1277 root.join(path)
1278 }
1279}
1280
1281fn write_new(path: &Path, contents: &str) -> Result<()> {
1282 if path.exists() {
1283 anyhow::bail!("refusing to overwrite existing file {}", path.display());
1284 }
1285 if let Some(parent) = path.parent() {
1286 fs::create_dir_all(parent)
1287 .with_context(|| format!("failed to create {}", parent.display()))?;
1288 }
1289 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295
1296 #[test]
1297 fn init_and_build_generates_index() {
1298 let dir = tempfile::tempdir().unwrap();
1299 init_project(dir.path()).unwrap();
1300
1301 let result = build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1302
1303 assert_eq!(result.page_count, 2);
1304 assert!(dir.path().join("dist/index.html").exists());
1305 assert!(dir.path().join("dist/private/index.html").exists());
1306 assert!(dir.path().join("dist/assets/search-index.json").exists());
1307 assert!(dir.path().join("dist/assets/search-index.json.br").exists());
1308 assert!(dir
1309 .path()
1310 .join("dist/assets/rustpress_search_bg.wasm")
1311 .exists());
1312
1313 let public_html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
1314 let masked_html = fs::read_to_string(dir.path().join("dist/private/index.html")).unwrap();
1315 let theme_js = fs::read_to_string(dir.path().join("dist/assets/rustpress.js")).unwrap();
1316 assert!(public_html.contains("rp-topnav-group"));
1317 assert!(public_html.contains("Masked Page"));
1318 assert!(!public_html.contains("data-rp-language-select"));
1319 assert!(!public_html.contains("data-rp-access-mask"));
1320 assert!(masked_html.contains("data-rp-access-mask"));
1321 assert!(theme_js.contains(r#"const accessPassword = "rustpress";"#));
1322 }
1323
1324 #[test]
1325 fn markdown_code_line_numbers_default_to_true() {
1326 let raw = r#"
1327title = "Docs"
1328src_dir = "docs"
1329out_dir = "dist"
1330base = "/"
1331
1332[markdown]
1333mermaid = true
1334"#;
1335 let config: Config = toml::from_str(raw).unwrap();
1336
1337 assert!(config.markdown.code_line_numbers);
1338 }
1339
1340 #[test]
1341 fn markdown_code_line_numbers_false_reaches_rendered_pages() {
1342 let dir = tempfile::tempdir().unwrap();
1343 fs::create_dir_all(dir.path().join("docs")).unwrap();
1344 fs::write(
1345 dir.path().join("rustpress.toml"),
1346 r#"title = "Docs"
1347src_dir = "docs"
1348out_dir = "dist"
1349base = "/"
1350
1351[markdown]
1352code_highlight = false
1353code_line_numbers = false
1354"#,
1355 )
1356 .unwrap();
1357 fs::write(
1358 dir.path().join("docs/index.md"),
1359 "# Home\n\n```rust\nfn main() {}\n```",
1360 )
1361 .unwrap();
1362
1363 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1364
1365 let html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
1366 assert!(html.contains("class=\"rp-code-content language-rust\""));
1367 assert!(!html.contains("rp-code-line-numbers"));
1368 assert!(!html.contains("rp-code-lines"));
1369 }
1370
1371 #[test]
1372 fn access_mask_requires_configured_password() {
1373 let mut config = Config::default();
1374 config.access.enabled = true;
1375 config.access.mode = "mask".to_string();
1376 config.access.password.clear();
1377 assert!(!access_mask_enabled(&config));
1378
1379 config.access.password = "secret".to_string();
1380 assert!(access_mask_enabled(&config));
1381
1382 config.access.enabled = false;
1383 assert!(!access_mask_enabled(&config));
1384 }
1385
1386 #[test]
1387 fn base_url_is_normalized() {
1388 let mut config = Config {
1389 base: "docs".to_string(),
1390 ..Config::default()
1391 };
1392 config.normalize().unwrap();
1393 assert_eq!(config.base, "/docs/");
1394 }
1395
1396 #[test]
1397 fn theme_skin_is_limited_to_light_and_dark() {
1398 let mut dark_config = Config {
1399 theme: ThemeSection {
1400 skin: "dark".to_string(),
1401 ..ThemeSection::default()
1402 },
1403 ..Config::default()
1404 };
1405 dark_config.normalize().unwrap();
1406 assert_eq!(dark_config.theme.skin, "dark");
1407
1408 let mut old_skin_config = Config {
1409 theme: ThemeSection {
1410 skin: "modern".to_string(),
1411 ..ThemeSection::default()
1412 },
1413 ..Config::default()
1414 };
1415 old_skin_config.normalize().unwrap();
1416 assert_eq!(old_skin_config.theme.skin, "light");
1417 }
1418
1419 #[test]
1420 fn theme_github_url_is_rendered_when_configured() {
1421 let raw = r#"
1422title = "Docs"
1423src_dir = "docs"
1424out_dir = "dist"
1425base = "/"
1426
1427[theme]
1428github_url = " https://github.com/example/docs "
1429"#;
1430 let mut config: Config = toml::from_str(raw).unwrap();
1431 config.normalize().unwrap();
1432
1433 assert_eq!(config.theme.github_url, "https://github.com/example/docs");
1434
1435 let site = base_site_render(&config);
1436 let html = render_page(
1437 &site,
1438 &PageRender {
1439 title: "Home".to_string(),
1440 route: "/".to_string(),
1441 html: "<h1>Home</h1>".to_string(),
1442 markdown_source: "---\ntitle: Home\n---\n# Home\n".to_string(),
1443 headings: vec![],
1444 masked: false,
1445 search: true,
1446 },
1447 );
1448
1449 assert!(html.contains("rp-github-link"));
1450 assert!(html.contains(r#"href="https://github.com/example/docs""#));
1451 }
1452
1453 #[test]
1454 fn nav_links_are_normalized() {
1455 let mut config = Config {
1456 nav: vec![NavSection {
1457 text: " Guide ".to_string(),
1458 link: Some("guide/cli/".to_string()),
1459 sidebar: None,
1460 items: vec![
1461 NavLinkSection {
1462 text: " CLI ".to_string(),
1463 link: "guide/cli/".to_string(),
1464 },
1465 NavLinkSection {
1466 text: String::new(),
1467 link: "/bad/".to_string(),
1468 },
1469 ],
1470 }],
1471 ..Config::default()
1472 };
1473
1474 config.normalize().unwrap();
1475
1476 assert_eq!(config.nav[0].text, "Guide");
1477 assert_eq!(config.nav[0].link.as_deref(), Some("/guide/cli/"));
1478 assert_eq!(config.nav[0].items.len(), 1);
1479 assert_eq!(config.nav[0].items[0].text, "CLI");
1480 assert_eq!(config.nav[0].items[0].link, "/guide/cli/");
1481 }
1482
1483 #[test]
1484 fn sidebar_links_are_normalized() {
1485 let mut sidebars = BTreeMap::new();
1486 sidebars.insert(
1487 "docs".to_string(),
1488 vec![SidebarSection {
1489 text: " Guide ".to_string(),
1490 link: String::new(),
1491 items: vec![
1492 SidebarLinkSection {
1493 text: " CLI ".to_string(),
1494 link: "guide/cli/".to_string(),
1495 },
1496 SidebarLinkSection {
1497 text: String::new(),
1498 link: "/bad/".to_string(),
1499 },
1500 ],
1501 }],
1502 );
1503 let mut config = Config {
1504 nav: vec![NavSection {
1505 text: "Guide".to_string(),
1506 link: Some("/guide/".to_string()),
1507 sidebar: Some(" docs ".to_string()),
1508 items: Vec::new(),
1509 }],
1510 sidebars,
1511 ..Config::default()
1512 };
1513
1514 config.normalize().unwrap();
1515
1516 assert_eq!(config.nav[0].sidebar.as_deref(), Some("docs"));
1517 let sidebar = &config.sidebars["docs"][0];
1518 assert_eq!(sidebar.text, "Guide");
1519 assert_eq!(sidebar.link, "/guide/cli/");
1520 assert_eq!(sidebar.items.len(), 1);
1521 assert_eq!(sidebar.items[0].text, "CLI");
1522 assert_eq!(sidebar.items[0].link, "/guide/cli/");
1523 }
1524
1525 #[test]
1526 fn locales_require_root() {
1527 let mut config = Config::default();
1528 config.locales.insert(
1529 "en".to_string(),
1530 LocaleSection {
1531 label: "English".to_string(),
1532 lang: "en-US".to_string(),
1533 ..LocaleSection::default()
1534 },
1535 );
1536
1537 let err = config.normalize().unwrap_err();
1538
1539 assert!(err.to_string().contains("locales.root"));
1540 }
1541
1542 #[test]
1543 fn locale_links_and_relative_nav_are_normalized() {
1544 let mut config = localized_config();
1545 let locale = config.locales.get_mut("en").unwrap();
1546 locale.nav[0].sidebar = Some(" guide ".to_string());
1547 locale.sidebars.insert(
1548 "guide".to_string(),
1549 vec![SidebarSection {
1550 text: " Guide ".to_string(),
1551 link: "guide/cli/".to_string(),
1552 items: vec![SidebarLinkSection {
1553 text: "CLI".to_string(),
1554 link: "guide/cli/".to_string(),
1555 }],
1556 }],
1557 );
1558
1559 config.normalize().unwrap();
1560
1561 let root = &config.locales["root"];
1562 let en = &config.locales["en"];
1563 assert_eq!(root.label, "简体中文");
1564 assert_eq!(root.lang, "zh-CN");
1565 assert_eq!(root.link, "/");
1566 assert_eq!(en.link, "/en/");
1567 assert_eq!(en.nav[0].link.as_deref(), Some("/en/guide/cli/"));
1568 assert_eq!(en.nav[0].sidebar.as_deref(), Some("guide"));
1569 assert_eq!(en.nav[0].items[0].link, "/en/guide/cli/");
1570 assert_eq!(en.sidebars["guide"][0].link, "/en/guide/cli/");
1571 assert_eq!(en.sidebars["guide"][0].items[0].link, "/en/guide/cli/");
1572 }
1573
1574 #[test]
1575 fn explicit_sidebars_are_selected_by_top_nav_section() {
1576 let dir = tempfile::tempdir().unwrap();
1577 fs::create_dir_all(dir.path().join("docs")).unwrap();
1578 fs::write(
1579 dir.path().join("rustpress.toml"),
1580 r#"title = "Docs"
1581src_dir = "docs"
1582out_dir = "dist"
1583base = "/"
1584
1585[[nav]]
1586text = "Guide"
1587link = "/guide/"
1588sidebar = "guide"
1589
1590[[nav]]
1591text = "Reference"
1592link = "/reference/"
1593sidebar = "reference"
1594
1595[[sidebars.guide]]
1596text = "Guide"
1597link = "/guide/"
1598
1599[[sidebars.guide.items]]
1600text = "CLI"
1601link = "/guide/cli/"
1602
1603[[sidebars.reference]]
1604text = "Reference"
1605link = "/reference/"
1606
1607[[sidebars.reference.items]]
1608text = "API"
1609link = "/reference/api/"
1610"#,
1611 )
1612 .unwrap();
1613 write_doc(dir.path(), "docs/guide/cli.md", "Guide CLI", "Guide CLI").unwrap();
1614 write_doc(
1615 dir.path(),
1616 "docs/reference/api.md",
1617 "Reference API",
1618 "Reference API",
1619 )
1620 .unwrap();
1621
1622 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1623
1624 let guide_html = fs::read_to_string(dir.path().join("dist/guide/cli/index.html")).unwrap();
1625 let reference_html =
1626 fs::read_to_string(dir.path().join("dist/reference/api/index.html")).unwrap();
1627 assert!(guide_html.contains("CLI"));
1628 assert!(!guide_html.contains("rp-nav-level-1\">API"));
1629 assert!(reference_html.contains("API"));
1630 assert!(!reference_html.contains("rp-nav-level-1\">CLI"));
1631 }
1632
1633 #[test]
1634 fn routes_markdown_pages_without_double_slashes() {
1635 let src = Path::new("/site/docs");
1636
1637 assert_eq!(
1638 route_for(src, Path::new("/site/docs/guide.md")).unwrap(),
1639 "/guide/"
1640 );
1641 assert_eq!(
1642 route_for(src, Path::new("/site/docs/guide/index.md")).unwrap(),
1643 "/guide/"
1644 );
1645 assert_eq!(
1646 route_for(src, Path::new("/site/docs/index.md")).unwrap(),
1647 "/"
1648 );
1649 }
1650
1651 #[test]
1652 fn localized_routes_use_locale_prefixes() {
1653 let mut config = localized_config();
1654 config.normalize().unwrap();
1655 let src = Path::new("/site/docs");
1656
1657 assert_eq!(
1658 page_metadata_for(src, Path::new("/site/docs/index.md"), &config)
1659 .unwrap()
1660 .route,
1661 "/"
1662 );
1663 assert_eq!(
1664 page_metadata_for(src, Path::new("/site/docs/guide.md"), &config)
1665 .unwrap()
1666 .route,
1667 "/guide/"
1668 );
1669 let en_home = page_metadata_for(src, Path::new("/site/docs/en/index.md"), &config).unwrap();
1670 assert_eq!(en_home.route, "/en/");
1671 assert_eq!(en_home.locale_key, "en");
1672 assert_eq!(en_home.translation_key, "/");
1673 let en_guide =
1674 page_metadata_for(src, Path::new("/site/docs/en/guide.md"), &config).unwrap();
1675 assert_eq!(en_guide.route, "/en/guide/");
1676 assert_eq!(en_guide.locale_key, "en");
1677 assert_eq!(en_guide.translation_key, "/guide/");
1678 }
1679
1680 #[test]
1681 fn builds_multilingual_pages_and_language_switcher() {
1682 let dir = tempfile::tempdir().unwrap();
1683 write_multilingual_config(dir.path()).unwrap();
1684 write_doc(dir.path(), "docs/index.md", "Root Home", "Root Home").unwrap();
1685 write_doc(dir.path(), "docs/guide.md", "Root Guide", "Root Guide").unwrap();
1686 write_doc(dir.path(), "docs/guide/cli.md", "Root CLI", "Root CLI").unwrap();
1687 write_doc(dir.path(), "docs/root-only.md", "Root Only", "Root Only").unwrap();
1688 write_doc(
1689 dir.path(),
1690 "docs/en/index.md",
1691 "English Home",
1692 "English Home",
1693 )
1694 .unwrap();
1695 write_doc(
1696 dir.path(),
1697 "docs/en/guide.md",
1698 "English Guide",
1699 "English Guide",
1700 )
1701 .unwrap();
1702 write_doc(
1703 dir.path(),
1704 "docs/en/guide/cli.md",
1705 "English CLI",
1706 "English CLI",
1707 )
1708 .unwrap();
1709
1710 let result = build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1711
1712 assert_eq!(result.page_count, 7);
1713 assert!(dir.path().join("dist/index.html").exists());
1714 assert!(dir.path().join("dist/en/index.html").exists());
1715 assert!(dir.path().join("dist/en/guide/index.html").exists());
1716 assert!(dir.path().join("dist/en/guide/cli/index.html").exists());
1717
1718 let root_guide = fs::read_to_string(dir.path().join("dist/guide/index.html")).unwrap();
1719 let en_guide = fs::read_to_string(dir.path().join("dist/en/guide/index.html")).unwrap();
1720 let root_cli = fs::read_to_string(dir.path().join("dist/guide/cli/index.html")).unwrap();
1721 assert!(root_guide.contains(r#"<html lang="zh-CN""#));
1722 assert!(root_guide.contains("data-rp-language-select"));
1723 assert!(root_guide.contains(r#"data-rp-language-href="/guide/">简体中文</button>"#));
1724 assert!(root_guide.contains(r#"data-rp-language-href="/en/guide/">English</button>"#));
1725 assert!(en_guide.contains(r#"<html lang="en-US""#));
1726 assert!(en_guide.contains(r#"data-rp-language-href="/guide/">简体中文</button>"#));
1727 assert!(en_guide.contains(r#"data-rp-language-href="/en/guide/">English</button>"#));
1728 assert!(root_cli.contains("rp-nav-group"));
1729 assert!(root_cli.contains("rp-nav-group-title"));
1730 assert!(root_cli.contains("Root Guide"));
1731 assert!(root_cli.contains("Root CLI"));
1732 assert!(!en_guide.contains("Root Only"));
1733 }
1734
1735 #[test]
1736 fn language_switcher_falls_back_to_locale_home_when_translation_is_missing() {
1737 let dir = tempfile::tempdir().unwrap();
1738 write_multilingual_config(dir.path()).unwrap();
1739 write_doc(dir.path(), "docs/index.md", "Root Home", "Root Home").unwrap();
1740 write_doc(dir.path(), "docs/guide.md", "Root Guide", "Root Guide").unwrap();
1741 write_doc(
1742 dir.path(),
1743 "docs/en/index.md",
1744 "English Home",
1745 "English Home",
1746 )
1747 .unwrap();
1748
1749 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1750
1751 let root_guide = fs::read_to_string(dir.path().join("dist/guide/index.html")).unwrap();
1752 assert!(root_guide.contains(r#"data-rp-language-href="/en/">English</button>"#));
1753 }
1754
1755 #[test]
1756 fn search_false_pages_are_excluded_from_index() {
1757 let dir = tempfile::tempdir().unwrap();
1758 init_project(dir.path()).unwrap();
1759 fs::write(
1760 dir.path().join("docs/hidden.md"),
1761 "---\ntitle: Hidden\nsearch: false\n---\n# Hidden\nUniqueSecret",
1762 )
1763 .unwrap();
1764
1765 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1766
1767 let index = fs::read_to_string(dir.path().join("dist/assets/search-index.json")).unwrap();
1768 assert!(!index.contains("UniqueSecret"));
1769 assert!(!index.contains("\"Hidden\""));
1770 }
1771
1772 #[test]
1773 fn rendered_pages_include_copyable_markdown_source() {
1774 let dir = tempfile::tempdir().unwrap();
1775 init_project(dir.path()).unwrap();
1776 fs::write(
1777 dir.path().join("docs/agent.md"),
1778 "---\ntitle: Agent Copy\naccess: public\n---\n# Agent Copy\n\nUse <agent> context.\n",
1779 )
1780 .unwrap();
1781
1782 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1783
1784 let html = fs::read_to_string(dir.path().join("dist/agent/index.html")).unwrap();
1785 assert!(html.contains("data-rp-copy-markdown"));
1786 assert!(html.contains("data-rp-markdown-source"));
1787 assert!(html.contains("---\ntitle: Agent Copy\naccess: public\n---"));
1788 assert!(html.contains("Use <agent> context."));
1789 }
1790
1791 fn localized_config() -> Config {
1792 let mut locales = BTreeMap::new();
1793 locales.insert(
1794 "root".to_string(),
1795 LocaleSection {
1796 label: " 简体中文 ".to_string(),
1797 lang: " zh-CN ".to_string(),
1798 ..LocaleSection::default()
1799 },
1800 );
1801 locales.insert(
1802 "en".to_string(),
1803 LocaleSection {
1804 label: "English".to_string(),
1805 lang: "en-US".to_string(),
1806 nav: vec![NavSection {
1807 text: "Guide".to_string(),
1808 link: Some("guide/cli/".to_string()),
1809 sidebar: None,
1810 items: vec![NavLinkSection {
1811 text: "CLI".to_string(),
1812 link: "guide/cli/".to_string(),
1813 }],
1814 }],
1815 ..LocaleSection::default()
1816 },
1817 );
1818 Config {
1819 locales,
1820 ..Config::default()
1821 }
1822 }
1823
1824 fn write_multilingual_config(root: &Path) -> Result<()> {
1825 fs::write(
1826 root.join("rustpress.toml"),
1827 r#"title = "Docs"
1828src_dir = "docs"
1829out_dir = "dist"
1830base = "/"
1831
1832[[nav]]
1833text = "Root Guide"
1834link = "/guide/"
1835
1836[locales.root]
1837label = "简体中文"
1838lang = "zh-CN"
1839title = "中文文档"
1840
1841[locales.en]
1842label = "English"
1843lang = "en-US"
1844link = "/en/"
1845title = "English Docs"
1846
1847[[locales.en.nav]]
1848text = "Guide"
1849link = "guide/"
1850"#,
1851 )?;
1852 Ok(())
1853 }
1854
1855 fn write_doc(root: &Path, relative: &str, title: &str, body: &str) -> Result<()> {
1856 let path = root.join(relative);
1857 if let Some(parent) = path.parent() {
1858 fs::create_dir_all(parent)?;
1859 }
1860 fs::write(
1861 path,
1862 format!(
1863 "---\ntitle: {title}\nlayout: doc\nsidebar: true\nsearch: true\naccess: public\n---\n\n# {body}\n"
1864 ),
1865 )?;
1866 Ok(())
1867 }
1868}