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