Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 132 additions & 27 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,16 @@ struct MarkdownState {
tracked_files: HashMap<String, TrackedFile>,
is_directory_mode: bool,
change_tx: broadcast::Sender<ServerMessage>,
rtl: bool,
}

impl MarkdownState {
fn new(base_dir: PathBuf, file_paths: Vec<PathBuf>, is_directory_mode: bool) -> Result<Self> {
fn new(
base_dir: PathBuf,
file_paths: Vec<PathBuf>,
is_directory_mode: bool,
rtl: bool,
) -> Result<Self> {
let (change_tx, _) = broadcast::channel::<ServerMessage>(16);

let mut tracked_files = HashMap::new();
Expand All @@ -114,6 +120,7 @@ impl MarkdownState {
tracked_files,
is_directory_mode,
change_tx,
rtl,
})
}

Expand All @@ -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(())
}

Expand All @@ -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,
},
);

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -272,13 +274,15 @@ fn new_router(
base_dir: PathBuf,
tracked_files: Vec<PathBuf>,
is_directory_mode: bool,
rtl: bool,
) -> Result<Router> {
let base_dir = base_dir.canonicalize()?;

let state = Arc::new(Mutex::new(MarkdownState::new(
base_dir.clone(),
tracked_files,
is_directory_mode,
rtl,
)?));

let watcher_state = state.clone();
Expand Down Expand Up @@ -343,11 +347,12 @@ pub(crate) async fn serve_markdown(
hostname: impl AsRef<str>,
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?;

Expand Down Expand Up @@ -441,7 +446,7 @@ fn open_browser(url: &str) -> Result<()> {
}

async fn serve_html_root(State(state): State<SharedMarkdownState>) -> 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,
Expand All @@ -453,8 +458,6 @@ async fn serve_html_root(State(state): State<SharedMarkdownState>) -> impl IntoR
}
};

let _ = state.refresh_file(&filename);

render_markdown(&state, &filename).await
}

Expand All @@ -463,14 +466,12 @@ async fn serve_file(
State(state): State<SharedMarkdownState>,
) -> 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) {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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"
);
}
}
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -59,6 +63,7 @@ async fn main() -> Result<()> {
args.hostname,
args.port,
args.open,
args.rtl,
)
.await?;

Expand Down
23 changes: 18 additions & 5 deletions templates/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -684,7 +697,7 @@
{% endif %}

<button class="theme-toggle" onclick="openThemeModal()">🎨</button>
<div id="content">
<div id="content"{% if is_rtl %} dir="rtl"{% endif %}>
{{ content }}
</div>

Expand Down
Loading
Loading