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