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:
Remco Smits
2025-10-05 13:31:17 +02:00
committed by GitHub
parent 1a9e9c5faa
commit 46b6adadf9
4 changed files with 1027 additions and 15 deletions
+191 -13
View File
@@ -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;