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