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 markdown_source_url: markdown_source_url(&config.base, &page.route),
463 headings: page.document.headings.clone(),
464 masked: page.document.frontmatter.access == "masked",
465 search: page.document.frontmatter.search,
466 },
467 );
468 write_page(&out_dir, &page.route, &rendered)?;
469 write_markdown_source(&out_dir, &page.route, &page.markdown_source)?;
470
471 if page.document.frontmatter.search {
472 search_pages.push(SearchPage {
473 title: page.document.title.clone(),
474 url: site_url(&config.base, &page.route),
475 headings: page
476 .document
477 .headings
478 .iter()
479 .map(|heading| heading.text.clone())
480 .collect(),
481 body: page.document.search_text.clone(),
482 });
483 }
484 }
485
486 if config.search.enabled {
487 write_search_index(&out_dir, &config.search, &search_pages)?;
488 }
489
490 Ok(BuildResult {
491 out_dir,
492 page_count: pages.len(),
493 })
494}
495
496#[derive(Debug, Clone)]
497struct Page {
498 route: String,
499 locale_key: String,
500 translation_key: String,
501 markdown_source: String,
502 document: Document,
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
506struct PageMetadata {
507 route: String,
508 locale_key: String,
509 translation_key: String,
510}
511
512fn read_pages(src_dir: &Path, config: &Config) -> Result<Vec<Page>> {
513 let mut pages = Vec::new();
514 for entry in WalkDir::new(src_dir).sort_by_file_name() {
515 let entry = entry.with_context(|| format!("failed to scan {}", src_dir.display()))?;
516 if !entry.file_type().is_file() {
517 continue;
518 }
519 if entry.path().extension().and_then(|value| value.to_str()) != Some("md") {
520 continue;
521 }
522
523 let markdown = fs::read_to_string(entry.path())
524 .with_context(|| format!("failed to read {}", entry.path().display()))?;
525 let document = parse_markdown(
526 &markdown,
527 MarkdownOptions {
528 mermaid: config.markdown.mermaid,
529 code_highlight: config.markdown.code_highlight,
530 code_line_numbers: config.markdown.code_line_numbers,
531 heading_anchors: config.markdown.heading_anchors,
532 index_code: config.search.index_code,
533 },
534 )
535 .with_context(|| format!("failed to parse {}", entry.path().display()))?;
536 let metadata = page_metadata_for(src_dir, entry.path(), config)?;
537 pages.push(Page {
538 route: metadata.route,
539 locale_key: metadata.locale_key,
540 translation_key: metadata.translation_key,
541 markdown_source: markdown,
542 document,
543 });
544 }
545 Ok(pages)
546}
547
548fn build_nav(pages: &[Page], config: &Config, page: &Page) -> Vec<NavItem> {
549 if !sidebars_for_locale(config, &page.locale_key).is_empty() {
550 return build_explicit_nav(config, &page.locale_key, &page.route);
551 }
552
553 build_legacy_nav(pages, config, &page.locale_key)
554}
555
556fn build_legacy_nav(pages: &[Page], config: &Config, locale_key: &str) -> Vec<NavItem> {
557 let locale_prefix = home_for_locale(config, locale_key);
558 let group_meta =
559 sidebar_group_meta(nav_sections_for_locale(config, locale_key), &locale_prefix);
560 let mut roots = Vec::new();
561 let mut groups = Vec::<SidebarGroup>::new();
562
563 for page in pages
564 .iter()
565 .filter(|page| page.locale_key == locale_key && page.document.frontmatter.sidebar)
566 {
567 let segments = route_segments(&page.translation_key);
568 let leaf = NavItem {
569 title: page.document.title.clone(),
570 href: page.route.clone(),
571 active_prefix: page.route.clone(),
572 items: Vec::new(),
573 };
574
575 if segments.len() < 2 {
576 roots.push(leaf);
577 continue;
578 }
579
580 let segment = segments[0].to_string();
581 let meta = group_meta.iter().find(|meta| meta.segment == segment);
582 let group_index =
583 if let Some(index) = groups.iter().position(|group| group.segment == segment) {
584 index
585 } else {
586 groups.push(SidebarGroup {
587 segment: segment.clone(),
588 title: meta
589 .map(|meta| meta.title.clone())
590 .unwrap_or_else(|| titleize_segment(&segment)),
591 href: meta
592 .map(|meta| meta.href.clone())
593 .unwrap_or_else(|| page.route.clone()),
594 active_prefix: route_with_prefix(&locale_prefix, &format!("/{segment}/")),
595 order: meta.map(|meta| meta.order).unwrap_or(usize::MAX),
596 item_order: meta.map(|meta| meta.item_order.clone()).unwrap_or_default(),
597 items: Vec::new(),
598 });
599 groups.len() - 1
600 };
601 groups[group_index].items.push(leaf);
602 }
603
604 roots.sort_by(|a, b| {
605 let a_home = a.href == locale_prefix;
606 let b_home = b.href == locale_prefix;
607 b_home.cmp(&a_home).then_with(|| a.href.cmp(&b.href))
608 });
609 groups.sort_by(|a, b| {
610 a.order
611 .cmp(&b.order)
612 .then_with(|| a.title.cmp(&b.title))
613 .then_with(|| a.href.cmp(&b.href))
614 });
615 for group in &mut groups {
616 group.items.sort_by(|a, b| {
617 nav_item_order(&group.item_order, &a.href)
618 .cmp(&nav_item_order(&group.item_order, &b.href))
619 .then_with(|| a.href.cmp(&b.href))
620 });
621 }
622
623 roots.extend(groups.into_iter().map(|group| NavItem {
624 title: group.title,
625 href: group.href,
626 active_prefix: group.active_prefix,
627 items: group.items,
628 }));
629 roots
630}
631
632fn build_explicit_nav(config: &Config, locale_key: &str, route: &str) -> Vec<NavItem> {
633 let nav = nav_sections_for_locale(config, locale_key);
634 let sidebars = sidebars_for_locale(config, locale_key);
635 let Some(sidebar_id) = active_sidebar_id(nav, sidebars, route) else {
636 return Vec::new();
637 };
638
639 sidebars
640 .get(sidebar_id)
641 .map(|sections| sidebar_sections_to_nav_items(sections))
642 .unwrap_or_default()
643}
644
645fn active_sidebar_id<'a>(
646 nav: &'a [NavSection],
647 sidebars: &'a BTreeMap<String, Vec<SidebarSection>>,
648 route: &str,
649) -> Option<&'a str> {
650 nav.iter()
651 .filter_map(|item| item.sidebar.as_deref().map(|sidebar| (item, sidebar)))
652 .find_map(|(item, sidebar)| {
653 let sections = sidebars.get(sidebar)?;
654 if nav_section_matches_route(item, route)
655 || sidebar_sections_match_route(sections, route)
656 {
657 Some(sidebar)
658 } else {
659 None
660 }
661 })
662}
663
664fn nav_section_matches_route(item: &NavSection, route: &str) -> bool {
665 item.link
666 .as_deref()
667 .is_some_and(|href| route_matches_link(route, href))
668 || item
669 .items
670 .iter()
671 .any(|child| route_matches_link(route, &child.link))
672}
673
674fn sidebar_sections_match_route(items: &[SidebarSection], route: &str) -> bool {
675 items.iter().any(|item| {
676 route_matches_link(route, &item.link)
677 || item
678 .items
679 .iter()
680 .any(|child| route_matches_link(route, &child.link))
681 })
682}
683
684fn sidebar_sections_to_nav_items(items: &[SidebarSection]) -> Vec<NavItem> {
685 items
686 .iter()
687 .map(|item| NavItem {
688 title: item.text.clone(),
689 href: item.link.clone(),
690 active_prefix: item.link.clone(),
691 items: item
692 .items
693 .iter()
694 .map(|child| NavItem {
695 title: child.text.clone(),
696 href: child.link.clone(),
697 active_prefix: child.link.clone(),
698 items: Vec::new(),
699 })
700 .collect(),
701 })
702 .collect()
703}
704
705fn route_matches_link(route: &str, href: &str) -> bool {
706 href.starts_with('/') && (route == href || (href != "/" && route.starts_with(href)))
707}
708
709fn build_top_nav(config: &Config, locale_key: &str) -> Vec<TopNavItem> {
710 nav_sections_for_locale(config, locale_key)
711 .iter()
712 .map(|item| TopNavItem {
713 title: item.text.clone(),
714 href: item.link.clone(),
715 items: item
716 .items
717 .iter()
718 .map(|child| TopNavLink {
719 title: child.text.clone(),
720 href: child.link.clone(),
721 })
722 .collect(),
723 })
724 .collect()
725}
726
727#[derive(Debug, Clone)]
728struct SidebarGroup {
729 segment: String,
730 title: String,
731 href: String,
732 active_prefix: String,
733 order: usize,
734 item_order: Vec<String>,
735 items: Vec<NavItem>,
736}
737
738#[derive(Debug, Clone)]
739struct SidebarGroupMeta {
740 segment: String,
741 title: String,
742 href: String,
743 order: usize,
744 item_order: Vec<String>,
745}
746
747fn nav_sections_for_locale<'a>(config: &'a Config, locale_key: &str) -> &'a [NavSection] {
748 config
749 .locales
750 .get(locale_key)
751 .filter(|locale| !locale.nav.is_empty())
752 .map(|locale| locale.nav.as_slice())
753 .unwrap_or(config.nav.as_slice())
754}
755
756fn sidebars_for_locale<'a>(
757 config: &'a Config,
758 locale_key: &str,
759) -> &'a BTreeMap<String, Vec<SidebarSection>> {
760 config
761 .locales
762 .get(locale_key)
763 .filter(|locale| !locale.sidebars.is_empty())
764 .map(|locale| &locale.sidebars)
765 .unwrap_or(&config.sidebars)
766}
767
768fn sidebar_group_meta(nav: &[NavSection], locale_prefix: &str) -> Vec<SidebarGroupMeta> {
769 let mut metas = Vec::new();
770 for item in nav {
771 let href = item
772 .link
773 .as_deref()
774 .or_else(|| item.items.first().map(|child| child.link.as_str()));
775 let Some(href) = href else { continue };
776 let Some(segment) = first_route_segment(href, locale_prefix) else {
777 continue;
778 };
779 if metas
780 .iter()
781 .any(|meta: &SidebarGroupMeta| meta.segment == segment)
782 {
783 continue;
784 }
785 metas.push(SidebarGroupMeta {
786 segment,
787 title: item.text.clone(),
788 href: href.to_string(),
789 order: metas.len(),
790 item_order: item.items.iter().map(|child| child.link.clone()).collect(),
791 });
792 }
793 metas
794}
795
796fn nav_item_order(order: &[String], href: &str) -> usize {
797 order
798 .iter()
799 .position(|item| item == href)
800 .unwrap_or(usize::MAX)
801}
802
803fn build_translation_map(pages: &[Page]) -> BTreeMap<(String, String), String> {
804 pages
805 .iter()
806 .map(|page| {
807 (
808 (page.locale_key.clone(), page.translation_key.clone()),
809 page.route.clone(),
810 )
811 })
812 .collect()
813}
814
815fn base_site_render(config: &Config) -> SiteRender {
816 SiteRender {
817 title: config.title.clone(),
818 lang: default_lang(config),
819 base: config.base.clone(),
820 home_href: "/".to_string(),
821 theme: theme_config(config),
822 search_enabled: config.search.enabled,
823 access_enabled: access_mask_enabled(config),
824 access_password: config.access.password.clone(),
825 password_hint: config.access.password_hint.clone(),
826 top_nav: build_top_nav(config, "root"),
827 nav: Vec::new(),
828 languages: Vec::new(),
829 }
830}
831
832fn site_render_for_page(
833 config: &Config,
834 pages: &[Page],
835 translations: &BTreeMap<(String, String), String>,
836 page: &Page,
837) -> SiteRender {
838 SiteRender {
839 title: title_for_locale(config, &page.locale_key),
840 lang: lang_for_locale(config, &page.locale_key),
841 base: config.base.clone(),
842 home_href: home_for_locale(config, &page.locale_key),
843 theme: theme_config(config),
844 search_enabled: config.search.enabled,
845 access_enabled: access_mask_enabled(config),
846 access_password: config.access.password.clone(),
847 password_hint: config.access.password_hint.clone(),
848 top_nav: build_top_nav(config, &page.locale_key),
849 nav: build_nav(pages, config, page),
850 languages: build_language_options(config, page, translations),
851 }
852}
853
854fn access_mask_enabled(config: &Config) -> bool {
855 config.access.enabled && config.access.mode == "mask" && !config.access.password.is_empty()
856}
857
858fn theme_config(config: &Config) -> ThemeConfig {
859 ThemeConfig {
860 skin: config.theme.skin.clone(),
861 allow_switch: config.theme.allow_switch,
862 github_url: config.theme.github_url.clone(),
863 }
864}
865
866fn title_for_locale(config: &Config, locale_key: &str) -> String {
867 config
868 .locales
869 .get(locale_key)
870 .and_then(|locale| locale.title.as_ref())
871 .cloned()
872 .unwrap_or_else(|| config.title.clone())
873}
874
875fn default_lang(config: &Config) -> String {
876 if config.locales.is_empty() {
877 "en".to_string()
878 } else {
879 lang_for_locale(config, "root")
880 }
881}
882
883fn lang_for_locale(config: &Config, locale_key: &str) -> String {
884 config
885 .locales
886 .get(locale_key)
887 .map(|locale| locale.lang.clone())
888 .unwrap_or_else(|| "en".to_string())
889}
890
891fn home_for_locale(config: &Config, locale_key: &str) -> String {
892 config
893 .locales
894 .get(locale_key)
895 .map(|locale| locale.link.clone())
896 .unwrap_or_else(|| "/".to_string())
897}
898
899fn build_language_options(
900 config: &Config,
901 page: &Page,
902 translations: &BTreeMap<(String, String), String>,
903) -> Vec<LanguageOption> {
904 if config.locales.is_empty() {
905 return Vec::new();
906 }
907
908 locale_keys(config)
909 .into_iter()
910 .filter_map(|locale_key| {
911 let locale = config.locales.get(&locale_key)?;
912 let href = translations
913 .get(&(locale_key.clone(), page.translation_key.clone()))
914 .cloned()
915 .unwrap_or_else(|| locale.link.clone());
916 Some(LanguageOption {
917 label: locale.label.clone(),
918 href,
919 current: locale_key == page.locale_key,
920 })
921 })
922 .collect()
923}
924
925fn locale_keys(config: &Config) -> Vec<String> {
926 let mut keys = Vec::new();
927 if config.locales.contains_key("root") {
928 keys.push("root".to_string());
929 }
930 keys.extend(config.locales.keys().filter(|key| *key != "root").cloned());
931 keys
932}
933
934fn write_search_index(out_dir: &Path, config: &SearchSection, pages: &[SearchPage]) -> Result<()> {
935 let assets_dir = out_dir.join("assets");
936 fs::create_dir_all(&assets_dir)
937 .with_context(|| format!("failed to create {}", assets_dir.display()))?;
938 let index = build_search_index(
939 SearchConfig {
940 languages: config.languages.clone(),
941 },
942 pages,
943 );
944 let json = serde_json::to_vec_pretty(&index)?;
945 fs::write(assets_dir.join("search-index.json"), &json)?;
946
947 let mut compressed = Vec::new();
948 {
949 let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 22);
950 writer.write_all(&json)?;
951 }
952 fs::write(assets_dir.join("search-index.json.br"), compressed)?;
953 fs::write(
954 assets_dir.join("rustpress_search_bg.wasm"),
955 rustpress_search::wasm_placeholder(),
956 )?;
957 Ok(())
958}
959
960fn copy_public_assets(public_dir: &Path, out_dir: &Path) -> Result<()> {
961 if !public_dir.exists() {
962 return Ok(());
963 }
964
965 for entry in WalkDir::new(public_dir) {
966 let entry = entry.with_context(|| format!("failed to scan {}", public_dir.display()))?;
967 if !entry.file_type().is_file() {
968 continue;
969 }
970 let relative = entry.path().strip_prefix(public_dir)?;
971 if relative.file_name().and_then(|value| value.to_str()) == Some(".gitkeep") {
972 continue;
973 }
974 let target = out_dir.join(relative);
975 if let Some(parent) = target.parent() {
976 fs::create_dir_all(parent)
977 .with_context(|| format!("failed to create {}", parent.display()))?;
978 }
979 fs::copy(entry.path(), &target).with_context(|| {
980 format!(
981 "failed to copy {} to {}",
982 entry.path().display(),
983 target.display()
984 )
985 })?;
986 }
987 Ok(())
988}
989
990fn write_page(out_dir: &Path, route: &str, html: &str) -> Result<()> {
991 let target = page_html_target(out_dir, route);
992 if let Some(parent) = target.parent() {
993 fs::create_dir_all(parent)
994 .with_context(|| format!("failed to create {}", parent.display()))?;
995 }
996 fs::write(&target, html).with_context(|| format!("failed to write {}", target.display()))
997}
998
999fn write_markdown_source(out_dir: &Path, route: &str, markdown_source: &str) -> Result<()> {
1000 let target = page_markdown_target(out_dir, route);
1001 if let Some(parent) = target.parent() {
1002 fs::create_dir_all(parent)
1003 .with_context(|| format!("failed to create {}", parent.display()))?;
1004 }
1005 fs::write(&target, markdown_source)
1006 .with_context(|| format!("failed to write {}", target.display()))
1007}
1008
1009fn page_html_target(out_dir: &Path, route: &str) -> PathBuf {
1010 let path = out_dir.join(route.trim_start_matches('/'));
1011 if route.ends_with('/') {
1012 path.join("index.html")
1013 } else {
1014 path
1015 }
1016}
1017
1018fn page_markdown_target(out_dir: &Path, route: &str) -> PathBuf {
1019 let mut target = page_html_target(out_dir, route);
1020 target.set_file_name("index.md.txt");
1021 target
1022}
1023
1024fn page_metadata_for(src_dir: &Path, path: &Path, config: &Config) -> Result<PageMetadata> {
1025 if config.locales.is_empty() {
1026 let route = route_for(src_dir, path)?;
1027 return Ok(PageMetadata {
1028 route: route.clone(),
1029 locale_key: "root".to_string(),
1030 translation_key: route,
1031 });
1032 }
1033
1034 let relative = path.strip_prefix(src_dir)?;
1035 if let Some((locale_key, locale_relative)) = locale_relative_path(relative, config) {
1036 let translation_key = route_for_relative(&locale_relative);
1037 let route = route_with_prefix(&config.locales[&locale_key].link, &translation_key);
1038 return Ok(PageMetadata {
1039 route,
1040 locale_key,
1041 translation_key,
1042 });
1043 }
1044
1045 let route = route_for_relative(relative);
1046 Ok(PageMetadata {
1047 route: route.clone(),
1048 locale_key: "root".to_string(),
1049 translation_key: route,
1050 })
1051}
1052
1053fn locale_relative_path(relative: &Path, config: &Config) -> Option<(String, PathBuf)> {
1054 let mut components = relative.components();
1055 let first = components.next()?.as_os_str().to_str()?;
1056 if first == "root" || !config.locales.contains_key(first) {
1057 return None;
1058 }
1059
1060 let mut locale_relative = PathBuf::new();
1061 for component in components {
1062 locale_relative.push(component.as_os_str());
1063 }
1064 Some((first.to_string(), locale_relative))
1065}
1066
1067fn route_for(src_dir: &Path, path: &Path) -> Result<String> {
1068 let relative = path.strip_prefix(src_dir)?;
1069 Ok(route_for_relative(relative))
1070}
1071
1072fn route_for_relative(relative: &Path) -> String {
1073 let without_ext = relative.with_extension("");
1074 if without_ext == Path::new("index") {
1075 return "/".to_string();
1076 }
1077
1078 if without_ext.file_name().and_then(|value| value.to_str()) == Some("index") {
1079 without_ext
1080 .parent()
1081 .map(path_to_route)
1082 .unwrap_or_else(|| "/".to_string())
1083 } else {
1084 path_to_route(&without_ext)
1085 }
1086}
1087
1088fn path_to_route(path: &Path) -> String {
1089 if path.as_os_str().is_empty() {
1090 return "/".to_string();
1091 }
1092
1093 let route = path
1094 .components()
1095 .map(|component| component.as_os_str().to_string_lossy())
1096 .collect::<Vec<_>>()
1097 .join("/");
1098 format!("/{route}/")
1099}
1100
1101fn route_with_prefix(prefix: &str, route: &str) -> String {
1102 if route == "/" {
1103 return prefix.to_string();
1104 }
1105 if prefix == "/" {
1106 route.to_string()
1107 } else {
1108 format!("{}{}", prefix, route.trim_start_matches('/'))
1109 }
1110}
1111
1112fn route_segments(route: &str) -> Vec<&str> {
1113 route
1114 .trim_matches('/')
1115 .split('/')
1116 .filter(|segment| !segment.is_empty())
1117 .collect()
1118}
1119
1120fn first_route_segment(route: &str, locale_prefix: &str) -> Option<String> {
1121 if route.starts_with("http://")
1122 || route.starts_with("https://")
1123 || route.starts_with("mailto:")
1124 || route.starts_with('#')
1125 {
1126 return None;
1127 }
1128
1129 let local_route = if locale_prefix != "/" && route.starts_with(locale_prefix) {
1130 let rest = &route[locale_prefix.len()..];
1131 if rest.is_empty() {
1132 "/".to_string()
1133 } else {
1134 format!("/{rest}")
1135 }
1136 } else {
1137 route.to_string()
1138 };
1139 route_segments(&local_route)
1140 .first()
1141 .map(|segment| (*segment).to_string())
1142}
1143
1144fn titleize_segment(segment: &str) -> String {
1145 segment
1146 .split(['-', '_'])
1147 .filter(|part| !part.is_empty())
1148 .map(|part| {
1149 let mut chars = part.chars();
1150 match chars.next() {
1151 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1152 None => String::new(),
1153 }
1154 })
1155 .collect::<Vec<_>>()
1156 .join(" ")
1157}
1158
1159fn site_url(base: &str, route: &str) -> String {
1160 if route == "/" {
1161 base.to_string()
1162 } else {
1163 format!("{}{}", base, route.trim_start_matches('/'))
1164 }
1165}
1166
1167fn markdown_source_url(base: &str, route: &str) -> String {
1168 format!("{}index.md.txt", site_url(base, route))
1169}
1170
1171fn normalize_nav(nav: &mut Vec<NavSection>, locale_prefix: Option<&str>) {
1172 nav.retain(|item| !item.text.trim().is_empty());
1173 for item in nav {
1174 item.text = item.text.trim().to_string();
1175 item.sidebar = item
1176 .sidebar
1177 .take()
1178 .map(|sidebar| sidebar.trim().to_string())
1179 .filter(|sidebar| !sidebar.is_empty());
1180 if item
1181 .link
1182 .as_deref()
1183 .is_some_and(|link| link.trim().is_empty())
1184 {
1185 item.link = None;
1186 }
1187 item.items
1188 .retain(|child| !child.text.trim().is_empty() && !child.link.trim().is_empty());
1189 for child in &mut item.items {
1190 child.text = child.text.trim().to_string();
1191 child.link = normalize_nav_link(&child.link, locale_prefix);
1192 }
1193 if let Some(link) = &mut item.link {
1194 *link = normalize_nav_link(link, locale_prefix);
1195 }
1196 }
1197}
1198
1199fn normalize_sidebars(
1200 sidebars: &mut BTreeMap<String, Vec<SidebarSection>>,
1201 locale_prefix: Option<&str>,
1202) {
1203 sidebars.retain(|id, items| {
1204 if id.trim().is_empty() {
1205 return false;
1206 }
1207
1208 for item in items.iter_mut() {
1209 item.text = item.text.trim().to_string();
1210 item.items
1211 .retain(|child| !child.text.trim().is_empty() && !child.link.trim().is_empty());
1212 for child in &mut item.items {
1213 child.text = child.text.trim().to_string();
1214 child.link = normalize_nav_link(&child.link, locale_prefix);
1215 }
1216
1217 item.link = item.link.trim().to_string();
1218 if item.link.is_empty() {
1219 if let Some(first_child) = item.items.first() {
1220 item.link = first_child.link.clone();
1221 }
1222 } else {
1223 item.link = normalize_nav_link(&item.link, locale_prefix);
1224 }
1225 }
1226
1227 items.retain(|item| !item.text.is_empty() && !item.link.is_empty());
1228
1229 !items.is_empty()
1230 });
1231}
1232
1233fn normalize_nav_link(link: &str, locale_prefix: Option<&str>) -> String {
1234 match locale_prefix {
1235 Some(prefix) => normalize_locale_nav_link(link, prefix),
1236 None => normalize_link(link),
1237 }
1238}
1239
1240fn normalize_locale_nav_link(link: &str, locale_prefix: &str) -> String {
1241 let link = link.trim();
1242 if link.is_empty()
1243 || link.starts_with('/')
1244 || link.starts_with('#')
1245 || link.starts_with("http://")
1246 || link.starts_with("https://")
1247 || link.starts_with("mailto:")
1248 {
1249 link.to_string()
1250 } else {
1251 route_with_prefix(locale_prefix, &normalize_link(link))
1252 }
1253}
1254
1255fn normalize_locale_prefix(key: &str, link: &str) -> Result<String> {
1256 let mut link = if link.trim().is_empty() {
1257 format!("/{key}/")
1258 } else {
1259 normalize_link(link)
1260 };
1261 if !link.starts_with('/') {
1262 anyhow::bail!("locale `{key}` link must be a path");
1263 }
1264 if link != "/" && !link.ends_with('/') {
1265 link.push('/');
1266 }
1267 Ok(link)
1268}
1269
1270fn normalize_theme_skin(skin: &str) -> String {
1271 match skin.trim().to_ascii_lowercase().as_str() {
1272 "dark" => "dark".to_string(),
1273 _ => "light".to_string(),
1274 }
1275}
1276
1277fn normalize_link(link: &str) -> String {
1278 let link = link.trim();
1279 if link.is_empty()
1280 || link.starts_with('/')
1281 || link.starts_with('#')
1282 || link.starts_with("http://")
1283 || link.starts_with("https://")
1284 || link.starts_with("mailto:")
1285 {
1286 link.to_string()
1287 } else {
1288 format!("/{link}")
1289 }
1290}
1291
1292fn normalize_config_path(path: &Path) -> Result<PathBuf> {
1293 if path.exists() {
1294 return Ok(path.to_path_buf());
1295 }
1296 anyhow::bail!("config file does not exist: {}", path.display());
1297}
1298
1299fn absolutize(root: &Path, path: &Path) -> PathBuf {
1300 if path.is_absolute() {
1301 path.to_path_buf()
1302 } else {
1303 root.join(path)
1304 }
1305}
1306
1307fn write_new(path: &Path, contents: &str) -> Result<()> {
1308 if path.exists() {
1309 anyhow::bail!("refusing to overwrite existing file {}", path.display());
1310 }
1311 if let Some(parent) = path.parent() {
1312 fs::create_dir_all(parent)
1313 .with_context(|| format!("failed to create {}", parent.display()))?;
1314 }
1315 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321
1322 #[test]
1323 fn init_and_build_generates_index() {
1324 let dir = tempfile::tempdir().unwrap();
1325 init_project(dir.path()).unwrap();
1326
1327 let result = build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1328
1329 assert_eq!(result.page_count, 2);
1330 assert!(dir.path().join("dist/index.html").exists());
1331 assert!(dir.path().join("dist/index.md.txt").exists());
1332 assert!(dir.path().join("dist/private/index.html").exists());
1333 assert!(dir.path().join("dist/private/index.md.txt").exists());
1334 assert!(dir.path().join("dist/assets/search-index.json").exists());
1335 assert!(dir.path().join("dist/assets/search-index.json.br").exists());
1336 assert!(dir
1337 .path()
1338 .join("dist/assets/rustpress_search_bg.wasm")
1339 .exists());
1340
1341 let public_html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
1342 let masked_html = fs::read_to_string(dir.path().join("dist/private/index.html")).unwrap();
1343 let public_markdown = fs::read_to_string(dir.path().join("dist/index.md.txt")).unwrap();
1344 let source_markdown = fs::read_to_string(dir.path().join("docs/index.md")).unwrap();
1345 let theme_js = fs::read_to_string(dir.path().join("dist/assets/rustpress.js")).unwrap();
1346 assert!(public_html.contains("rp-topnav-group"));
1347 assert!(public_html.contains("Masked Page"));
1348 assert_eq!(public_markdown, source_markdown);
1349 assert!(!public_html.contains("data-rp-language-select"));
1350 assert!(!public_html.contains("data-rp-access-mask"));
1351 assert!(masked_html.contains("data-rp-access-mask"));
1352 assert!(theme_js.contains(r#"const accessPassword = "rustpress";"#));
1353 }
1354
1355 #[test]
1356 fn markdown_code_line_numbers_default_to_true() {
1357 let raw = r#"
1358title = "Docs"
1359src_dir = "docs"
1360out_dir = "dist"
1361base = "/"
1362
1363[markdown]
1364mermaid = true
1365"#;
1366 let config: Config = toml::from_str(raw).unwrap();
1367
1368 assert!(config.markdown.code_line_numbers);
1369 }
1370
1371 #[test]
1372 fn markdown_code_line_numbers_false_reaches_rendered_pages() {
1373 let dir = tempfile::tempdir().unwrap();
1374 fs::create_dir_all(dir.path().join("docs")).unwrap();
1375 fs::write(
1376 dir.path().join("rustpress.toml"),
1377 r#"title = "Docs"
1378src_dir = "docs"
1379out_dir = "dist"
1380base = "/"
1381
1382[markdown]
1383code_highlight = false
1384code_line_numbers = false
1385"#,
1386 )
1387 .unwrap();
1388 fs::write(
1389 dir.path().join("docs/index.md"),
1390 "# Home\n\n```rust\nfn main() {}\n```",
1391 )
1392 .unwrap();
1393
1394 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1395
1396 let html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
1397 assert!(html.contains("class=\"rp-code-content language-rust\""));
1398 assert!(!html.contains("rp-code-line-numbers"));
1399 assert!(!html.contains("rp-code-lines"));
1400 }
1401
1402 #[test]
1403 fn access_mask_requires_configured_password() {
1404 let mut config = Config::default();
1405 config.access.enabled = true;
1406 config.access.mode = "mask".to_string();
1407 config.access.password.clear();
1408 assert!(!access_mask_enabled(&config));
1409
1410 config.access.password = "secret".to_string();
1411 assert!(access_mask_enabled(&config));
1412
1413 config.access.enabled = false;
1414 assert!(!access_mask_enabled(&config));
1415 }
1416
1417 #[test]
1418 fn base_url_is_normalized() {
1419 let mut config = Config {
1420 base: "docs".to_string(),
1421 ..Config::default()
1422 };
1423 config.normalize().unwrap();
1424 assert_eq!(config.base, "/docs/");
1425 }
1426
1427 #[test]
1428 fn markdown_source_urls_use_page_directory() {
1429 assert_eq!(markdown_source_url("/", "/"), "/index.md.txt");
1430 assert_eq!(
1431 markdown_source_url("/docs/", "/guide/cli/"),
1432 "/docs/guide/cli/index.md.txt"
1433 );
1434 }
1435
1436 #[test]
1437 fn theme_skin_is_limited_to_light_and_dark() {
1438 let mut dark_config = Config {
1439 theme: ThemeSection {
1440 skin: "dark".to_string(),
1441 ..ThemeSection::default()
1442 },
1443 ..Config::default()
1444 };
1445 dark_config.normalize().unwrap();
1446 assert_eq!(dark_config.theme.skin, "dark");
1447
1448 let mut old_skin_config = Config {
1449 theme: ThemeSection {
1450 skin: "modern".to_string(),
1451 ..ThemeSection::default()
1452 },
1453 ..Config::default()
1454 };
1455 old_skin_config.normalize().unwrap();
1456 assert_eq!(old_skin_config.theme.skin, "light");
1457 }
1458
1459 #[test]
1460 fn theme_github_url_is_rendered_when_configured() {
1461 let raw = r#"
1462title = "Docs"
1463src_dir = "docs"
1464out_dir = "dist"
1465base = "/"
1466
1467[theme]
1468github_url = " https://github.com/example/docs "
1469"#;
1470 let mut config: Config = toml::from_str(raw).unwrap();
1471 config.normalize().unwrap();
1472
1473 assert_eq!(config.theme.github_url, "https://github.com/example/docs");
1474
1475 let site = base_site_render(&config);
1476 let html = render_page(
1477 &site,
1478 &PageRender {
1479 title: "Home".to_string(),
1480 route: "/".to_string(),
1481 html: "<h1>Home</h1>".to_string(),
1482 markdown_source: "---\ntitle: Home\n---\n# Home\n".to_string(),
1483 markdown_source_url: "/index.md.txt".to_string(),
1484 headings: vec![],
1485 masked: false,
1486 search: true,
1487 },
1488 );
1489
1490 assert!(html.contains("rp-github-link"));
1491 assert!(html.contains(r#"href="https://github.com/example/docs""#));
1492 }
1493
1494 #[test]
1495 fn nav_links_are_normalized() {
1496 let mut config = Config {
1497 nav: vec![NavSection {
1498 text: " Guide ".to_string(),
1499 link: Some("guide/cli/".to_string()),
1500 sidebar: None,
1501 items: vec![
1502 NavLinkSection {
1503 text: " CLI ".to_string(),
1504 link: "guide/cli/".to_string(),
1505 },
1506 NavLinkSection {
1507 text: String::new(),
1508 link: "/bad/".to_string(),
1509 },
1510 ],
1511 }],
1512 ..Config::default()
1513 };
1514
1515 config.normalize().unwrap();
1516
1517 assert_eq!(config.nav[0].text, "Guide");
1518 assert_eq!(config.nav[0].link.as_deref(), Some("/guide/cli/"));
1519 assert_eq!(config.nav[0].items.len(), 1);
1520 assert_eq!(config.nav[0].items[0].text, "CLI");
1521 assert_eq!(config.nav[0].items[0].link, "/guide/cli/");
1522 }
1523
1524 #[test]
1525 fn sidebar_links_are_normalized() {
1526 let mut sidebars = BTreeMap::new();
1527 sidebars.insert(
1528 "docs".to_string(),
1529 vec![SidebarSection {
1530 text: " Guide ".to_string(),
1531 link: String::new(),
1532 items: vec![
1533 SidebarLinkSection {
1534 text: " CLI ".to_string(),
1535 link: "guide/cli/".to_string(),
1536 },
1537 SidebarLinkSection {
1538 text: String::new(),
1539 link: "/bad/".to_string(),
1540 },
1541 ],
1542 }],
1543 );
1544 let mut config = Config {
1545 nav: vec![NavSection {
1546 text: "Guide".to_string(),
1547 link: Some("/guide/".to_string()),
1548 sidebar: Some(" docs ".to_string()),
1549 items: Vec::new(),
1550 }],
1551 sidebars,
1552 ..Config::default()
1553 };
1554
1555 config.normalize().unwrap();
1556
1557 assert_eq!(config.nav[0].sidebar.as_deref(), Some("docs"));
1558 let sidebar = &config.sidebars["docs"][0];
1559 assert_eq!(sidebar.text, "Guide");
1560 assert_eq!(sidebar.link, "/guide/cli/");
1561 assert_eq!(sidebar.items.len(), 1);
1562 assert_eq!(sidebar.items[0].text, "CLI");
1563 assert_eq!(sidebar.items[0].link, "/guide/cli/");
1564 }
1565
1566 #[test]
1567 fn locales_require_root() {
1568 let mut config = Config::default();
1569 config.locales.insert(
1570 "en".to_string(),
1571 LocaleSection {
1572 label: "English".to_string(),
1573 lang: "en-US".to_string(),
1574 ..LocaleSection::default()
1575 },
1576 );
1577
1578 let err = config.normalize().unwrap_err();
1579
1580 assert!(err.to_string().contains("locales.root"));
1581 }
1582
1583 #[test]
1584 fn locale_links_and_relative_nav_are_normalized() {
1585 let mut config = localized_config();
1586 let locale = config.locales.get_mut("en").unwrap();
1587 locale.nav[0].sidebar = Some(" guide ".to_string());
1588 locale.sidebars.insert(
1589 "guide".to_string(),
1590 vec![SidebarSection {
1591 text: " Guide ".to_string(),
1592 link: "guide/cli/".to_string(),
1593 items: vec![SidebarLinkSection {
1594 text: "CLI".to_string(),
1595 link: "guide/cli/".to_string(),
1596 }],
1597 }],
1598 );
1599
1600 config.normalize().unwrap();
1601
1602 let root = &config.locales["root"];
1603 let en = &config.locales["en"];
1604 assert_eq!(root.label, "简体中文");
1605 assert_eq!(root.lang, "zh-CN");
1606 assert_eq!(root.link, "/");
1607 assert_eq!(en.link, "/en/");
1608 assert_eq!(en.nav[0].link.as_deref(), Some("/en/guide/cli/"));
1609 assert_eq!(en.nav[0].sidebar.as_deref(), Some("guide"));
1610 assert_eq!(en.nav[0].items[0].link, "/en/guide/cli/");
1611 assert_eq!(en.sidebars["guide"][0].link, "/en/guide/cli/");
1612 assert_eq!(en.sidebars["guide"][0].items[0].link, "/en/guide/cli/");
1613 }
1614
1615 #[test]
1616 fn explicit_sidebars_are_selected_by_top_nav_section() {
1617 let dir = tempfile::tempdir().unwrap();
1618 fs::create_dir_all(dir.path().join("docs")).unwrap();
1619 fs::write(
1620 dir.path().join("rustpress.toml"),
1621 r#"title = "Docs"
1622src_dir = "docs"
1623out_dir = "dist"
1624base = "/"
1625
1626[[nav]]
1627text = "Guide"
1628link = "/guide/"
1629sidebar = "guide"
1630
1631[[nav]]
1632text = "Reference"
1633link = "/reference/"
1634sidebar = "reference"
1635
1636[[sidebars.guide]]
1637text = "Guide"
1638link = "/guide/"
1639
1640[[sidebars.guide.items]]
1641text = "CLI"
1642link = "/guide/cli/"
1643
1644[[sidebars.reference]]
1645text = "Reference"
1646link = "/reference/"
1647
1648[[sidebars.reference.items]]
1649text = "API"
1650link = "/reference/api/"
1651"#,
1652 )
1653 .unwrap();
1654 write_doc(dir.path(), "docs/guide/cli.md", "Guide CLI", "Guide CLI").unwrap();
1655 write_doc(
1656 dir.path(),
1657 "docs/reference/api.md",
1658 "Reference API",
1659 "Reference API",
1660 )
1661 .unwrap();
1662
1663 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1664
1665 let guide_html = fs::read_to_string(dir.path().join("dist/guide/cli/index.html")).unwrap();
1666 let reference_html =
1667 fs::read_to_string(dir.path().join("dist/reference/api/index.html")).unwrap();
1668 assert!(guide_html.contains("CLI"));
1669 assert!(!guide_html.contains("rp-nav-level-1\">API"));
1670 assert!(reference_html.contains("API"));
1671 assert!(!reference_html.contains("rp-nav-level-1\">CLI"));
1672 }
1673
1674 #[test]
1675 fn routes_markdown_pages_without_double_slashes() {
1676 let src = Path::new("/site/docs");
1677
1678 assert_eq!(
1679 route_for(src, Path::new("/site/docs/guide.md")).unwrap(),
1680 "/guide/"
1681 );
1682 assert_eq!(
1683 route_for(src, Path::new("/site/docs/guide/index.md")).unwrap(),
1684 "/guide/"
1685 );
1686 assert_eq!(
1687 route_for(src, Path::new("/site/docs/index.md")).unwrap(),
1688 "/"
1689 );
1690 }
1691
1692 #[test]
1693 fn localized_routes_use_locale_prefixes() {
1694 let mut config = localized_config();
1695 config.normalize().unwrap();
1696 let src = Path::new("/site/docs");
1697
1698 assert_eq!(
1699 page_metadata_for(src, Path::new("/site/docs/index.md"), &config)
1700 .unwrap()
1701 .route,
1702 "/"
1703 );
1704 assert_eq!(
1705 page_metadata_for(src, Path::new("/site/docs/guide.md"), &config)
1706 .unwrap()
1707 .route,
1708 "/guide/"
1709 );
1710 let en_home = page_metadata_for(src, Path::new("/site/docs/en/index.md"), &config).unwrap();
1711 assert_eq!(en_home.route, "/en/");
1712 assert_eq!(en_home.locale_key, "en");
1713 assert_eq!(en_home.translation_key, "/");
1714 let en_guide =
1715 page_metadata_for(src, Path::new("/site/docs/en/guide.md"), &config).unwrap();
1716 assert_eq!(en_guide.route, "/en/guide/");
1717 assert_eq!(en_guide.locale_key, "en");
1718 assert_eq!(en_guide.translation_key, "/guide/");
1719 }
1720
1721 #[test]
1722 fn builds_multilingual_pages_and_language_switcher() {
1723 let dir = tempfile::tempdir().unwrap();
1724 write_multilingual_config(dir.path()).unwrap();
1725 write_doc(dir.path(), "docs/index.md", "Root Home", "Root Home").unwrap();
1726 write_doc(dir.path(), "docs/guide.md", "Root Guide", "Root Guide").unwrap();
1727 write_doc(dir.path(), "docs/guide/cli.md", "Root CLI", "Root CLI").unwrap();
1728 write_doc(dir.path(), "docs/root-only.md", "Root Only", "Root Only").unwrap();
1729 write_doc(
1730 dir.path(),
1731 "docs/en/index.md",
1732 "English Home",
1733 "English Home",
1734 )
1735 .unwrap();
1736 write_doc(
1737 dir.path(),
1738 "docs/en/guide.md",
1739 "English Guide",
1740 "English Guide",
1741 )
1742 .unwrap();
1743 write_doc(
1744 dir.path(),
1745 "docs/en/guide/cli.md",
1746 "English CLI",
1747 "English CLI",
1748 )
1749 .unwrap();
1750
1751 let result = build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1752
1753 assert_eq!(result.page_count, 7);
1754 assert!(dir.path().join("dist/index.html").exists());
1755 assert!(dir.path().join("dist/en/index.html").exists());
1756 assert!(dir.path().join("dist/en/guide/index.html").exists());
1757 assert!(dir.path().join("dist/en/guide/cli/index.html").exists());
1758
1759 let root_guide = fs::read_to_string(dir.path().join("dist/guide/index.html")).unwrap();
1760 let en_guide = fs::read_to_string(dir.path().join("dist/en/guide/index.html")).unwrap();
1761 let root_cli = fs::read_to_string(dir.path().join("dist/guide/cli/index.html")).unwrap();
1762 assert!(root_guide.contains(r#"<html lang="zh-CN""#));
1763 assert!(root_guide.contains("data-rp-language-select"));
1764 assert!(root_guide.contains(r#"data-rp-language-href="/guide/">简体中文</button>"#));
1765 assert!(root_guide.contains(r#"data-rp-language-href="/en/guide/">English</button>"#));
1766 assert!(en_guide.contains(r#"<html lang="en-US""#));
1767 assert!(en_guide.contains(r#"data-rp-language-href="/guide/">简体中文</button>"#));
1768 assert!(en_guide.contains(r#"data-rp-language-href="/en/guide/">English</button>"#));
1769 assert!(root_cli.contains("rp-nav-group"));
1770 assert!(root_cli.contains("rp-nav-group-title"));
1771 assert!(root_cli.contains("Root Guide"));
1772 assert!(root_cli.contains("Root CLI"));
1773 assert!(!en_guide.contains("Root Only"));
1774 }
1775
1776 #[test]
1777 fn language_switcher_falls_back_to_locale_home_when_translation_is_missing() {
1778 let dir = tempfile::tempdir().unwrap();
1779 write_multilingual_config(dir.path()).unwrap();
1780 write_doc(dir.path(), "docs/index.md", "Root Home", "Root Home").unwrap();
1781 write_doc(dir.path(), "docs/guide.md", "Root Guide", "Root Guide").unwrap();
1782 write_doc(
1783 dir.path(),
1784 "docs/en/index.md",
1785 "English Home",
1786 "English Home",
1787 )
1788 .unwrap();
1789
1790 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1791
1792 let root_guide = fs::read_to_string(dir.path().join("dist/guide/index.html")).unwrap();
1793 assert!(root_guide.contains(r#"data-rp-language-href="/en/">English</button>"#));
1794 }
1795
1796 #[test]
1797 fn search_false_pages_are_excluded_from_index() {
1798 let dir = tempfile::tempdir().unwrap();
1799 init_project(dir.path()).unwrap();
1800 fs::write(
1801 dir.path().join("docs/hidden.md"),
1802 "---\ntitle: Hidden\nsearch: false\n---\n# Hidden\nUniqueSecret",
1803 )
1804 .unwrap();
1805
1806 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1807
1808 let index = fs::read_to_string(dir.path().join("dist/assets/search-index.json")).unwrap();
1809 assert!(!index.contains("UniqueSecret"));
1810 assert!(!index.contains("\"Hidden\""));
1811 }
1812
1813 #[test]
1814 fn rendered_pages_include_copyable_markdown_source() {
1815 let dir = tempfile::tempdir().unwrap();
1816 init_project(dir.path()).unwrap();
1817 fs::write(
1818 dir.path().join("docs/agent.md"),
1819 "---\ntitle: Agent Copy\naccess: public\n---\n# Agent Copy\n\nUse <agent> context.\n",
1820 )
1821 .unwrap();
1822
1823 build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
1824
1825 let html = fs::read_to_string(dir.path().join("dist/agent/index.html")).unwrap();
1826 let markdown = fs::read_to_string(dir.path().join("dist/agent/index.md.txt")).unwrap();
1827 let source = fs::read_to_string(dir.path().join("docs/agent.md")).unwrap();
1828 assert!(html.contains("data-rp-copy-markdown"));
1829 assert!(html.contains("data-rp-copy-markdown-url"));
1830 assert!(html.contains("data-rp-markdown-source"));
1831 assert!(html.contains(r#"data-rp-markdown-source-url="/agent/index.md.txt""#));
1832 assert!(html.contains("---\ntitle: Agent Copy\naccess: public\n---"));
1833 assert!(html.contains("Use <agent> context."));
1834 assert_eq!(markdown, source);
1835 }
1836
1837 fn localized_config() -> Config {
1838 let mut locales = BTreeMap::new();
1839 locales.insert(
1840 "root".to_string(),
1841 LocaleSection {
1842 label: " 简体中文 ".to_string(),
1843 lang: " zh-CN ".to_string(),
1844 ..LocaleSection::default()
1845 },
1846 );
1847 locales.insert(
1848 "en".to_string(),
1849 LocaleSection {
1850 label: "English".to_string(),
1851 lang: "en-US".to_string(),
1852 nav: vec![NavSection {
1853 text: "Guide".to_string(),
1854 link: Some("guide/cli/".to_string()),
1855 sidebar: None,
1856 items: vec![NavLinkSection {
1857 text: "CLI".to_string(),
1858 link: "guide/cli/".to_string(),
1859 }],
1860 }],
1861 ..LocaleSection::default()
1862 },
1863 );
1864 Config {
1865 locales,
1866 ..Config::default()
1867 }
1868 }
1869
1870 fn write_multilingual_config(root: &Path) -> Result<()> {
1871 fs::write(
1872 root.join("rustpress.toml"),
1873 r#"title = "Docs"
1874src_dir = "docs"
1875out_dir = "dist"
1876base = "/"
1877
1878[[nav]]
1879text = "Root Guide"
1880link = "/guide/"
1881
1882[locales.root]
1883label = "简体中文"
1884lang = "zh-CN"
1885title = "中文文档"
1886
1887[locales.en]
1888label = "English"
1889lang = "en-US"
1890link = "/en/"
1891title = "English Docs"
1892
1893[[locales.en.nav]]
1894text = "Guide"
1895link = "guide/"
1896"#,
1897 )?;
1898 Ok(())
1899 }
1900
1901 fn write_doc(root: &Path, relative: &str, title: &str, body: &str) -> Result<()> {
1902 let path = root.join(relative);
1903 if let Some(parent) = path.parent() {
1904 fs::create_dir_all(parent)?;
1905 }
1906 fs::write(
1907 path,
1908 format!(
1909 "---\ntitle: {title}\nlayout: doc\nsidebar: true\nsearch: true\naccess: public\n---\n\n# {body}\n"
1910 ),
1911 )?;
1912 Ok(())
1913 }
1914}