From d8cafdf9378769b658a36348c2bb10527cad9d41 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Tue, 30 Sep 2025 12:39:22 +0200 Subject: [PATCH] markdown: Add support for HTML `heading` elements (#38590) This PR adds support for HTML heading (h1, h2, h3, h4, h5, h6) elements. **Before** Screenshot 2025-09-21 at 11 05 18 **After** Screenshot 2025-09-21 at 10 58 12 cc @SomeoneToIgnore Release Notes: - Markdown: Added support for HTML `heading` elements --- .../markdown_preview/src/markdown_parser.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index 1b116c50d9820dc4fea9d6b2e5816543d75e7d52..d76ecb15a99bc963cea47dcb443e9c137ae49acf 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/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, + node: &Rc, + 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, + node: &Rc, + 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, @@ -1269,6 +1330,85 @@ mod tests { ); } + #[gpui::test] + async fn test_html_heading_tags() { + let parsed = parse("

Heading

Heading

Heading

Heading

Heading
Heading
").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("").await;