Skip to main content

rustpress_core/
lib.rs

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 &lt;agent&gt; 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}