Compare commits

...

10 Commits
0.4.0 ... main

10 changed files with 405 additions and 36 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
target/
.git/
.gitignore
Dockerfile
.dockerignore
README.md
.env
*.md

107
CHANGELOG.md Normal file
View File

@ -0,0 +1,107 @@
# MDPreview Changelog
All notable changes to this project will be documented in this file.
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/).
## v0.4.4 - 2026-03-29
### Fixed
- Fixed removing newlines.
---
## v0.4.3 - 2026-03-28
### Changed
- Change footer.
---
## v0.4.2 - 2026-03-27
### Added
- Added version and project name in footer.
---
## v0.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.
---
## v0.4.0 - 2026-03-25
### Added
- Added a button to copy the edit command (`note edit current_note.md`).
---
## v0.3.0 - 2026-03-24
### Added
- Added the `--random` flag to open the browser on a random page when used with
`--browser` (`--browser --random`).
---
## v0.2.1 - 2026-03-23
### Fixed
- Fixed an issue where listening on port 0 (`--port 0`) caused the browser to
open the wrong port.
---
## v0.2.0 - 2026-03-23
### Added
- Added the `--browser` flag to automatically open the page in the browser.
---
## v0.1.3 - 2026-03-23
### Added
- Added a "Random File" button to the header.
### Fixed
- Non-Markdown files now render correctly.
### Changed
- Translated the interface from Russian to English.
---
## v0.1.2 - 2026-03-22
### Added
- Added a "Random File" button.
- Added an "On Main" button.
### Changed
- Changed the default port to 8080.
---
## v0.1.1 - 2026-03-21
### Added
- Added page titles.

2
Cargo.lock generated
View File

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

View File

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

43
Dockerfile Normal file
View File

@ -0,0 +1,43 @@
FROM rust:1.94 AS chef
RUN cargo install --locked cargo-chef
WORKDIR /app
COPY Cargo.toml Cargo.lock .
RUN mkdir src && touch src/main.rs
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=chef /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY rust-toolchain.toml rust-toolchain.toml
RUN cargo fetch
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
RUN useradd -m -s /bin/bash appuser
WORKDIR /app
COPY --from=builder /app/target/release/mdpreview /usr/local/bin/mdpreview
USER appuser
EXPOSE 8080
CMD ["mdpreview"]

View File

@ -1,4 +1,4 @@
# TODO <!-- [NOGREP] --> # TODO <!-- [NOGREP] -->
* [ ] Сделать кнопку скопировать путь * [X] Сделать кнопку скопировать путь
* [ ] Сделать кнопку скопировать путь вместе с командой (`note edit ...`) * [X] Сделать кнопку скопировать путь вместе с командой (`note edit ...`)

3
rust-toolchain.toml Normal file
View File

@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["clippy", "rustfmt"]

View File

@ -2,7 +2,10 @@ use askama::Template;
use axum::{ use axum::{
Router, Router,
extract::{Path as AxumPath, State}, extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode, header}, http::{
HeaderMap, StatusCode,
header::{self},
},
response::{IntoResponse, Redirect, Sse}, response::{IntoResponse, Redirect, Sse},
routing::get, routing::get,
}; };
@ -47,6 +50,9 @@ pub struct FileEntry {
pub struct DirectoryTemplate { pub struct DirectoryTemplate {
pub title_path: String, pub title_path: String,
pub files: Vec<FileEntry>, pub files: Vec<FileEntry>,
pub package_name: String,
pub authors: String,
pub version: String,
} }
#[derive(Template)] #[derive(Template)]
@ -57,8 +63,15 @@ pub struct NoteTemplate {
pub content: String, pub content: String,
pub sse_url: String, pub sse_url: String,
pub copy_path: String, pub copy_path: String,
pub package_name: String,
pub authors: String,
pub version: String,
} }
const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Args { struct Args {
@ -74,11 +87,11 @@ struct Args {
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
browser: bool, 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")] #[arg(long, default_value_t = false, requires = "browser")]
random: bool, random: bool,
/// Notes root /// Notes root (can be a directory or a single file)
#[arg()] #[arg()]
root: PathBuf, root: PathBuf,
} }
@ -89,17 +102,20 @@ struct AppState {
theme_set: Arc<ThemeSet>, theme_set: Arc<ThemeSet>,
tx: Arc<broadcast::Sender<String>>, tx: Arc<broadcast::Sender<String>>,
root: PathBuf, root: PathBuf,
is_root_file: bool,
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = Args::parse(); let args = Args::parse();
if !args.root.is_dir() { if !args.root.exists() {
eprintln!("Root {} is not a directory", args.root.display()); eprintln!("Root path {} does not exist", args.root.display());
std::process::exit(1); std::process::exit(1);
} }
let is_root_file = args.root.is_file();
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new( .with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=warn".into()), std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=warn".into()),
@ -117,6 +133,7 @@ async fn main() {
theme_set: Arc::new(ts), theme_set: Arc::new(ts),
tx: Arc::new(tx), tx: Arc::new(tx),
root: args.root.clone(), root: args.root.clone(),
is_root_file,
}; };
let watcher_state = state.clone(); let watcher_state = state.clone();
@ -129,7 +146,7 @@ async fn main() {
.route("/random", get(random_file)) .route("/random", get(random_file))
.route("/*path", get(serve_file)) .route("/*path", get(serve_file))
.route("/events/*path", get(sse_handler)) .route("/events/*path", get(sse_handler))
.with_state(state) .with_state(state.clone())
.layer(TraceLayer::new_for_http()); .layer(TraceLayer::new_for_http());
let addr = resolve_addr(&args.host, args.port).expect("Failed to resolve address"); let addr = resolve_addr(&args.host, args.port).expect("Failed to resolve address");
@ -137,13 +154,15 @@ async fn main() {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
let actual_addr = listener.local_addr().expect("Failed to get local address"); let actual_addr = listener.local_addr().expect("Failed to get local address");
println!("Server started on http://{actual_addr}"); println!("mdpreview v{VERSION} server started on http://{actual_addr}");
if args.browser { if args.browser {
let mut url = format!("http://{actual_addr}"); let mut url = format!("http://{actual_addr}");
if args.random { if args.random {
if !state.is_root_file {
url = format!("{url}/random") url = format!("{url}/random")
} }
}
let _ = webbrowser::open(url.as_str()); let _ = webbrowser::open(url.as_str());
} }
@ -159,16 +178,87 @@ fn resolve_addr(host: &str, port: u16) -> io::Result<SocketAddr> {
} }
async fn root_handler(State(state): State<AppState>) -> impl IntoResponse { async fn root_handler(State(state): State<AppState>) -> impl IntoResponse {
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 { match render_directory_index(&state.root, "").await {
Ok(template) => template.into_response(), Ok(template) => template.into_response(),
Err(e) => e.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,
package_name: PACKAGE_NAME.to_string(),
authors: AUTHORS.to_string(),
version: VERSION.to_string(),
})
} }
async fn serve_file( async fn serve_file(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(full_path): AxumPath<String>, AxumPath(full_path): AxumPath<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if state.is_root_file {
return Err(StatusCode::NOT_FOUND);
}
if full_path.is_empty() { if full_path.is_empty() {
return Err(StatusCode::NOT_FOUND); return Err(StatusCode::NOT_FOUND);
} }
@ -264,6 +354,9 @@ async fn serve_file(
content: html_content, content: html_content,
sse_url, sse_url,
copy_path, copy_path,
package_name: PACKAGE_NAME.to_string(),
authors: AUTHORS.to_string(),
version: VERSION.to_string(),
}; };
Ok(template.into_response()) Ok(template.into_response())
@ -314,7 +407,17 @@ async fn render_directory_index(
let title_path = request_path.trim_start_matches('/').to_string(); let title_path = request_path.trim_start_matches('/').to_string();
Ok(DirectoryTemplate { title_path, files }) let package_name = env!("CARGO_PKG_NAME").to_string();
let authors = env!("CARGO_PKG_AUTHORS").to_string();
let version = env!("CARGO_PKG_VERSION").to_string();
Ok(DirectoryTemplate {
title_path,
files,
package_name,
authors,
version,
})
} }
async fn sse_handler( async fn sse_handler(
@ -337,13 +440,23 @@ async fn sse_handler(
let rx = state.tx.subscribe(); let rx = state.tx.subscribe();
let requested_path = full_path.clone(); 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 stream = BroadcastStream::new(rx).filter_map(move |res| {
let req_path = requested_path.clone(); let req_path = requested_path.clone();
let root_str = root_path_str.clone();
let is_root_file_mode = state.is_root_file;
async move { async move {
match res { match res {
Ok(changed_path) => { 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>( Some(Ok::<axum::response::sse::Event, Infallible>(
axum::response::sse::Event::default() axum::response::sse::Event::default()
.event("reload") .event("reload")
@ -368,6 +481,10 @@ async fn sse_handler(
} }
async fn random_file(State(state): State<AppState>) -> impl IntoResponse { 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 files: Vec<PathBuf> = Vec::new();
let mut stack = vec![state.root.clone()]; let mut stack = vec![state.root.clone()];
@ -427,7 +544,13 @@ async fn run_file_watcher(state: AppState) {
) )
.expect("Failed to create watcher"); .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}"); eprintln!("Failed set watcher: {e}");
return; return;
} }

View File

@ -4,8 +4,64 @@ use syntect::highlighting::ThemeSet;
use syntect::html::{IncludeBackground, styled_line_to_highlighted_html}; use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
use syntect::parsing::SyntaxSet; use syntect::parsing::SyntaxSet;
fn normalize_lists(input: &str) -> String {
let mut result = String::with_capacity(input.len() + input.len() / 10);
for line in input.lines() {
if let Some(first_non_ws) = line.find(|c: char| !c.is_whitespace()) {
let marker_char = line.chars().nth(first_non_ws);
if let Some(marker) = marker_char {
if marker == '*' || marker == '-' || marker == '+' {
let rest_start = first_non_ws + 1;
if rest_start < line.len() {
let next_char = line.chars().nth(rest_start).unwrap();
if !next_char.is_whitespace() {
let mut marker_count = 1;
let mut is_only_markers = true;
for c in line.chars().skip(rest_start) {
if c == marker {
marker_count += 1;
} else if !c.is_whitespace() {
is_only_markers = false;
break;
}
}
if !(is_only_markers && marker_count >= 2) {
result.push_str(&line[..rest_start]);
result.push(' ');
result.push_str(&line[rest_start..]);
result.push('\n');
continue;
}
}
}
}
}
}
result.push_str(line);
result.push('\n');
}
if result.ends_with('\n') && !input.ends_with('\n') {
result.pop();
}
result
}
pub fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_path: &str) -> String { pub fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_path: &str) -> String {
let theme = &ts.themes["base16-ocean.dark"]; let normalized_markdown = normalize_lists(markdown);
let theme = ts
.themes
.get("base16-ocean.dark")
.unwrap_or_else(|| ts.themes.values().next().expect("No themes available"));
let mut options = Options::empty(); let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_TABLES);
@ -14,7 +70,7 @@ pub fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_pat
options.insert(Options::ENABLE_TASKLISTS); options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_SMART_PUNCTUATION); options.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = pulldown_cmark::Parser::new_ext(markdown, options); let parser = pulldown_cmark::Parser::new_ext(&normalized_markdown, options);
let mut processed_events: Vec<Event> = Vec::new(); let mut processed_events: Vec<Event> = Vec::new();
let mut in_code_block = false; let mut in_code_block = false;
@ -23,6 +79,10 @@ pub fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_pat
for event in parser { for event in parser {
match event { match event {
Event::Rule => {
continue;
}
Event::Start(Tag::CodeBlock(kind)) => { Event::Start(Tag::CodeBlock(kind)) => {
in_code_block = true; in_code_block = true;
current_code.clear(); current_code.clear();
@ -56,19 +116,24 @@ pub fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_pat
if let Some(syntax) = ss.find_syntax_by_token(lang) { if let Some(syntax) = ss.find_syntax_by_token(lang) {
let mut h = HighlightLines::new(syntax, theme); let mut h = HighlightLines::new(syntax, theme);
let mut result_html = String::new(); let mut result_html = String::new();
for line in current_code.lines() {
let line_with_newline = format!("{line}\n"); let lines: Vec<&str> = current_code.lines().collect();
match h.highlight_line(&line_with_newline, ss) {
for line in lines.iter() {
match h.highlight_line(&line, ss) {
Ok(regions) => { Ok(regions) => {
let html_line = styled_line_to_highlighted_html( let html_line = styled_line_to_highlighted_html(
&regions[..], &regions[..],
IncludeBackground::No, IncludeBackground::No,
) )
.unwrap_or_else(|_| escape_html(&line_with_newline)); .unwrap_or_else(|_| escape_html(&line));
result_html.push_str(&html_line); result_html.push_str(&html_line);
result_html.push('\n');
} }
Err(_) => { Err(_) => {
result_html.push_str(&escape_html(&line_with_newline)); result_html.push_str(&escape_html(&line));
result_html.push('\n');
} }
} }
} }
@ -104,6 +169,7 @@ pub fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_pat
} }
let mut body_html = String::new(); let mut body_html = String::new();
html::push_html(&mut body_html, processed_events.into_iter()); html::push_html(&mut body_html, processed_events.into_iter());
body_html body_html
} }

View File

@ -120,17 +120,33 @@
border-radius: 6px; border-radius: 6px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1); box-shadow: 0 4px 6px rgba(0,0,0,0.1);
} }
/* Стили для списка файлов */ ul {
ul { list-style-type: none; padding: 0; margin: 0; } list-style-type: disc;
li { padding: 10px 0; border-bottom: 1px solid #333; } padding: 0;
margin: 0;
padding-left: 1.5em;
}
ol {
padding-left: 1.5em;
list-style-type: decimal;
}
li {
padding: .5em 0;
}
ul > li::marker, ol > li::marker { display: inline-block; }
.file-link { .file-link {
color: #64b5f6; color: #64b5f6;
font-size: 1.1em; font-size: 1.1em;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.file-link:hover { color: #90caf9; } .file-link:hover {
.icon { margin-right: 10px; min-width: 24px; } color: #90caf9;
}
.icon {
margin-right: 10px;
min-width: 24px;
}
</style> </style>
</head> </head>
<body> <body>
@ -149,6 +165,9 @@
<div class="footer"> <div class="footer">
<a href="/">← Main</a> <a href="/">← Main</a>
<a href="/random">🎲 Random note</a> <a href="/random">🎲 Random note</a>
{% block footer %}
<p>{{ package_name }} v{{ version }}</p>
{% endblock %}
</div> </div>
</div> </div>