Initial commit
This commit is contained in:
commit
f49895c5f8
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
notes
|
||||||
|
target
|
||||||
1114
Cargo.lock
generated
Normal file
1114
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "md-renderer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pulldown-cmark = "0.9"
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
syntect = "5.0"
|
||||||
242
src/main.rs
Normal file
242
src/main.rs
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
http::StatusCode,
|
||||||
|
response::Html,
|
||||||
|
};
|
||||||
|
use pulldown_cmark::{Parser, Options, html, Event, Tag, CodeBlockKind};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
// Импорт для 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Инициализация логгера
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
// Загружаем синтаксисы и темы ОДИН РАЗ при старте
|
||||||
|
// Это может занять несколько сотен миллисекунд, поэтому делаем это до запуска сервера
|
||||||
|
println!("Загрузка баз синтаксисов и тем...");
|
||||||
|
let ss = SyntaxSet::load_defaults_newlines();
|
||||||
|
let ts = ThemeSet::load_defaults();
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
syntax_set: Arc::new(ss),
|
||||||
|
theme_set: Arc::new(ts),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/*path", get(serve_file))
|
||||||
|
.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 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Убеждаемся, что файл лежит внутри папки ./notes
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Конвертация Markdown -> HTML с подсветкой
|
||||||
|
let html_content = markdown_to_html(&content, &state.syntax_set, &state.theme_set);
|
||||||
|
|
||||||
|
Ok(Html(html_content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Основная функция конвертации
|
||||||
|
fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet) -> String {
|
||||||
|
// Выбираем тему (base16-ocean.dark отлично смотрится)
|
||||||
|
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);
|
||||||
|
|
||||||
|
// --- ЭТАП 1: Обработка событий для замены блоков кода ---
|
||||||
|
// Мы собираем новые события, где блоки кода заменены на сырой HTML с подсветкой
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Генерируем подсвеченный HTML
|
||||||
|
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 regions = h.highlight(¤t_code, ss);
|
||||||
|
styled_line_to_highlighted_html(®ions[..], IncludeBackground::No)
|
||||||
|
.unwrap_or_else(|_| escape_html(¤t_code))
|
||||||
|
} else {
|
||||||
|
escape_html(¤t_code)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
escape_html(¤t_code)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Формируем тег <pre><code>...</code></pre> с нашими стилями
|
||||||
|
let full_html = format!(
|
||||||
|
r#"<pre style="background-color: #2b303b; border-radius: 6px; padding: 15px; overflow-x: auto; margin: 1em 0; border: 1px solid #444;"><code>{}</code></pre>"#,
|
||||||
|
highlighted_html
|
||||||
|
);
|
||||||
|
|
||||||
|
// Вставляем как сырое HTML событие
|
||||||
|
processed_events.push(Event::Html(full_html.into()));
|
||||||
|
},
|
||||||
|
Event::Text(text) if in_code_block => {
|
||||||
|
// Накопление текста кода (не добавляем в events сразу)
|
||||||
|
current_code.push_str(&text);
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
// Все остальные события (заголовки, параграфы, обычный текст) пропускаем как есть
|
||||||
|
if !in_code_block {
|
||||||
|
processed_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ЭТАП 2: Рендеринг итогового HTML ---
|
||||||
|
let mut body_html = String::new();
|
||||||
|
html::push_html(&mut body_html, processed_events.into_iter());
|
||||||
|
|
||||||
|
// Оборачиваем в полный HTML документ с темной темой
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="content">
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
body_html
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция экранирования HTML для случаев, когда синтаксис не найден или это простой блок кода
|
||||||
|
fn escape_html(text: &str) -> String {
|
||||||
|
text.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user