From 8ed58fe9de45e836a09c384e6ea43a7f3cbacd51 Mon Sep 17 00:00:00 2001 From: Warren Lo Date: Wed, 18 Mar 2026 21:32:45 +0800 Subject: [PATCH] Update main.rs with Mermaid support --- src/main.rs | 507 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 src/main.rs diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..65e5929 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,507 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use pulldown_cmark::{html, Options, Parser as MarkdownParser}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; + +#[derive(Parser)] +#[command(name = "md_reader")] +#[command(about = "A simple Markdown reader", version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Render { + #[arg(help = "Markdown file to render")] + file: String, + #[arg(short, long, help = "Output file path")] + output: Option, + }, + Batch { + #[arg(help = "Directory containing markdown files")] + directory: String, + #[arg(short, long, help = "Output directory")] + output: Option, + }, + Server { + #[arg(short, long, default_value = "8080")] + port: u16, + #[arg(short, long, help = "Directory to serve")] + path: Option, + }, + Preview { + #[arg(help = "Markdown file to preview")] + file: String, + #[arg(short, long, default_value = "Preview")] + title: Option, + #[arg(short = 'w', long, default_value = "900")] + width: u32, + #[arg(short = 'e', long, default_value = "700")] + height: u32, + }, +} + +fn render_markdown(content: &str) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + + let parser = MarkdownParser::new_ext(content, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + let html_output = process_mermaid_blocks(&html_output); + html_output +} + +fn process_mermaid_blocks(html: &str) -> String { + let mut result = String::new(); + let mut code_content = Vec::new(); + let mut skip_until_close = false; + + for line in html.lines() { + if line.contains("
 = line.split("
').skip(1).collect::>().join(">");
+                if !after_lang.is_empty() && !after_lang.contains("
") {
+                    code_content.push(after_lang.trim_end_matches("").to_string());
+                }
+            }
+            continue;
+        }
+
+        if skip_until_close {
+            if line.contains("") {
+                let clean = line
+                    .replace("", "")
+                    .replace("
", "") + .trim_end() + .to_string(); + if !clean.is_empty() { + code_content.push(clean); + } + skip_until_close = false; + result.push_str(&format!( + "
{}
", + code_content.join("\n").trim() + )); + code_content.clear(); + } else { + code_content.push(line.to_string()); + } + continue; + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +fn wrap_in_html(title: &str, content: &str) -> String { + format!( + r#" + + + + + {} + + + + +{} + + +"#, + title, content + ) +} + +fn read_file(path: &Path) -> Result { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read file: {}", path.display())) +} + +fn process_file(file_path: &str, output: Option) -> Result<()> { + let path = Path::new(file_path); + let content = read_file(path)?; + + let html = render_markdown(&content); + let title = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Markdown"); + let full_html = wrap_in_html(title, &html); + + let out_path = if let Some(o) = output { + o + } else { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let out_dir = home.join("docs/html"); + std::fs::create_dir_all(&out_dir)?; + out_dir + .join(format!("{}.html", title)) + .to_string_lossy() + .to_string() + }; + + std::fs::write(&out_path, &full_html) + .with_context(|| format!("Failed to write to {}", out_path))?; + println!("Saved to {}", out_path); + Ok(()) +} + +fn batch_process(input_dir: &str, output_dir: Option) -> Result<()> { + let out_dir = if let Some(o) = output_dir { + PathBuf::from(o) + } else { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join("docs/html") + }; + + std::fs::create_dir_all(&out_dir)?; + + let dir_path = Path::new(input_dir); + let mut count = 0; + + for entry in std::fs::read_dir(dir_path)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("md") { + let content = read_file(&path)?; + let html = render_markdown(&content); + let title = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Markdown"); + let full_html = wrap_in_html(title, &html); + + let out_path = out_dir.join(format!("{}.html", title)); + std::fs::write(&out_path, &full_html)?; + println!("Saved: {}", out_path.display()); + count += 1; + } + } + + println!("\nConverted {} files to {}", count, out_dir.display()); + Ok(()) +} + +fn start_server(port: u16, root_path: Option) -> Result<()> { + let addr = format!("0.0.0.0:{}", port); + let listener = TcpListener::bind(&addr)?; + + println!("Server running at http://localhost:{}/", port); + println!("Press Ctrl+C to stop"); + + let root = root_path.unwrap_or_else(|| ".".to_string()); + + for stream in listener.incoming() { + let mut stream = stream?; + let mut buffer = [0; 8192]; + let bytes_read = stream.read(&mut buffer)?; + + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let lines: Vec<&str> = request.lines().collect(); + + if let Some(request_line) = lines.first() { + let parts: Vec<&str> = request_line.split_whitespace().collect(); + if parts.len() >= 2 { + let path_info = if parts[1] == "/" { + "/index.html" + } else { + parts[1] + }; + + let file_path = Path::new(&root).join(path_info.trim_start_matches('/')); + let response = handle_request(&file_path); + + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + } + } + + Ok(()) +} + +fn handle_request(path: &Path) -> String { + if path.is_file() { + match read_file(path) { + Ok(content) => { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("text/plain"); + + if ext == "md" { + let html = render_markdown(&content); + return make_response("200 OK", "text/html", &wrap_in_html("Markdown", &html)); + } + + let ct = match ext { + "html" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + "json" => "application/json", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "gif" => "image/gif", + _ => "text/plain", + }; + make_response("200 OK", ct, &content) + } + Err(_) => make_response("404 Not Found", "text/plain", "File not found"), + } + } else if path.is_dir() { + let index_path = path.join("index.html"); + if index_path.exists() { + if let Ok(content) = read_file(&index_path) { + return make_response("200 OK", "text/html", &content); + } + } + make_response("200 OK", "text/html", &list_directory(path)) + } else { + make_response("404 Not Found", "text/plain", "File not found") + } +} + +fn make_response(status: &str, content_type: &str, body: &str) -> String { + format!( + "HTTP/1.1 {}\r\n\ + Content-Type: {}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + status, + content_type, + body.len(), + body + ) +} + +fn list_directory(path: &Path) -> String { + let mut files = Vec::new(); + files.push(format!("

Index of {}

", path.display())); + files.push("
    ".to_string()); + + if let Ok(entries) = std::fs::read_dir(path) { + let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|a| a.file_name()); + + for entry in entries { + let name = entry.file_name().to_string_lossy().to_string(); + let display_name = if entry.path().is_dir() { + format!("{}/", name) + } else { + name.clone() + }; + files.push(format!( + "
  • {}
  • ", + name, display_name + )); + } + } + + files.push("
".to_string()); + files.join("\n") +} + +fn preview_markdown(file_path: &str, title: &str, _width: u32, _height: u32) -> Result<()> { + let path = Path::new(file_path); + let content = read_file(path)?; + let html = render_markdown(&content); + let full_html = wrap_in_html(title, &html); + + let temp_dir = std::env::temp_dir(); + let safe_title: String = title + .replace(' ', "_") + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '.') + .collect(); + let temp_file = temp_dir.join(format!("{}.html", safe_title)); + println!("Temp file: {}", temp_file.display()); + + std::fs::write(&temp_file, &full_html) + .with_context(|| format!("Failed to write temp file: {}", temp_file.display()))?; + + open::that(&temp_file).with_context(|| format!("Failed to open file in browser"))?; + + println!("Opening preview in browser... (close browser window to exit)"); + Ok(()) +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Render { file, output } => { + if let Err(e) = process_file(&file, output) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Commands::Batch { directory, output } => { + if let Err(e) = batch_process(&directory, output) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + Commands::Server { port, path } => { + if let Err(e) = start_server(port, path) { + eprintln!("Server error: {}", e); + std::process::exit(1); + } + } + Commands::Preview { + file, + title, + width, + height, + } => { + let window_title = title.unwrap_or_else(|| { + Path::new(&file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Preview") + .to_string() + }); + if let Err(e) = preview_markdown(&file, &window_title, width, height) { + eprintln!("Preview error: {}", e); + std::process::exit(1); + } + } + } +}