use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use rustpress_md::{parse_markdown, Document, MarkdownOptions};
use rustpress_search::{build_search_index, SearchConfig, SearchPage};
use rustpress_theme::{
render_page, write_theme_assets, LanguageOption, NavItem, PageRender, SiteRender, ThemeConfig,
TopNavItem, TopNavLink,
};
#[derive(Debug, Clone)]
pub struct BuildOptions {
pub config_path: PathBuf,
pub base_override: Option<String>,
}
impl BuildOptions {
pub fn new(config_path: impl Into<PathBuf>) -> Self {
Self {
config_path: config_path.into(),
base_override: None,
}
}
pub fn with_base_override(mut self, base: impl Into<String>) -> Self {
self.base_override = Some(base.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildResult {
pub out_dir: PathBuf,
pub page_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub title: String,
pub src_dir: PathBuf,
pub out_dir: PathBuf,
pub base: String,
pub theme: ThemeSection,
pub markdown: MarkdownSection,
pub search: SearchSection,
pub access: AccessSection,
pub top_nav: Vec<TopNavSection>,
pub locales: BTreeMap<String, LocaleSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeSection {
pub name: String,
pub skin: String,
pub allow_switch: bool,
pub github_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MarkdownSection {
pub mermaid: bool,
pub code_highlight: bool,
pub code_line_numbers: bool,
pub heading_anchors: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchSection {
pub enabled: bool,
pub languages: Vec<String>,
pub index_code: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AccessSection {
pub enabled: bool,
pub mode: String,
pub password: String,
pub password_hint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TopNavSection {
pub text: String,
pub link: Option<String>,
pub items: Vec<TopNavLinkSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TopNavLinkSection {
pub text: String,
pub link: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LocaleSection {
pub label: String,
pub lang: String,
pub link: String,
pub title: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
title: "My Docs".to_string(),
src_dir: "docs".into(),
out_dir: "dist".into(),
base: "/".to_string(),
theme: ThemeSection::default(),
markdown: MarkdownSection::default(),
search: SearchSection::default(),
access: AccessSection::default(),
top_nav: Vec::new(),
locales: BTreeMap::new(),
}
}
}
impl Default for ThemeSection {
fn default() -> Self {
Self {
name: "default".to_string(),
skin: "light".to_string(),
allow_switch: true,
github_url: String::new(),
}
}
}
impl Default for MarkdownSection {
fn default() -> Self {
Self {
mermaid: true,
code_highlight: true,
code_line_numbers: true,
heading_anchors: true,
}
}
}
impl Default for SearchSection {
fn default() -> Self {
Self {
enabled: true,
languages: vec!["zh".to_string(), "en".to_string()],
index_code: false,
}
}
}
impl Default for AccessSection {
fn default() -> Self {
Self {
enabled: true,
mode: "mask".to_string(),
password: String::new(),
password_hint: "Enter password".to_string(),
}
}
}
impl Default for TopNavSection {
fn default() -> Self {
Self {
text: String::new(),
link: None,
items: Vec::new(),
}
}
}
impl Default for TopNavLinkSection {
fn default() -> Self {
Self {
text: String::new(),
link: String::new(),
}
}
}
impl Default for LocaleSection {
fn default() -> Self {
Self {
label: String::new(),
lang: String::new(),
link: String::new(),
title: None,
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let raw = fs::read_to_string(path)
.with_context(|| format!("failed to read config {}", path.display()))?;
reject_removed_config(&raw, path)?;
let mut config: Config = toml::from_str(&raw)
.with_context(|| format!("failed to parse config {}", path.display()))?;
config.normalize()?;
Ok(config)
}
fn normalize(&mut self) -> Result<()> {
normalize_base(&mut self.base);
self.theme.skin = normalize_theme_skin(&self.theme.skin);
self.theme.github_url = self.theme.github_url.trim().to_string();
self.access.password = self.access.password.trim().to_string();
normalize_top_nav(&mut self.top_nav, None);
if !self.locales.is_empty() {
if !self.locales.contains_key("root") {
anyhow::bail!("locales.root is required when locales are configured");
}
let keys = self.locales.keys().cloned().collect::<Vec<_>>();
for key in keys {
let locale = self
.locales
.get_mut(&key)
.expect("locale key collected from map");
locale.label = locale.label.trim().to_string();
if locale.label.is_empty() {
locale.label = key.clone();
}
locale.lang = locale.lang.trim().to_string();
if locale.lang.is_empty() {
locale.lang = if key == "root" {
"en".to_string()
} else {
key.clone()
};
}
locale.title = locale
.title
.take()
.map(|title| title.trim().to_string())
.filter(|title| !title.is_empty());
locale.link = if key == "root" {
"/".to_string()
} else {
normalize_locale_prefix(&key, &locale.link)?
};
}
}
Ok(())
}
}
fn reject_removed_config(raw: &str, path: &Path) -> Result<()> {
let value: toml::Value = toml::from_str(raw)
.with_context(|| format!("failed to parse config {}", path.display()))?;
if value.get("nav").is_some() {
anyhow::bail!(
"deprecated config key `nav` found in {}; rename `[[nav]]` to `[[top_nav]]` and `[[nav.items]]` to `[[top_nav.items]]`",
path.display()
);
}
if value.get("sidebars").is_some() {
anyhow::bail!(
"removed config key `sidebars` found in {}; sidebars are generated from docs/ paths",
path.display()
);
}
if let Some(top_nav) = value.get("top_nav").and_then(toml::Value::as_array) {
if top_nav
.iter()
.filter_map(toml::Value::as_table)
.any(|item| item.contains_key("sidebar"))
{
anyhow::bail!(
"removed config key `top_nav.sidebar` found in {}; sidebars are generated from docs/ paths",
path.display()
);
}
}
if let Some(locales) = value.get("locales").and_then(toml::Value::as_table) {
for (locale_key, locale) in locales {
if locale.get("nav").is_some() {
anyhow::bail!(
"deprecated config key `locales.{locale_key}.nav` found in {}; configure `[[top_nav]]` once at the root",
path.display()
);
}
if locale.get("top_nav").is_some() {
anyhow::bail!(
"removed config key `locales.{locale_key}.top_nav` found in {}; configure `top_nav` once at the root",
path.display()
);
}
if locale.get("sidebars").is_some() {
anyhow::bail!(
"removed config key `locales.{locale_key}.sidebars` found in {}; sidebars are generated from docs/ paths",
path.display()
);
}
}
}
Ok(())
}
pub fn init_project(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
let docs_dir = dir.join("docs");
let public_dir = dir.join("public");
fs::create_dir_all(&docs_dir)
.with_context(|| format!("failed to create {}", docs_dir.display()))?;
fs::create_dir_all(&public_dir)
.with_context(|| format!("failed to create {}", public_dir.display()))?;
write_new(
&dir.join("rustpress.toml"),
r#"title = "My Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[[top_nav]]
text = "Guide"
link = "/"
[[top_nav.items]]
text = "Masked Page"
link = "/private/"
[theme]
name = "default"
skin = "light"
allow_switch = true
github_url = ""
[markdown]
mermaid = true
code_highlight = true
code_line_numbers = true
heading_anchors = true
[search]
enabled = true
languages = ["zh", "en"]
index_code = false
[access]
enabled = true
mode = "mask"
password = "rustpress"
password_hint = "Enter password"
"#,
)?;
write_new(
&docs_dir.join("index.md"),
r#"---
title: Welcome
layout: doc
sidebar: true
search: true
access: public
---
# Welcome
RustPress turns Markdown into a static documentation site.
## Mermaid
```mermaid
flowchart LR
A[Markdown] --> B[RustPress]
B --> C[Static HTML]
```
## Search
Local search indexes English and 中文 content by default.
"#,
)?;
write_new(
&docs_dir.join("private.md"),
r#"---
title: Masked Page
layout: doc
sidebar: true
search: true
access: masked
---
# Masked Page
This page demonstrates the front-end password mask. The HTML content is still present in the static output.
"#,
)?;
write_new(&public_dir.join(".gitkeep"), "")?;
Ok(())
}
pub fn build_site(options: BuildOptions) -> Result<BuildResult> {
let config_path = normalize_config_path(&options.config_path)?;
let project_root = config_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let mut config = Config::load(&config_path)?;
if let Some(base_override) = options.base_override {
config.base = base_override;
normalize_base(&mut config.base);
}
let src_dir = absolutize(&project_root, &config.src_dir);
let out_dir = absolutize(&project_root, &config.out_dir);
let public_dir = project_root.join("public");
if out_dir.exists() {
fs::remove_dir_all(&out_dir)
.with_context(|| format!("failed to clean {}", out_dir.display()))?;
}
fs::create_dir_all(&out_dir)
.with_context(|| format!("failed to create {}", out_dir.display()))?;
let pages = read_pages(&src_dir, &config)?;
let translations = build_translation_map(&pages);
let site = base_site_render(&config);
write_theme_assets(&out_dir, &site)?;
copy_public_assets(&public_dir, &out_dir)?;
let mut search_pages = Vec::new();
for page in &pages {
let page_site = site_render_for_page(&config, &pages, &translations, page);
let rendered = render_page(
&page_site,
&PageRender {
title: page.document.title.clone(),
route: page.route.clone(),
html: page.document.html.clone(),
markdown_source: page.markdown_source.clone(),
markdown_source_url: markdown_source_url(&config.base, &page.route),
headings: page.document.headings.clone(),
masked: page.document.frontmatter.access == "masked",
search: page.document.frontmatter.search,
},
);
write_page(&out_dir, &page.route, &rendered)?;
write_markdown_source(&out_dir, &page.route, &page.markdown_source)?;
if page.document.frontmatter.search {
search_pages.push(SearchPage {
title: page.document.title.clone(),
url: site_url(&config.base, &page.route),
headings: page
.document
.headings
.iter()
.map(|heading| heading.text.clone())
.collect(),
body: page.document.search_text.clone(),
});
}
}
if config.search.enabled {
write_search_index(&out_dir, &config.search, &search_pages)?;
}
Ok(BuildResult {
out_dir,
page_count: pages.len(),
})
}
#[derive(Debug, Clone)]
struct Page {
route: String,
locale_key: String,
translation_key: String,
markdown_source: String,
document: Document,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PageMetadata {
route: String,
locale_key: String,
translation_key: String,
}
fn read_pages(src_dir: &Path, config: &Config) -> Result<Vec<Page>> {
let mut pages = Vec::new();
for entry in WalkDir::new(src_dir).sort_by_file_name() {
let entry = entry.with_context(|| format!("failed to scan {}", src_dir.display()))?;
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|value| value.to_str()) != Some("md") {
continue;
}
let markdown = fs::read_to_string(entry.path())
.with_context(|| format!("failed to read {}", entry.path().display()))?;
let document = parse_markdown(
&markdown,
MarkdownOptions {
mermaid: config.markdown.mermaid,
code_highlight: config.markdown.code_highlight,
code_line_numbers: config.markdown.code_line_numbers,
heading_anchors: config.markdown.heading_anchors,
index_code: config.search.index_code,
},
)
.with_context(|| format!("failed to parse {}", entry.path().display()))?;
let metadata = page_metadata_for(src_dir, entry.path(), config)?;
pages.push(Page {
route: metadata.route,
locale_key: metadata.locale_key,
translation_key: metadata.translation_key,
markdown_source: markdown,
document,
});
}
Ok(pages)
}
fn build_nav(pages: &[Page], config: &Config, page: &Page) -> Vec<NavItem> {
let current_segments = route_segments(&page.translation_key);
let Some(section) = current_segments.first().copied() else {
return Vec::new();
};
let locale_prefix = home_for_locale(config, &page.locale_key);
let section_prefix = route_with_prefix(&locale_prefix, &format!("/{section}/"));
let mut section_page = None::<&Page>;
let mut children = BTreeMap::<String, SidebarChild>::new();
let mut candidates = pages
.iter()
.filter(|candidate| {
candidate.locale_key == page.locale_key && candidate.document.frontmatter.sidebar
})
.collect::<Vec<_>>();
candidates.sort_by(|a, b| a.translation_key.cmp(&b.translation_key));
for candidate in candidates {
let segments = route_segments(&candidate.translation_key);
if segments.first().copied() != Some(section) {
continue;
}
if segments.len() == 1 {
if section_page.is_none() {
section_page = Some(candidate);
}
continue;
}
let child_segment = segments[1].to_string();
let direct_child = segments.len() == 2;
let entry = children
.entry(child_segment.clone())
.or_insert_with(|| SidebarChild {
title: if direct_child {
candidate.document.title.clone()
} else {
titleize_segment(&child_segment)
},
href: candidate.route.clone(),
active_prefix: route_with_prefix(
&locale_prefix,
&format!("/{section}/{child_segment}/"),
),
direct_child,
});
if direct_child && !entry.direct_child {
entry.title = candidate.document.title.clone();
entry.href = candidate.route.clone();
entry.direct_child = true;
}
}
if section_page.is_none() && children.is_empty() {
return Vec::new();
}
let first_child_href = children.values().next().map(|child| child.href.clone());
let title = section_page
.map(|page| page.document.title.clone())
.unwrap_or_else(|| titleize_segment(section));
let href = section_page
.map(|page| page.route.clone())
.or(first_child_href)
.unwrap_or_else(|| section_prefix.clone());
vec![NavItem {
title,
href,
active_prefix: section_prefix,
items: children
.into_values()
.map(|child| NavItem {
title: child.title,
href: child.href,
active_prefix: child.active_prefix,
items: Vec::new(),
})
.collect(),
}]
}
fn build_top_nav(config: &Config, locale_key: &str) -> Vec<TopNavItem> {
config
.top_nav
.iter()
.map(|item| TopNavItem {
title: item.text.clone(),
href: item
.link
.as_ref()
.map(|link| top_nav_link_for_locale(config, locale_key, link)),
items: item
.items
.iter()
.map(|child| TopNavLink {
title: child.text.clone(),
href: top_nav_link_for_locale(config, locale_key, &child.link),
})
.collect(),
})
.collect()
}
#[derive(Debug, Clone)]
struct SidebarChild {
title: String,
href: String,
active_prefix: String,
direct_child: bool,
}
fn top_nav_link_for_locale(config: &Config, locale_key: &str, link: &str) -> String {
if locale_key == "root" || is_external_or_anchor_link(link) {
link.to_string()
} else {
route_with_prefix(&home_for_locale(config, locale_key), link)
}
}
fn build_translation_map(pages: &[Page]) -> BTreeMap<(String, String), String> {
pages
.iter()
.map(|page| {
(
(page.locale_key.clone(), page.translation_key.clone()),
page.route.clone(),
)
})
.collect()
}
fn base_site_render(config: &Config) -> SiteRender {
SiteRender {
title: config.title.clone(),
lang: default_lang(config),
base: config.base.clone(),
home_href: "/".to_string(),
theme: theme_config(config),
search_enabled: config.search.enabled,
access_enabled: access_mask_enabled(config),
access_password: config.access.password.clone(),
password_hint: config.access.password_hint.clone(),
top_nav: build_top_nav(config, "root"),
nav: Vec::new(),
languages: Vec::new(),
}
}
fn site_render_for_page(
config: &Config,
pages: &[Page],
translations: &BTreeMap<(String, String), String>,
page: &Page,
) -> SiteRender {
SiteRender {
title: title_for_locale(config, &page.locale_key),
lang: lang_for_locale(config, &page.locale_key),
base: config.base.clone(),
home_href: home_for_locale(config, &page.locale_key),
theme: theme_config(config),
search_enabled: config.search.enabled,
access_enabled: access_mask_enabled(config),
access_password: config.access.password.clone(),
password_hint: config.access.password_hint.clone(),
top_nav: build_top_nav(config, &page.locale_key),
nav: build_nav(pages, config, page),
languages: build_language_options(config, page, translations),
}
}
fn access_mask_enabled(config: &Config) -> bool {
config.access.enabled && config.access.mode == "mask" && !config.access.password.is_empty()
}
fn theme_config(config: &Config) -> ThemeConfig {
ThemeConfig {
skin: config.theme.skin.clone(),
allow_switch: config.theme.allow_switch,
github_url: config.theme.github_url.clone(),
}
}
fn title_for_locale(config: &Config, locale_key: &str) -> String {
config
.locales
.get(locale_key)
.and_then(|locale| locale.title.as_ref())
.cloned()
.unwrap_or_else(|| config.title.clone())
}
fn default_lang(config: &Config) -> String {
if config.locales.is_empty() {
"en".to_string()
} else {
lang_for_locale(config, "root")
}
}
fn lang_for_locale(config: &Config, locale_key: &str) -> String {
config
.locales
.get(locale_key)
.map(|locale| locale.lang.clone())
.unwrap_or_else(|| "en".to_string())
}
fn home_for_locale(config: &Config, locale_key: &str) -> String {
config
.locales
.get(locale_key)
.map(|locale| locale.link.clone())
.unwrap_or_else(|| "/".to_string())
}
fn build_language_options(
config: &Config,
page: &Page,
translations: &BTreeMap<(String, String), String>,
) -> Vec<LanguageOption> {
if config.locales.is_empty() {
return Vec::new();
}
locale_keys(config)
.into_iter()
.filter_map(|locale_key| {
let locale = config.locales.get(&locale_key)?;
let href = translations
.get(&(locale_key.clone(), page.translation_key.clone()))
.cloned()
.unwrap_or_else(|| locale.link.clone());
Some(LanguageOption {
label: locale.label.clone(),
href,
current: locale_key == page.locale_key,
})
})
.collect()
}
fn locale_keys(config: &Config) -> Vec<String> {
let mut keys = Vec::new();
if config.locales.contains_key("root") {
keys.push("root".to_string());
}
keys.extend(config.locales.keys().filter(|key| *key != "root").cloned());
keys
}
fn write_search_index(out_dir: &Path, config: &SearchSection, pages: &[SearchPage]) -> Result<()> {
let assets_dir = out_dir.join("assets");
fs::create_dir_all(&assets_dir)
.with_context(|| format!("failed to create {}", assets_dir.display()))?;
let index = build_search_index(
SearchConfig {
languages: config.languages.clone(),
},
pages,
);
let json = serde_json::to_vec_pretty(&index)?;
fs::write(assets_dir.join("search-index.json"), &json)?;
let mut compressed = Vec::new();
{
let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 22);
writer.write_all(&json)?;
}
fs::write(assets_dir.join("search-index.json.br"), compressed)?;
fs::write(
assets_dir.join("rustpress_search_bg.wasm"),
rustpress_search::wasm_placeholder(),
)?;
Ok(())
}
fn copy_public_assets(public_dir: &Path, out_dir: &Path) -> Result<()> {
if !public_dir.exists() {
return Ok(());
}
for entry in WalkDir::new(public_dir) {
let entry = entry.with_context(|| format!("failed to scan {}", public_dir.display()))?;
if !entry.file_type().is_file() {
continue;
}
let relative = entry.path().strip_prefix(public_dir)?;
if relative.file_name().and_then(|value| value.to_str()) == Some(".gitkeep") {
continue;
}
let target = out_dir.join(relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::copy(entry.path(), &target).with_context(|| {
format!(
"failed to copy {} to {}",
entry.path().display(),
target.display()
)
})?;
}
Ok(())
}
fn write_page(out_dir: &Path, route: &str, html: &str) -> Result<()> {
let target = page_html_target(out_dir, route);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&target, html).with_context(|| format!("failed to write {}", target.display()))
}
fn write_markdown_source(out_dir: &Path, route: &str, markdown_source: &str) -> Result<()> {
let target = page_markdown_target(out_dir, route);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&target, markdown_source)
.with_context(|| format!("failed to write {}", target.display()))
}
fn page_html_target(out_dir: &Path, route: &str) -> PathBuf {
let path = out_dir.join(route.trim_start_matches('/'));
if route.ends_with('/') {
path.join("index.html")
} else {
path
}
}
fn page_markdown_target(out_dir: &Path, route: &str) -> PathBuf {
let mut target = page_html_target(out_dir, route);
target.set_file_name("index.md.txt");
target
}
fn page_metadata_for(src_dir: &Path, path: &Path, config: &Config) -> Result<PageMetadata> {
let relative = path.strip_prefix(src_dir)?;
if config.locales.is_empty() {
let route = route_for_relative(relative);
return Ok(PageMetadata {
route: route.clone(),
locale_key: "root".to_string(),
translation_key: route,
});
}
let (locale_key, translation_relative) = suffixed_locale_relative_path(relative, config)
.unwrap_or_else(|| ("root".to_string(), relative.to_path_buf()));
let translation_key = route_for_relative(&translation_relative);
let route = if locale_key == "root" {
translation_key.clone()
} else {
route_with_prefix(&config.locales[&locale_key].link, &translation_key)
};
Ok(PageMetadata {
route,
locale_key,
translation_key,
})
}
fn suffixed_locale_relative_path(relative: &Path, config: &Config) -> Option<(String, PathBuf)> {
let file_name = relative.file_name()?.to_str()?;
let stem = file_name.strip_suffix(".md")?;
let (base_stem, locale_key) = stem.rsplit_once('.')?;
if locale_key == "root" || !config.locales.contains_key(locale_key) {
return None;
}
let mut translation_relative = relative.to_path_buf();
translation_relative.set_file_name(format!("{base_stem}.md"));
Some((locale_key.to_string(), translation_relative))
}
#[cfg(test)]
fn route_for(src_dir: &Path, path: &Path) -> Result<String> {
let relative = path.strip_prefix(src_dir)?;
Ok(route_for_relative(relative))
}
fn route_for_relative(relative: &Path) -> String {
let without_ext = relative.with_extension("");
if without_ext == Path::new("index") {
return "/".to_string();
}
if without_ext.file_name().and_then(|value| value.to_str()) == Some("index") {
without_ext
.parent()
.map(path_to_route)
.unwrap_or_else(|| "/".to_string())
} else {
path_to_route(&without_ext)
}
}
fn path_to_route(path: &Path) -> String {
if path.as_os_str().is_empty() {
return "/".to_string();
}
let route = path
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
format!("/{route}/")
}
fn route_with_prefix(prefix: &str, route: &str) -> String {
if route == "/" {
return prefix.to_string();
}
if prefix == "/" {
route.to_string()
} else {
format!("{}{}", prefix, route.trim_start_matches('/'))
}
}
fn route_segments(route: &str) -> Vec<&str> {
route
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect()
}
fn titleize_segment(segment: &str) -> String {
segment
.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn site_url(base: &str, route: &str) -> String {
if route == "/" {
base.to_string()
} else {
format!("{}{}", base, route.trim_start_matches('/'))
}
}
fn markdown_source_url(base: &str, route: &str) -> String {
format!("{}index.md.txt", site_url(base, route))
}
fn normalize_top_nav(top_nav: &mut Vec<TopNavSection>, locale_prefix: Option<&str>) {
top_nav.retain(|item| !item.text.trim().is_empty());
for item in top_nav {
item.text = item.text.trim().to_string();
if item
.link
.as_deref()
.is_some_and(|link| link.trim().is_empty())
{
item.link = None;
}
item.items
.retain(|child| !child.text.trim().is_empty() && !child.link.trim().is_empty());
for child in &mut item.items {
child.text = child.text.trim().to_string();
child.link = normalize_nav_link(&child.link, locale_prefix);
}
if let Some(link) = &mut item.link {
*link = normalize_nav_link(link, locale_prefix);
}
}
}
fn normalize_nav_link(link: &str, locale_prefix: Option<&str>) -> String {
match locale_prefix {
Some(prefix) => normalize_locale_nav_link(link, prefix),
None => normalize_link(link),
}
}
fn normalize_locale_nav_link(link: &str, locale_prefix: &str) -> String {
let link = link.trim();
if link.is_empty()
|| link.starts_with('/')
|| link.starts_with('#')
|| link.starts_with("http://")
|| link.starts_with("https://")
|| link.starts_with("mailto:")
{
link.to_string()
} else {
route_with_prefix(locale_prefix, &normalize_link(link))
}
}
fn normalize_locale_prefix(key: &str, link: &str) -> Result<String> {
let mut link = if link.trim().is_empty() {
format!("/{key}/")
} else {
normalize_link(link)
};
if !link.starts_with('/') {
anyhow::bail!("locale `{key}` link must be a path");
}
if link != "/" && !link.ends_with('/') {
link.push('/');
}
Ok(link)
}
fn normalize_base(base: &mut String) {
if base.is_empty() {
*base = "/".to_string();
}
if !base.starts_with('/') {
base.insert(0, '/');
}
if !base.ends_with('/') {
base.push('/');
}
}
fn normalize_theme_skin(skin: &str) -> String {
match skin.trim().to_ascii_lowercase().as_str() {
"dark" => "dark".to_string(),
_ => "light".to_string(),
}
}
fn normalize_link(link: &str) -> String {
let link = link.trim();
if link.is_empty() || link.starts_with('/') || is_external_or_anchor_link(link) {
link.to_string()
} else {
format!("/{link}")
}
}
fn is_external_or_anchor_link(link: &str) -> bool {
link.starts_with('#')
|| link.starts_with("http://")
|| link.starts_with("https://")
|| link.starts_with("mailto:")
}
fn normalize_config_path(path: &Path) -> Result<PathBuf> {
if path.exists() {
return Ok(path.to_path_buf());
}
anyhow::bail!("config file does not exist: {}", path.display());
}
fn absolutize(root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
}
fn write_new(path: &Path, contents: &str) -> Result<()> {
if path.exists() {
anyhow::bail!("refusing to overwrite existing file {}", path.display());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_and_build_generates_index() {
let dir = tempfile::tempdir().unwrap();
init_project(dir.path()).unwrap();
let result = build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
assert_eq!(result.page_count, 2);
assert!(dir.path().join("dist/index.html").exists());
assert!(dir.path().join("dist/index.md.txt").exists());
assert!(dir.path().join("dist/private/index.html").exists());
assert!(dir.path().join("dist/private/index.md.txt").exists());
assert!(dir.path().join("dist/assets/search-index.json").exists());
assert!(dir.path().join("dist/assets/search-index.json.br").exists());
assert!(dir
.path()
.join("dist/assets/rustpress_search_bg.wasm")
.exists());
let public_html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
let masked_html = fs::read_to_string(dir.path().join("dist/private/index.html")).unwrap();
let public_markdown = fs::read_to_string(dir.path().join("dist/index.md.txt")).unwrap();
let source_markdown = fs::read_to_string(dir.path().join("docs/index.md")).unwrap();
let theme_js = fs::read_to_string(dir.path().join("dist/assets/rustpress.js")).unwrap();
assert!(public_html.contains("rp-topnav-group"));
assert!(public_html.contains("Masked Page"));
assert_eq!(public_markdown, source_markdown);
assert!(!public_html.contains("data-rp-language-select"));
assert!(!public_html.contains("data-rp-access-mask"));
assert!(masked_html.contains("data-rp-access-mask"));
assert!(theme_js.contains(r#"const accessPassword = "rustpress";"#));
}
#[test]
fn markdown_code_line_numbers_default_to_true() {
let raw = r#"
title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[markdown]
mermaid = true
"#;
let config: Config = toml::from_str(raw).unwrap();
assert!(config.markdown.code_line_numbers);
}
#[test]
fn markdown_code_line_numbers_false_reaches_rendered_pages() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("docs")).unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[markdown]
code_highlight = false
code_line_numbers = false
"#,
)
.unwrap();
fs::write(
dir.path().join("docs/index.md"),
"# Home\n\n```rust\nfn main() {}\n```",
)
.unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
let html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
assert!(html.contains("class=\"rp-code-content language-rust\""));
assert!(!html.contains("rp-code-line-numbers"));
assert!(!html.contains("rp-code-lines"));
}
#[test]
fn access_mask_requires_configured_password() {
let mut config = Config::default();
config.access.enabled = true;
config.access.mode = "mask".to_string();
config.access.password.clear();
assert!(!access_mask_enabled(&config));
config.access.password = "secret".to_string();
assert!(access_mask_enabled(&config));
config.access.enabled = false;
assert!(!access_mask_enabled(&config));
}
#[test]
fn base_url_is_normalized() {
let mut config = Config {
base: "docs".to_string(),
..Config::default()
};
config.normalize().unwrap();
assert_eq!(config.base, "/docs/");
}
#[test]
fn markdown_source_urls_use_page_directory() {
assert_eq!(markdown_source_url("/", "/"), "/index.md.txt");
assert_eq!(
markdown_source_url("/docs/", "/guide/cli/"),
"/docs/guide/cli/index.md.txt"
);
}
#[test]
fn build_keeps_configured_deployment_base() {
let dir = tempfile::tempdir().unwrap();
write_base_prefixed_project(dir.path()).unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
let html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
let theme_js = fs::read_to_string(dir.path().join("dist/assets/rustpress.js")).unwrap();
let search_index =
fs::read_to_string(dir.path().join("dist/assets/search-index.json")).unwrap();
assert!(html.contains(r#"href="/action-gateway/assets/rustpress.css""#));
assert!(html.contains(r#"src="/action-gateway/assets/rustpress.js""#));
assert!(html.contains(r#"href="/action-gateway/guide/""#));
assert!(html.contains(r#"data-rp-markdown-source-url="/action-gateway/index.md.txt""#));
assert!(theme_js.contains(r#"const base = "/action-gateway/";"#));
assert!(search_index.contains(r#""url": "/action-gateway/""#));
}
#[test]
fn base_override_renders_local_root_urls() {
let dir = tempfile::tempdir().unwrap();
write_base_prefixed_project(dir.path()).unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml")).with_base_override("/"))
.unwrap();
let html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
let theme_js = fs::read_to_string(dir.path().join("dist/assets/rustpress.js")).unwrap();
let search_index =
fs::read_to_string(dir.path().join("dist/assets/search-index.json")).unwrap();
assert!(html.contains(r#"href="/assets/rustpress.css""#));
assert!(html.contains(r#"src="/assets/rustpress.js""#));
assert!(html.contains(r#"href="/guide/""#));
assert!(html.contains(r#"data-rp-markdown-source-url="/index.md.txt""#));
assert!(theme_js.contains(r#"const base = "/";"#));
assert!(search_index.contains(r#""url": "/""#));
assert!(!html.contains("/action-gateway/assets/"));
assert!(!theme_js.contains("/action-gateway/"));
}
#[test]
fn theme_skin_is_limited_to_light_and_dark() {
let mut dark_config = Config {
theme: ThemeSection {
skin: "dark".to_string(),
..ThemeSection::default()
},
..Config::default()
};
dark_config.normalize().unwrap();
assert_eq!(dark_config.theme.skin, "dark");
let mut old_skin_config = Config {
theme: ThemeSection {
skin: "modern".to_string(),
..ThemeSection::default()
},
..Config::default()
};
old_skin_config.normalize().unwrap();
assert_eq!(old_skin_config.theme.skin, "light");
}
#[test]
fn theme_github_url_is_rendered_when_configured() {
let raw = r#"
title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[theme]
github_url = " https://github.com/example/docs "
"#;
let mut config: Config = toml::from_str(raw).unwrap();
config.normalize().unwrap();
assert_eq!(config.theme.github_url, "https://github.com/example/docs");
let site = base_site_render(&config);
let html = render_page(
&site,
&PageRender {
title: "Home".to_string(),
route: "/".to_string(),
html: "<h1>Home</h1>".to_string(),
markdown_source: "---\ntitle: Home\n---\n# Home\n".to_string(),
markdown_source_url: "/index.md.txt".to_string(),
headings: vec![],
masked: false,
search: true,
},
);
assert!(html.contains("rp-github-link"));
assert!(html.contains(r#"href="https://github.com/example/docs""#));
}
#[test]
fn top_nav_links_are_normalized() {
let mut config = Config {
top_nav: vec![TopNavSection {
text: " Guide ".to_string(),
link: Some("guide/cli/".to_string()),
items: vec![
TopNavLinkSection {
text: " CLI ".to_string(),
link: "guide/cli/".to_string(),
},
TopNavLinkSection {
text: String::new(),
link: "/bad/".to_string(),
},
],
}],
..Config::default()
};
config.normalize().unwrap();
assert_eq!(config.top_nav[0].text, "Guide");
assert_eq!(config.top_nav[0].link.as_deref(), Some("/guide/cli/"));
assert_eq!(config.top_nav[0].items.len(), 1);
assert_eq!(config.top_nav[0].items[0].text, "CLI");
assert_eq!(config.top_nav[0].items[0].link, "/guide/cli/");
}
#[test]
fn removed_root_sidebars_config_is_rejected_on_load() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[[sidebars.guide]]
text = "Guide"
link = "/guide/"
"#,
)
.unwrap();
let err = Config::load(&dir.path().join("rustpress.toml")).unwrap_err();
assert!(err.to_string().contains("sidebars"));
}
#[test]
fn removed_top_nav_sidebar_config_is_rejected_on_load() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[[top_nav]]
text = "Guide"
link = "/guide/"
sidebar = "guide"
"#,
)
.unwrap();
let err = Config::load(&dir.path().join("rustpress.toml")).unwrap_err();
assert!(err.to_string().contains("top_nav.sidebar"));
}
#[test]
fn locales_require_root() {
let mut config = Config::default();
config.locales.insert(
"en".to_string(),
LocaleSection {
label: "English".to_string(),
lang: "en-US".to_string(),
..LocaleSection::default()
},
);
let err = config.normalize().unwrap_err();
assert!(err.to_string().contains("locales.root"));
}
#[test]
fn locale_links_and_root_top_nav_are_localized() {
let mut config = localized_config();
config.normalize().unwrap();
let root = &config.locales["root"];
let en = &config.locales["en"];
assert_eq!(root.label, "简体中文");
assert_eq!(root.lang, "zh-CN");
assert_eq!(root.link, "/");
assert_eq!(en.link, "/en/");
let en_top_nav = build_top_nav(&config, "en");
assert_eq!(en_top_nav[0].href.as_deref(), Some("/en/guide/cli/"));
assert_eq!(en_top_nav[0].items[0].href, "/en/guide/cli/");
assert_eq!(en_top_nav[1].href.as_deref(), Some("https://example.com"));
}
#[test]
fn legacy_root_nav_config_is_rejected_on_load() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[[nav]]
text = "Guide"
link = "/guide/"
"#,
)
.unwrap();
let err = Config::load(&dir.path().join("rustpress.toml")).unwrap_err();
assert!(err.to_string().contains("nav"));
assert!(err.to_string().contains("top_nav"));
}
#[test]
fn legacy_locale_nav_config_is_rejected_on_load() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[locales.root]
label = "简体中文"
lang = "zh-CN"
[locales.en]
label = "English"
lang = "en-US"
[[locales.en.nav]]
text = "Guide"
link = "guide/"
"#,
)
.unwrap();
let err = Config::load(&dir.path().join("rustpress.toml")).unwrap_err();
assert!(err.to_string().contains("locales.en.nav"));
assert!(err.to_string().contains("top_nav"));
}
#[test]
fn removed_locale_top_nav_config_is_rejected_on_load() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[locales.root]
label = "简体中文"
lang = "zh-CN"
[locales.en]
label = "English"
lang = "en-US"
[[locales.en.top_nav]]
text = "Guide"
link = "guide/"
"#,
)
.unwrap();
let err = Config::load(&dir.path().join("rustpress.toml")).unwrap_err();
assert!(err.to_string().contains("locales.en.top_nav"));
}
#[test]
fn removed_locale_sidebars_config_is_rejected_on_load() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[locales.root]
label = "简体中文"
lang = "zh-CN"
[locales.en]
label = "English"
lang = "en-US"
[[locales.en.sidebars.guide]]
text = "Guide"
link = "guide/"
"#,
)
.unwrap();
let err = Config::load(&dir.path().join("rustpress.toml")).unwrap_err();
assert!(err.to_string().contains("locales.en.sidebars"));
}
#[test]
fn generated_sidebars_are_scoped_to_current_directory_section() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("docs")).unwrap();
fs::write(
dir.path().join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[[top_nav]]
text = "Guide"
link = "/guide/"
[[top_nav.items]]
text = "Quick Start"
link = "/guide/quick-start/"
[[top_nav]]
text = "Features"
link = "/features/"
"#,
)
.unwrap();
write_doc(
dir.path(),
"docs/guide/quick-start.md",
"Quick Start",
"Quick Start",
)
.unwrap();
write_doc(dir.path(), "docs/guide/cli.md", "Guide CLI", "Guide CLI").unwrap();
write_doc(
dir.path(),
"docs/guide/configuration.md",
"Configuration",
"Configuration",
)
.unwrap();
write_doc(
dir.path(),
"docs/guide/advanced/deep.md",
"Deep Guide",
"Deep Guide",
)
.unwrap();
write_doc(dir.path(), "docs/features/search.md", "Search", "Search").unwrap();
fs::write(
dir.path().join("docs/guide/hidden.md"),
"---\ntitle: Hidden\nsidebar: false\n---\n# Hidden\n",
)
.unwrap();
write_doc(dir.path(), "docs/index.md", "Home", "Home").unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
let guide_html = fs::read_to_string(dir.path().join("dist/guide/cli/index.html")).unwrap();
let features_html =
fs::read_to_string(dir.path().join("dist/features/search/index.html")).unwrap();
let home_html = fs::read_to_string(dir.path().join("dist/index.html")).unwrap();
assert!(dir.path().join("dist/guide/hidden/index.html").exists());
assert!(guide_html.contains("rp-topnav-menu-link"));
assert!(guide_html.contains(
r#"<a class="rp-topnav-menu-link" href="/guide/quick-start/">Quick Start</a>"#
));
assert!(guide_html.contains(
r#"<a class="rp-nav-link rp-nav-level-1" href="/guide/configuration/">Configuration</a>"#
));
assert!(guide_html.contains(
r#"<a class="rp-nav-link rp-nav-level-1" href="/guide/quick-start/">Quick Start</a>"#
));
assert!(guide_html.contains(
r#"<a class="rp-nav-link rp-nav-level-1" href="/guide/advanced/deep/">Advanced</a>"#
));
assert!(!guide_html
.contains(r#"<a class="rp-nav-link rp-nav-level-1" href="/guide/hidden/">Hidden</a>"#));
assert!(!guide_html.contains(
r#"<a class="rp-nav-link rp-nav-level-1" href="/features/search/">Search</a>"#
));
assert!(features_html.contains(
r#"<a class="rp-nav-link rp-nav-level-1 is-active" href="/features/search/">Search</a>"#
));
assert!(!features_html
.contains(r#"<a class="rp-nav-link rp-nav-level-1" href="/guide/cli/">Guide CLI</a>"#));
assert!(!home_html.contains("rp-nav-link"));
}
#[test]
fn routes_markdown_pages_without_double_slashes() {
let src = Path::new("/site/docs");
assert_eq!(
route_for(src, Path::new("/site/docs/guide.md")).unwrap(),
"/guide/"
);
assert_eq!(
route_for(src, Path::new("/site/docs/guide/index.md")).unwrap(),
"/guide/"
);
assert_eq!(
route_for(src, Path::new("/site/docs/index.md")).unwrap(),
"/"
);
}
#[test]
fn localized_routes_use_locale_prefixes() {
let mut config = localized_config();
config.normalize().unwrap();
let src = Path::new("/site/docs");
assert_eq!(
page_metadata_for(src, Path::new("/site/docs/index.md"), &config)
.unwrap()
.route,
"/"
);
assert_eq!(
page_metadata_for(src, Path::new("/site/docs/guide.md"), &config)
.unwrap()
.route,
"/guide/"
);
let en_home = page_metadata_for(src, Path::new("/site/docs/index.en.md"), &config).unwrap();
assert_eq!(en_home.route, "/en/");
assert_eq!(en_home.locale_key, "en");
assert_eq!(en_home.translation_key, "/");
let en_guide =
page_metadata_for(src, Path::new("/site/docs/guide.en.md"), &config).unwrap();
assert_eq!(en_guide.route, "/en/guide/");
assert_eq!(en_guide.locale_key, "en");
assert_eq!(en_guide.translation_key, "/guide/");
let en_cli =
page_metadata_for(src, Path::new("/site/docs/guide/cli.en.md"), &config).unwrap();
assert_eq!(en_cli.route, "/en/guide/cli/");
assert_eq!(en_cli.locale_key, "en");
assert_eq!(en_cli.translation_key, "/guide/cli/");
}
#[test]
fn builds_multilingual_pages_and_language_switcher() {
let dir = tempfile::tempdir().unwrap();
write_multilingual_config(dir.path()).unwrap();
write_doc(dir.path(), "docs/index.md", "Root Home", "Root Home").unwrap();
write_doc(dir.path(), "docs/guide.md", "Root Guide", "Root Guide").unwrap();
write_doc(dir.path(), "docs/guide/cli.md", "Root CLI", "Root CLI").unwrap();
write_doc(dir.path(), "docs/root-only.md", "Root Only", "Root Only").unwrap();
write_doc(
dir.path(),
"docs/index.en.md",
"English Home",
"English Home",
)
.unwrap();
write_doc(
dir.path(),
"docs/guide.en.md",
"English Guide",
"English Guide",
)
.unwrap();
write_doc(
dir.path(),
"docs/guide/cli.en.md",
"English CLI",
"English CLI",
)
.unwrap();
let result = build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
assert_eq!(result.page_count, 7);
assert!(dir.path().join("dist/index.html").exists());
assert!(dir.path().join("dist/en/index.html").exists());
assert!(dir.path().join("dist/en/guide/index.html").exists());
assert!(dir.path().join("dist/en/guide/cli/index.html").exists());
let root_guide = fs::read_to_string(dir.path().join("dist/guide/index.html")).unwrap();
let en_guide = fs::read_to_string(dir.path().join("dist/en/guide/index.html")).unwrap();
let root_cli = fs::read_to_string(dir.path().join("dist/guide/cli/index.html")).unwrap();
assert!(root_guide.contains(r#"<html lang="zh-CN""#));
assert!(root_guide.contains("data-rp-language-select"));
assert!(root_guide.contains(r#"data-rp-language-href="/guide/">简体中文</button>"#));
assert!(root_guide.contains(r#"data-rp-language-href="/en/guide/">English</button>"#));
assert!(en_guide.contains(r#"<html lang="en-US""#));
assert!(en_guide.contains(r#"data-rp-language-href="/guide/">简体中文</button>"#));
assert!(en_guide.contains(r#"data-rp-language-href="/en/guide/">English</button>"#));
assert!(root_cli.contains("rp-nav-group"));
assert!(root_cli.contains("rp-nav-group-title"));
assert!(root_cli.contains("Root Guide"));
assert!(root_cli.contains("Root CLI"));
assert!(!en_guide.contains("Root Only"));
}
#[test]
fn language_switcher_falls_back_to_locale_home_when_translation_is_missing() {
let dir = tempfile::tempdir().unwrap();
write_multilingual_config(dir.path()).unwrap();
write_doc(dir.path(), "docs/index.md", "Root Home", "Root Home").unwrap();
write_doc(dir.path(), "docs/guide.md", "Root Guide", "Root Guide").unwrap();
write_doc(
dir.path(),
"docs/index.en.md",
"English Home",
"English Home",
)
.unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
let root_guide = fs::read_to_string(dir.path().join("dist/guide/index.html")).unwrap();
assert!(root_guide.contains(r#"data-rp-language-href="/en/">English</button>"#));
}
#[test]
fn search_false_pages_are_excluded_from_index() {
let dir = tempfile::tempdir().unwrap();
init_project(dir.path()).unwrap();
fs::write(
dir.path().join("docs/hidden.md"),
"---\ntitle: Hidden\nsearch: false\n---\n# Hidden\nUniqueSecret",
)
.unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
let index = fs::read_to_string(dir.path().join("dist/assets/search-index.json")).unwrap();
assert!(!index.contains("UniqueSecret"));
assert!(!index.contains("\"Hidden\""));
}
#[test]
fn rendered_pages_include_copyable_markdown_source() {
let dir = tempfile::tempdir().unwrap();
init_project(dir.path()).unwrap();
fs::write(
dir.path().join("docs/agent.md"),
"---\ntitle: Agent Copy\naccess: public\n---\n# Agent Copy\n\nUse <agent> context.\n",
)
.unwrap();
build_site(BuildOptions::new(dir.path().join("rustpress.toml"))).unwrap();
let html = fs::read_to_string(dir.path().join("dist/agent/index.html")).unwrap();
let markdown = fs::read_to_string(dir.path().join("dist/agent/index.md.txt")).unwrap();
let source = fs::read_to_string(dir.path().join("docs/agent.md")).unwrap();
assert!(html.contains("data-rp-copy-markdown"));
assert!(html.contains("data-rp-copy-markdown-url"));
assert!(html.contains("data-rp-markdown-source"));
assert!(html.contains(r#"data-rp-markdown-source-url="/agent/index.md.txt""#));
assert!(html.contains("---\ntitle: Agent Copy\naccess: public\n---"));
assert!(html.contains("Use <agent> context."));
assert_eq!(markdown, source);
}
fn localized_config() -> Config {
let mut locales = BTreeMap::new();
locales.insert(
"root".to_string(),
LocaleSection {
label: " 简体中文 ".to_string(),
lang: " zh-CN ".to_string(),
..LocaleSection::default()
},
);
locales.insert(
"en".to_string(),
LocaleSection {
label: "English".to_string(),
lang: "en-US".to_string(),
..LocaleSection::default()
},
);
Config {
top_nav: vec![
TopNavSection {
text: "Guide".to_string(),
link: Some("guide/cli/".to_string()),
items: vec![TopNavLinkSection {
text: "CLI".to_string(),
link: "guide/cli/".to_string(),
}],
},
TopNavSection {
text: "External".to_string(),
link: Some("https://example.com".to_string()),
items: Vec::new(),
},
],
locales,
..Config::default()
}
}
fn write_multilingual_config(root: &Path) -> Result<()> {
fs::write(
root.join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/"
[[top_nav]]
text = "Root Guide"
link = "/guide/"
[locales.root]
label = "简体中文"
lang = "zh-CN"
title = "中文文档"
[locales.en]
label = "English"
lang = "en-US"
link = "/en/"
title = "English Docs"
"#,
)?;
Ok(())
}
fn write_base_prefixed_project(root: &Path) -> Result<()> {
fs::write(
root.join("rustpress.toml"),
r#"title = "Docs"
src_dir = "docs"
out_dir = "dist"
base = "/action-gateway/"
[[top_nav]]
text = "Guide"
link = "/guide/"
[search]
enabled = true
"#,
)?;
write_doc(root, "docs/index.md", "Home", "Home")?;
write_doc(root, "docs/guide.md", "Guide", "Guide")?;
Ok(())
}
fn write_doc(root: &Path, relative: &str, title: &str, body: &str) -> Result<()> {
let path = root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(
path,
format!(
"---\ntitle: {title}\nlayout: doc\nsidebar: true\nsearch: true\naccess: public\n---\n\n# {body}\n"
),
)?;
Ok(())
}
}