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