markdown: Add support for HTML `heading` elements (#38590)

Remco Smits created

This PR adds support for HTML heading (h1, h2, h3, h4, h5, h6) elements.

**Before**
<img width="1440" height="556" alt="Screenshot 2025-09-21 at 11 05 18"
src="https://github.com/user-attachments/assets/6e7241a5-be1c-4018-ba04-f29058f97941"
/>

**After**
<img width="1436" height="598" alt="Screenshot 2025-09-21 at 10 58 12"
src="https://github.com/user-attachments/assets/3f74b5f7-6c35-41db-989b-fcaaede264b5"
/>

cc @SomeoneToIgnore

Release Notes:

- Markdown: Added support for HTML `heading` elements

Change summary

crates/markdown_preview/src/markdown_parser.rs | 140 ++++++++++++++++++++
1 file changed, 140 insertions(+)

Detailed changes

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -826,6 +826,33 @@ impl<'a> MarkdownParser<'a> {
                     if let Some(image) = self.extract_image(source_range, attrs) {
                         elements.push(ParsedMarkdownElement::Image(image));
                     }
+                } else if matches!(
+                    name.local,
+                    local_name!("h1")
+                        | local_name!("h2")
+                        | local_name!("h3")
+                        | local_name!("h4")
+                        | local_name!("h5")
+                        | local_name!("h6")
+                ) {
+                    let mut paragraph = MarkdownParagraph::new();
+                    self.consume_paragraph(source_range.clone(), node, &mut paragraph);
+
+                    if !paragraph.is_empty() {
+                        elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                            source_range,
+                            level: match name.local {
+                                local_name!("h1") => HeadingLevel::H1,
+                                local_name!("h2") => HeadingLevel::H2,
+                                local_name!("h3") => HeadingLevel::H3,
+                                local_name!("h4") => HeadingLevel::H4,
+                                local_name!("h5") => HeadingLevel::H5,
+                                local_name!("h6") => HeadingLevel::H6,
+                                _ => unreachable!(),
+                            },
+                            contents: paragraph,
+                        }));
+                    }
                 } else {
                     self.consume_children(source_range, node, elements);
                 }
@@ -834,6 +861,40 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
+    fn parse_paragraph(
+        &self,
+        source_range: Range<usize>,
+        node: &Rc<markup5ever_rcdom::Node>,
+        paragraph: &mut MarkdownParagraph,
+    ) {
+        match &node.data {
+            markup5ever_rcdom::NodeData::Text { contents } => {
+                paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                    source_range,
+                    regions: Vec::default(),
+                    contents: contents.borrow().to_string(),
+                    region_ranges: Vec::default(),
+                    highlights: Vec::default(),
+                }));
+            }
+            markup5ever_rcdom::NodeData::Element { .. } => {
+                self.consume_paragraph(source_range, node, paragraph);
+            }
+            _ => {}
+        }
+    }
+
+    fn consume_paragraph(
+        &self,
+        source_range: Range<usize>,
+        node: &Rc<markup5ever_rcdom::Node>,
+        paragraph: &mut MarkdownParagraph,
+    ) {
+        for node in node.children.borrow().iter() {
+            self.parse_paragraph(source_range.clone(), node, paragraph);
+        }
+    }
+
     fn consume_children(
         &self,
         source_range: Range<usize>,
@@ -1269,6 +1330,85 @@ mod tests {
         );
     }
 
+    #[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;
+
+        assert_eq!(
+            ParsedMarkdown {
+                children: vec![
+                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                        level: HeadingLevel::H1,
+                        source_range: 0..96,
+                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                            source_range: 0..96,
+                            contents: "Heading".into(),
+                            highlights: Vec::default(),
+                            region_ranges: Vec::default(),
+                            regions: Vec::default()
+                        })],
+                    }),
+                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                        level: HeadingLevel::H2,
+                        source_range: 0..96,
+                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                            source_range: 0..96,
+                            contents: "Heading".into(),
+                            highlights: Vec::default(),
+                            region_ranges: Vec::default(),
+                            regions: Vec::default()
+                        })],
+                    }),
+                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                        level: HeadingLevel::H3,
+                        source_range: 0..96,
+                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                            source_range: 0..96,
+                            contents: "Heading".into(),
+                            highlights: Vec::default(),
+                            region_ranges: Vec::default(),
+                            regions: Vec::default()
+                        })],
+                    }),
+                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                        level: HeadingLevel::H4,
+                        source_range: 0..96,
+                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                            source_range: 0..96,
+                            contents: "Heading".into(),
+                            highlights: Vec::default(),
+                            region_ranges: Vec::default(),
+                            regions: Vec::default()
+                        })],
+                    }),
+                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                        level: HeadingLevel::H5,
+                        source_range: 0..96,
+                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                            source_range: 0..96,
+                            contents: "Heading".into(),
+                            highlights: Vec::default(),
+                            region_ranges: Vec::default(),
+                            regions: Vec::default()
+                        })],
+                    }),
+                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+                        level: HeadingLevel::H6,
+                        source_range: 0..96,
+                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                            source_range: 0..96,
+                            contents: "Heading".into(),
+                            highlights: Vec::default(),
+                            region_ranges: Vec::default(),
+                            regions: Vec::default()
+                        })],
+                    }),
+                ],
+            },
+            parsed
+        );
+    }
+
     #[gpui::test]
     async fn test_html_image_tag() {
         let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;