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