mdpreview/src/main.rs
2026-02-20 03:12:25 +03:00

431 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use axum::{
extract::{Path, State},
routing::get,
Router,
http::{StatusCode, HeaderMap, header},
response::{Html, Sse, IntoResponse},
};
use pulldown_cmark::{Parser, Options, html, Event, Tag, CodeBlockKind};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, EventKind};
use futures::StreamExt;
use std::convert::Infallible;
use std::time::Duration;
// Импорт для syntect
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
use syntect::parsing::SyntaxSet;
/// Храним тяжелые ресурсы и канал для уведомлений
#[derive(Clone)]
struct AppState {
syntax_set: Arc<SyntaxSet>,
theme_set: Arc<ThemeSet>,
tx: Arc<broadcast::Sender<String>>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
println!("Загрузка баз синтаксисов и тем...");
let ss = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
// Создаем канал broadcast для SSE
let (tx, _rx) = broadcast::channel::<String>(100);
let state = AppState {
syntax_set: Arc::new(ss),
theme_set: Arc::new(ts),
tx: Arc::new(tx),
};
// Запускаем глобальный вотчер в отдельной задаче
let watcher_state = state.clone();
tokio::spawn(async move {
run_file_watcher(watcher_state).await;
});
let app = Router::new()
.route("/", get(root))
.route("/*path", get(serve_file))
.route("/events/*path", get(sse_handler))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("Сервер запущен на http://{}", addr);
println!("Пример: http://127.0.0.1:3000/files/readme.md");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn root() -> Html<&'static str> {
Html("<h1 style='color:white; text-align:center;'>Markdown Server</h1><p style='color:#aaa; text-align:center;'>Перейдите на <a href='/files/example.md'>/files/example.md</a></p>")
}
async fn sse_handler(
State(state): State<AppState>,
Path(full_path): Path<String>,
) -> impl IntoResponse {
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("text/event-stream"));
headers.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-cache"));
headers.insert(header::CONNECTION, header::HeaderValue::from_static("keep-alive"));
let rx = state.tx.subscribe();
let requested_path = full_path.clone();
let stream = BroadcastStream::new(rx)
.filter_map(move |res| {
let req_path = requested_path.clone();
async move {
match res {
Ok(changed_path) => {
if changed_path.contains(&req_path) {
Some(Ok::<axum::response::sse::Event, Infallible>(
axum::response::sse::Event::default()
.event("reload")
.data("")
))
} else {
None
}
}
Err(_) => None,
}
}
});
let sse = Sse::new(stream)
.keep_alive(
axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(15))
.text("ping")
);
(headers, sse)
}
async fn serve_file(
State(state): State<AppState>,
Path(full_path): Path<String>,
) -> Result<Html<String>, StatusCode> {
if full_path.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
let mut requested_path = PathBuf::from("./notes");
requested_path.push(&full_path);
let safe_path = match fs::canonicalize(&requested_path).await {
Ok(p) => p,
Err(_) => return Err(StatusCode::NOT_FOUND),
};
let base_dir = match fs::canonicalize("./notes").await {
Ok(p) => p,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if !safe_path.starts_with(&base_dir) {
eprintln!("Попытка выхода за пределы директории: {:?}", safe_path);
return Err(StatusCode::FORBIDDEN);
}
let content = match fs::read_to_string(&safe_path).await {
Ok(c) => c,
Err(e) => {
eprintln!("Ошибка чтения: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let html_content = markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path);
Ok(Html(html_content))
}
/// Запуск наблюдателя за файловой системой
async fn run_file_watcher(state: AppState) {
let (tx_fs, mut rx_fs) = tokio::sync::mpsc::channel::<PathBuf>(100);
let tx_fs_clone = tx_fs.clone();
let mut watcher = RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
if matches!(event.kind, EventKind::Modify(_)) {
for path in event.paths {
let _ = tx_fs_clone.blocking_send(path);
}
}
}
},
Config::default(),
).expect("Failed to create watcher");
let watch_path = PathBuf::from("./notes");
if let Err(e) = watcher.watch(&watch_path, RecursiveMode::Recursive) {
eprintln!("Ошибка настройки watcher: {}", e);
return;
}
println!("Watcher запущен для директории: {:?}", watch_path);
while let Some(path) = rx_fs.recv().await {
if let Some(path_str) = path.to_str() {
let _ = state.tx.send(path_str.to_string());
}
}
}
fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, file_path: &str) -> String {
let theme = &ts.themes["base16-ocean.dark"];
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);
options.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = Parser::new_ext(markdown, options);
let mut processed_events: Vec<Event> = Vec::new();
let mut in_code_block = false;
let mut current_lang: Option<String> = None;
let mut current_code = String::new();
for event in parser {
match event {
Event::Start(Tag::CodeBlock(kind)) => {
in_code_block = true;
current_code.clear();
current_lang = if let CodeBlockKind::Fenced(l) = kind {
Some(l.to_string())
} else {
None
};
},
Event::End(Tag::CodeBlock(_)) => {
in_code_block = false;
// Определяем отображаемое имя языка
let lang_display = current_lang.as_deref().unwrap_or("text");
// Экранируем имя языка для HTML атрибута и текста
let lang_escaped = escape_html(lang_display);
// Подсветка синтаксиса (построчно)
let highlighted_html = if let Some(lang) = &current_lang {
if let Some(syntax) = ss.find_syntax_by_token(lang) {
let mut h = HighlightLines::new(syntax, theme);
let mut result_html = String::new();
for line in current_code.lines() {
let line_with_newline = format!("{}\n", line);
match h.highlight_line(&line_with_newline, ss) {
Ok(regions) => {
let html_line = styled_line_to_highlighted_html(&regions[..], IncludeBackground::No)
.unwrap_or_else(|_| escape_html(&line_with_newline));
result_html.push_str(&html_line);
},
Err(_) => result_html.push_str(&escape_html(&line_with_newline)),
}
}
result_html
} else {
escape_html(&current_code)
}
} else {
escape_html(&current_code)
};
// Формируем HTML с заголовком и кнопкой копирования
// Мы экранируем current_code еще раз для data-атрибута, хотя для копирования будем брать текст из pre
let code_container = format!(
r#"<div class="code-block-wrapper">
<div class="code-header">
<span class="code-lang">{}</span>
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
</div>
<pre style="margin: 0; border-radius: 0 0 6px 6px;"><code>{}</code></pre>
</div>"#,
lang_escaped,
highlighted_html
);
processed_events.push(Event::Html(code_container.into()));
},
Event::Text(text) if in_code_block => {
current_code.push_str(&text);
},
_ => {
if !in_code_block {
processed_events.push(event);
}
}
}
}
let mut body_html = String::new();
html::push_html(&mut body_html, processed_events.into_iter());
let sse_url = format!("/events/{}", file_path);
format!(
r#"<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Preview</title>
<style>
body {{ background-color: #121212; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 40px 20px; display: flex; justify-content: center; line-height: 1.6; }}
.content {{ max-width: 800px; width: 100%; background-color: #1e1e1e; padding: 40px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }}
a {{ color: #bb86fc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
h1, h2, h3, h4 {{ color: #ffffff; margin-top: 1.5em; margin-bottom: 0.5em; }}
h1 {{ border-bottom: 1px solid #333; padding-bottom: 10px; }}
table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
th, td {{ border: 1px solid #444; padding: 8px; text-align: left; }}
th {{ background-color: #2c2c2c; }}
blockquote {{ border-left: 4px solid #bb86fc; margin: 1em 0; padding-left: 1em; color: #aaa; background: #252525; padding: 10px; }}
code {{ font-family: 'Consolas', 'Monaco', monospace; }}
/* Стили для обычных инлайн кодов */
p > code, li > code {{ background-color: #2c2c2c; padding: 2px 6px; border-radius: 4px; color: #ff79c6; }}
/* Стили для блоков кода с подсветкой */
.code-block-wrapper {{
margin: 1em 0;
border: 1px solid #444;
border-radius: 6px;
overflow: hidden;
background-color: #2b303b;
}}
.code-header {{
display: flex;
justify-content: space-between;
align-items: center;
background-color: #232730;
padding: 6px 12px;
border-bottom: 1px solid #444;
font-size: 0.85em;
color: #a0a0a0;
}}
.code-lang {{
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.copy-btn {{
background: transparent;
border: 1px solid #555;
color: #ccc;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s;
}}
.copy-btn:hover {{
background-color: #444;
color: #fff;
border-color: #777;
}}
.copy-btn:active {{
transform: scale(0.95);
}}
pre {{
padding: 15px;
overflow-x: auto;
margin: 0;
}}
pre code {{
background: transparent;
padding: 0;
color: inherit;
}}
#status {{ position: fixed; top: 10px; right: 10px; padding: 5px 10px; border-radius: 4px; font-size: 12px; font-weight: bold; }}
.connected {{ background-color: #2ecc71; color: #000; }}
.disconnected {{ background-color: #e74c3c; color: #fff; }}
.reconnecting {{ background-color: #f1c40f; color: #000; }}
</style>
</head>
<body>
<div class="content">
{}
</div>
<script>
const sseUrl = "{}";
function connect() {{
const evtSource = new EventSource(sseUrl);
evtSource.onerror = (err) => {{
evtSource.close();
setTimeout(connect, 3000);
}};
evtSource.addEventListener("reload", (event) => {{
console.log("Получено событие обновления");
location.reload();
}});
}}
connect();
function copyCode(button) {{
// Находим обертку, затем pre внутри неё
const wrapper = button.closest('.code-block-wrapper');
if (!wrapper) return;
const pre = wrapper.querySelector('pre');
if (!pre) return;
// Получаем текстовое содержимое (без HTML тегов подсветки)
const codeText = pre.innerText;
navigator.clipboard.writeText(codeText).then(() => {{
const originalText = button.innerText;
button.innerText = 'Copied!';
button.style.borderColor = '#2ecc71';
button.style.color = '#2ecc71';
setTimeout(() => {{
button.innerText = originalText;
button.style.borderColor = '';
button.style.color = '';
}}, 2000);
}}).catch(err => {{
console.error('Ошибка копирования:', err);
button.innerText = 'Error';
}});
}}
</script>
</body>
</html>"#,
body_html,
sse_url
)
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}