markdown: Add HTML table element support (#38605)
Follow-up: https://github.com/zed-industries/zed/pull/38590 **Note**: this PR contains changes from the [previous PR](https://github.com/zed-industries/zed/pull/38590), when that PR gets merged we should see the real changes. This PR fixes 4 things in order to make: 1. Add html/markdown minifier to remove all the **\t** and **\n** characters. This is needed as you cannot create new lines with markdown by just adding an enter to the source file. 2. The event Event::HTML only contained a chunk of the real html for multiline HTML code. I fixed this by storing the currently watched HTML inside a buffer and at the end we parse it into the right elements. Instead of trying to parse a chunck into multiple elements which would always fail before. 3. Add support for html tables. 4. Fixed panic that occured when table does not have an header. I also decided to keep the html minifier inside Zed, because making it a dependency for just a few 100 lines seems to be an overkill. The original crate had a few cve in their dependencies, so figured this would be the best. **Html table support** <img width="1439" height="801" alt="Screenshot 2025-09-27 at 12 19 07" src="https://github.com/user-attachments/assets/a884cc6f-cf47-45a2-81fa-91300c7bbf3f" /> **Before & after Zed's README (no changes)** <img width="3440" height="1378" alt="Screenshot 2025-09-27 at 12 34 47" src="https://github.com/user-attachments/assets/1273b094-fb24-4abd-bffa-56ef3b44670c" /> Release Notes: - Markdown: Added support for html tables
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
use crate::markdown_elements::*;
|
||||
use crate::{
|
||||
markdown_elements::*,
|
||||
markdown_minifier::{Minifier, MinifierOptions},
|
||||
};
|
||||
use async_recursion::async_recursion;
|
||||
use collections::FxHashMap;
|
||||
use gpui::{DefiniteLength, FontWeight, px, relative};
|
||||
@@ -28,6 +31,24 @@ pub async fn parse_markdown(
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_html(source: &str) -> Vec<u8> {
|
||||
let mut writer = std::io::Cursor::new(Vec::new());
|
||||
let mut reader = std::io::Cursor::new(source);
|
||||
let mut minify = Minifier::new(
|
||||
&mut writer,
|
||||
MinifierOptions {
|
||||
omit_doctype: true,
|
||||
collapse_whitespace: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if let Ok(()) = minify.minify(&mut reader) {
|
||||
writer.into_inner()
|
||||
} else {
|
||||
source.bytes().collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkdownParser<'a> {
|
||||
tokens: Vec<(Event<'a>, Range<usize>)>,
|
||||
/// The current index in the tokens array
|
||||
@@ -764,6 +785,10 @@ impl<'a> MarkdownParser<'a> {
|
||||
return elements;
|
||||
};
|
||||
|
||||
let mut html_source_range_start = None;
|
||||
let mut html_source_range_end = None;
|
||||
let mut html_buffer = String::new();
|
||||
|
||||
while !self.eof() {
|
||||
let Some((current, source_range)) = self.current() else {
|
||||
break;
|
||||
@@ -771,19 +796,10 @@ impl<'a> MarkdownParser<'a> {
|
||||
let source_range = source_range.clone();
|
||||
match current {
|
||||
Event::Html(html) => {
|
||||
let mut cursor = std::io::Cursor::new(html.as_bytes());
|
||||
let Some(dom) = parse_document(RcDom::default(), ParseOpts::default())
|
||||
.from_utf8()
|
||||
.read_from(&mut cursor)
|
||||
.ok()
|
||||
else {
|
||||
self.cursor += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
html_source_range_start.get_or_insert(source_range.start);
|
||||
html_source_range_end = Some(source_range.end);
|
||||
html_buffer.push_str(html);
|
||||
self.cursor += 1;
|
||||
|
||||
self.parse_html_node(source_range, &dom.document, &mut elements);
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
self.cursor += 1;
|
||||
@@ -795,6 +811,17 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = cleanup_html(&html_buffer);
|
||||
|
||||
let mut cursor = std::io::Cursor::new(bytes);
|
||||
if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default())
|
||||
.from_utf8()
|
||||
.read_from(&mut cursor)
|
||||
&& let Some((start, end)) = html_source_range_start.zip(html_source_range_end)
|
||||
{
|
||||
self.parse_html_node(start..end, &dom.document, &mut elements);
|
||||
}
|
||||
|
||||
elements
|
||||
}
|
||||
|
||||
@@ -853,6 +880,10 @@ impl<'a> MarkdownParser<'a> {
|
||||
contents: paragraph,
|
||||
}));
|
||||
}
|
||||
} else if local_name!("table") == name.local {
|
||||
if let Some(table) = self.extract_html_table(node, source_range) {
|
||||
elements.push(ParsedMarkdownElement::Table(table));
|
||||
}
|
||||
} else {
|
||||
self.consume_children(source_range, node, elements);
|
||||
}
|
||||
@@ -971,6 +1002,55 @@ impl<'a> MarkdownParser<'a> {
|
||||
Some(image)
|
||||
}
|
||||
|
||||
fn extract_html_table(
|
||||
&self,
|
||||
node: &Rc<markup5ever_rcdom::Node>,
|
||||
source_range: Range<usize>,
|
||||
) -> Option<ParsedMarkdownTable> {
|
||||
let mut header_columns = Vec::new();
|
||||
let mut body_rows = Vec::new();
|
||||
|
||||
// node should be a thead or tbody element
|
||||
for node in node.children.borrow().iter() {
|
||||
match &node.data {
|
||||
markup5ever_rcdom::NodeData::Element { name, .. } => {
|
||||
if local_name!("thead") == name.local {
|
||||
// node should be a tr element
|
||||
for node in node.children.borrow().iter() {
|
||||
let mut paragraph = MarkdownParagraph::new();
|
||||
self.consume_paragraph(source_range.clone(), node, &mut paragraph);
|
||||
|
||||
for paragraph in paragraph.into_iter() {
|
||||
header_columns.push(vec![paragraph]);
|
||||
}
|
||||
}
|
||||
} else if local_name!("tbody") == name.local {
|
||||
// node should be a tr element
|
||||
for node in node.children.borrow().iter() {
|
||||
let mut row = MarkdownParagraph::new();
|
||||
self.consume_paragraph(source_range.clone(), node, &mut row);
|
||||
body_rows.push(ParsedMarkdownTableRow::with_children(
|
||||
row.into_iter().map(|column| vec![column]).collect(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !header_columns.is_empty() || !body_rows.is_empty() {
|
||||
Some(ParsedMarkdownTable {
|
||||
source_range,
|
||||
body: body_rows,
|
||||
column_alignments: Vec::default(),
|
||||
header: ParsedMarkdownTableRow::with_children(header_columns),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the width/height attribute value of an html element (e.g. img element)
|
||||
fn parse_length(value: &str) -> Option<DefiniteLength> {
|
||||
if value.ends_with("%") {
|
||||
@@ -1330,6 +1410,104 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_table() {
|
||||
let parsed = parse(
|
||||
"<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>Chris</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Dennis</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Table(table(
|
||||
0..366,
|
||||
row(vec![text("Id", 0..366), text("Name ", 0..366)]),
|
||||
vec![
|
||||
row(vec![text("1", 0..366), text("Chris", 0..366)]),
|
||||
row(vec![text("2", 0..366), text("Dennis", 0..366)]),
|
||||
],
|
||||
))],
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_table_without_headings() {
|
||||
let parsed = parse(
|
||||
"<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>Chris</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Dennis</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Table(table(
|
||||
0..240,
|
||||
row(vec![]),
|
||||
vec![
|
||||
row(vec![text("1", 0..240), text("Chris", 0..240)]),
|
||||
row(vec![text("2", 0..240), text("Dennis", 0..240)]),
|
||||
],
|
||||
))],
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_table_without_body() {
|
||||
let parsed = parse(
|
||||
"<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Table(table(
|
||||
0..150,
|
||||
row(vec![text("Id", 0..150), text("Name", 0..150)]),
|
||||
vec![],
|
||||
))],
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_heading_tags() {
|
||||
let parsed = parse("<h1>Heading</h1><h2>Heading</h2><h3>Heading</h3><h4>Heading</h4><h5>Heading</h5><h6>Heading</h6>").await;
|
||||
|
||||
Reference in New Issue
Block a user