use clap::{Parser, ValueEnum};
use mdx::{render_file_to_file, Config, Error};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
input: String,
output: Option<String>,
#[arg(short, long, value_enum, default_value_t = ThemeArg::Modern)]
theme: ThemeArg,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
components: Option<PathBuf>,
#[arg(long)]
watch: bool,
#[arg(long)]
serve: Option<Option<u16>>,
#[arg(long)]
minify: bool,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ThemeArg {
Modern,
Minimal,
Dark,
Light,
}
fn main() -> Result<(), Error> {
let cli = Cli::parse();
let mut config = match &cli.config {
Some(path) => Config::from_file(path)?,
None => Config::default(),
};
config.theme.name = match cli.theme {
ThemeArg::Modern => "modern".to_string(),
ThemeArg::Minimal => "minimal".to_string(),
ThemeArg::Dark => "dark".to_string(),
ThemeArg::Light => "light".to_string(),
};
config.renderer.minify = cli.minify;
if let Some(components_dir) = &cli.components {
config
.components
.push(components_dir.to_string_lossy().to_string());
}
let input_path = PathBuf::from(&cli.input);
if input_path.is_dir() {
let output_path = match &cli.output {
Some(output) => PathBuf::from(output),
None => input_path.clone(),
};
if !output_path.exists() {
fs::create_dir_all(&output_path)?;
}
process_directory(&input_path, &output_path, &config)?;
if cli.watch {
watch_directory(&input_path, &output_path, &config)?;
}
} else {
let output_path = match &cli.output {
Some(output) => PathBuf::from(output),
None => {
let mut output = input_path.clone();
output.set_extension("html");
output
}
};
if let Some(parent) = output_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
render_file_to_file(
input_path.to_str().unwrap(),
output_path.to_str().unwrap(),
Some(config.clone()),
)?;
println!(
"Rendered {} to {}",
input_path.display(),
output_path.display()
);
if cli.watch {
watch_file(&input_path, &output_path, &config)?;
}
}
if let Some(port_option) = cli.serve {
let port = port_option.unwrap_or(3000);
start_server(cli.output.unwrap_or_else(|| cli.input.clone()), port)?;
}
Ok(())
}
fn process_directory(input_dir: &Path, output_dir: &Path, config: &Config) -> Result<(), Error> {
let entries = fs::read_dir(input_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let relative_path = path.strip_prefix(input_dir).unwrap();
let new_output_dir = output_dir.join(relative_path);
if !new_output_dir.exists() {
fs::create_dir_all(&new_output_dir)?;
}
process_directory(&path, &new_output_dir, config)?;
} else if is_markdown_file(&path) {
let relative_path = path.strip_prefix(input_dir).unwrap();
let mut output_path = output_dir.join(relative_path);
output_path.set_extension("html");
if let Some(parent) = output_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
render_file_to_file(
path.to_str().unwrap(),
output_path.to_str().unwrap(),
Some(config.clone()),
)?;
println!("Rendered {} to {}", path.display(), output_path.display());
}
}
Ok(())
}
fn is_markdown_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
ext == "md" || ext == "markdown"
} else {
false
}
}
fn watch_directory(input_dir: &Path, output_dir: &Path, config: &Config) -> Result<(), Error> {
#[cfg(feature = "server")]
{
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
println!("Watching directory {} for changes...", input_dir.display());
let (tx, rx) = mpsc::channel();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
watcher.watch(input_dir, RecursiveMode::Recursive)?;
while running.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_secs(1)) {
Ok(event) => {
if let Ok(event) = event {
for path in event.paths {
if path.is_file() && is_markdown_file(&path) {
let relative_path = path.strip_prefix(input_dir).unwrap();
let mut output_path = output_dir.join(relative_path);
output_path.set_extension("html");
if let Some(parent) = output_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
match render_file_to_file(
path.to_str().unwrap(),
output_path.to_str().unwrap(),
Some(config.clone()),
) {
Ok(_) => println!(
"Rendered {} to {}",
path.display(),
output_path.display()
),
Err(e) => {
eprintln!("Error rendering {}: {}", path.display(), e)
}
}
}
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
}
Err(e) => {
eprintln!("Watch error: {:?}", e);
break;
}
}
}
}
#[cfg(not(feature = "server"))]
{
eprintln!("Watch feature is not enabled. Please build with --features server");
}
Ok(())
}
fn watch_file(input_file: &Path, output_file: &Path, config: &Config) -> Result<(), Error> {
#[cfg(feature = "server")]
{
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
println!("Watching file {} for changes...", input_file.display());
let (tx, rx) = mpsc::channel();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
watcher.watch(input_file, RecursiveMode::Recursive)?;
while running.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_secs(1)) {
Ok(event) => {
if let Ok(event) = event {
for path in event.paths {
if path == *input_file {
match render_file_to_file(
input_file.to_str().unwrap(),
output_file.to_str().unwrap(),
Some(config.clone()),
) {
Ok(_) => println!(
"Rendered {} to {}",
input_file.display(),
output_file.display()
),
Err(e) => {
eprintln!("Error rendering {}: {}", input_file.display(), e)
}
}
}
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
}
Err(e) => {
eprintln!("Watch error: {:?}", e);
break;
}
}
}
}
#[cfg(not(feature = "server"))]
{
eprintln!("Watch feature is not enabled. Please build with --features server");
}
Ok(())
}
fn start_server(dir: String, port: u16) -> Result<(), Error> {
#[cfg(feature = "server")]
{
use std::thread;
use tiny_http::{Method, Response, Server, StatusCode};
let server_dir = PathBuf::from(&dir);
if !server_dir.exists() {
return Err(Error::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Directory not found: {}", dir),
)));
}
let server = Server::http(format!("0.0.0.0:{}", port))
.map_err(|e| Error::IoError(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
println!("Server started at http://localhost:{}", port);
println!("Press Ctrl+C to stop the server");
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
println!("Stopping server...");
})
.expect("Error setting Ctrl-C handler");
while running.load(Ordering::SeqCst) {
if let Ok(Some(request)) = server.try_recv() {
let method = request.method().clone();
let url = request.url().to_string();
if method != Method::Get {
let response = Response::from_string("Method not allowed")
.with_status_code(StatusCode(405));
let _ = request.respond(response);
continue;
}
let url_path = url.split('?').next().unwrap_or("");
let mut file_path = server_dir.clone();
if url_path == "/" {
file_path.push("index.html");
} else {
let decoded_path = url_decode(url_path);
let path = Path::new(&decoded_path);
let path_without_slash = path.strip_prefix("/").unwrap_or(path);
file_path.push(path_without_slash);
}
if file_path.is_dir() {
let index_path = file_path.join("index.html");
if index_path.exists() {
file_path = index_path;
} else {
let listing = generate_directory_listing(&file_path, url_path);
let response =
Response::from_string(&listing).with_header(tiny_http::Header {
field: "Content-Type".parse().unwrap(),
value: "text/html; charset=utf-8".parse().unwrap(),
});
let _ = request.respond(response);
continue;
}
}
if !file_path.exists() && file_path.extension().is_none() {
let html_path = file_path.with_extension("html");
if html_path.exists() {
file_path = html_path;
}
}
if file_path.exists() {
let content_type = get_content_type(&file_path);
match fs::read(&file_path) {
Ok(content) => {
let response =
Response::from_data(content).with_header(tiny_http::Header {
field: "Content-Type".parse().unwrap(),
value: content_type.parse().unwrap(),
});
let _ = request.respond(response);
}
Err(e) => {
eprintln!("Error reading file {}: {}", file_path.display(), e);
let response = Response::from_string(format!("Error: {}", e))
.with_status_code(StatusCode(500));
let _ = request.respond(response);
}
}
} else {
let response =
Response::from_string("404 Not Found").with_status_code(StatusCode(404));
let _ = request.respond(response);
}
} else {
thread::sleep(Duration::from_millis(100));
}
}
}
#[cfg(not(feature = "server"))]
{
eprintln!("Server feature is not enabled. Please build with --features server");
}
Ok(())
}
fn url_decode(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut i = 0;
let bytes = input.as_bytes();
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) {
result.push(((h << 4) | l) as char);
i += 3;
} else {
result.push('%');
i += 1;
}
} else if bytes[i] == b'+' {
result.push(' ');
i += 1;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
fn from_hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'A'..=b'F' => Some(c - b'A' + 10),
b'a'..=b'f' => Some(c - b'a' + 10),
_ => None,
}
}
fn get_content_type(path: &Path) -> &'static str {
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"json" => "application/json; charset=utf-8",
"pdf" => "application/pdf",
"xml" => "application/xml; charset=utf-8",
"md" | "markdown" => "text/markdown; charset=utf-8",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
}
}
fn generate_directory_listing(dir: &Path, url_path: &str) -> String {
let mut html = String::from(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Directory Listing</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: #333;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
h1 {
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.listing {
list-style: none;
padding: 0;
}
.listing li {
padding: 0.5rem;
border-bottom: 1px solid #f4f4f4;
}
.listing li:hover {
background-color: #f8f9fa;
}
.listing a {
display: block;
text-decoration: none;
color: #0366d6;
}
.listing a:hover {
text-decoration: underline;
}
.folder:before {
content: "📁 ";
}
.file:before {
content: "📄 ";
}
</style>
</head>
<body>
<h1>Directory Listing: "#,
);
html.push_str(url_path);
html.push_str("</h1>\n <ul class=\"listing\">\n");
if url_path != "/" {
let parent_path = Path::new(url_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("/");
html.push_str(&format!(
" <li><a href=\"{}\" class=\"folder\">..</a></li>\n",
parent_path
));
}
if let Ok(entries) = fs::read_dir(dir) {
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with('.') {
continue;
}
let mut url = format!(
"{}{}{}",
url_path,
if url_path.ends_with('/') { "" } else { "/" },
file_name
);
if path.is_dir() {
url.push('/');
dirs.push((url, file_name, true));
} else {
files.push((url, file_name, false));
}
}
dirs.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
files.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
for (url, name, _) in dirs {
html.push_str(&format!(
" <li><a href=\"{}\" class=\"folder\">{}</a></li>\n",
url, name
));
}
for (url, name, _) in files {
html.push_str(&format!(
" <li><a href=\"{}\" class=\"file\">{}</a></li>\n",
url, name
));
}
}
html.push_str(
r#" </ul>
<p><em>Generated by MDX</em></p>
</body>
</html>"#,
);
html
}