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