diff --git a/src/app.rs b/src/app.rs index 9b9ae2a..626b2e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -84,10 +84,16 @@ struct MarkdownState { tracked_files: HashMap, is_directory_mode: bool, change_tx: broadcast::Sender, + rtl: bool, } impl MarkdownState { - fn new(base_dir: PathBuf, file_paths: Vec, is_directory_mode: bool) -> Result { + fn new( + base_dir: PathBuf, + file_paths: Vec, + is_directory_mode: bool, + rtl: bool, + ) -> Result { let (change_tx, _) = broadcast::channel::(16); let mut tracked_files = HashMap::new(); @@ -114,6 +120,7 @@ impl MarkdownState { tracked_files, is_directory_mode, change_tx, + rtl, }) } @@ -129,16 +136,10 @@ impl MarkdownState { fn refresh_file(&mut self, filename: &str) -> Result<()> { if let Some(tracked) = self.tracked_files.get_mut(filename) { - let metadata = fs::metadata(&tracked.path)?; - let current_modified = metadata.modified()?; - - if current_modified > tracked.last_modified { - let content = fs::read_to_string(&tracked.path)?; - tracked.html = Self::markdown_to_html(&content)?; - tracked.last_modified = current_modified; - } + let content = fs::read_to_string(&tracked.path)?; + tracked.html = Self::markdown_to_html(&content)?; + tracked.last_modified = fs::metadata(&tracked.path)?.modified()?; } - Ok(()) } @@ -151,13 +152,14 @@ impl MarkdownState { let metadata = fs::metadata(&file_path)?; let content = fs::read_to_string(&file_path)?; + let html = Self::markdown_to_html(&content)?; self.tracked_files.insert( filename, TrackedFile { path: file_path, last_modified: metadata.modified()?, - html: Self::markdown_to_html(&content)?, + html, }, ); @@ -169,10 +171,10 @@ impl MarkdownState { options.compile.allow_dangerous_html = true; options.parse.constructs.frontmatter = true; - let html_body = markdown::to_html_with_options(content, &options) + let html = markdown::to_html_with_options(content, &options) .unwrap_or_else(|_| "Error parsing markdown".to_string()); - Ok(html_body) + Ok(html) } } @@ -272,6 +274,7 @@ fn new_router( base_dir: PathBuf, tracked_files: Vec, is_directory_mode: bool, + rtl: bool, ) -> Result { let base_dir = base_dir.canonicalize()?; @@ -279,6 +282,7 @@ fn new_router( base_dir.clone(), tracked_files, is_directory_mode, + rtl, )?)); let watcher_state = state.clone(); @@ -343,11 +347,12 @@ pub(crate) async fn serve_markdown( hostname: impl AsRef, port: u16, open: bool, + rtl: bool, ) -> Result<()> { let hostname = hostname.as_ref(); let first_file = tracked_files.first().cloned(); - let router = new_router(base_dir.clone(), tracked_files, is_directory_mode)?; + let router = new_router(base_dir.clone(), tracked_files, is_directory_mode, rtl)?; let (listener, actual_port) = bind_with_retry(hostname, port).await?; @@ -441,7 +446,7 @@ fn open_browser(url: &str) -> Result<()> { } async fn serve_html_root(State(state): State) -> impl IntoResponse { - let mut state = state.lock().await; + let state = state.lock().await; let filename = match state.get_sorted_filenames().into_iter().next() { Some(name) => name, @@ -453,8 +458,6 @@ async fn serve_html_root(State(state): State) -> impl IntoR } }; - let _ = state.refresh_file(&filename); - render_markdown(&state, &filename).await } @@ -463,14 +466,12 @@ async fn serve_file( State(state): State, ) -> axum::response::Response { if filename.ends_with(".md") || filename.ends_with(".markdown") { - let mut state = state.lock().await; + let state = state.lock().await; if !state.tracked_files.contains_key(&filename) { return (StatusCode::NOT_FOUND, Html("File not found".to_string())).into_response(); } - let _ = state.refresh_file(&filename); - let (status, html) = render_markdown(&state, &filename).await; (status, html).into_response() } else if is_image_file(&filename) { @@ -519,6 +520,7 @@ async fn render_markdown(state: &MarkdownState, current_file: &str) -> (StatusCo show_navigation => true, files => files, current_file => current_file, + is_rtl => state.rtl, }) { Ok(r) => r, Err(e) => { @@ -533,6 +535,7 @@ async fn render_markdown(state: &MarkdownState, current_file: &str) -> (StatusCo content => content, mermaid_enabled => has_mermaid, show_navigation => false, + is_rtl => state.rtl, }) { Ok(r) => r, Err(e) => { @@ -873,7 +876,11 @@ mod tests { "---\ntitle: Test Post\nauthor: Name\n---\n\n# Test Post\n"; const TOML_FRONTMATTER_CONTENT: &str = "+++\ntitle = \"Test Post\"\n+++\n\n# Test Post\n"; - fn create_test_server_impl(content: &str, use_http: bool) -> (TestServer, NamedTempFile) { + fn create_test_server_impl( + content: &str, + use_http: bool, + rtl: bool, + ) -> (TestServer, NamedTempFile) { let temp_file = Builder::new() .suffix(".md") .tempfile() @@ -892,7 +899,7 @@ mod tests { let tracked_files = vec![canonical_path]; let is_directory_mode = false; - let router = new_router(base_dir, tracked_files, is_directory_mode) + let router = new_router(base_dir, tracked_files, is_directory_mode, rtl) .expect("Failed to create router"); let server = if use_http { @@ -908,11 +915,11 @@ mod tests { } async fn create_test_server(content: &str) -> (TestServer, NamedTempFile) { - create_test_server_impl(content, false) + create_test_server_impl(content, false, false) } async fn create_test_server_with_http(content: &str) -> (TestServer, NamedTempFile) { - create_test_server_impl(content, true) + create_test_server_impl(content, true, false) } fn create_directory_server_impl(use_http: bool) -> (TestServer, TempDir) { @@ -929,7 +936,7 @@ mod tests { let tracked_files = scan_markdown_files(&base_dir).expect("Failed to scan markdown files"); let is_directory_mode = true; - let router = new_router(base_dir, tracked_files, is_directory_mode) + let router = new_router(base_dir, tracked_files, is_directory_mode, false) .expect("Failed to create router"); let server = if use_http { @@ -1064,7 +1071,7 @@ fn main() { let base_dir = temp_dir.path().to_path_buf(); let tracked_files = vec![md_path]; let is_directory_mode = false; - let router = new_router(base_dir, tracked_files, is_directory_mode) + let router = new_router(base_dir, tracked_files, is_directory_mode, false) .expect("Failed to create router"); let server = TestServer::new(router).expect("Failed to create test server"); @@ -1093,7 +1100,7 @@ fn main() { let base_dir = temp_dir.path().to_path_buf(); let tracked_files = vec![md_path]; let is_directory_mode = false; - let router = new_router(base_dir, tracked_files, is_directory_mode) + let router = new_router(base_dir, tracked_files, is_directory_mode, false) .expect("Failed to create router"); let server = TestServer::new(router).expect("Failed to create test server"); @@ -1710,4 +1717,102 @@ classDiagram "Should not serve old content" ); } + + #[tokio::test] + async fn test_rtl_flag_sets_dir_attribute() { + let (server, _temp_file) = create_test_server_impl("# Hello\n\nSome content.", false, true); + + let response = server.get("/").await; + let body = response.text(); + + assert!( + body.contains(r#"id="content" dir="rtl""#), + "RTL flag should set dir=\"rtl\" on content div" + ); + assert!( + body.contains("Arial Hebrew"), + "RTL flag should include RTL font stack" + ); + } + + #[tokio::test] + async fn test_no_rtl_flag_no_dir_attribute() { + let (server, _temp_file) = + create_test_server_impl("# Hello\n\nSome content.", false, false); + + let response = server.get("/").await; + let body = response.text(); + + assert!( + !body.contains(r#"dir="rtl""#), + "Without RTL flag, content should not have dir=\"rtl\"" + ); + } + + #[tokio::test] + async fn test_code_blocks_ltr_in_rtl_mode() { + let content_with_code = "# Title\n\n```\ncode block\n```"; + let (server, _temp_file) = create_test_server_impl(content_with_code, false, true); + + let response = server.get("/").await; + let body = response.text(); + + assert!( + body.contains("direction: ltr"), + "Code blocks should be forced LTR in RTL mode" + ); + } + + #[tokio::test] + async fn test_no_direction_override_without_rtl_flag() { + let content_with_code = "# Hello\n\n```\ncode block\n```"; + let (server, _temp_file) = create_test_server_impl(content_with_code, false, false); + + let response = server.get("/").await; + let body = response.text(); + + assert!( + !body.contains("direction: ltr"), + "Without RTL flag, no direction override should be present" + ); + } + + #[tokio::test] + async fn test_rtl_flag_renders_fixture() { + let content = + fs::read_to_string("tests/fixtures/rtl.md").expect("Failed to read RTL fixture"); + let (server, _temp_file) = create_test_server_impl(&content, false, true); + + let response = server.get("/").await; + assert_eq!(response.status_code(), 200); + let body = response.text(); + + // Content div gets dir="rtl" + assert!( + body.contains(r#"id="content" dir="rtl""#), + "Content div should have dir=\"rtl\"" + ); + + // Code blocks are scoped to LTR within RTL content + assert!( + body.contains("#content pre, #content code {\n direction: ltr;"), + "Code blocks should be forced LTR via #content pre/code selector" + ); + + // RTL font stack applied + assert!( + body.contains("Arial Hebrew"), + "RTL font stack should be present" + ); + + // Blockquotes use logical properties (no physical border-left/right) + assert!( + body.contains("border-inline-start:"), + "Blockquotes should use border-inline-start" + ); + assert!( + !body.contains("border-left:"), + "Blockquotes should not use physical border-left" + ); + } } diff --git a/src/main.rs b/src/main.rs index 2bdc5fa..0d27d52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,10 @@ struct Args { /// Open the preview in the default browser #[arg(short, long)] open: bool, + + /// Enable right-to-left rendering + #[arg(long)] + rtl: bool, } #[tokio::main] @@ -59,6 +63,7 @@ async fn main() -> Result<()> { args.hostname, args.port, args.open, + args.rtl, ) .await?; diff --git a/templates/main.html b/templates/main.html index d072661..565e0ec 100644 --- a/templates/main.html +++ b/templates/main.html @@ -118,6 +118,14 @@ color var(--transition-speed) var(--transition-timing); } + {% if is_rtl %} + #content { + font-family: 'Arial Hebrew', 'David', 'FrankRuehl', 'Miriam', 'Narkisim', + 'Arabic Typesetting', 'Arial Arabic', 'Geeza Pro', 'Scheherazade', + system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + {% endif %} + {% if show_navigation %} /* ============================================ Multi-file Navigation Layout @@ -418,10 +426,15 @@ background-color: transparent; padding: 0; } + {% if is_rtl %} + #content pre, #content code { + direction: ltr; + } + {% endif %} blockquote { - border-left: 4px solid var(--border-color-light); - padding-left: 16px; - margin-left: 0; + border-inline-start: 4px solid var(--border-color-light); + padding-inline-start: 16px; + margin-inline-start: 0; color: var(--blockquote-color); } table { @@ -432,7 +445,7 @@ th, td { border: 1px solid var(--border-color-light); padding: 8px 12px; - text-align: left; + text-align: start; } th { background-color: var(--table-header-bg); @@ -684,7 +697,7 @@ {% endif %} -
+
{{ content }}
diff --git a/tests/fixtures/rtl.md b/tests/fixtures/rtl.md new file mode 100644 index 0000000..49c13ee --- /dev/null +++ b/tests/fixtures/rtl.md @@ -0,0 +1,19 @@ +# 砖诇讜诐 注讜诇诐 + +讝讛讜 诪住诪讱 诇讘讚讬拽转 转诪讬讻讛 讘-RTL. + +## 爪讬讟讜讟 + +> 讛爪讬讟讜讟 讛讝讛 讗诪讜专 诇讛讜驻讬注 注诐 讙讘讜诇 讘爪讚 讬诪讬谉. + +## 讟讘诇讛 + +| 注诪讜讚讛 讗 | 注诪讜讚讛 讘 | +|---------|---------| +| 注专讱 1 | 注专讱 2 | + +## 拽讜讚 + +```bash +echo "Code blocks should remain LTR" +```