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