markdown: Add support for `HTML` table captions (#41192)

Remco Smits created

Thanks to @Angelk90 for pointing out that, we were missing this feature.
So this PR implement the caption feature for HTML tables for the
markdown preview.

**Code example**
```html
<table>
    <caption>Revenue by Region</caption>
    <tr>
        <th rowspan="2">Region</th>
        <th colspan="2">Revenue</th>
        <th rowspan="2">Growth</th>
    </tr>
    <tr>
        <th>Q2 2024</th>
        <th>Q3 2024</th>
    </tr>
    <tr>
        <td>North America</td>
        <td>$2.8M</td>
        <td>$2.4B</td>
        <td>+85,614%</td>
    </tr>
    <tr>
        <td>Europe</td>
        <td>$1.2M</td>
        <td>$1.9B</td>
        <td>+158,233%</td>
    </tr>
    <tr>
        <td>Asia-Pacific</td>
        <td>$0.5M</td>
        <td>$1.4B</td>
        <td>+279,900%</td>
    </tr>
</table>
```

**Result**:
<img width="1201" height="774" alt="Screenshot 2025-10-25 at 21 18 01"
src="https://github.com/user-attachments/assets/c2a8c1c2-f861-40df-b5c9-549932818f6e"
/>

Release Notes:

- Markdown preview: Added support for `HTML` table captions

Change summary

crates/markdown_preview/src/markdown_elements.rs |  1 
crates/markdown_preview/src/markdown_parser.rs   | 88 +++++++++++++++++
crates/markdown_preview/src/markdown_renderer.rs | 17 ++-
3 files changed, 99 insertions(+), 7 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -108,6 +108,7 @@ pub struct ParsedMarkdownTable {
     pub source_range: Range<usize>,
     pub header: Vec<ParsedMarkdownTableRow>,
     pub body: Vec<ParsedMarkdownTableRow>,
+    pub caption: Option<MarkdownParagraph>,
 }
 
 #[derive(Debug, Clone, Copy, Default)]

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -533,6 +533,7 @@ impl<'a> MarkdownParser<'a> {
             source_range,
             header,
             body,
+            caption: None,
         }
     }
 
@@ -1232,11 +1233,17 @@ impl<'a> MarkdownParser<'a> {
     ) -> Option<ParsedMarkdownTable> {
         let mut header_rows = Vec::new();
         let mut body_rows = Vec::new();
+        let mut caption = None;
 
-        // node should be a thead or tbody element
+        // node should be a thead, tbody or caption element
         for node in node.children.borrow().iter() {
             match &node.data {
                 markup5ever_rcdom::NodeData::Element { name, .. } => {
+                    if local_name!("caption") == name.local {
+                        let mut paragraph = MarkdownParagraph::new();
+                        self.parse_paragraph(source_range.clone(), node, &mut paragraph);
+                        caption = Some(paragraph);
+                    }
                     if local_name!("thead") == name.local {
                         // node should be a tr element
                         for node in node.children.borrow().iter() {
@@ -1262,6 +1269,7 @@ impl<'a> MarkdownParser<'a> {
                 source_range,
                 body: body_rows,
                 header: header_rows,
+                caption,
             })
         } else {
             None
@@ -1919,6 +1927,7 @@ mod tests {
             ParsedMarkdown {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..366,
+                    None,
                     vec![row(vec![
                         column(
                             1,
@@ -1975,6 +1984,77 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_html_table_with_caption() {
+        let parsed = parse(
+            "<table>
+            <caption>My Table</caption>
+          <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..280,
+                    Some(vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                        source_range: 0..280,
+                        contents: "My Table".into(),
+                        highlights: Default::default(),
+                        region_ranges: Default::default(),
+                        regions: Default::default()
+                    })]),
+                    vec![],
+                    vec![
+                        row(vec![
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("1", 0..280),
+                                ParsedMarkdownTableAlignment::None
+                            ),
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("Chris", 0..280),
+                                ParsedMarkdownTableAlignment::None
+                            )
+                        ]),
+                        row(vec![
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("2", 0..280),
+                                ParsedMarkdownTableAlignment::None
+                            ),
+                            column(
+                                1,
+                                1,
+                                false,
+                                text("Dennis", 0..280),
+                                ParsedMarkdownTableAlignment::None
+                            )
+                        ]),
+                    ],
+                ))],
+            },
+            parsed
+        );
+    }
+
     #[gpui::test]
     async fn test_html_table_without_headings() {
         let parsed = parse(
@@ -1997,6 +2077,7 @@ mod tests {
             ParsedMarkdown {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..240,
+                    None,
                     vec![],
                     vec![
                         row(vec![
@@ -2056,6 +2137,7 @@ mod tests {
             ParsedMarkdown {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..150,
+                    None,
                     vec![row(vec![
                         column(
                             1,
@@ -2253,6 +2335,7 @@ Some other content
 
         let expected_table = table(
             0..48,
+            None,
             vec![row(vec![
                 column(
                     1,
@@ -2288,6 +2371,7 @@ Some other content
 
         let expected_table = table(
             0..95,
+            None,
             vec![row(vec![
                 column(
                     1,
@@ -2809,6 +2893,7 @@ fn main() {
 
     fn table(
         source_range: Range<usize>,
+        caption: Option<MarkdownParagraph>,
         header: Vec<ParsedMarkdownTableRow>,
         body: Vec<ParsedMarkdownTableRow>,
     ) -> ParsedMarkdownTable {
@@ -2816,6 +2901,7 @@ fn main() {
             source_range,
             header,
             body,
+            caption,
         }
     }
 

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -561,12 +561,17 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
     }
 
     cx.with_common_p(div())
-        .grid()
-        .size_full()
-        .grid_cols(max_column_count as u16)
-        .border_1()
-        .border_color(cx.border_color)
-        .children(cells)
+        .when_some(parsed.caption.as_ref(), |this, caption| {
+            this.children(render_markdown_text(caption, cx))
+        })
+        .child(
+            div()
+                .grid()
+                .grid_cols(max_column_count as u16)
+                .border_1()
+                .border_color(cx.border_color)
+                .children(cells),
+        )
         .into_any()
 }