Compare commits

..

No commits in common. "main" and "refactor" have entirely different histories.

18 changed files with 547 additions and 5132 deletions

View File

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

6
.gitignore vendored
View File

@ -1,4 +1,2 @@
notes/
target/
assets/
templates/*.min.html
notes
target

View File

@ -27,7 +27,7 @@ repos:
- id: clippy
name: clippy
language: system
entry: cargo clippy -- -D clippy::all -D clippy::pedantic -W clippy::struct-excessive-bools
entry: cargo clippy -- -W clippy::all -W clippy::pedantic
pass_filenames: false
always_run: true
@ -41,6 +41,6 @@ repos:
- id: check-todos # [NOGREP]
name: check-todos # [NOGREP]
language: system
entry: sh -c "! grep --color=always --binary-files=without-match --dereference-recursive --exclude-dir='notes' --exclude-dir='target' --exclude-dir='node_modules' --exclude-dir='.git' --exclude='.pre-commit-config.yaml' --exclude-dir='venv' --exclude-dir='assets' 'TODO' | grep -v '\[NOGREP\]'" # [NOGREP]
entry: sh -c "! grep --color=always --binary-files=without-match --dereference-recursive --exclude-dir='notes' --exclude-dir='node_modules' --exclude-dir='.git' --exclude='.pre-commit-config.yaml' --exclude-dir='venv' 'TODO'" # [NOGREP]
pass_filenames: false
always_run: true

View File

@ -1,174 +0,0 @@
# 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.6.3` - 20.07.2026
### Fixed
- Fixed minify script and templates.
---
## `v0.6.2` - 20.07.2026
### Changed
- Links are now opens in new tab.
---
## `v0.6.1` - 17.07.2026
### Added
- Add compile-time templates minificattion.
---
## `v0.6.0` - 17.07.2026
### Added
- Add text emoji support (`:smile:` for example).
---
## `v0.5.3` - 16.07.2026
### Fixed
- Fixed contend width
---
## `v0.5.2` - 16.07.2026
### Fixed
- Fixed tables rendering
---
## `v0.5.1` - 2026-07-14
### Fixed
- Fixed tables rendering
---
## `v0.5.0` - 2026-07-14
### Changed
- Migrate to embed js/css deps
---
## `v0.4.5` - 2026-07-14
### Changed
- Refactoring
---
## 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.

2448
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,19 @@
[package]
name = "mdpreview"
version = "0.6.3"
version = "0.1.0"
edition = "2024"
authors = ["Vladislav Kan <thek4n@yandex.ru>"]
build = "build.rs"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" # Оптимизация именно по размеру (z > s)
lto = true # Link Time Optimization: объединяет и оптимизирует весь код целиком
codegen-units = 1 # Уменьшает количество параллельных единиц компиляции, позволяя лучше оптимизировать
panic = "abort" # Отключает механизм развертывания стека при панике (экономит много места)
strip = true # Автоматически удаляет символы отладки (доступно в стабильной версии Rust 1.59+)
[build-dependencies]
reqwest = { version = "0.11", features = ["blocking"] }
minify-html = "0.18"
[dependencies]
pulldown-cmark = { version = "0.13.4", features = ["simd"] }
pulldown-cmark = "0.9"
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
@ -30,9 +26,3 @@ clap = { version = "4.5", features = ["derive"] }
tower-http = { version = "0.6.8", features = ["trace", "compression-gzip", "cors"] }
mime_guess = "2"
rand = "0.8"
askama = { version = "0.12", features = ["with-axum"] }
askama_axum = "0.4"
webbrowser = "1.0"
sha2 = "0.11.0"
hex = "0.4"
mime = "0.3.17"

View File

@ -1,43 +0,0 @@
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,8 +0,0 @@
# TODO <!-- [NOGREP] -->
* [X] ~~Сделать кнопку скопировать путь~~
* [X] ~~Сделать кнопку скопировать путь вместе с командой (`note edit ...`)~~
* [X] ~~Сделать отображение типа кода и кнопку скопировать код~~
* [X] ~~Вынести зависимости, типа скриптов mermaid и highlight.js в
include_bytes~~
* [X] ~~Починить заголовки таблицы~~

115
build.rs
View File

@ -1,115 +0,0 @@
use minify_html::{Cfg, minify};
use std::env;
use std::fs;
use std::io;
use std::path::Path;
const HIGHLIGHT_STYLE_URL_TEMPLATE: &str =
"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/{VERSION}/styles/atom-one-dark.min.css";
const HIGHLIGHT_SCRIPT_URL_TEMPLATE: &str =
"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/{VERSION}/highlight.min.js";
const MERMAID_SCRIPT_URL_TEMPLATE: &str =
"https://cdn.jsdelivr.net/npm/mermaid@{VERSION}/dist/mermaid.min.js";
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=assets/");
println!("cargo:rerun-if-env-changed=FORCE_DEPS_DOWNLOAD");
println!("cargo:rerun-if-changed=templates/");
minify_template_file("templates/base.html", "templates/.base.min.html").unwrap();
minify_template_file("templates/dir.html", "templates/.dir.min.html").unwrap();
minify_template_file("templates/file.html", "templates/.file.min.html").unwrap();
let out_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let assets_path = Path::new(&out_dir).join("assets");
fs::create_dir_all(&assets_path).expect("Fail to create directory");
let highlight_version = match env::var("HIGHLIGHT_VERSION") {
Ok(var) => var,
Err(_) => "11.9.0".to_string(),
};
let mermaid_version = match env::var("MERMAID_VERSION") {
Ok(var) => var,
Err(_) => "10".to_string(),
};
download(
HIGHLIGHT_STYLE_URL_TEMPLATE,
&highlight_version,
&assets_path.join("highlight.css"),
);
download(
HIGHLIGHT_SCRIPT_URL_TEMPLATE,
&highlight_version,
&assets_path.join("highlight.js"),
);
download(
MERMAID_SCRIPT_URL_TEMPLATE,
&mermaid_version,
&assets_path.join("mermaid.js"),
);
}
fn download(url: &str, version: &str, dest_path: &Path) {
if !should_download(dest_path) {
println!(
"cargo:warning=✅ {} actual, skip downloading",
dest_path.to_string_lossy()
);
return;
}
println!(
"cargo:warning=📥 Downloading {}...",
dest_path.to_string_lossy()
);
let response = reqwest::blocking::get(url.replace("{VERSION}", version))
.expect("Fail to download sources")
.error_for_status()
.expect("Fail to download sources: non-2xx status code");
let content = response.text().expect("Fail to read answer");
fs::write(dest_path, &content).expect("Fail to write file");
}
fn should_download(path: &Path) -> bool {
if env::var("FORCE_DEPS_DOWNLOAD").is_ok() {
return true;
}
if path.exists() {
return false;
}
true
}
fn minify_template_file<P: AsRef<str>>(input_path: P, output_path: P) -> io::Result<()> {
let html_content = fs::read_to_string(input_path.as_ref())?;
let minified = minify_template_content(&html_content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(output_path.as_ref(), minified)?;
Ok(())
}
#[allow(clippy::missing_errors_doc)]
pub fn minify_template_content(template: &str) -> Result<String, String> {
let mut cfg = Cfg::new();
cfg.allow_removing_spaces_between_attributes = true;
cfg.keep_comments = false;
cfg.minify_css = true;
cfg.minify_js = true;
cfg.keep_html_and_head_opening_tags = true;
cfg.keep_closing_tags = true;
cfg.remove_processing_instructions = true;
let minified_bytes = minify(template.as_bytes(), &cfg);
String::from_utf8(minified_bytes).map_err(|e| format!("Invalid UTF-8 in output: {e}"))
}

View File

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

View File

@ -1,12 +1,8 @@
use askama::Template;
use axum::{
Router,
extract::{Path as AxumPath, State},
http::{
HeaderMap, StatusCode,
header::{self},
},
response::{IntoResponse, Redirect, Sse},
extract::{Path, State},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Sse},
routing::get,
};
use futures::StreamExt;
@ -14,9 +10,9 @@ use mime_guess::from_path;
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use rand::seq::SliceRandom;
use std::convert::Infallible;
use std::ffi::OsStr;
use std::fmt::Write;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::{Path as StdPath, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::fs;
@ -25,64 +21,17 @@ use tokio_stream::wrappers::BroadcastStream;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use sha2::{Digest, Sha256};
use clap::Parser;
use std::io;
mod markdown;
use markdown::markdown_to_html;
mod other;
use other::code_to_html;
const HIGHLIGHT_STYLE: &str = include_str!("../assets/highlight.css");
const HIGHLIGHT_SCRIPT: &str = include_str!("../assets/highlight.js");
const MERMAID_SCRIPT: &str = include_str!("../assets/mermaid.js");
#[derive(Clone)]
pub struct FileEntry {
pub name: String,
pub link: String,
pub is_dir: bool,
}
#[derive(Template)]
#[template(path = ".dir.min.html")]
pub struct DirectoryTemplate {
pub title_path: String,
pub files: Vec<FileEntry>,
pub package_name: String,
pub authors: String,
pub version: String,
pub highlight_style_url: String,
pub highlight_script_url: String,
pub mermaid_script_url: String,
}
#[derive(Template)]
#[template(path = ".file.min.html")]
pub struct NoteTemplate {
pub filename: String,
pub back_link: String,
pub content: String,
pub sse_url: String,
pub copy_path: String,
pub package_name: String,
pub authors: String,
pub version: String,
pub highlight_style_url: String,
pub highlight_script_url: String,
pub mermaid_script_url: String,
}
const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
const VERSION: &str = env!("CARGO_PKG_VERSION");
const TEMPLATE_FILE: &str = include_str!("../templates/file.html");
const TEMPLATE_DIR: &str = include_str!("../templates/dir.html");
#[derive(clap::Parser, Debug)]
#[command(author, version, about, long_about = None)]
@ -92,80 +41,48 @@ struct Args {
host: String,
/// Port to listen
#[arg(short, long, default_value_t = 8080)]
#[arg(short, long, default_value_t = 8000)]
port: u16,
/// Open browser
#[arg(long, default_value_t = false)]
browser: bool,
/// Open browser on random note. Requires flag --browser
#[arg(long, default_value_t = false, requires = "browser")]
random: bool,
/// Notes root (can be a directory or a single file)
/// Markdown documents directory root
#[arg()]
root: PathBuf,
}
#[derive(Clone)]
struct AppState {
syntax_set: Arc<SyntaxSet>,
theme_set: Arc<ThemeSet>,
tx: Arc<broadcast::Sender<String>>,
root: PathBuf,
is_root_file: bool,
highlight_style_url: String,
highlight_style_content: &'static str,
highlight_script_url: String,
highlight_script_content: &'static str,
mermaid_script_url: String,
mermaid_script_content: &'static str,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
if !args.root.exists() {
eprintln!("Root path {} does not exist", args.root.display());
if !args.root.is_dir() {
eprintln!("Root {root} is not a directory", root = 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()),
std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let ss = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let (tx, _rx) = broadcast::channel::<String>(100);
let highlight_style_hash = calculate_sha256(HIGHLIGHT_STYLE);
let highlight_style_url = format!("/assets/highlight-{highlight_style_hash}.css");
let highlight_script_hash = calculate_sha256(HIGHLIGHT_SCRIPT);
let highlight_script_url = format!("/assets/highlight-{highlight_script_hash}.js");
let mermaid_script_hash = calculate_sha256(MERMAID_SCRIPT);
let mermaid_script_url = format!("/assets/mermaid-{mermaid_script_hash}.js");
let state = AppState {
syntax_set: Arc::new(ss),
theme_set: Arc::new(ts),
tx: Arc::new(tx),
root: args.root.clone(),
is_root_file,
highlight_style_url: highlight_style_url.clone(),
highlight_style_content: HIGHLIGHT_STYLE,
highlight_script_url: highlight_script_url.clone(),
highlight_script_content: HIGHLIGHT_SCRIPT,
mermaid_script_url: mermaid_script_url.clone(),
mermaid_script_content: MERMAID_SCRIPT,
root: args.root,
};
let watcher_state = state.clone();
@ -174,290 +91,37 @@ async fn main() {
});
let app = Router::new()
.route("/", get(root_handler))
.route("/", get(root))
.route("/random", get(random_file))
.route("/*path", get(serve_file))
.route("/events/*path", get(sse_handler))
.route(&highlight_style_url.clone(), get(highlight_style_handler))
.route(&highlight_script_url.clone(), get(highlight_script_handler))
.route(&mermaid_script_url.clone(), get(mermaid_script_handler))
.with_state(state.clone())
.with_state(state)
.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).unwrap();
println!("Server started on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
let actual_addr = listener.local_addr().expect("Failed to get local address");
println!("mdpreview v{VERSION} server started on http://{actual_addr}");
if args.browser {
let mut url = format!("http://{actual_addr}");
if args.random && !state.is_root_file {
url = format!("{url}/random");
}
let _ = webbrowser::open(url.as_str());
}
axum::serve(listener, app).await.unwrap();
}
fn resolve_addr(host: &str, port: u16) -> io::Result<SocketAddr> {
let addr_str = format!("{host}:{port}");
addr_str
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Cannot resolve addr"))
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Не удалось разрешить адрес"))
}
async fn root_handler(State(state): State<AppState>) -> impl IntoResponse {
if state.is_root_file {
match render_single_file(axum::extract::State(state.clone()), &state.root).await {
Ok(template) => template.into_response(),
Err(e) => e.into_response(),
}
} else {
match render_directory_index(axum::extract::State(state.clone()), &state.root, "").await {
Ok(template) => template.into_response(),
Err(e) => e.into_response(),
}
}
}
async fn render_single_file(
State(state): State<AppState>,
file_path: &StdPath,
) -> Result<NoteTemplate, StatusCode> {
let Ok(metadata) = fs::metadata(file_path).await else {
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 Ok(content) = fs::read_to_string(file_path).await else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let display_path = "";
let ss = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let html_content = if extension == "md" {
markdown_to_html(&content, display_path)
} else {
code_to_html(&content, extension.as_str(), &ss, &ts)
};
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(),
highlight_style_url: state.highlight_style_url,
highlight_script_url: state.highlight_script_url,
mermaid_script_url: state.mermaid_script_url,
})
}
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);
}
let mut requested_path = state.root.clone();
requested_path.push(&full_path);
let Ok(safe_path) = fs::canonicalize(&requested_path).await else {
return Err(StatusCode::NOT_FOUND);
};
let Ok(base_dir) = fs::canonicalize(&state.root).await else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
if !safe_path.starts_with(&base_dir) {
eprintln!("Path traversal attempt: {}", safe_path.display());
return Err(StatusCode::FORBIDDEN);
}
let Ok(metadata) = fs::metadata(&safe_path).await else {
return Err(StatusCode::NOT_FOUND);
};
if metadata.is_dir() {
return match render_directory_index(axum::extract::State(state), &safe_path, &full_path)
.await
{
Ok(t) => Ok(t.into_response()),
Err(e) => Err(e),
};
}
let extension = safe_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_lowercase();
let is_image = matches!(
extension.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico"
);
if is_image {
let Ok(file_content) = fs::read(&safe_path).await else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let mime_type = from_path(&safe_path).first_or_octet_stream();
return Ok(([(header::CONTENT_TYPE, mime_type.as_ref())], file_content).into_response());
}
let Ok(content) = fs::read_to_string(&safe_path).await else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let ss = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let html_content = if extension == "md" {
markdown_to_html(&content, &full_path)
} else {
code_to_html(&content, extension.as_str(), &ss, &ts)
};
let filename = safe_path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("Unknown")
.to_string();
let back_link = if let Some(pos) = full_path.rfind('/') {
let parent = &full_path[..pos];
if parent.is_empty() {
"/".to_string()
} else {
format!("/{parent}")
}
} else {
"/".to_string()
};
let sse_url = format!("/events/{full_path}");
let copy_path = format!("note edit {full_path}");
let template = 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(),
highlight_style_url: state.highlight_style_url,
highlight_script_url: state.highlight_script_url,
mermaid_script_url: state.mermaid_script_url,
};
Ok(template.into_response())
}
async fn render_directory_index(
State(state): State<AppState>,
dir_path: &StdPath,
request_path: &str,
) -> Result<DirectoryTemplate, StatusCode> {
let Ok(mut entries) = fs::read_dir(dir_path).await else {
return Err(StatusCode::FORBIDDEN);
};
let mut files: Vec<FileEntry> = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with('.') {
continue;
}
let is_dir = entry.metadata().await.map(|m| m.is_dir()).unwrap_or(false);
let link = if request_path.is_empty() {
file_name.clone()
} else {
format!("{request_path}/{file_name}")
};
files.push(FileEntry {
name: file_name,
link,
is_dir,
});
}
files.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
let title_path = request_path.trim_start_matches('/').to_string();
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,
highlight_style_url: state.highlight_style_url,
highlight_script_url: state.highlight_script_url,
mermaid_script_url: state.mermaid_script_url,
})
async fn root(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
render_directory_index(&state.root, "").await
}
async fn sse_handler(
State(state): State<AppState>,
AxumPath(full_path): AxumPath<String>,
Path(full_path): Path<String>,
) -> impl IntoResponse {
let mut headers = HeaderMap::new();
headers.insert(
@ -475,23 +139,13 @@ 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) => {
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 {
if changed_path.contains(&req_path) {
Some(Ok::<axum::response::sse::Event, Infallible>(
axum::response::sse::Event::default()
.event("reload")
@ -515,21 +169,233 @@ async fn sse_handler(
(headers, sse)
}
async fn random_file(State(state): State<AppState>) -> impl IntoResponse {
if state.is_root_file {
return Ok(Redirect::temporary("/"));
async fn serve_file(
State(state): State<AppState>,
Path(full_path): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
if full_path.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
let mut requested_path = PathBuf::from(&state.root);
requested_path.push(&full_path);
let Ok(safe_path) = fs::canonicalize(&requested_path).await else {
return Err(StatusCode::NOT_FOUND);
};
let Ok(base_dir) = fs::canonicalize(&state.root).await else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
if !safe_path.starts_with(&base_dir) {
eprintln!("Path traversal: {}", safe_path.display());
return Err(StatusCode::FORBIDDEN);
}
let metadata = match fs::metadata(&safe_path).await {
Ok(m) => m,
Err(e) => {
eprintln!("Error getting metadata: {e}");
return Err(StatusCode::NOT_FOUND);
}
};
if metadata.is_dir() {
return render_directory_index(&safe_path, &full_path)
.await
.map(|h| h.into_response());
}
let extension = safe_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
let is_image = matches!(
extension.to_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico"
);
if is_image {
let file_content = match fs::read(&safe_path).await {
Ok(content) => content,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let mime_type = from_path(&safe_path).first_or_octet_stream();
return Ok(([(header::CONTENT_TYPE, mime_type.as_ref())], file_content).into_response());
}
let content = match fs::read_to_string(&safe_path).await {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let back_link = if let Some(pos) = full_path.rfind('/') {
let parent = &full_path[..pos];
if parent.is_empty() {
"/".to_string()
} else {
format!("/{parent}")
}
} else {
"/".to_string()
};
let html_content = markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path);
// Заполнение шаблона
let final_html = TEMPLATE_FILE
.replace("{{CONTENT}}", &html_content)
.replace("{{SSE_URL}}", &format!("/events/{full_path}"))
.replace("{{BACK_LINK}}", &back_link);
Ok(Html(final_html).into_response())
}
async fn render_directory_index(
dir_path: &PathBuf,
request_path: &str,
) -> Result<Html<String>, StatusCode> {
let mut entries = match fs::read_dir(dir_path).await {
Ok(list) => list,
Err(e) => {
eprintln!("Error directory reading: {e}");
return Err(StatusCode::FORBIDDEN);
}
};
let mut files: Vec<(String, String, bool)> = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with('.') {
continue;
}
let is_dir = entry.metadata().await.map(|m| m.is_dir()).unwrap_or(false);
let mut link_path = request_path.to_string();
if !link_path.ends_with('/') {
link_path.push('/');
}
link_path.push_str(&file_name);
files.push((file_name, link_path, is_dir));
}
files.sort_by(|a, b| match (a.2, b.2) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.0.cmp(&b.0),
});
let mut list_html = String::from("<ul>");
if !request_path.is_empty() && request_path != "files" {
let parent_path = request_path.rsplit_once('/').map_or("", |(p, _)| p);
let parent_link = if parent_path.is_empty() {
"/".to_string()
} else {
format!("/{parent_path}")
};
let _ = write!(
list_html,
r#"<li>
<a href="{parent_link}" class="back-link">📁 ..</a>
</li>"#
);
}
for (name, link, is_dir) in files {
let icon = if is_dir { "📁" } else { "📄" };
let _ = write!(
list_html,
r#"<li>
<a href="/{}" class="file-link">
<span class="icon">{}</span>
<span>{}</span>
</a>
</li>"#,
link.trim_start_matches('/'),
icon,
name
);
}
list_html.push_str("</ul>");
let title_path = if request_path.is_empty() {
""
} else {
request_path.trim_start_matches('/')
};
let final_html = TEMPLATE_DIR
.replace("{{TITLE_PATH}}", title_path)
.replace("{{FILE_LIST}}", &list_html);
Ok(Html(final_html))
}
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
&& 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 = state.root;
if let Err(e) = watcher.watch(&watch_path, RecursiveMode::Recursive) {
eprintln!("Ошибка настройки watcher: {e}");
return;
}
while let Some(path) = rx_fs.recv().await {
if let Some(path_str) = path.to_str() {
let _ = state.tx.send(path_str.to_string());
}
}
}
async fn random_file(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> {
let mut files: Vec<PathBuf> = Vec::new();
let mut stack = vec![state.root.clone()];
while let Some(current_dir) = stack.pop() {
let Ok(mut entries) = fs::read_dir(&current_dir).await else {
continue;
let mut entries = match fs::read_dir(&current_dir).await {
Ok(list) => list,
Err(_) => continue,
};
while let Ok(Some(entry)) = entries.next_entry().await {
while let Some(entry) = entries
.next_entry()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let path = entry.path();
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
@ -549,93 +415,11 @@ async fn random_file(State(state): State<AppState>) -> impl IntoResponse {
let mut rng = rand::thread_rng();
let random_path = files.choose(&mut rng).unwrap();
let Ok(relative_path) = random_path.strip_prefix(&state.root) else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let relative_path = random_path
.strip_prefix(&state.root)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let url_path = relative_path.to_string_lossy().replace('\\', "/");
Ok(Redirect::temporary(&format!("/{url_path}")))
}
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
&& matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
)
{
for path in event.paths {
let _ = tx_fs_clone.blocking_send(path);
}
}
},
Config::default(),
)
.expect("Failed to create watcher");
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;
}
while let Some(path) = rx_fs.recv().await {
if let Some(path_str) = path.to_str() {
let _ = state.tx.send(path_str.to_string());
}
}
}
async fn highlight_style_handler(State(state): State<AppState>) -> impl IntoResponse {
let headers = [
(
axum::http::header::CONTENT_TYPE,
mime::TEXT_CSS_UTF_8.as_ref(),
),
(axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
];
(headers, state.highlight_style_content).into_response()
}
async fn highlight_script_handler(State(state): State<AppState>) -> impl IntoResponse {
let headers = [
(
axum::http::header::CONTENT_TYPE,
mime::APPLICATION_JAVASCRIPT.as_ref(),
),
(axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
];
(headers, state.highlight_script_content).into_response()
}
async fn mermaid_script_handler(State(state): State<AppState>) -> impl IntoResponse {
let headers = [
(
axum::http::header::CONTENT_TYPE,
mime::APPLICATION_JAVASCRIPT.as_ref(),
),
(axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
];
(headers, state.mermaid_script_content).into_response()
}
fn calculate_sha256(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
hex::encode(result)[..6].to_string()
Ok(axum::response::Redirect::temporary(&format!("/{url_path}")))
}

117
src/markdown.rs Normal file
View File

@ -0,0 +1,117 @@
use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, html};
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
use syntect::parsing::SyntaxSet;
pub 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 = pulldown_cmark::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 is_mermaid = current_lang.as_deref() == Some("mermaid");
if is_mermaid {
let escaped_code = escape_html(&current_code);
let mermaid_html = format!(
r#"<div class="code-block-wrapper mermaid-wrapper">
<div class="code-header">
<span class="code-lang">Mermaid Diagram</span>
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
</div>
<div class="mermaid" style="background: transparent; padding: 20px; text-align: center;">{escaped_code}</div>
</div>"#
);
processed_events.push(Event::Html(mermaid_html.into()));
} else {
let lang_display = current_lang.as_deref().unwrap_or("text");
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!("{line}\n");
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)
};
let code_container = format!(
r#"<div class="code-block-wrapper">
<div class="code-header">
<span class="code-lang">{lang_escaped}</span>
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
</div>
<pre style="margin: 0; border-radius: 0 0 6px 6px;"><code>{highlighted_html}</code></pre>
</div>"#
);
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());
body_html
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

View File

@ -1,910 +0,0 @@
use std::collections::HashMap;
use std::sync::LazyLock;
static EMOJIS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
HashMap::from([
("bowtie", "🙇"),
("smile", "😄"),
("laughing", "😆"),
("blush", "😊"),
("smiley", "😃"),
("relaxed", "☺️"),
("smirk", "😏"),
("heart_eyes", "😍"),
("kissing_heart", "😘"),
("kissing_closed_eyes", "😚"),
("flushed", "😳"),
("relieved", "😌"),
("satisfied", "😌"),
("grin", "😁"),
("wink", "😉"),
("stuck_out_tongue_winking_eye", "😜"),
("stuck_out_tongue_closed_eyes", "😝"),
("grinning", "😀"),
("kissing", "😗"),
("kissing_smiling_eyes", "😙"),
("stuck_out_tongue", "😛"),
("sleeping", "😴"),
("worried", "😟"),
("frowning", "😦"),
("anguished", "😧"),
("open_mouth", "😮"),
("grimacing", "😬"),
("confused", "😕"),
("hushed", "😯"),
("expressionless", "😑"),
("unamused", "😒"),
("sweat_smile", "😅"),
("sweat", "😓"),
("disappointed_relieved", "😥"),
("weary", "😩"),
("pensive", "😔"),
("disappointed", "😞"),
("confounded", "😖"),
("fearful", "😨"),
("cold_sweat", "😰"),
("persevere", "😣"),
("cry", "😢"),
("sob", "😭"),
("joy", "😂"),
("astonished", "😲"),
("scream", "😱"),
("neckbeard", "🧔"),
("tired_face", "😫"),
("angry", "😠"),
("rage", "😡"),
("triumph", "😤"),
("sleepy", "😪"),
("yum", "😋"),
("mask", "😷"),
("sunglasses", "😎"),
("dizzy_face", "😵"),
("imp", "👿"),
("smiling_imp", "😈"),
("neutral_face", "😐"),
("no_mouth", "😶"),
("innocent", "😇"),
("alien", "👽"),
("yellow_heart", "💛"),
("blue_heart", "💙"),
("purple_heart", "💜"),
("heart", "❤️"),
("green_heart", "💚"),
("broken_heart", "💔"),
("heartbeat", "💓"),
("heartpulse", "💗"),
("two_hearts", "💕"),
("revolving_hearts", "💞"),
("cupid", "💘"),
("sparkling_heart", "💖"),
("sparkles", ""),
("star", ""),
("star2", "🌟"),
("dizzy", "💫"),
("boom", "💥"),
("collision", "💥"),
("anger", "💢"),
("exclamation", ""),
("question", ""),
("grey_exclamation", ""),
("grey_question", ""),
("zzz", "💤"),
("dash", "💨"),
("sweat_drops", "💦"),
("notes", "🎶"),
("musical_note", "🎵"),
("fire", "🔥"),
("hankey", "💩"),
("poop", "💩"),
("shit", "💩"),
("+1", "👍"),
("thumbsup", "👍"),
("-1", "👎"),
("thumbsdown", "👎"),
("ok_hand", "👌"),
("punch", "👊"),
("facepunch", "👊"),
("fist", ""),
("v", "✌️"),
("wave", "👋"),
("hand", ""),
("raised_hand", ""),
("open_hands", "👐"),
("point_up", "☝️"),
("point_down", "👇"),
("point_left", "👈"),
("point_right", "👉"),
("raised_hands", "🙌"),
("pray", "🙏"),
("point_up_2", "👆"),
("clap", "👏"),
("muscle", "💪"),
("metal", "🤘"),
("fu", "🖕"),
("walking", "🚶"),
("runner", "🏃"),
("running", "🏃"),
("couple", "👫"),
("family", "👨‍👩‍👦"),
("two_men_holding_hands", "👬"),
("two_women_holding_hands", "👭"),
("dancer", "💃"),
("dancers", "👯"),
("ok_woman", "🙆"),
("no_good", "🙅"),
("information_desk_person", "💁"),
("raising_hand", "🙋"),
("bride_with_veil", "👰"),
("person_with_pouting_face", "🙎"),
("person_frowning", "🙍"),
("bow", "🙇"),
("couplekiss", "💏"),
("couple_with_heart", "💑"),
("massage", "💆"),
("haircut", "💇"),
("nail_care", "💅"),
("boy", "👦"),
("girl", "👧"),
("woman", "👩"),
("man", "👨"),
("baby", "👶"),
("older_woman", "👵"),
("older_man", "👴"),
("person_with_blond_hair", "👱"),
("man_with_gua_pi_mao", "👲"),
("man_with_turban", "👳"),
("construction_worker", "👷"),
("cop", "👮"),
("angel", "👼"),
("princess", "👸"),
("smiley_cat", "😺"),
("smile_cat", "😸"),
("heart_eyes_cat", "😻"),
("kissing_cat", "😽"),
("smirk_cat", "😼"),
("scream_cat", "🙀"),
("crying_cat_face", "😿"),
("joy_cat", "😹"),
("pouting_cat", "😾"),
("japanese_ogre", "👹"),
("japanese_goblin", "👺"),
("see_no_evil", "🙈"),
("hear_no_evil", "🙉"),
("speak_no_evil", "🙊"),
("guardsman", "💂"),
("skull", "💀"),
("feet", "🐾"),
("lips", "👄"),
("kiss", "💋"),
("droplet", "💧"),
("ear", "👂"),
("eyes", "👀"),
("nose", "👃"),
("tongue", "👅"),
("love_letter", "💌"),
("bust_in_silhouette", "👤"),
("busts_in_silhouette", "👥"),
("speech_balloon", "💬"),
("thought_balloon", "💭"),
("feelsgood", "😎"),
("finnadie", "😵"),
("goberserk", "😈"),
("godmode", "😇"),
("hurtrealbad", "😰"),
("rage1", "😡"),
("rage2", "😡"),
("rage3", "😡"),
("rage4", "😡"),
("suspect", "😕"),
("trollface", "😂"),
// Nature
("sunny", "☀️"),
("umbrella", "☂️"),
("cloud", "☁️"),
("snowflake", "❄️"),
("snowman", ""),
("zap", ""),
("cyclone", "🌀"),
("foggy", "🌁"),
("ocean", "🌊"),
("cat", "🐱"),
("dog", "🐶"),
("mouse", "🐭"),
("hamster", "🐹"),
("rabbit", "🐰"),
("wolf", "🐺"),
("frog", "🐸"),
("tiger", "🐯"),
("koala", "🐨"),
("bear", "🐻"),
("pig", "🐷"),
("pig_nose", "🐽"),
("cow", "🐮"),
("boar", "🐗"),
("monkey_face", "🐵"),
("monkey", "🐒"),
("horse", "🐴"),
("racehorse", "🐎"),
("camel", "🐫"),
("sheep", "🐑"),
("elephant", "🐘"),
("panda_face", "🐼"),
("snake", "🐍"),
("bird", "🐦"),
("baby_chick", "🐤"),
("hatched_chick", "🐥"),
("hatching_chick", "🐣"),
("chicken", "🐔"),
("penguin", "🐧"),
("turtle", "🐢"),
("bug", "🐛"),
("honeybee", "🐝"),
("ant", "🐜"),
("beetle", "🐞"),
("snail", "🐌"),
("octopus", "🐙"),
("tropical_fish", "🐠"),
("fish", "🐟"),
("whale", "🐳"),
("whale2", "🐋"),
("dolphin", "🐬"),
("cow2", "🐄"),
("ram", "🐏"),
("rat", "🐀"),
("water_buffalo", "🐃"),
("tiger2", "🐅"),
("rabbit2", "🐇"),
("dragon", "🐉"),
("goat", "🐐"),
("rooster", "🐓"),
("dog2", "🐕"),
("pig2", "🐖"),
("mouse2", "🐁"),
("ox", "🐂"),
("dragon_face", "🐲"),
("blowfish", "🐡"),
("crocodile", "🐊"),
("dromedary_camel", "🐪"),
("leopard", "🐆"),
("cat2", "🐈"),
("poodle", "🐩"),
("paw_prints", "🐾"),
("bouquet", "💐"),
("cherry_blossom", "🌸"),
("tulip", "🌷"),
("four_leaf_clover", "🍀"),
("rose", "🌹"),
("sunflower", "🌻"),
("hibiscus", "🌺"),
("maple_leaf", "🍁"),
("leaves", "🍃"),
("fallen_leaf", "🍂"),
("herb", "🌿"),
("mushroom", "🍄"),
("cactus", "🌵"),
("palm_tree", "🌴"),
("evergreen_tree", "🌲"),
("deciduous_tree", "🌳"),
("chestnut", "🌰"),
("seedling", "🌱"),
("blossom", "🌼"),
("ear_of_rice", "🌾"),
("shell", "🐚"),
("globe_with_meridians", "🌐"),
("sun_with_face", "🌞"),
("full_moon_with_face", "🌝"),
("new_moon_with_face", "🌚"),
("new_moon", "🌑"),
("waxing_crescent_moon", "🌒"),
("first_quarter_moon", "🌓"),
("waxing_gibbous_moon", "🌔"),
("full_moon", "🌕"),
("waning_gibbous_moon", "🌖"),
("last_quarter_moon", "🌗"),
("waning_crescent_moon", "🌘"),
("last_quarter_moon_with_face", "🌜"),
("first_quarter_moon_with_face", "🌛"),
("moon", "🌙"),
("earth_africa", "🌍"),
("earth_americas", "🌎"),
("earth_asia", "🌏"),
("volcano", "🌋"),
("milky_way", "🌌"),
("partly_sunny", ""),
("octocat", "🐙"),
("squirrel", "🐿️"),
// Objects
("bamboo", "🎍"),
("gift_heart", "💝"),
("dolls", "🎎"),
("school_satchel", "🎒"),
("mortar_board", "🎓"),
("flags", "🎏"),
("fireworks", "🎆"),
("sparkler", "🎇"),
("wind_chime", "🎐"),
("rice_scene", "🎑"),
("jack_o_lantern", "🎃"),
("ghost", "👻"),
("santa", "🎅"),
("christmas_tree", "🎄"),
("gift", "🎁"),
("bell", "🔔"),
("no_bell", "🔕"),
("tanabata_tree", "🎋"),
("tada", "🎉"),
("confetti_ball", "🎊"),
("balloon", "🎈"),
("crystal_ball", "🔮"),
("cd", "💿"),
("dvd", "📀"),
("floppy_disk", "💾"),
("camera", "📷"),
("video_camera", "📹"),
("movie_camera", "🎥"),
("computer", "💻"),
("tv", "📺"),
("iphone", "📱"),
("phone", "📞"),
("telephone", "☎️"),
("telephone_receiver", "📞"),
("pager", "📟"),
("fax", "📠"),
("minidisc", "💽"),
("vhs", "📼"),
("sound", "🔊"),
("speaker", "🔈"),
("mute", "🔇"),
("loudspeaker", "📢"),
("mega", "📣"),
("hourglass", ""),
("hourglass_flowing_sand", ""),
("alarm_clock", ""),
("watch", ""),
("radio", "📻"),
("satellite", "📡"),
("loop", ""),
("mag", "🔍"),
("mag_right", "🔎"),
("unlock", "🔓"),
("lock", "🔒"),
("lock_with_ink_pen", "🔏"),
("closed_lock_with_key", "🔐"),
("key", "🔑"),
("bulb", "💡"),
("flashlight", "🔦"),
("high_brightness", "🔆"),
("low_brightness", "🔅"),
("electric_plug", "🔌"),
("battery", "🔋"),
("calling", "📲"),
("email", "📧"),
("mailbox", "📫"),
("postbox", "📮"),
("bath", "🛀"),
("bathtub", "🛁"),
("shower", "🚿"),
("toilet", "🚽"),
("wrench", "🔧"),
("nut_and_bolt", "🔩"),
("hammer", "🔨"),
("seat", "💺"),
("moneybag", "💰"),
("yen", "💴"),
("dollar", "💵"),
("pound", "💷"),
("euro", "💶"),
("credit_card", "💳"),
("money_with_wings", "💸"),
("e-mail", "📧"),
("inbox_tray", "📥"),
("outbox_tray", "📤"),
("envelope", "✉️"),
("incoming_envelope", "📨"),
("postal_horn", "📯"),
("mailbox_closed", "📪"),
("mailbox_with_mail", "📬"),
("mailbox_with_no_mail", "📭"),
("door", "🚪"),
("smoking", "🚬"),
("bomb", "💣"),
("gun", "🔫"),
("hocho", "🔪"),
("pill", "💊"),
("syringe", "💉"),
("page_facing_up", "📄"),
("page_with_curl", "📃"),
("bookmark_tabs", "📑"),
("bar_chart", "📊"),
("chart_with_upwards_trend", "📈"),
("chart_with_downwards_trend", "📉"),
("scroll", "📜"),
("clipboard", "📋"),
("calendar", "📅"),
("date", "📆"),
("card_index", "📇"),
("file_folder", "📁"),
("open_file_folder", "📂"),
("scissors", "✂️"),
("pushpin", "📌"),
("paperclip", "📎"),
("black_nib", "✒️"),
("pencil2", "✏️"),
("straight_ruler", "📏"),
("triangular_ruler", "📐"),
("closed_book", "📕"),
("green_book", "📗"),
("blue_book", "📘"),
("orange_book", "📙"),
("notebook", "📓"),
("notebook_with_decorative_cover", "📔"),
("ledger", "📒"),
("books", "📚"),
("bookmark", "🔖"),
("name_badge", "📛"),
("microscope", "🔬"),
("telescope", "🔭"),
("newspaper", "📰"),
("football", "🏈"),
("basketball", "🏀"),
("soccer", ""),
("baseball", ""),
("tennis", "🎾"),
("8ball", "🎱"),
("rugby_football", "🏉"),
("bowling", "🎳"),
("golf", ""),
("mountain_bicyclist", "🚵"),
("bicyclist", "🚴"),
("horse_racing", "🏇"),
("snowboarder", "🏂"),
("swimmer", "🏊"),
("surfer", "🏄"),
("ski", "⛷️"),
("spades", "♠️"),
("hearts", "♥️"),
("clubs", "♣️"),
("diamonds", "♦️"),
("gem", "💎"),
("ring", "💍"),
("trophy", "🏆"),
("musical_score", "🎼"),
("musical_keyboard", "🎹"),
("violin", "🎻"),
("space_invader", "👾"),
("video_game", "🎮"),
("black_joker", "🃏"),
("flower_playing_cards", "🎴"),
("game_die", "🎲"),
("dart", "🎯"),
("mahjong", "🀄"),
("clapper", "🎬"),
("memo", "📝"),
("pencil", "📝"),
("book", "📖"),
("art", "🎨"),
("microphone", "🎤"),
("headphones", "🎧"),
("trumpet", "🎺"),
("saxophone", "🎷"),
("guitar", "🎸"),
("shoe", "👞"),
("sandal", "👡"),
("high_heel", "👠"),
("lipstick", "💄"),
("boot", "👢"),
("shirt", "👕"),
("tshirt", "👕"),
("necktie", "👔"),
("womans_clothes", "👚"),
("dress", "👗"),
("running_shirt_with_sash", "🎽"),
("jeans", "👖"),
("kimono", "👘"),
("bikini", "👙"),
("ribbon", "🎀"),
("tophat", "🎩"),
("crown", "👑"),
("womans_hat", "👒"),
("mans_shoe", "👞"),
("closed_umbrella", "🌂"),
("briefcase", "💼"),
("handbag", "👜"),
("pouch", "👝"),
("purse", "👛"),
("eyeglasses", "👓"),
("fishing_pole_and_fish", "🎣"),
("coffee", ""),
("tea", "🍵"),
("sake", "🍶"),
("baby_bottle", "🍼"),
("beer", "🍺"),
("beers", "🍻"),
("cocktail", "🍸"),
("tropical_drink", "🍹"),
("wine_glass", "🍷"),
("fork_and_knife", "🍴"),
("pizza", "🍕"),
("hamburger", "🍔"),
("fries", "🍟"),
("poultry_leg", "🍗"),
("meat_on_bone", "🍖"),
("spaghetti", "🍝"),
("curry", "🍛"),
("fried_shrimp", "🍤"),
("bento", "🍱"),
("sushi", "🍣"),
("fish_cake", "🍥"),
("rice_ball", "🍙"),
("rice_cracker", "🍘"),
("rice", "🍚"),
("ramen", "🍜"),
("stew", "🍲"),
("oden", "🍢"),
("dango", "🍡"),
("egg", "🍳"),
("bread", "🍞"),
("doughnut", "🍩"),
("custard", "🍮"),
("icecream", "🍦"),
("ice_cream", "🍨"),
("shaved_ice", "🍧"),
("birthday", "🎂"),
("cake", "🍰"),
("cookie", "🍪"),
("chocolate_bar", "🍫"),
("candy", "🍬"),
("lollipop", "🍭"),
("honey_pot", "🍯"),
("apple", "🍎"),
("green_apple", "🍏"),
("tangerine", "🍊"),
("lemon", "🍋"),
("cherries", "🍒"),
("grapes", "🍇"),
("watermelon", "🍉"),
("strawberry", "🍓"),
("peach", "🍑"),
("melon", "🍈"),
("banana", "🍌"),
("pear", "🍐"),
("pineapple", "🍍"),
("sweet_potato", "🍠"),
("eggplant", "🍆"),
("tomato", "🍅"),
("corn", "🌽"),
// Places
("house", "🏠"),
("house_with_garden", "🏡"),
("school", "🏫"),
("office", "🏢"),
("post_office", "🏣"),
("hospital", "🏥"),
("bank", "🏦"),
("convenience_store", "🏪"),
("love_hotel", "🏩"),
("hotel", "🏨"),
("wedding", "💒"),
("church", ""),
("department_store", "🏬"),
("european_post_office", "🏤"),
("city_sunrise", "🌇"),
("city_sunset", "🌆"),
("japanese_castle", "🏯"),
("european_castle", "🏰"),
("tent", ""),
("factory", "🏭"),
("tokyo_tower", "🗼"),
("japan", "🗾"),
("mount_fuji", "🗻"),
("sunrise_over_mountains", "🌄"),
("sunrise", "🌅"),
("stars", "🌠"),
("statue_of_liberty", "🗽"),
("bridge_at_night", "🌉"),
("carousel_horse", "🎠"),
("rainbow", "🌈"),
("ferris_wheel", "🎡"),
("fountain", ""),
("roller_coaster", "🎢"),
("ship", "🚢"),
("speedboat", "🚤"),
("boat", ""),
("sailboat", ""),
("rowboat", "🚣"),
("anchor", ""),
("rocket", "🚀"),
("airplane", "✈️"),
("helicopter", "🚁"),
("steam_locomotive", "🚂"),
("tram", "🚋"),
("mountain_railway", "🚞"),
("bike", "🚲"),
("aerial_tramway", "🚡"),
("suspension_railway", "🚟"),
("mountain_cableway", "🚠"),
("tractor", "🚜"),
("blue_car", "🚙"),
("oncoming_automobile", "🚘"),
("car", "🚗"),
("red_car", "🚗"),
("taxi", "🚕"),
("oncoming_taxi", "🚖"),
("articulated_lorry", "🚛"),
("bus", "🚌"),
("oncoming_bus", "🚍"),
("rotating_light", "🚨"),
("police_car", "🚓"),
("oncoming_police_car", "🚔"),
("fire_engine", "🚒"),
("ambulance", "🚑"),
("minibus", "🚐"),
("truck", "🚚"),
("train", "🚆"),
("station", "🚉"),
("train2", "🚄"),
("bullettrain_front", "🚅"),
("bullettrain_side", "🚄"),
("light_rail", "🚈"),
("monorail", "🚝"),
("railway_car", "🚃"),
("trolleybus", "🚎"),
("ticket", "🎫"),
("fuelpump", ""),
("vertical_traffic_light", "🚦"),
("traffic_light", "🚥"),
("warning", "⚠️"),
("construction", "🚧"),
("beginner", "🔰"),
("atm", "🏧"),
("slot_machine", "🎰"),
("busstop", "🚏"),
("barber", "💈"),
("hotsprings", "♨️"),
("checkered_flag", "🏁"),
("crossed_flags", "🎌"),
("izakaya_lantern", "🏮"),
("moyai", "🗿"),
("circus_tent", "🎪"),
("performing_arts", "🎭"),
("round_pushpin", "📍"),
("triangular_flag_on_post", "🚩"),
("jp", "🇯🇵"),
("kr", "🇰🇷"),
("cn", "🇨🇳"),
("us", "🇺🇸"),
("fr", "🇫🇷"),
("es", "🇪🇸"),
("it", "🇮🇹"),
("ru", "🇷🇺"),
("gb", "🇬🇧"),
("uk", "🇬🇧"),
("de", "🇩🇪"),
// Symbols
("one", "1"),
("two", "2"),
("three", "3"),
("four", "4"),
("five", "5"),
("six", "6"),
("seven", "7"),
("eight", "8"),
("nine", "9"),
("keycap_ten", "🔟"),
("1234", "🔢"),
("zero", "0"),
("hash", "#️⃣"),
("symbols", "🔣"),
("arrow_backward", "◀️"),
("arrow_down", "⬇️"),
("arrow_forward", "▶️"),
("arrow_left", "⬅️"),
("capital_abcd", "🔠"),
("abcd", "🔡"),
("abc", "🔤"),
("arrow_lower_left", "↙️"),
("arrow_lower_right", "↘️"),
("arrow_right", "➡️"),
("arrow_up", "⬆️"),
("arrow_upper_left", "↖️"),
("arrow_upper_right", "↗️"),
("arrow_double_down", ""),
("arrow_double_up", ""),
("arrow_down_small", "🔽"),
("arrow_heading_down", "↘️"),
("arrow_heading_up", "↗️"),
("leftwards_arrow_with_hook", "↩️"),
("arrow_right_hook", "↪️"),
("left_right_arrow", "↔️"),
("arrow_up_down", "↕️"),
("arrow_up_small", "🔼"),
("arrows_clockwise", "🔃"),
("arrows_counterclockwise", "🔄"),
("rewind", ""),
("fast_forward", ""),
("information_source", ""),
("ok", "🆗"),
("twisted_rightwards_arrows", "🔀"),
("repeat", "🔁"),
("repeat_one", "🔂"),
("new", "🆕"),
("top", "🔝"),
("up", "🆙"),
("cool", "🆒"),
("free", "🆓"),
("ng", "🆖"),
("cinema", "🎦"),
("koko", "🈁"),
("signal_strength", "📶"),
("u5272", "🈹"),
("u5408", "🈴"),
("u55b6", "🈺"),
("u6307", "🈯"),
("u6708", "🈷️"),
("u6709", "🈶"),
("u6e80", "🈵"),
("u7121", "🈚"),
("u7533", "🈸"),
("u7a7a", "🈳"),
("u7981", "🈲"),
("sa", "🈂️"),
("restroom", "🚻"),
("mens", "🚹"),
("womens", "🚺"),
("baby_symbol", "🚼"),
("no_smoking", "🚭"),
("parking", "🅿️"),
("wheelchair", ""),
("metro", "🚇"),
("baggage_claim", "🛄"),
("accept", "🉑"),
("wc", "🚾"),
("potable_water", "🚰"),
("put_litter_in_its_place", "🚮"),
("secret", "㊙️"),
("congratulations", "㊗️"),
("m", "Ⓜ️"),
("passport_control", "🛂"),
("left_luggage", "🛅"),
("customs", "🛃"),
("ideograph_advantage", "🉐"),
("cl", "🆑"),
("sos", "🆘"),
("id", "🆔"),
("no_entry_sign", "🚫"),
("underage", "🔞"),
("no_mobile_phones", "📵"),
("do_not_litter", "🚯"),
("non-potable_water", "🚱"),
("no_bicycles", "🚳"),
("no_pedestrians", "🚷"),
("children_crossing", "🚸"),
("no_entry", ""),
("eight_spoked_asterisk", "✳️"),
("eight_pointed_black_star", "✴️"),
("heart_decoration", "💟"),
("vs", "🆚"),
("vibration_mode", "📳"),
("mobile_phone_off", "📴"),
("chart", "💹"),
("currency_exchange", "💱"),
("aries", ""),
("taurus", ""),
("gemini", ""),
("cancer", ""),
("leo", ""),
("virgo", ""),
("libra", ""),
("scorpius", ""),
("sagittarius", ""),
("capricorn", ""),
("aquarius", ""),
("pisces", ""),
("ophiuchus", ""),
("six_pointed_star", "🔯"),
("negative_squared_cross_mark", ""),
("a", "🅰️"),
("b", "🅱️"),
("ab", "🆎"),
("o2", "🅾️"),
("diamond_shape_with_a_dot_inside", "💠"),
("recycle", "♻️"),
("end", "🔚"),
("on", "🔛"),
("soon", "🔜"),
("clock1", "🕐"),
("clock130", "🕜"),
("clock10", "🕙"),
("clock1030", "🕥"),
("clock11", "🕚"),
("clock1130", "🕦"),
("clock12", "🕛"),
("clock1230", "🕧"),
("clock2", "🕑"),
("clock230", "🕝"),
("clock3", "🕒"),
("clock330", "🕞"),
("clock4", "🕓"),
("clock430", "🕟"),
("clock5", "🕔"),
("clock530", "🕠"),
("clock6", "🕕"),
("clock630", "🕡"),
("clock7", "🕖"),
("clock730", "🕢"),
("clock8", "🕗"),
("clock830", "🕣"),
("clock9", "🕘"),
("clock930", "🕤"),
("heavy_dollar_sign", "💲"),
("copyright", "©️"),
("registered", "®️"),
("tm", "™️"),
("x", ""),
("heavy_exclamation_mark", ""),
("bangbang", "‼️"),
("interrobang", "⁉️"),
("o", ""),
("heavy_multiplication_x", "✖️"),
("heavy_plus_sign", ""),
("heavy_minus_sign", ""),
("heavy_division_sign", ""),
("white_flower", "💮"),
("100", "💯"),
("heavy_check_mark", "✔️"),
("ballot_box_with_check", "☑️"),
("radio_button", "🔘"),
("link", "🔗"),
("curly_loop", ""),
("wavy_dash", "〰️"),
("part_alternation_mark", "〽️"),
("trident", "🔱"),
("black_square", ""),
("white_square", ""),
("white_check_mark", ""),
("black_square_button", "🔲"),
("white_square_button", "🔳"),
("black_circle", ""),
("white_circle", ""),
("red_circle", "🔴"),
("large_blue_circle", "🔵"),
("large_blue_diamond", "🔷"),
("large_orange_diamond", "🔶"),
("small_blue_diamond", "🔹"),
("small_orange_diamond", "🔸"),
("small_red_triangle", "🔺"),
("small_red_triangle_down", "🔻"),
("shipit", "🚢"),
])
});
pub fn replace_emoji(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut current = text;
while let Some((before, after)) = current.split_once(':') {
result.push_str(before);
if let Some((name, rest)) = after.split_once(':') {
if let Some(emoji) = EMOJIS.get(name) {
result.push_str(emoji);
} else {
result.push(':');
result.push_str(name);
result.push(':');
}
current = rest;
} else {
result.push(':');
result.push_str(after);
current = "";
break;
}
}
if !current.is_empty() {
result.push_str(current);
}
result
}

View File

@ -1,530 +0,0 @@
use pulldown_cmark::{
Alignment, BlockQuoteKind, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag,
TagEnd, TextMergeStream,
};
use std::collections::HashMap;
use std::fmt::Write;
mod emoji;
use emoji::replace_emoji;
#[derive(Debug, Clone, PartialEq)]
pub enum AlignmentState {
Left,
Center,
Right,
None,
}
impl From<Alignment> for AlignmentState {
fn from(alignment: Alignment) -> Self {
match alignment {
Alignment::Left => AlignmentState::Left,
Alignment::Center => AlignmentState::Center,
Alignment::Right => AlignmentState::Right,
Alignment::None => AlignmentState::None,
}
}
}
impl AlignmentState {
pub fn to_css_class(&self) -> &'static str {
match self {
AlignmentState::Left => "align-left",
AlignmentState::Center => "align-center",
AlignmentState::Right => "align-right",
AlignmentState::None => "",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TableState {
pub alignments: Vec<AlignmentState>,
pub column_count: usize,
pub is_inside_table: bool,
pub is_inside_head: bool,
pub is_inside_body: bool,
pub current_row: usize,
pub current_column: usize,
pub cell_data: HashMap<(usize, usize), String>,
pub has_header: bool,
}
impl TableState {
pub fn new() -> Self {
Self::default()
}
pub fn start_table(&mut self, alignments: Vec<Alignment>) {
self.alignments = alignments.into_iter().map(AlignmentState::from).collect();
self.column_count = self.alignments.len();
self.is_inside_table = true;
self.is_inside_head = false;
self.is_inside_body = false;
self.current_row = 0;
self.current_column = 0;
self.cell_data.clear();
self.has_header = false;
}
pub fn end_table(&mut self) {
self.is_inside_table = false;
self.is_inside_head = false;
self.is_inside_body = false;
}
pub fn start_head(&mut self) {
if self.is_inside_table {
self.is_inside_head = true;
self.is_inside_body = false;
self.current_row = 0;
self.current_column = 0;
self.has_header = true;
}
}
pub fn end_head(&mut self) {
self.is_inside_head = false;
}
pub fn start_row(&mut self) {
if self.is_inside_table {
if self.is_inside_body {
self.current_row += 1;
}
self.current_column = 0;
}
}
pub fn end_row(&mut self) {
if self.current_column != self.column_count && self.is_inside_table && self.column_count > 0
{
// Исправляем неполные строки, добавляя недостающие ячейки
while self.current_column < self.column_count {
let row = if self.is_inside_head {
0
} else {
self.current_row
};
let col = self.current_column;
self.cell_data.insert((row, col), String::new());
self.current_column += 1;
}
}
}
pub fn start_cell(&mut self) {
if self.is_inside_table {
self.current_column += 1;
}
}
pub fn end_cell(&mut self, data: Option<String>) {
if let Some(text) = data {
let row = if self.is_inside_head {
0
} else {
self.current_row
};
let col = self.current_column - 1;
self.cell_data.insert((row, col), text);
}
}
pub fn get_current_alignment(&self) -> &AlignmentState {
if self.current_column == 0 || self.current_column > self.alignments.len() {
return &AlignmentState::None;
}
let col_index = self.current_column - 1;
&self.alignments[col_index]
}
}
// ========== Основной рендерер ==========
pub struct MarkdownRenderer {
table_state: TableState,
in_code_block: bool,
code_content: String,
}
impl MarkdownRenderer {
pub fn new() -> Self {
Self {
table_state: TableState::new(),
in_code_block: false,
code_content: String::new(),
}
}
pub fn render_start_tag(&mut self, tag: Tag<'_>) -> String {
let mut output = String::new();
match tag {
Tag::Heading { level, .. } => {
output.push_str(format!("<{level}>").as_str());
}
Tag::Strikethrough => {
output.push_str("<strike>");
}
Tag::List(Some(_)) => output.push_str("<ol>"),
Tag::List(None) => output.push_str("<ul>"),
Tag::Item => output.push_str("<li>"),
Tag::Paragraph => output.push_str("<p>"),
Tag::CodeBlock(kind) => {
output.push_str(render_codeblock(kind).as_str());
}
Tag::Emphasis => output.push_str("<em>"),
Tag::Strong => output.push_str("<strong>"),
Tag::BlockQuote(kind) => output.push_str(render_blockquote(kind).as_str()),
Tag::Link {
link_type,
dest_url,
title,
..
} => output.push_str(render_link(link_type, &dest_url, &title).as_str()),
Tag::Image {
link_type,
dest_url,
title,
..
} => output.push_str(render_image(link_type, &dest_url, &title).as_str()),
Tag::Table(alignments) => {
self.table_state.start_table(alignments.clone());
output.push_str("<table>");
}
Tag::TableHead => {
self.table_state.start_head();
output.push_str("<thead>");
}
Tag::TableCell => {
self.table_state.start_cell();
let alignment = self.table_state.get_current_alignment();
let css_class = alignment.to_css_class();
if css_class.is_empty() {
output.push_str("<td>");
} else {
let _ = write!(output, "<td class=\"{css_class}\">");
}
}
Tag::TableRow => {
self.table_state.start_row();
output.push_str("<tr>");
}
Tag::FootnoteDefinition(fref) => {
let _ = write!(output, "<sup><a href=\"#{fref}\" id=\"{fref}\">");
}
Tag::Superscript => {
output.push_str("<sup>");
}
Tag::Subscript => {
output.push_str("<sub>");
}
Tag::HtmlBlock
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::MetadataBlock(_) => (),
}
output
}
pub fn render_end_tag(&mut self, tag: TagEnd) -> String {
let mut output = String::new();
match tag {
TagEnd::Heading(level) => {
output.push_str(format!("</{level}>").as_str());
}
TagEnd::Strikethrough => {
output.push_str("</strike>");
}
TagEnd::List(true) => output.push_str("</ol>"),
TagEnd::List(false) => output.push_str("</ul>"),
TagEnd::Item => output.push_str("</li>"),
TagEnd::Paragraph => output.push_str("</p>"),
TagEnd::CodeBlock => {
output.push_str("</code></pre></div>");
}
TagEnd::Emphasis => output.push_str("</em>"),
TagEnd::Strong => output.push_str("</strong>"),
TagEnd::BlockQuote(_) => output.push_str("</blockquote>"),
TagEnd::Link => output.push_str("</a>"),
TagEnd::Image => output.push_str("</img>"),
TagEnd::Table => {
self.table_state.end_table();
output.push_str("</table>");
}
TagEnd::TableHead => {
self.table_state.end_head();
output.push_str("</thead>");
if self.table_state.is_inside_table {
output.push_str("<tbody>");
self.table_state.is_inside_body = true;
}
}
TagEnd::TableCell => {
self.table_state.end_cell(None);
output.push_str("</td>");
}
TagEnd::TableRow => {
self.table_state.end_row();
output.push_str("</tr>");
}
TagEnd::FootnoteDefinition => {
output.push_str("</a></sup>");
}
TagEnd::Superscript => {
output.push_str("</sup>");
}
TagEnd::Subscript => {
output.push_str("</sub>");
}
TagEnd::HtmlBlock
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::MetadataBlock(_) => (),
}
output
}
}
// ========== Основная функция ==========
pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String {
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_HEADING_ATTRIBUTES);
options.insert(Options::ENABLE_GFM);
options.insert(Options::ENABLE_SUBSCRIPT);
options.insert(Options::ENABLE_SUPERSCRIPT);
let mut renderer = MarkdownRenderer::new();
let mut html_output = String::new();
let iterator = TextMergeStream::new(Parser::new_ext(markdown, options));
for event in iterator {
match event {
Event::Start(tag) => {
if let Tag::CodeBlock(_) = tag {
renderer.in_code_block = true;
renderer.code_content.clear();
}
html_output.push_str(renderer.render_start_tag(tag).as_str());
}
Event::End(tag) => {
if let TagEnd::CodeBlock = tag {
renderer.in_code_block = false;
html_output.push_str(escape_html(&renderer.code_content).as_str());
}
html_output.push_str(renderer.render_end_tag(tag).as_str());
}
Event::Text(text) => {
if renderer.in_code_block {
renderer.code_content.push_str(&text);
} else {
html_output.push_str(&escape_html(&replace_emoji(&text)));
}
}
Event::TaskListMarker(true) => {
html_output.push_str("<input type=\"checkbox\" checked/>");
}
Event::TaskListMarker(false) => {
html_output.push_str("<input type=\"checkbox\"/>");
}
Event::Code(code) => {
html_output.push_str(format!("<code>{}</code>", escape_html(&code)).as_str());
}
Event::SoftBreak => html_output.push(' '),
Event::HardBreak => html_output.push_str("<br>"),
Event::Html(html) | Event::InlineHtml(html) => html_output.push_str(&html),
Event::Rule => html_output.push_str("<hr>"),
Event::FootnoteReference(fref) => {
let _ = write!(
html_output,
"<sup><a href=\"#{fref}\" id=\"{fref}\">{fref}</a></sup>"
);
}
Event::InlineMath(_) | Event::DisplayMath(_) => (),
}
}
html_output
}
// ========== Вспомогательные функции ==========
fn render_codeblock(block_kind: CodeBlockKind<'_>) -> String {
let header = String::from(
r#"
<div class="code-block-wrapper">
<div class="code-header">
<span class="code-lang">%CODE%</span> <button class="copy-btn" onclick="copyCode(this)">Copy</button>
</div>
<pre>"#,
);
let mut output = String::new();
match block_kind {
CodeBlockKind::Indented => {
output.push_str(&header.replace("%CODE%", "code"));
output.push_str("<code>");
}
CodeBlockKind::Fenced(CowStr::Borrowed("mermaid")) => {
output.push_str(&header.replace("%CODE%", "mermaid"));
output.push_str("<code class=\"language-text mermaid\">");
}
CodeBlockKind::Fenced(CowStr::Borrowed("rawmermaid")) => {
output.push_str(&header.replace("%CODE%", "mermaid"));
output.push_str("<code class=\"language-text\">");
}
CodeBlockKind::Fenced(code_type) => {
output.push_str(&header.replace("%CODE%", &code_type));
output.push_str(format!("<code class=\"language-{code_type}\">").as_str());
}
}
output
}
fn render_blockquote(quote_kind: Option<BlockQuoteKind>) -> String {
let alert_class_and_title = match quote_kind {
None => return "<blockquote>".to_string(),
Some(BlockQuoteKind::Note) => ("note", "Note"),
Some(BlockQuoteKind::Tip) => ("tip", "Tip"),
Some(BlockQuoteKind::Important) => ("important", "Important"),
Some(BlockQuoteKind::Warning) => ("warning", "Warning"),
Some(BlockQuoteKind::Caution) => ("caution", "Caution"),
};
format!(
"<blockquote><div class=\"markdown-alert markdown-alert-{}\"><p class=\"markdown-alert-title\">{}</p>",
alert_class_and_title.0, alert_class_and_title.1
)
}
fn render_link(link_type: LinkType, dest_url: &CowStr<'_>, title: &CowStr<'_>) -> String {
match link_type {
LinkType::Email => format!("<a href=\"mailto:{dest_url}\" title=\"{title}\">"),
_ => format!(
"<a href=\"{dest_url}\" title=\"{title}\" target=\"_blank\" rel=\"noopener noreferrer\">"
),
}
}
fn render_image(_link_type: LinkType, dest_url: &CowStr<'_>, title: &CowStr<'_>) -> String {
format!("<img src=\"{dest_url}\" alt=\"{title}\">")
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
// ========== Тесты ==========
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_with_alignments() {
let markdown = r#"
| Header 1 | Header 2 | Header 3 |
|----------|:--------:|---------:|
| Left | Center | Right |
| Data 1 | Data 2 | Data 3 |
"#;
let html = markdown_to_html(markdown, "");
// Проверяем, что HTML содержит правильные классы и атрибуты
assert!(html.contains("<table>"));
assert!(html.contains("<thead>"));
assert!(html.contains("<tbody>"));
assert!(html.contains("text-left"));
assert!(html.contains("text-center"));
assert!(html.contains("text-right"));
}
#[test]
fn test_table_state_tracking() {
let mut renderer = MarkdownRenderer::new();
let alignments = vec![Alignment::Left, Alignment::Center, Alignment::Right];
renderer.render_start_tag(Tag::Table(alignments.clone()));
assert!(renderer.table_state.is_inside_table);
assert_eq!(renderer.table_state.column_count, 3);
renderer.render_start_tag(Tag::TableHead);
assert!(renderer.table_state.is_inside_head);
renderer.render_start_tag(Tag::TableRow);
renderer.render_start_tag(Tag::TableCell);
assert_eq!(
renderer.table_state.get_current_alignment(),
&AlignmentState::Left
);
renderer.render_end_tag(TagEnd::TableCell);
renderer.render_start_tag(Tag::TableCell);
assert_eq!(
renderer.table_state.get_current_alignment(),
&AlignmentState::Center
);
renderer.render_end_tag(TagEnd::TableCell);
renderer.render_start_tag(Tag::TableCell);
assert_eq!(
renderer.table_state.get_current_alignment(),
&AlignmentState::Right
);
renderer.render_end_tag(TagEnd::TableCell);
renderer.render_end_tag(TagEnd::TableRow);
renderer.render_end_tag(TagEnd::TableHead);
// Проверяем, что тело было автоматически открыто
renderer.render_start_tag(Tag::TableRow);
renderer.render_start_tag(Tag::TableCell);
assert_eq!(
renderer.table_state.get_current_alignment(),
&AlignmentState::Left
);
renderer.render_end_tag(TagEnd::TableCell);
renderer.render_end_tag(TagEnd::TableRow);
renderer.render_end_tag(TagEnd::Table);
assert!(!renderer.table_state.is_inside_table);
}
}

View File

@ -1,78 +0,0 @@
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
use syntect::parsing::SyntaxSet;
/// Преобразует исходный код и его язык в подсвеченный HTML.
///
/// # Аргументы
/// * `code` - Исходный код как строка.
/// * `lang` - Идентификатор языка (например, "rust", "python", "mermaid").
/// * `ss` - Набор синтаксисов (`SyntaxSet`).
/// * `ts` - Набор тем (`ThemeSet`).
///
/// # Возвращает
/// Строку HTML, содержащую обертку блока кода с заголовком и кнопкой копирования.
pub fn code_to_html(code: &str, lang: &str, ss: &SyntaxSet, ts: &ThemeSet) -> String {
let theme = &ts.themes["base16-ocean.dark"];
if lang == "mermaid" {
let escaped_code = escape_html(code);
return format!(
r#"<div class="code-block-wrapper mermaid-wrapper">
<div class="code-header">
<span class="code-lang">Mermaid Diagram</span>
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
</div>
<div class="mermaid" style="background: transparent; padding: 20px; text-align: center;">{escaped_code}</div>
</div>"#
);
}
let lang_display = if lang.is_empty() { "text" } else { lang };
let lang_escaped = escape_html(lang_display);
let highlighted_html = if lang.is_empty() {
escape_html(code)
} else 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 code.lines() {
let line_with_newline = format!("{line}\n");
match h.highlight_line(&line_with_newline, ss) {
Ok(regions) => {
match styled_line_to_highlighted_html(&regions[..], IncludeBackground::No) {
Ok(html_line) => result_html.push_str(&html_line),
Err(_) => result_html.push_str(&escape_html(&line_with_newline)),
}
}
Err(_) => {
result_html.push_str(&escape_html(&line_with_newline));
}
}
}
result_html
} else {
escape_html(code)
};
format!(
r#"<div class="code-block-wrapper">
<div class="code-header">
<span class="code-lang">{lang_escaped}</span>
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
</div>
<pre style="margin: 0; border-radius: 0 0 6px 6px;"><code>{highlighted_html}</code></pre>
</div>"#
)
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

View File

@ -1,279 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=0.7">
<title>{% block title %}Note{% endblock %}</title>
<link rel="stylesheet" href="{{ highlight_style_url }}">
<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;
background-color: #1e1e1e;
width: 100%;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
a {
color: #bb86fc;
text-decoration: none !important;
}
p {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
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;
}
th, thead td {
font-weight: bold;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-none {
text-align: default;
}
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;
}
.header a {
padding: 1em;
color: #757575;
font-size: 0.95em;
}
.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); }
.copy-path-btn {
background: transparent;
color: #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s;
}
.copy-path-btn:hover { background-color: #444; color: #fff; border-color: #777; }
.copy-path-btn:active { transform: scale(0.95); }
pre { padding: 15px; overflow-x: auto; margin: 0; }
pre code { background: transparent; padding: 0; color: inherit; }
.mermaid-wrapper .mermaid {
background-color: #f9f9f9;
border-radius: 0 0 6px 6px;
display: flex;
justify-content: center;
}
.footer {
margin-top: 30px;
color: #666;
font-size: 0.9em;
border-top: 1px solid #333;
padding-top: 15px;
}
.footer a { padding: 1em; color: #757575; }
.back-link {
display: inline-flex;
align-items: center;
color: #90a4ae;
text-decoration: none;
font-size: 0.95em;
transition: color 0.2s;
}
.back-link span { margin-right: 8px; font-size: 1.2em; }
img {
max-width: 100%;
height: auto;
display: block;
margin: 1.5em auto;
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.file-link {
color: #64b5f6;
font-size: 1.1em;
display: flex;
align-items: center;
}
.file-link:hover {
color: #90caf9;
}
.icon {
margin-right: 10px;
min-width: 24px;
}
</style>
</head>
<body>
<div class="content">
<div class="header">
{% block header %}
<a href="/" class="back-link" onmouseover="this.style.color='#ffffff'" onmouseout="this.style.color='#90a4ae'">
<span></span> Back
</a>
<a href="/random">🎲 Random note</a>
{% endblock %}
</div>
{% block content %}{% endblock %}
<div class="footer">
<a href="/">← Main</a>
<a href="/random">🎲 Random note</a>
{% block footer %}
<p>{{ package_name }} v{{ version }}</p>
{% endblock %}
</div>
</div>
<script src="{{ mermaid_script_url }}"></script>
<script src="{{ highlight_script_url }}"></script>
<script>
function copyCode(button) {
const wrapper = button.closest('.code-block-wrapper');
if (!wrapper) return;
const target = wrapper.querySelector('pre') || wrapper.querySelector('.mermaid');
if (!target) return;
const codeText = target.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('Error copying:', err);
button.innerText = 'Error';
});
}
function copyPath(path) {
navigator.clipboard.writeText(path).then(() => {}).catch(err => {
console.error('Error copying:', err);
button.innerText = 'Error';
});
}
mermaid.initialize({
startOnLoad: true,
theme: 'dark',
securityLevel: 'loose',
});
hljs.highlightAll();
</script>
<script>{% block scripts %}{% endblock %}</script>
</body>
</html>

View File

@ -1,34 +1,33 @@
{% extends ".base.min.html" %}
{% block title %}Directory: /{{ title_path }}{% endblock %}
{% block header %}
{% if !title_path.is_empty() %}
<a href="../" class="back-link">
<span></span> Up
</a>
{% else %}
<span style="color: #757575; font-size: 0.95em;">🏠 Root</span>
{% endif %}
<a href="/random">🎲 Random note</a>
{% endblock %}
{% block content %}
<h1>📂 Directory: /{{ title_path }}</h1>
<ul>
{% if !title_path.is_empty() %}
<li>
<a href="../" class="back-link">📁 ..</a>
</li>
{% endif %}
{% for file in files %}
<li>
<a href="/{{ file.link }}" class="file-link">
<span class="icon">{% if file.is_dir %}📁{% else %}📄{% endif %}</span>
<span>{{ file.name }}</span>
</a>
</li>
{% endfor %}
</ul>
{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Обзор директории</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; }
.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); }
h1 { color: #ffffff; border-bottom: 1px solid #333; padding-bottom: 10px; margin-top: 0; }
a { text-decoration: none !important; transition: opacity 0.2s; }
a:hover { opacity: 0.8; }
ul { list-style-type: none; padding: 0; margin: 0; }
li { padding: 10px 0; border-bottom: 1px solid #333; transition: background-color 0.2s; }
.file-link { color: #64b5f6; text-decoration: none !important; font-size: 1.1em; display: flex; align-items: center; }
.file-link:hover { color: #90caf9; }
.back-link { color: #90a4ae; font-weight: bold; text-decoration: none !important; display: block; }
.icon { margin-right: 10px; min-width: 24px; }
.footer { margin-top: 30px; color: #666; font-size: 0.9em; border-top: 1px solid #333; padding-top: 15px; }
.footer a { color: #757575; }
</style>
</head>
<body>
<div class="content">
<h1>📂 Обзор директории: /{{TITLE_PATH}}</h1>
{{FILE_LIST}}
<div class="footer">
<a href="/">На главную</a>
<a href="/random">🎲 Случайный файл</a>
</div>
</div>
</body>
</html>

View File

@ -1,47 +1,104 @@
{% extends ".base.min.html" %}
<!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>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<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 !important; }
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; }
{% block title %}{{ filename }}{% endblock %}
.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; }
{% block header %}
<a href="{{ back_link }}" class="back-link" onmouseover="this.style.color='#ffffff'" onmouseout="this.style.color='#90a4ae'">
<span></span> Back
</a>
<a href="/random">🎲 Random note</a>
<a class="copy-path-btn" onclick="copyPath('{{ copy_path }}')">Copy edit command</a>
{% endblock %}
.mermaid-wrapper .mermaid { background-color: #f9f9f9; border-radius: 0 0 6px 6px; display: flex; justify-content: center; }
{% block content %}
{{ content|safe }}
{% endblock %}
#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; }
{% block scripts %}
const sseUrl = "{{ sse_url }}";
function connect() {
const evtSource = new EventSource(sseUrl);
evtSource.onerror = (err) => {
evtSource.close();
setTimeout(connect, 3000);
};
evtSource.addEventListener("reload", (event) => {
console.log("Get new update event");
location.reload();
});
}
connect();
document.querySelectorAll('.task-list-item-checkbox').forEach(cb => {
cb.addEventListener('change', function() {
const taskText = this.parentElement.textContent.trim();
const items = JSON.parse(localStorage.getItem('taskStates') || '{}');
items[taskText] = this.checked;
localStorage.setItem('taskStates', JSON.stringify(items));
});
// Восстановление состояния
const taskText = this.parentElement.textContent.trim();
const items = JSON.parse(localStorage.getItem('taskStates') || '{}');
if (items[taskText] !== undefined) {
this.checked = items[taskText];
img {
max-width: 100%;
height: auto;
display: block;
margin: 1.5em auto;
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
});
{% endblock %}
</style>
</head>
<body>
<div class="content">
<div style="margin-bottom: 20px;">
<a href="{{BACK_LINK}}" style="display: inline-flex; align-items: center; color: #90a4ae; text-decoration: none; font-size: 0.95em; transition: color 0.2s;"
onmouseover="this.style.color='#ffffff'" onmouseout="this.style.color='#90a4ae'">
<span style="margin-right: 8px; font-size: 1.2em;"></span> Назад
</a>
</div>
{{CONTENT}}
</div>
<script>
const sseUrl = "{{SSE_URL}}";
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) {
const wrapper = button.closest('.code-block-wrapper');
if (!wrapper) return;
const target = wrapper.querySelector('pre') || wrapper.querySelector('.mermaid');
if (!target) return;
const codeText = target.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';
});
}
mermaid.initialize({
startOnLoad: true,
theme: 'dark',
securityLevel: 'loose',
});
</script>
</body>
</html>