fix: support for single-file rendering

This commit is contained in:
thek4n 2026-03-28 00:59:26 +03:00
parent 845d8c9475
commit b990725af1
4 changed files with 116 additions and 13 deletions

View File

@ -5,6 +5,15 @@ The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/lang/en/).
## [0.4.1] - 2026-03-26
### Fixed
- Added support for rendering individual files, not just directories (e.g.,
`mdpreview dir/file.md`).
- Fixed the rendering of Markdown lists.
---
## [0.4.0] - 2026-03-25
### Added

2
Cargo.lock generated
View File

@ -982,7 +982,7 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "mdpreview"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"askama",
"askama_axum",

View File

@ -1,6 +1,6 @@
[package]
name = "mdpreview"
version = "0.4.0"
version = "0.4.1"
edition = "2024"
authors = ["Vladislav Kan <thek4n@yandex.ru>"]

View File

@ -74,11 +74,11 @@ struct Args {
#[arg(long, default_value_t = false)]
browser: bool,
/// Open browser on random note. Requires flag --broswer
/// Open browser on random note. Requires flag --browser
#[arg(long, default_value_t = false, requires = "browser")]
random: bool,
/// Notes root
/// Notes root (can be a directory or a single file)
#[arg()]
root: PathBuf,
}
@ -89,17 +89,20 @@ struct AppState {
theme_set: Arc<ThemeSet>,
tx: Arc<broadcast::Sender<String>>,
root: PathBuf,
is_root_file: bool,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
if !args.root.is_dir() {
eprintln!("Root {} is not a directory", args.root.display());
if !args.root.exists() {
eprintln!("Root path {} does not exist", args.root.display());
std::process::exit(1);
}
let is_root_file = args.root.is_file();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=warn".into()),
@ -117,6 +120,7 @@ async fn main() {
theme_set: Arc::new(ts),
tx: Arc::new(tx),
root: args.root.clone(),
is_root_file,
};
let watcher_state = state.clone();
@ -129,7 +133,7 @@ async fn main() {
.route("/random", get(random_file))
.route("/*path", get(serve_file))
.route("/events/*path", get(sse_handler))
.with_state(state)
.with_state(state.clone())
.layer(TraceLayer::new_for_http());
let addr = resolve_addr(&args.host, args.port).expect("Failed to resolve address");
@ -142,7 +146,9 @@ async fn main() {
if args.browser {
let mut url = format!("http://{actual_addr}");
if args.random {
url = format!("{url}/random")
if !state.is_root_file {
url = format!("{url}/random")
}
}
let _ = webbrowser::open(url.as_str());
}
@ -159,16 +165,84 @@ fn resolve_addr(host: &str, port: u16) -> io::Result<SocketAddr> {
}
async fn root_handler(State(state): State<AppState>) -> impl IntoResponse {
match render_directory_index(&state.root, "").await {
Ok(template) => template.into_response(),
Err(e) => e.into_response(),
if state.is_root_file {
match render_single_file(&state.root, &state).await {
Ok(template) => template.into_response(),
Err(e) => e.into_response(),
}
} else {
match render_directory_index(&state.root, "").await {
Ok(template) => template.into_response(),
Err(e) => e.into_response(),
}
}
}
async fn render_single_file(
file_path: &StdPath,
state: &AppState,
) -> Result<NoteTemplate, StatusCode> {
let metadata = match fs::metadata(file_path).await {
Ok(m) => m,
Err(_) => return Err(StatusCode::NOT_FOUND),
};
if metadata.is_dir() {
return Err(StatusCode::BAD_REQUEST);
}
let extension = file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_lowercase();
let content = match fs::read_to_string(file_path).await {
Ok(c) => c,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let display_path = "";
let html_content = if extension == "md" {
markdown_to_html(&content, &state.syntax_set, &state.theme_set, display_path)
} else {
code_to_html(
&content,
extension.as_str(),
&state.syntax_set,
&state.theme_set,
)
};
let filename = file_path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("Unknown")
.to_string();
let back_link = "/".to_string();
let sse_url = "/events/".to_string();
let copy_path = format!("note fe {filename}").to_string();
Ok(NoteTemplate {
filename,
back_link,
content: html_content,
sse_url,
copy_path,
})
}
async fn serve_file(
State(state): State<AppState>,
AxumPath(full_path): AxumPath<String>,
) -> impl IntoResponse {
if state.is_root_file {
return Err(StatusCode::NOT_FOUND);
}
if full_path.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
@ -337,13 +411,23 @@ async fn sse_handler(
let rx = state.tx.subscribe();
let requested_path = full_path.clone();
let root_path_str = state.root.to_string_lossy().to_string();
let stream = BroadcastStream::new(rx).filter_map(move |res| {
let req_path = requested_path.clone();
let root_str = root_path_str.clone();
let is_root_file_mode = state.is_root_file;
async move {
match res {
Ok(changed_path) => {
if changed_path.contains(&req_path) || changed_path.ends_with(&req_path) {
let should_notify = if is_root_file_mode {
changed_path == root_str || changed_path.ends_with(&root_str)
} else {
changed_path.contains(&req_path) || changed_path.ends_with(&req_path)
};
if should_notify {
Some(Ok::<axum::response::sse::Event, Infallible>(
axum::response::sse::Event::default()
.event("reload")
@ -368,6 +452,10 @@ async fn sse_handler(
}
async fn random_file(State(state): State<AppState>) -> impl IntoResponse {
if state.is_root_file {
return Ok(Redirect::temporary("/"));
}
let mut files: Vec<PathBuf> = Vec::new();
let mut stack = vec![state.root.clone()];
@ -427,7 +515,13 @@ async fn run_file_watcher(state: AppState) {
)
.expect("Failed to create watcher");
if let Err(e) = watcher.watch(&state.root, RecursiveMode::Recursive) {
let watch_result = if state.is_root_file {
watcher.watch(&state.root, RecursiveMode::NonRecursive)
} else {
watcher.watch(&state.root, RecursiveMode::Recursive)
};
if let Err(e) = watch_result {
eprintln!("Failed set watcher: {e}");
return;
}