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, theme_set: Arc, tx: Arc>, } #[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::(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("

Markdown Server

Перейдите на /files/example.md

") } async fn sse_handler( State(state): State, Path(full_path): Path, ) -> 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::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, Path(full_path): Path, ) -> Result, 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::(100); let tx_fs_clone = tx_fs.clone(); let mut watcher = RecommendedWatcher::new( move |res: Result| { 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 = Vec::new(); let mut in_code_block = false; let mut current_lang: Option = 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) = ¤t_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(®ions[..], 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(¤t_code) } } else { escape_html(¤t_code) }; // Формируем HTML с заголовком и кнопкой копирования // Мы экранируем current_code еще раз для data-атрибута, хотя для копирования будем брать текст из pre let code_container = format!( r#"
{}
{}
"#, 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#" Markdown Preview
{}
"#, body_html, sse_url ) } fn escape_html(text: &str) -> String { text.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }