Compare commits

..

No commits in common. "94ece0f5a9bf46e3ed6f5453d10d4783e0c46166" and "a8769fb85ca2a85eb2021fd3a98d5d892f38beca" have entirely different histories.

4 changed files with 136 additions and 467 deletions

View File

@ -3,7 +3,7 @@ FROM rust:1.94 AS chef
RUN cargo install --locked cargo-chef
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY Cargo.toml Cargo.lock .
RUN mkdir src && touch src/main.rs
RUN cargo chef prepare --recipe-path recipe.json

View File

@ -5,4 +5,4 @@
* [X] ~~Сделать отображение типа кода и кнопку скопировать код~~
* [X] ~~Вынести зависимости, типа скриптов mermaid и highlight.js в
include_bytes~~
* [X] ~~Починить заголовки таблицы~~
* [ ] Починить заголовки таблицы

View File

@ -1,300 +1,10 @@
use pulldown_cmark::{
Alignment, BlockQuoteKind, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag,
TagEnd, TextMergeStream,
BlockQuoteKind, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd,
TextMergeStream,
};
use std::collections::HashMap;
use std::fmt::Write;
// ========== Класс состояния таблицы ==========
#[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::HtmlBlock
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::Superscript
| Tag::Subscript
| 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::HtmlBlock
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::MetadataBlock(_) => (),
}
output
}
}
// ========== Основная функция ==========
pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
@ -304,7 +14,9 @@ pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String {
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
options.insert(Options::ENABLE_GFM);
let mut renderer = MarkdownRenderer::new();
let mut in_code_block = false;
let mut code_content = String::new();
let mut html_output = String::new();
let iterator = TextMergeStream::new(Parser::new_ext(markdown, options));
@ -313,21 +25,21 @@ pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String {
match event {
Event::Start(tag) => {
if let Tag::CodeBlock(_) = tag {
renderer.in_code_block = true;
renderer.code_content.clear();
in_code_block = true;
code_content.clear();
}
html_output.push_str(renderer.render_start_tag(tag).as_str());
html_output.push_str(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());
in_code_block = false;
html_output.push_str(escape_html(&code_content).as_str());
}
html_output.push_str(renderer.render_end_tag(tag).as_str());
html_output.push_str(render_end_tag(tag).as_str());
}
Event::Text(text) => {
if renderer.in_code_block {
renderer.code_content.push_str(&text);
if in_code_block {
code_content.push_str(&text);
} else {
html_output.push_str(&escape_html(&text));
}
@ -362,7 +74,96 @@ pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String {
html_output
}
// ========== Вспомогательные функции ==========
fn render_start_tag(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(_) => output.push_str("<table>"),
Tag::TableHead => output.push_str("<thead>"),
Tag::TableCell => output.push_str("<td>"),
Tag::TableRow => output.push_str("<tr>"),
Tag::FootnoteDefinition(fref) => {
let _ = write!(output, "<sup><a href=\"#{fref}\" id=\"{fref}\">");
}
Tag::HtmlBlock
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::Superscript
| Tag::Subscript
| Tag::MetadataBlock(_) => (),
}
output
}
fn render_end_tag(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 => output.push_str("</table>"),
TagEnd::TableHead => output.push_str("</thead>"),
TagEnd::TableCell => output.push_str("</td>"),
TagEnd::TableRow => output.push_str("</tr>"),
TagEnd::FootnoteDefinition => output.push_str("</a></sup>"),
TagEnd::HtmlBlock
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::MetadataBlock(_) => (),
}
output
}
fn render_codeblock(block_kind: CodeBlockKind<'_>) -> String {
let header = String::from(
r#"
@ -434,79 +235,3 @@ fn escape_html(text: &str) -> String {
.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

@ -16,7 +16,6 @@
justify-content: center;
line-height: 1.6;
}
.content {
max-width: 800px;
width: 100%;
@ -25,54 +24,12 @@
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;
}
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;
}
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;
@ -81,24 +38,14 @@
background: #252525;
padding: 10px;
}
code {
font-family: 'Consolas', 'Monaco', monospace;
}
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;
}
.header a { padding: 1em; color: #757575; font-size: 0.95em; }
.code-block-wrapper {
margin: 1em 0;
border: 1px solid #444;
@ -117,9 +64,7 @@
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;
@ -130,9 +75,7 @@
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 {
@ -143,22 +86,16 @@
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;
@ -166,9 +103,7 @@
border-top: 1px solid #333;
padding-top: 15px;
}
.footer a { padding: 1em; color: #757575; }
.back-link {
display: inline-flex;
align-items: center;
@ -177,9 +112,7 @@
font-size: 0.95em;
transition: color 0.2s;
}
.back-link span { margin-right: 8px; font-size: 1.2em; }
img {
max-width: 100%;
height: auto;
@ -188,18 +121,29 @@
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
ul {
list-style-type: disc;
padding: 0;
margin: 0;
padding-left: 1.5em;
}
ol {
padding-left: 1.5em;
list-style-type: decimal;
}
li {
padding: .5em 0;
}
ul > li::marker, ol > li::marker { display: inline-block; }
.file-link {
color: #64b5f6;
font-size: 1.1em;
display: flex;
align-items: center;
}
.file-link:hover {
color: #90caf9;
}
.icon {
margin-right: 10px;
min-width: 24px;