use std::path::Path;
#[cfg(feature = "gui")]
use std::path::PathBuf;
use clap::Parser;
#[cfg(feature = "gui")]
use mbr::browser::{self, BrowserContext};
use mbr::{
Config, ConfigError, MbrError, build::Builder, cli, link_transform::LinkTransformConfig,
markdown, server, templates,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(feature = "gui")]
fn needs_folder_picker(path: &Path) -> bool {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
#[cfg(unix)]
{
canonical.components().count() <= 1
}
#[cfg(windows)]
{
canonical.parent().is_none()
|| canonical.starts_with(r"C:\Windows")
|| canonical.starts_with(r"C:\Program Files")
|| canonical.starts_with(r"C:\Program Files (x86)")
}
}
#[cfg(feature = "gui")]
fn show_folder_picker() -> Option<PathBuf> {
rfd::FileDialog::new()
.set_title("Select Markdown Folder")
.pick_folder()
}
#[tokio::main]
async fn main() -> Result<(), MbrError> {
#[cfg(feature = "media-metadata")]
ffmpeg_next::log::set_level(ffmpeg_next::log::Level::Fatal);
let args = cli::Args::parse();
let log_filter = args.log_level_filter();
let _ = tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| log_filter.into()),
)
.with(tracing_subscriber::fmt::layer())
.try_init();
#[cfg(all(feature = "gui", feature = "media-metadata"))]
let is_gui_mode = !args.server
&& !args.stdout
&& !args.build
&& !args.extract_video_metadata
&& !args.extract_pdf_cover;
#[cfg(all(feature = "gui", not(feature = "media-metadata")))]
let is_gui_mode = !args.server && !args.stdout && !args.build;
#[cfg(not(feature = "gui"))]
let _is_gui_mode = false;
#[cfg(feature = "gui")]
let input_path = if is_gui_mode && needs_folder_picker(&args.path) {
match show_folder_picker() {
Some(path) => path,
None => {
std::process::exit(0);
}
}
} else {
args.path.clone()
};
#[cfg(not(feature = "gui"))]
let input_path = args.path.clone();
let input_path_ref = Path::new(&input_path);
let absolute_path =
input_path_ref
.canonicalize()
.map_err(|e| ConfigError::CanonicalizeFailed {
path: input_path_ref.to_path_buf(),
source: e,
})?;
let is_directory = absolute_path.is_dir();
let mut config = Config::read(&absolute_path)?;
if let Some(timeout) = args.oembed_timeout_ms {
config.oembed_timeout_ms = timeout;
}
if let Some(cache_size) = args.oembed_cache_size {
config.oembed_cache_size = cache_size;
}
if let Some(ref template_folder) = args.template_folder {
let template_path =
template_folder
.canonicalize()
.map_err(|e| ConfigError::CanonicalizeFailed {
path: template_folder.clone(),
source: e,
})?;
if !template_path.is_dir() {
return Err(ConfigError::TemplateFolderNotDirectory {
path: template_path,
}
.into());
}
config.template_folder = Some(template_path);
}
if let Some(port) = args.port {
config.port = port;
}
if let Some(ref host) = args.host {
let ip: std::net::IpAddr = host
.parse()
.map_err(|_| ConfigError::InvalidHost { host: host.clone() })?;
match ip {
std::net::IpAddr::V4(v4) => {
config.host = mbr::config::IpArray(v4.octets());
}
std::net::IpAddr::V6(_) => {
return Err(ConfigError::InvalidHost { host: host.clone() }.into());
}
}
}
if let Some(ref theme) = args.theme {
config.theme = theme.clone();
}
if let Some(concurrency) = args.build_concurrency {
config.build_concurrency = Some(concurrency);
}
#[cfg(feature = "media-metadata")]
if args.transcode {
config.transcode = true;
}
if args.skip_link_checks {
config.skip_link_checks = true;
}
if args.no_link_tracking {
config.link_tracking = false;
}
if args.mark_incomplete {
config.mark_incomplete = Some(true);
} else if args.no_mark_incomplete {
config.mark_incomplete = Some(false);
}
if let Some(ref prefix) = args.title_prefix {
config.title_prefix = prefix.clone();
}
if let Some(ref suffix) = args.title_suffix {
config.title_suffix = suffix.clone();
}
let path_relative_to_root =
pathdiff::diff_paths(&absolute_path, &config.root_dir).ok_or_else(|| {
ConfigError::RelativePathFailed {
from: config.root_dir.clone(),
to: absolute_path.clone(),
}
})?;
tracing::info!(
"Root dir: {}; File relative to root: {}",
&config.root_dir.display(),
&path_relative_to_root.display()
);
#[cfg(feature = "media-metadata")]
if args.extract_video_metadata {
if is_directory {
eprintln!("Error: --extract-video-metadata requires a video file, not a directory.");
eprintln!("Usage: mbr --extract-video-metadata /path/to/video.mp4");
std::process::exit(1);
}
mbr::video_metadata::extract_and_save(&absolute_path)?;
return Ok(());
}
#[cfg(feature = "media-metadata")]
if args.extract_pdf_cover {
use mbr::pdf_metadata::{extract_pdf_covers_recursive, save_cover};
if is_directory {
let result = extract_pdf_covers_recursive(&absolute_path, |pdf_path, sidecar_path| {
if let Some(sidecar) = sidecar_path {
println!(
"Extracting cover: {} -> {}",
pdf_path.display(),
sidecar.display()
);
}
});
for (path, error) in &result.failures {
eprintln!("Error: {} - {}", path.display(), error);
}
if result.failure_count > 0 && result.success_count > 0 {
eprintln!(
"\u{26a0} {} PDFs failed, {} succeeded",
result.failure_count, result.success_count
);
std::process::exit(1); } else if result.failure_count > 0 && result.success_count == 0 {
eprintln!(
"\u{26a0} {} PDFs failed, none succeeded",
result.failure_count
);
std::process::exit(2); } else if result.success_count > 0 {
println!("\u{2713} Created {} cover images", result.success_count);
std::process::exit(0); } else {
println!("No PDF files found in directory.");
std::process::exit(0);
}
} else {
let extension = absolute_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
if extension.as_deref() != Some("pdf") {
eprintln!(
"Error: {} is not a PDF file (expected .pdf extension)",
absolute_path.display()
);
std::process::exit(2);
}
match save_cover(&absolute_path) {
Ok(sidecar_path) => {
println!(
"Extracting cover: {} -> {}",
absolute_path.display(),
sidecar_path.display()
);
println!("\u{2713} Created 1 cover image");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error: {} - {}", absolute_path.display(), e);
std::process::exit(2);
}
}
}
}
if args.build {
if args.oembed_timeout_ms.is_none() {
config.oembed_timeout_ms = 0;
}
#[cfg(target_os = "windows")]
{
eprintln!("Error: Static site generation is not supported on Windows");
std::process::exit(1);
}
#[cfg(not(target_os = "windows"))]
{
let output_dir = if args.output.is_absolute() {
args.output.clone()
} else {
std::env::current_dir()
.map_err(ConfigError::CurrentDirFailed)?
.join(&args.output)
};
tracing::info!("Building static site to: {}", output_dir.display());
let builder = Builder::new(config, output_dir)?;
let stats = builder.build().await?;
if stats.broken_links > 0 {
println!(
"Build complete: {} markdown pages, {} section pages, {} assets linked, {} broken links in {:?}",
stats.markdown_pages,
stats.section_pages,
stats.assets_linked,
stats.broken_links,
stats.duration
);
} else {
println!(
"Build complete: {} markdown pages, {} section pages, {} assets linked in {:?}",
stats.markdown_pages, stats.section_pages, stats.assets_linked, stats.duration
);
}
return Ok(());
}
} else if args.stdout {
if is_directory {
eprintln!(
"Cannot render a directory to stdout. Use -s to start a server or omit -o for GUI mode."
);
eprintln!(" mbr -s {} # Start server", input_path.display());
eprintln!(" mbr {} # Open in GUI (default)", input_path.display());
std::process::exit(1);
}
let is_index_file = input_path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == config.index_file);
let link_transform_config = LinkTransformConfig {
markdown_extensions: config.markdown_extensions.clone(),
index_file: config.index_file.clone(),
is_index_file,
url_depth: None,
};
let valid_tag_sources = mbr::config::tag_sources_to_set(&config.tag_sources);
let mark_incomplete = config.mark_incomplete.unwrap_or(false);
let render_result = markdown::render(
input_path,
config.root_dir.as_path(),
config.oembed_timeout_ms,
link_transform_config,
false, false, valid_tag_sources,
mark_incomplete,
&config.incomplete_markers,
)
.await
.inspect_err(|e| tracing::error!("Error rendering markdown: {:?}", e))?;
let templates =
templates::Templates::new(&config.root_dir, config.template_folder.as_deref())
.inspect_err(|e| tracing::error!("Error parsing template: {e}"))?;
let html_output = templates.render_markdown(
&render_result.html,
render_result.frontmatter,
std::collections::HashMap::new(),
)?;
println!("{}", &html_output);
} else if args.server {
let server_config = server::ServerConfig::from(&config).with_gui_mode(false);
let server = server::Server::init(server_config)?;
let url_path = build_url_path(
&path_relative_to_root,
is_directory,
&config.markdown_extensions,
);
tracing::info!(
"Server running at http://{}:{}/{}",
config.host,
config.port,
url_path
);
server.start().await?;
} else {
#[cfg(feature = "gui")]
{
let config_copy = config.clone();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<u16>();
let handle = tokio::spawn(async move {
let server_config = server::ServerConfig::from(&config_copy).with_gui_mode(true);
let server = server::Server::init(server_config);
match server {
Ok(mut s) => {
if let Err(e) = s.start_with_port_retry(Some(ready_tx), 10).await {
tracing::error!("Server error: {e}");
}
}
Err(e) => {
tracing::error!(
"Couldn't initialize the server: {e}. Try with -s for more info"
);
drop(ready_tx);
}
}
});
let actual_port = match ready_rx.await {
Ok(port) => port,
Err(_) => {
tracing::error!("Server failed to start");
return Ok(());
}
};
let base_url =
url::Url::parse(format!("http://{}:{}/", config.host, actual_port).as_str())?;
let url = if !is_directory {
if let Some(media_type) = server::MediaViewerType::from_path(&path_relative_to_root)
{
let file_url_path =
build_url_path(&path_relative_to_root, false, &config.markdown_extensions);
let viewer_url = build_media_viewer_url(media_type, &file_url_path);
base_url.join(&viewer_url)?
} else {
let url_path = build_url_path(
&path_relative_to_root,
is_directory,
&config.markdown_extensions,
);
base_url.join(&url_path)?
}
} else {
let url_path = build_url_path(
&path_relative_to_root,
is_directory,
&config.markdown_extensions,
);
base_url.join(&url_path)?
};
let ctx = BrowserContext {
url: url.to_string(),
server_handle: handle,
config,
tokio_runtime: tokio::runtime::Handle::current(),
};
browser::launch_browser(ctx)?;
}
#[cfg(not(feature = "gui"))]
{
tracing::error!(
"GUI mode is not available in this build. Use -s for server mode or --stdout for stdout mode."
);
std::process::exit(1);
}
}
Ok(())
}
pub fn build_url_path(
relative_path: &std::path::Path,
is_directory: bool,
markdown_extensions: &[String],
) -> String {
let relative_str = relative_path.to_str().unwrap_or_default();
if is_directory {
if relative_str.is_empty() {
String::new()
} else {
format!("{}/", relative_str)
}
} else {
replace_markdown_extension_with_slash(relative_str, markdown_extensions)
}
}
fn replace_markdown_extension_with_slash(s: &str, extensions: &[String]) -> String {
if let Some((base, extension)) = s.rsplit_once('.') {
match extensions
.iter()
.find(|cur_ext| extension == cur_ext.as_str())
{
Some(_) => format!("{}/", base), None => s.to_string(), }
} else {
s.to_string() }
}
fn build_media_viewer_url(media_type: server::MediaViewerType, file_url_path: &str) -> String {
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
const QUERY_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'+')
.add(b'=')
.add(b'?');
let full_path = if file_url_path.starts_with('/') {
file_url_path.to_string()
} else {
format!("/{file_url_path}")
};
let encoded_path = utf8_percent_encode(&full_path, QUERY_ENCODE_SET).to_string();
format!("{}?path={}", media_type.route_path(), encoded_path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_build_url_path_root_directory() {
let path = Path::new("");
let extensions = vec!["md".to_string()];
assert_eq!(build_url_path(path, true, &extensions), "");
}
#[test]
fn test_build_url_path_subdirectory() {
let path = Path::new("docs/api");
let extensions = vec!["md".to_string()];
assert_eq!(build_url_path(path, true, &extensions), "docs/api/");
}
#[test]
fn test_build_url_path_markdown_file() {
let path = Path::new("readme.md");
let extensions = vec!["md".to_string()];
assert_eq!(build_url_path(path, false, &extensions), "readme/");
}
#[test]
fn test_build_url_path_markdown_file_in_subdir() {
let path = Path::new("docs/guide.md");
let extensions = vec!["md".to_string()];
assert_eq!(build_url_path(path, false, &extensions), "docs/guide/");
}
#[test]
fn test_build_url_path_alternate_extension() {
let path = Path::new("notes.markdown");
let extensions = vec!["md".to_string(), "markdown".to_string()];
assert_eq!(build_url_path(path, false, &extensions), "notes/");
}
#[test]
fn test_build_url_path_non_markdown_file() {
let path = Path::new("image.png");
let extensions = vec!["md".to_string()];
assert_eq!(build_url_path(path, false, &extensions), "image.png");
}
#[test]
fn test_replace_markdown_extension_with_slash() {
let extensions = ["md".to_string()];
assert_eq!(
replace_markdown_extension_with_slash("test.md", &extensions),
"test/"
);
assert_eq!(
replace_markdown_extension_with_slash("test.txt", &extensions),
"test.txt"
);
assert_eq!(
replace_markdown_extension_with_slash("noext", &extensions),
"noext"
);
}
#[test]
fn test_build_media_viewer_url_video() {
let url = build_media_viewer_url(server::MediaViewerType::Video, "videos/example.mp4");
assert_eq!(url, "/.mbr/videos/?path=/videos/example.mp4");
}
#[test]
fn test_build_media_viewer_url_audio() {
let url = build_media_viewer_url(server::MediaViewerType::Audio, "music/song.mp3");
assert_eq!(url, "/.mbr/audio/?path=/music/song.mp3");
}
#[test]
fn test_build_media_viewer_url_image() {
let url = build_media_viewer_url(server::MediaViewerType::Image, "images/photo.jpg");
assert_eq!(url, "/.mbr/images/?path=/images/photo.jpg");
}
#[test]
fn test_build_media_viewer_url_pdf() {
let url = build_media_viewer_url(server::MediaViewerType::Pdf, "docs/paper.pdf");
assert_eq!(url, "/.mbr/pdfs/?path=/docs/paper.pdf");
}
#[test]
fn test_build_media_viewer_url_with_leading_slash() {
let url = build_media_viewer_url(server::MediaViewerType::Video, "/videos/example.mp4");
assert_eq!(url, "/.mbr/videos/?path=/videos/example.mp4");
}
#[test]
fn test_build_media_viewer_url_encodes_spaces() {
let url = build_media_viewer_url(server::MediaViewerType::Video, "videos/my video.mp4");
assert!(url.contains("path=/videos/my%20video.mp4"));
}
#[test]
fn test_build_media_viewer_url_encodes_special_chars() {
let url = build_media_viewer_url(server::MediaViewerType::Video, "videos/file#1&2=3.mp4");
assert!(url.contains("path=/videos/file%231%262%3D3.mp4"));
}
#[test]
#[cfg(feature = "gui")]
fn test_needs_folder_picker_root() {
assert!(needs_folder_picker(Path::new("/")));
}
#[test]
#[cfg(feature = "gui")]
fn test_needs_folder_picker_normal_path() {
assert!(!needs_folder_picker(Path::new("/Users/foo")));
}
#[test]
#[cfg(feature = "gui")]
fn test_needs_folder_picker_current_dir() {
let cwd = std::env::current_dir().unwrap();
if cwd.components().count() > 1 {
assert!(!needs_folder_picker(&cwd));
}
}
}