Detailed changes
@@ -10265,6 +10265,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
name = "markdown"
version = "0.1.0"
dependencies = [
+ "anyhow",
"assets",
"base64 0.22.1",
"collections",
@@ -10273,13 +10274,17 @@ dependencies = [
"futures 0.3.31",
"gpui",
"gpui_platform",
+ "html5ever 0.27.0",
"language",
"languages",
"linkify",
"log",
+ "markup5ever_rcdom",
+ "mermaid-rs-renderer",
"node_runtime",
"pulldown-cmark 0.13.0",
"settings",
+ "stacksafe",
"sum_tree",
"theme",
"ui",
@@ -10291,21 +10296,13 @@ name = "markdown_preview"
version = "0.1.0"
dependencies = [
"anyhow",
- "async-recursion",
- "collections",
"editor",
"gpui",
- "html5ever 0.27.0",
"language",
- "linkify",
"log",
"markdown",
- "markup5ever_rcdom",
- "mermaid-rs-renderer",
- "pretty_assertions",
- "pulldown-cmark 0.13.0",
"settings",
- "stacksafe",
+ "tempfile",
"theme",
"ui",
"urlencoding",
@@ -19,15 +19,20 @@ test-support = [
]
[dependencies]
+anyhow.workspace = true
base64.workspace = true
collections.workspace = true
futures.workspace = true
gpui.workspace = true
+html5ever.workspace = true
language.workspace = true
linkify.workspace = true
log.workspace = true
+markup5ever_rcdom.workspace = true
+mermaid-rs-renderer.workspace = true
pulldown-cmark.workspace = true
settings.workspace = true
+stacksafe.workspace = true
sum_tree.workspace = true
theme.workspace = true
ui.workspace = true
@@ -0,0 +1,3 @@
+mod html_minifier;
+pub(crate) mod html_parser;
+mod html_rendering;
@@ -0,0 +1,883 @@
+use std::{cell::RefCell, collections::HashMap, mem, ops::Range};
+
+use gpui::{DefiniteLength, FontWeight, SharedString, px, relative};
+use html5ever::{
+ Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink,
+};
+use markup5ever_rcdom::{Node, NodeData, RcDom};
+use pulldown_cmark::{Alignment, HeadingLevel};
+use stacksafe::stacksafe;
+
+use crate::html::html_minifier::{Minifier, MinifierOptions};
+
+#[derive(Debug, Clone, Default)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlBlock {
+ pub source_range: Range<usize>,
+ pub children: Vec<ParsedHtmlElement>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) enum ParsedHtmlElement {
+ Heading(ParsedHtmlHeading),
+ List(ParsedHtmlList),
+ Table(ParsedHtmlTable),
+ BlockQuote(ParsedHtmlBlockQuote),
+ Paragraph(HtmlParagraph),
+ Image(HtmlImage),
+}
+
+impl ParsedHtmlElement {
+ pub fn source_range(&self) -> Option<Range<usize>> {
+ Some(match self {
+ Self::Heading(heading) => heading.source_range.clone(),
+ Self::List(list) => list.source_range.clone(),
+ Self::Table(table) => table.source_range.clone(),
+ Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
+ Self::Paragraph(text) => match text.first()? {
+ HtmlParagraphChunk::Text(text) => text.source_range.clone(),
+ HtmlParagraphChunk::Image(image) => image.source_range.clone(),
+ },
+ Self::Image(image) => image.source_range.clone(),
+ })
+ }
+}
+
+pub(crate) type HtmlParagraph = Vec<HtmlParagraphChunk>;
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) enum HtmlParagraphChunk {
+ Text(ParsedHtmlText),
+ Image(HtmlImage),
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlList {
+ pub source_range: Range<usize>,
+ pub depth: u16,
+ pub ordered: bool,
+ pub items: Vec<ParsedHtmlListItem>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlListItem {
+ pub source_range: Range<usize>,
+ pub item_type: ParsedHtmlListItemType,
+ pub content: Vec<ParsedHtmlElement>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) enum ParsedHtmlListItemType {
+ Ordered(u64),
+ Unordered,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlHeading {
+ pub source_range: Range<usize>,
+ pub level: HeadingLevel,
+ pub contents: HtmlParagraph,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlTable {
+ pub source_range: Range<usize>,
+ pub header: Vec<ParsedHtmlTableRow>,
+ pub body: Vec<ParsedHtmlTableRow>,
+ pub caption: Option<HtmlParagraph>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlTableColumn {
+ pub col_span: usize,
+ pub row_span: usize,
+ pub is_header: bool,
+ pub children: HtmlParagraph,
+ pub alignment: Alignment,
+}
+
+#[derive(Debug, Clone, Default)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlTableRow {
+ pub columns: Vec<ParsedHtmlTableColumn>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlBlockQuote {
+ pub source_range: Range<usize>,
+ pub children: Vec<ParsedHtmlElement>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlText {
+ pub source_range: Range<usize>,
+ pub contents: SharedString,
+ pub highlights: Vec<(Range<usize>, HtmlHighlightStyle)>,
+ pub links: Vec<(Range<usize>, SharedString)>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub(crate) struct HtmlHighlightStyle {
+ pub italic: bool,
+ pub underline: bool,
+ pub strikethrough: bool,
+ pub weight: FontWeight,
+ pub link: bool,
+ pub oblique: bool,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct HtmlImage {
+ pub dest_url: SharedString,
+ pub source_range: Range<usize>,
+ pub alt_text: Option<SharedString>,
+ pub width: Option<DefiniteLength>,
+ pub height: Option<DefiniteLength>,
+}
+
+impl HtmlImage {
+ fn new(dest_url: String, source_range: Range<usize>) -> Self {
+ Self {
+ dest_url: dest_url.into(),
+ source_range,
+ alt_text: None,
+ width: None,
+ height: None,
+ }
+ }
+
+ fn set_alt_text(&mut self, alt_text: SharedString) {
+ self.alt_text = Some(alt_text);
+ }
+
+ fn set_width(&mut self, width: DefiniteLength) {
+ self.width = Some(width);
+ }
+
+ fn set_height(&mut self, height: DefiniteLength) {
+ self.height = Some(height);
+ }
+}
+
+#[derive(Debug)]
+struct ParseHtmlNodeContext {
+ list_item_depth: u16,
+}
+
+impl Default for ParseHtmlNodeContext {
+ fn default() -> Self {
+ Self { list_item_depth: 1 }
+ }
+}
+
+pub(crate) fn parse_html_block(
+ source: &str,
+ source_range: Range<usize>,
+) -> Option<ParsedHtmlBlock> {
+ let bytes = cleanup_html(source);
+ let mut cursor = std::io::Cursor::new(bytes);
+ let dom = parse_document(RcDom::default(), ParseOpts::default())
+ .from_utf8()
+ .read_from(&mut cursor)
+ .ok()?;
+
+ let mut children = Vec::new();
+ parse_html_node(
+ source_range.clone(),
+ &dom.document,
+ &mut children,
+ &ParseHtmlNodeContext::default(),
+ );
+
+ Some(ParsedHtmlBlock {
+ source_range,
+ children,
+ })
+}
+
+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()
+ }
+}
+
+#[stacksafe]
+fn parse_html_node(
+ source_range: Range<usize>,
+ node: &Node,
+ elements: &mut Vec<ParsedHtmlElement>,
+ context: &ParseHtmlNodeContext,
+) {
+ match &node.data {
+ NodeData::Document => {
+ consume_children(source_range, node, elements, context);
+ }
+ NodeData::Text { contents } => {
+ elements.push(ParsedHtmlElement::Paragraph(vec![
+ HtmlParagraphChunk::Text(ParsedHtmlText {
+ source_range,
+ highlights: Vec::default(),
+ links: Vec::default(),
+ contents: contents.borrow().to_string().into(),
+ }),
+ ]));
+ }
+ NodeData::Comment { .. } => {}
+ NodeData::Element { name, attrs, .. } => {
+ let mut styles = if let Some(styles) =
+ html_style_from_html_styles(extract_styles_from_attributes(attrs))
+ {
+ vec![styles]
+ } else {
+ Vec::default()
+ };
+
+ if name.local == local_name!("img") {
+ if let Some(image) = extract_image(source_range, attrs) {
+ elements.push(ParsedHtmlElement::Image(image));
+ }
+ } else if name.local == local_name!("p") {
+ let mut paragraph = HtmlParagraph::new();
+ parse_paragraph(
+ source_range,
+ node,
+ &mut paragraph,
+ &mut styles,
+ &mut Vec::new(),
+ );
+
+ if !paragraph.is_empty() {
+ elements.push(ParsedHtmlElement::Paragraph(paragraph));
+ }
+ } 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 = HtmlParagraph::new();
+ consume_paragraph(
+ source_range.clone(),
+ node,
+ &mut paragraph,
+ &mut styles,
+ &mut Vec::new(),
+ );
+
+ if !paragraph.is_empty() {
+ elements.push(ParsedHtmlElement::Heading(ParsedHtmlHeading {
+ 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 if name.local == local_name!("ul") || name.local == local_name!("ol") {
+ if let Some(list) = extract_html_list(
+ node,
+ name.local == local_name!("ol"),
+ context.list_item_depth,
+ source_range,
+ ) {
+ elements.push(ParsedHtmlElement::List(list));
+ }
+ } else if name.local == local_name!("blockquote") {
+ if let Some(blockquote) = extract_html_blockquote(node, source_range) {
+ elements.push(ParsedHtmlElement::BlockQuote(blockquote));
+ }
+ } else if name.local == local_name!("table") {
+ if let Some(table) = extract_html_table(node, source_range) {
+ elements.push(ParsedHtmlElement::Table(table));
+ }
+ } else {
+ consume_children(source_range, node, elements, context);
+ }
+ }
+ _ => {}
+ }
+}
+
+#[stacksafe]
+fn parse_paragraph(
+ source_range: Range<usize>,
+ node: &Node,
+ paragraph: &mut HtmlParagraph,
+ highlights: &mut Vec<HtmlHighlightStyle>,
+ links: &mut Vec<SharedString>,
+) {
+ fn items_with_range<T>(
+ range: Range<usize>,
+ items: impl IntoIterator<Item = T>,
+ ) -> Vec<(Range<usize>, T)> {
+ items
+ .into_iter()
+ .map(|item| (range.clone(), item))
+ .collect()
+ }
+
+ match &node.data {
+ NodeData::Text { contents } => {
+ if let Some(text) =
+ paragraph
+ .iter_mut()
+ .last()
+ .and_then(|paragraph_chunk| match paragraph_chunk {
+ HtmlParagraphChunk::Text(text) => Some(text),
+ _ => None,
+ })
+ {
+ let mut new_text = text.contents.to_string();
+ new_text.push_str(&contents.borrow());
+
+ text.highlights.extend(items_with_range(
+ text.contents.len()..new_text.len(),
+ mem::take(highlights),
+ ));
+ text.links.extend(items_with_range(
+ text.contents.len()..new_text.len(),
+ mem::take(links),
+ ));
+ text.contents = SharedString::from(new_text);
+ } else {
+ let contents = contents.borrow().to_string();
+ paragraph.push(HtmlParagraphChunk::Text(ParsedHtmlText {
+ source_range,
+ highlights: items_with_range(0..contents.len(), mem::take(highlights)),
+ links: items_with_range(0..contents.len(), mem::take(links)),
+ contents: contents.into(),
+ }));
+ }
+ }
+ NodeData::Element { name, attrs, .. } => {
+ if name.local == local_name!("img") {
+ if let Some(image) = extract_image(source_range, attrs) {
+ paragraph.push(HtmlParagraphChunk::Image(image));
+ }
+ } else if name.local == local_name!("b") || name.local == local_name!("strong") {
+ highlights.push(HtmlHighlightStyle {
+ weight: FontWeight::BOLD,
+ ..Default::default()
+ });
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ } else if name.local == local_name!("i") {
+ highlights.push(HtmlHighlightStyle {
+ italic: true,
+ ..Default::default()
+ });
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ } else if name.local == local_name!("em") {
+ highlights.push(HtmlHighlightStyle {
+ oblique: true,
+ ..Default::default()
+ });
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ } else if name.local == local_name!("del") {
+ highlights.push(HtmlHighlightStyle {
+ strikethrough: true,
+ ..Default::default()
+ });
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ } else if name.local == local_name!("ins") {
+ highlights.push(HtmlHighlightStyle {
+ underline: true,
+ ..Default::default()
+ });
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ } else if name.local == local_name!("a") {
+ if let Some(url) = attr_value(attrs, local_name!("href")) {
+ highlights.push(HtmlHighlightStyle {
+ link: true,
+ ..Default::default()
+ });
+ links.push(url.into());
+ }
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ } else {
+ consume_paragraph(source_range, node, paragraph, highlights, links);
+ }
+ }
+ _ => {}
+ }
+}
+
+fn consume_paragraph(
+ source_range: Range<usize>,
+ node: &Node,
+ paragraph: &mut HtmlParagraph,
+ highlights: &mut Vec<HtmlHighlightStyle>,
+ links: &mut Vec<SharedString>,
+) {
+ for child in node.children.borrow().iter() {
+ parse_paragraph(source_range.clone(), child, paragraph, highlights, links);
+ }
+}
+
+fn parse_table_row(source_range: Range<usize>, node: &Node) -> Option<ParsedHtmlTableRow> {
+ let mut columns = Vec::new();
+
+ if let NodeData::Element { name, .. } = &node.data {
+ if name.local != local_name!("tr") {
+ return None;
+ }
+
+ for child in node.children.borrow().iter() {
+ if let Some(column) = parse_table_column(source_range.clone(), child) {
+ columns.push(column);
+ }
+ }
+ }
+
+ if columns.is_empty() {
+ None
+ } else {
+ Some(ParsedHtmlTableRow { columns })
+ }
+}
+
+fn parse_table_column(source_range: Range<usize>, node: &Node) -> Option<ParsedHtmlTableColumn> {
+ match &node.data {
+ NodeData::Element { name, attrs, .. } => {
+ if !matches!(name.local, local_name!("th") | local_name!("td")) {
+ return None;
+ }
+
+ let mut children = HtmlParagraph::new();
+ consume_paragraph(
+ source_range,
+ node,
+ &mut children,
+ &mut Vec::new(),
+ &mut Vec::new(),
+ );
+
+ let is_header = name.local == local_name!("th");
+
+ Some(ParsedHtmlTableColumn {
+ col_span: std::cmp::max(
+ attr_value(attrs, local_name!("colspan"))
+ .and_then(|span| span.parse().ok())
+ .unwrap_or(1),
+ 1,
+ ),
+ row_span: std::cmp::max(
+ attr_value(attrs, local_name!("rowspan"))
+ .and_then(|span| span.parse().ok())
+ .unwrap_or(1),
+ 1,
+ ),
+ is_header,
+ children,
+ alignment: attr_value(attrs, local_name!("align"))
+ .and_then(|align| match align.as_str() {
+ "left" => Some(Alignment::Left),
+ "center" => Some(Alignment::Center),
+ "right" => Some(Alignment::Right),
+ _ => None,
+ })
+ .unwrap_or(if is_header {
+ Alignment::Center
+ } else {
+ Alignment::None
+ }),
+ })
+ }
+ _ => None,
+ }
+}
+
+fn consume_children(
+ source_range: Range<usize>,
+ node: &Node,
+ elements: &mut Vec<ParsedHtmlElement>,
+ context: &ParseHtmlNodeContext,
+) {
+ for child in node.children.borrow().iter() {
+ parse_html_node(source_range.clone(), child, elements, context);
+ }
+}
+
+fn attr_value(attrs: &RefCell<Vec<Attribute>>, name: LocalName) -> Option<String> {
+ attrs.borrow().iter().find_map(|attr| {
+ if attr.name.local == name {
+ Some(attr.value.to_string())
+ } else {
+ None
+ }
+ })
+}
+
+fn html_style_from_html_styles(styles: HashMap<String, String>) -> Option<HtmlHighlightStyle> {
+ let mut html_style = HtmlHighlightStyle::default();
+
+ if let Some(text_decoration) = styles.get("text-decoration") {
+ match text_decoration.to_lowercase().as_str() {
+ "underline" => {
+ html_style.underline = true;
+ }
+ "line-through" => {
+ html_style.strikethrough = true;
+ }
+ _ => {}
+ }
+ }
+
+ if let Some(font_style) = styles.get("font-style") {
+ match font_style.to_lowercase().as_str() {
+ "italic" => {
+ html_style.italic = true;
+ }
+ "oblique" => {
+ html_style.oblique = true;
+ }
+ _ => {}
+ }
+ }
+
+ if let Some(font_weight) = styles.get("font-weight") {
+ match font_weight.to_lowercase().as_str() {
+ "bold" => {
+ html_style.weight = FontWeight::BOLD;
+ }
+ "lighter" => {
+ html_style.weight = FontWeight::THIN;
+ }
+ _ => {
+ if let Ok(weight) = font_weight.parse::<f32>() {
+ html_style.weight = FontWeight(weight);
+ }
+ }
+ }
+ }
+
+ if html_style != HtmlHighlightStyle::default() {
+ Some(html_style)
+ } else {
+ None
+ }
+}
+
+fn extract_styles_from_attributes(attrs: &RefCell<Vec<Attribute>>) -> HashMap<String, String> {
+ let mut styles = HashMap::new();
+
+ if let Some(style) = attr_value(attrs, local_name!("style")) {
+ for declaration in style.split(';') {
+ let mut parts = declaration.splitn(2, ':');
+ if let Some((key, value)) = parts.next().zip(parts.next()) {
+ styles.insert(key.trim().to_lowercase(), value.trim().to_string());
+ }
+ }
+ }
+
+ styles
+}
+
+fn extract_image(source_range: Range<usize>, attrs: &RefCell<Vec<Attribute>>) -> Option<HtmlImage> {
+ let src = attr_value(attrs, local_name!("src"))?;
+
+ let mut image = HtmlImage::new(src, source_range);
+
+ if let Some(alt) = attr_value(attrs, local_name!("alt")) {
+ image.set_alt_text(alt.into());
+ }
+
+ let styles = extract_styles_from_attributes(attrs);
+
+ if let Some(width) = attr_value(attrs, local_name!("width"))
+ .or_else(|| styles.get("width").cloned())
+ .and_then(|width| parse_html_element_dimension(&width))
+ {
+ image.set_width(width);
+ }
+
+ if let Some(height) = attr_value(attrs, local_name!("height"))
+ .or_else(|| styles.get("height").cloned())
+ .and_then(|height| parse_html_element_dimension(&height))
+ {
+ image.set_height(height);
+ }
+
+ Some(image)
+}
+
+fn extract_html_list(
+ node: &Node,
+ ordered: bool,
+ depth: u16,
+ source_range: Range<usize>,
+) -> Option<ParsedHtmlList> {
+ let mut items = Vec::with_capacity(node.children.borrow().len());
+
+ for (index, child) in node.children.borrow().iter().enumerate() {
+ if let NodeData::Element { name, .. } = &child.data {
+ if name.local != local_name!("li") {
+ continue;
+ }
+
+ let mut content = Vec::new();
+ consume_children(
+ source_range.clone(),
+ child,
+ &mut content,
+ &ParseHtmlNodeContext {
+ list_item_depth: depth + 1,
+ },
+ );
+
+ if !content.is_empty() {
+ items.push(ParsedHtmlListItem {
+ source_range: source_range.clone(),
+ item_type: if ordered {
+ ParsedHtmlListItemType::Ordered(index as u64 + 1)
+ } else {
+ ParsedHtmlListItemType::Unordered
+ },
+ content,
+ });
+ }
+ }
+ }
+
+ if items.is_empty() {
+ None
+ } else {
+ Some(ParsedHtmlList {
+ source_range,
+ depth,
+ ordered,
+ items,
+ })
+ }
+}
+
+fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
+ if value.ends_with('%') {
+ value
+ .trim_end_matches('%')
+ .parse::<f32>()
+ .ok()
+ .map(|value| relative(value / 100.))
+ } else {
+ value
+ .trim_end_matches("px")
+ .parse()
+ .ok()
+ .map(|value| px(value).into())
+ }
+}
+
+fn extract_html_blockquote(
+ node: &Node,
+ source_range: Range<usize>,
+) -> Option<ParsedHtmlBlockQuote> {
+ let mut children = Vec::new();
+ consume_children(
+ source_range.clone(),
+ node,
+ &mut children,
+ &ParseHtmlNodeContext::default(),
+ );
+
+ if children.is_empty() {
+ None
+ } else {
+ Some(ParsedHtmlBlockQuote {
+ children,
+ source_range,
+ })
+ }
+}
+
+fn extract_html_table(node: &Node, source_range: Range<usize>) -> Option<ParsedHtmlTable> {
+ let mut header_rows = Vec::new();
+ let mut body_rows = Vec::new();
+ let mut caption = None;
+
+ for child in node.children.borrow().iter() {
+ if let NodeData::Element { name, .. } = &child.data {
+ if name.local == local_name!("caption") {
+ let mut paragraph = HtmlParagraph::new();
+ parse_paragraph(
+ source_range.clone(),
+ child,
+ &mut paragraph,
+ &mut Vec::new(),
+ &mut Vec::new(),
+ );
+ caption = Some(paragraph);
+ }
+
+ if name.local == local_name!("thead") {
+ for row in child.children.borrow().iter() {
+ if let Some(row) = parse_table_row(source_range.clone(), row) {
+ header_rows.push(row);
+ }
+ }
+ } else if name.local == local_name!("tbody") {
+ for row in child.children.borrow().iter() {
+ if let Some(row) = parse_table_row(source_range.clone(), row) {
+ body_rows.push(row);
+ }
+ }
+ }
+ }
+ }
+
+ if !header_rows.is_empty() || !body_rows.is_empty() {
+ Some(ParsedHtmlTable {
+ source_range,
+ body: body_rows,
+ header: header_rows,
+ caption,
+ })
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_html_styled_text() {
+ let parsed = parse_html_block(
+ "<p>Some text <strong>strong</strong> <a href=\"https://example.com\">link</a></p>",
+ 0..79,
+ )
+ .unwrap();
+
+ assert_eq!(parsed.children.len(), 1);
+ let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
+ panic!("expected paragraph");
+ };
+ let HtmlParagraphChunk::Text(text) = ¶graph[0] else {
+ panic!("expected text chunk");
+ };
+
+ assert_eq!(text.contents.as_ref(), "Some text strong link");
+ assert_eq!(
+ text.highlights,
+ vec![
+ (
+ 10..16,
+ HtmlHighlightStyle {
+ weight: FontWeight::BOLD,
+ ..Default::default()
+ }
+ ),
+ (
+ 17..21,
+ HtmlHighlightStyle {
+ link: true,
+ ..Default::default()
+ }
+ )
+ ]
+ );
+ assert_eq!(
+ text.links,
+ vec![(17..21, SharedString::from("https://example.com"))]
+ );
+ }
+
+ #[test]
+ fn parses_html_table_spans() {
+ let parsed = parse_html_block(
+ "<table><tbody><tr><td colspan=\"2\">a</td></tr><tr><td>b</td><td>c</td></tr></tbody></table>",
+ 0..91,
+ )
+ .unwrap();
+
+ let ParsedHtmlElement::Table(table) = &parsed.children[0] else {
+ panic!("expected table");
+ };
+ assert_eq!(table.body.len(), 2);
+ assert_eq!(table.body[0].columns[0].col_span, 2);
+ assert_eq!(table.body[1].columns.len(), 2);
+ }
+
+ #[test]
+ fn parses_html_list_as_explicit_list_node() {
+ let parsed = parse_html_block(
+ "<ul><li>parent<ul><li>child</li></ul></li><li>sibling</li></ul>",
+ 0..64,
+ )
+ .unwrap();
+
+ assert_eq!(parsed.children.len(), 1);
+
+ let ParsedHtmlElement::List(list) = &parsed.children[0] else {
+ panic!("expected list");
+ };
+
+ assert!(!list.ordered);
+ assert_eq!(list.depth, 1);
+ assert_eq!(list.items.len(), 2);
+
+ let first_item = &list.items[0];
+ let ParsedHtmlElement::Paragraph(paragraph) = &first_item.content[0] else {
+ panic!("expected first item paragraph");
+ };
+ let HtmlParagraphChunk::Text(text) = ¶graph[0] else {
+ panic!("expected first item text");
+ };
+ assert_eq!(text.contents.as_ref(), "parent");
+
+ let ParsedHtmlElement::List(nested_list) = &first_item.content[1] else {
+ panic!("expected nested list");
+ };
+ assert_eq!(nested_list.depth, 2);
+ assert_eq!(nested_list.items.len(), 1);
+
+ let ParsedHtmlElement::Paragraph(nested_paragraph) = &nested_list.items[0].content[0]
+ else {
+ panic!("expected nested item paragraph");
+ };
+ let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph[0] else {
+ panic!("expected nested item text");
+ };
+ assert_eq!(nested_text.contents.as_ref(), "child");
+
+ let second_item = &list.items[1];
+ let ParsedHtmlElement::Paragraph(second_paragraph) = &second_item.content[0] else {
+ panic!("expected second item paragraph");
+ };
+ let HtmlParagraphChunk::Text(second_text) = &second_paragraph[0] else {
+ panic!("expected second item text");
+ };
+ assert_eq!(second_text.contents.as_ref(), "sibling");
+ }
+}
@@ -0,0 +1,613 @@
+use std::ops::Range;
+
+use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle};
+use pulldown_cmark::Alignment;
+use ui::prelude::*;
+
+use crate::html::html_parser::{
+ HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock,
+ ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow,
+ ParsedHtmlText,
+};
+use crate::{MarkdownElement, MarkdownElementBuilder};
+
+pub(crate) struct HtmlSourceAllocator {
+ source_range: Range<usize>,
+ next_source_index: usize,
+}
+
+impl HtmlSourceAllocator {
+ pub(crate) fn new(source_range: Range<usize>) -> Self {
+ Self {
+ next_source_index: source_range.start,
+ source_range,
+ }
+ }
+
+ pub(crate) fn allocate(&mut self, requested_len: usize) -> Range<usize> {
+ let remaining = self.source_range.end.saturating_sub(self.next_source_index);
+ let len = requested_len.min(remaining);
+ let start = self.next_source_index;
+ let end = start + len;
+ self.next_source_index = end;
+ start..end
+ }
+}
+
+impl MarkdownElement {
+ pub(crate) fn render_html_block(
+ &self,
+ block: &ParsedHtmlBlock,
+ builder: &mut MarkdownElementBuilder,
+ markdown_end: usize,
+ cx: &mut App,
+ ) {
+ let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone());
+ self.render_html_elements(
+ &block.children,
+ &mut source_allocator,
+ builder,
+ markdown_end,
+ cx,
+ );
+ }
+
+ fn render_html_elements(
+ &self,
+ elements: &[ParsedHtmlElement],
+ source_allocator: &mut HtmlSourceAllocator,
+ builder: &mut MarkdownElementBuilder,
+ markdown_end: usize,
+ cx: &mut App,
+ ) {
+ for element in elements {
+ self.render_html_element(element, source_allocator, builder, markdown_end, cx);
+ }
+ }
+
+ fn render_html_element(
+ &self,
+ element: &ParsedHtmlElement,
+ source_allocator: &mut HtmlSourceAllocator,
+ builder: &mut MarkdownElementBuilder,
+ markdown_end: usize,
+ cx: &mut App,
+ ) {
+ let Some(source_range) = element.source_range() else {
+ return;
+ };
+
+ match element {
+ ParsedHtmlElement::Paragraph(paragraph) => {
+ self.push_markdown_paragraph(builder, &source_range, markdown_end);
+ self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end);
+ builder.pop_div();
+ }
+ ParsedHtmlElement::Heading(heading) => {
+ self.push_markdown_heading(
+ builder,
+ heading.level,
+ &heading.source_range,
+ markdown_end,
+ );
+ self.render_html_paragraph(
+ &heading.contents,
+ source_allocator,
+ builder,
+ cx,
+ markdown_end,
+ );
+ self.pop_markdown_heading(builder);
+ }
+ ParsedHtmlElement::List(list) => {
+ self.render_html_list(list, source_allocator, builder, markdown_end, cx);
+ }
+ ParsedHtmlElement::BlockQuote(block_quote) => {
+ self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end);
+ self.render_html_elements(
+ &block_quote.children,
+ source_allocator,
+ builder,
+ markdown_end,
+ cx,
+ );
+ self.pop_markdown_block_quote(builder);
+ }
+ ParsedHtmlElement::Table(table) => {
+ self.render_html_table(table, source_allocator, builder, markdown_end, cx);
+ }
+ ParsedHtmlElement::Image(image) => {
+ self.render_html_image(image, builder);
+ }
+ }
+ }
+
+ fn render_html_list(
+ &self,
+ list: &ParsedHtmlList,
+ source_allocator: &mut HtmlSourceAllocator,
+ builder: &mut MarkdownElementBuilder,
+ markdown_end: usize,
+ cx: &mut App,
+ ) {
+ builder.push_div(div().pl_2p5(), &list.source_range, markdown_end);
+
+ for list_item in &list.items {
+ let bullet = match list_item.item_type {
+ ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix(
+ order as usize,
+ list.ordered,
+ list.depth.saturating_sub(1) as usize,
+ ),
+ ParsedHtmlListItemType::Unordered => {
+ html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize)
+ }
+ };
+
+ self.push_markdown_list_item(
+ builder,
+ div().child(bullet).into_any_element(),
+ &list_item.source_range,
+ markdown_end,
+ );
+ self.render_html_elements(
+ &list_item.content,
+ source_allocator,
+ builder,
+ markdown_end,
+ cx,
+ );
+ self.pop_markdown_list_item(builder);
+ }
+
+ builder.pop_div();
+ }
+
+ fn render_html_table(
+ &self,
+ table: &ParsedHtmlTable,
+ source_allocator: &mut HtmlSourceAllocator,
+ builder: &mut MarkdownElementBuilder,
+ markdown_end: usize,
+ cx: &mut App,
+ ) {
+ if let Some(caption) = &table.caption {
+ builder.push_div(
+ div().when(!self.style.height_is_multiple_of_line_height, |el| {
+ el.mb_2().line_height(rems(1.3))
+ }),
+ &table.source_range,
+ markdown_end,
+ );
+ self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end);
+ builder.pop_div();
+ }
+
+ let actual_header_column_count = html_table_columns_count(&table.header);
+ let actual_body_column_count = html_table_columns_count(&table.body);
+ let max_column_count = actual_header_column_count.max(actual_body_column_count);
+
+ if max_column_count == 0 {
+ return;
+ }
+
+ let total_rows = table.header.len() + table.body.len();
+ let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
+
+ builder.push_div(
+ div()
+ .id(("html-table", table.source_range.start))
+ .grid()
+ .grid_cols(max_column_count as u16)
+ .when(self.style.table_columns_min_size, |this| {
+ this.grid_cols_min_content(max_column_count as u16)
+ })
+ .when(!self.style.table_columns_min_size, |this| {
+ this.grid_cols(max_column_count as u16)
+ })
+ .w_full()
+ .mb_2()
+ .border(px(1.5))
+ .border_color(cx.theme().colors().border)
+ .rounded_sm()
+ .overflow_hidden(),
+ &table.source_range,
+ markdown_end,
+ );
+
+ for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() {
+ let mut column_index = 0;
+
+ for cell in &row.columns {
+ while column_index < max_column_count && grid_occupied[row_index][column_index] {
+ column_index += 1;
+ }
+
+ if column_index >= max_column_count {
+ break;
+ }
+
+ let max_span = max_column_count.saturating_sub(column_index);
+ let mut cell_div = div()
+ .col_span(cell.col_span.min(max_span) as u16)
+ .row_span(cell.row_span.min(total_rows - row_index) as u16)
+ .when(column_index > 0, |this| this.border_l_1())
+ .when(row_index > 0, |this| this.border_t_1())
+ .border_color(cx.theme().colors().border)
+ .px_2()
+ .py_1()
+ .when(cell.is_header, |this| {
+ this.bg(cx.theme().colors().title_bar_background)
+ })
+ .when(!cell.is_header && row_index % 2 == 1, |this| {
+ this.bg(cx.theme().colors().panel_background)
+ });
+
+ cell_div = match cell.alignment {
+ Alignment::Center => cell_div.items_center(),
+ Alignment::Right => cell_div.items_end(),
+ _ => cell_div,
+ };
+
+ builder.push_div(cell_div, &table.source_range, markdown_end);
+ self.render_html_paragraph(
+ &cell.children,
+ source_allocator,
+ builder,
+ cx,
+ markdown_end,
+ );
+ builder.pop_div();
+
+ for row_offset in 0..cell.row_span {
+ for column_offset in 0..cell.col_span {
+ if row_index + row_offset < total_rows
+ && column_index + column_offset < max_column_count
+ {
+ grid_occupied[row_index + row_offset][column_index + column_offset] =
+ true;
+ }
+ }
+ }
+
+ column_index += cell.col_span;
+ }
+
+ while column_index < max_column_count {
+ if grid_occupied[row_index][column_index] {
+ column_index += 1;
+ continue;
+ }
+
+ builder.push_div(
+ div()
+ .when(column_index > 0, |this| this.border_l_1())
+ .when(row_index > 0, |this| this.border_t_1())
+ .border_color(cx.theme().colors().border)
+ .when(row_index % 2 == 1, |this| {
+ this.bg(cx.theme().colors().panel_background)
+ }),
+ &table.source_range,
+ markdown_end,
+ );
+ builder.pop_div();
+ column_index += 1;
+ }
+ }
+
+ builder.pop_div();
+ }
+
+ fn render_html_paragraph(
+ &self,
+ paragraph: &HtmlParagraph,
+ source_allocator: &mut HtmlSourceAllocator,
+ builder: &mut MarkdownElementBuilder,
+ cx: &mut App,
+ _markdown_end: usize,
+ ) {
+ for chunk in paragraph {
+ match chunk {
+ HtmlParagraphChunk::Text(text) => {
+ self.render_html_text(text, source_allocator, builder, cx);
+ }
+ HtmlParagraphChunk::Image(image) => {
+ self.render_html_image(image, builder);
+ }
+ }
+ }
+ }
+
+ fn render_html_text(
+ &self,
+ text: &ParsedHtmlText,
+ source_allocator: &mut HtmlSourceAllocator,
+ builder: &mut MarkdownElementBuilder,
+ cx: &mut App,
+ ) {
+ let text_contents = text.contents.as_ref();
+ if text_contents.is_empty() {
+ return;
+ }
+
+ let allocated_range = source_allocator.allocate(text_contents.len());
+ let allocated_len = allocated_range.end.saturating_sub(allocated_range.start);
+
+ let mut boundaries = vec![0, text_contents.len()];
+ for (range, _) in &text.highlights {
+ boundaries.push(range.start);
+ boundaries.push(range.end);
+ }
+ for (range, _) in &text.links {
+ boundaries.push(range.start);
+ boundaries.push(range.end);
+ }
+ boundaries.sort_unstable();
+ boundaries.dedup();
+
+ for segment in boundaries.windows(2) {
+ let start = segment[0];
+ let end = segment[1];
+ if start >= end {
+ continue;
+ }
+
+ let source_start = allocated_range.start + start.min(allocated_len);
+ let source_end = allocated_range.start + end.min(allocated_len);
+ if source_start >= source_end {
+ continue;
+ }
+
+ let mut refinement = TextStyleRefinement::default();
+ let mut has_refinement = false;
+
+ for (highlight_range, style) in &text.highlights {
+ if highlight_range.start < end && highlight_range.end > start {
+ apply_html_highlight_style(&mut refinement, style);
+ has_refinement = true;
+ }
+ }
+
+ let link = text.links.iter().find_map(|(link_range, link)| {
+ if link_range.start < end && link_range.end > start {
+ Some(link.clone())
+ } else {
+ None
+ }
+ });
+
+ if let Some(link) = link.as_ref() {
+ builder.push_link(link.clone(), source_start..source_end);
+ let link_style = self
+ .style
+ .link_callback
+ .as_ref()
+ .and_then(|callback| callback(link.as_ref(), cx))
+ .unwrap_or_else(|| self.style.link.clone());
+ builder.push_text_style(link_style);
+ }
+
+ if has_refinement {
+ builder.push_text_style(refinement);
+ }
+
+ builder.push_text(&text_contents[start..end], source_start..source_end);
+
+ if has_refinement {
+ builder.pop_text_style();
+ }
+
+ if link.is_some() {
+ builder.pop_text_style();
+ }
+ }
+ }
+
+ fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) {
+ let Some(source) = self
+ .image_resolver
+ .as_ref()
+ .and_then(|resolve| resolve(image.dest_url.as_ref()))
+ else {
+ return;
+ };
+
+ self.push_markdown_image(
+ builder,
+ &image.source_range,
+ source,
+ image.width,
+ image.height,
+ );
+ }
+}
+
+fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) {
+ if style.weight != FontWeight::default() {
+ refinement.font_weight = Some(style.weight);
+ }
+
+ if style.oblique {
+ refinement.font_style = Some(FontStyle::Oblique);
+ } else if style.italic {
+ refinement.font_style = Some(FontStyle::Italic);
+ }
+
+ if style.underline {
+ refinement.underline = Some(UnderlineStyle {
+ thickness: px(1.),
+ color: None,
+ ..Default::default()
+ });
+ }
+
+ if style.strikethrough {
+ refinement.strikethrough = Some(StrikethroughStyle {
+ thickness: px(1.),
+ color: None,
+ });
+ }
+}
+
+fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
+ let index = order.saturating_sub(1);
+ const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
+ const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"];
+
+ if ordered {
+ match depth {
+ 0 => format!("{}. ", order),
+ 1 => format!(
+ "{}. ",
+ NUMBERED_PREFIXES_1
+ .chars()
+ .nth(index % NUMBERED_PREFIXES_1.len())
+ .unwrap()
+ ),
+ _ => format!(
+ "{}. ",
+ NUMBERED_PREFIXES_2
+ .chars()
+ .nth(index % NUMBERED_PREFIXES_2.len())
+ .unwrap()
+ ),
+ }
+ } else {
+ let depth = depth.min(BULLETS.len() - 1);
+ format!("{} ", BULLETS[depth])
+ }
+}
+
+fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize {
+ let mut actual_column_count = 0;
+ for row in rows {
+ actual_column_count = actual_column_count.max(
+ row.columns
+ .iter()
+ .map(|column| column.col_span)
+ .sum::<usize>(),
+ );
+ }
+ actual_column_count
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::{TestAppContext, size};
+ use ui::prelude::*;
+
+ use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
+
+ fn ensure_theme_initialized(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ if !cx.has_global::<settings::SettingsStore>() {
+ settings::init(cx);
+ }
+ if !cx.has_global::<theme::GlobalTheme>() {
+ theme::init(theme::LoadThemes::JustBase, cx);
+ }
+ });
+ }
+
+ fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
+ struct TestWindow;
+
+ impl Render for TestWindow {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ div()
+ }
+ }
+
+ ensure_theme_initialized(cx);
+
+ let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+ let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
+ cx.run_until_parked();
+ let (rendered, _) = cx.draw(
+ Default::default(),
+ size(px(600.0), px(600.0)),
+ |_window, _cx| {
+ MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
+ CodeBlockRenderer::Default {
+ copy_button: false,
+ copy_button_on_hover: false,
+ border: false,
+ },
+ )
+ },
+ );
+ rendered.text
+ }
+
+ #[gpui::test]
+ fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
+ let rendered = render_markdown_text(
+ "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
+ cx,
+ );
+
+ let rendered_lines = rendered
+ .lines
+ .iter()
+ .map(|line| line.layout.wrapped_text())
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ rendered_lines.concat().replace('\n', ""),
+ "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
+ );
+ }
+
+ #[gpui::test]
+ fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
+ struct TestWindow;
+
+ impl Render for TestWindow {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ div()
+ }
+ }
+
+ ensure_theme_initialized(cx);
+
+ let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+ let markdown = cx.new(|cx| {
+ Markdown::new_with_options(
+ "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
+ None,
+ None,
+ MarkdownOptions {
+ parse_html: true,
+ ..Default::default()
+ },
+ cx,
+ )
+ });
+ cx.run_until_parked();
+ let (rendered, _) = cx.draw(
+ Default::default(),
+ size(px(600.0), px(600.0)),
+ |_window, _cx| {
+ MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
+ CodeBlockRenderer::Default {
+ copy_button: false,
+ copy_button_on_hover: false,
+ border: false,
+ },
+ )
+ },
+ );
+
+ let rendered_lines = rendered
+ .text
+ .lines
+ .iter()
+ .map(|line| line.layout.wrapped_text())
+ .collect::<Vec<_>>();
+
+ assert_eq!(rendered_lines[0], "Hello");
+ assert_eq!(rendered_lines[1], "world");
+ assert!(rendered_lines.iter().any(|line| line.contains("item")));
+ }
+}
@@ -1,3 +1,5 @@
+pub mod html;
+mod mermaid;
pub mod parser;
mod path_range;
@@ -9,6 +11,9 @@ use gpui::UnderlineStyle;
use language::LanguageName;
use log::Level;
+use mermaid::{
+ MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram,
+};
pub use path_range::{LineCol, PathWithRange};
use settings::Settings as _;
use theme::ThemeSettings;
@@ -29,13 +34,16 @@ use collections::{HashMap, HashSet};
use gpui::{
AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
- ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent,
- MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText,
- Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
+ ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent,
+ MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle,
+ StyleRefinement, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement,
+ actions, img, point, quad,
};
use language::{CharClassifier, Language, LanguageRegistry, Rope};
use parser::CodeBlockMetadata;
-use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown};
+use parser::{
+ MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options,
+};
use pulldown_cmark::Alignment;
use sum_tree::TreeMap;
use theme::SyntaxTheme;
@@ -47,7 +55,8 @@ use crate::parser::CodeBlockKind;
/// A callback function that can be used to customize the style of links based on the destination URL.
/// If the callback returns `None`, the default link style will be used.
type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
-
+type SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
+type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
/// Defines custom style refinements for each heading level (H1-H6)
#[derive(Clone, Default)]
pub struct HeadingLevelStyles {
@@ -239,6 +248,7 @@ pub struct Markdown {
selection: Selection,
pressed_link: Option<RenderedLink>,
autoscroll_request: Option<usize>,
+ active_root_block: Option<usize>,
parsed_markdown: ParsedMarkdown,
images_by_source_offset: HashMap<usize, Arc<Image>>,
should_reparse: bool,
@@ -246,14 +256,18 @@ pub struct Markdown {
focus_handle: FocusHandle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<LanguageName>,
- options: Options,
+ options: MarkdownOptions,
+ mermaid_state: MermaidState,
copied_code_blocks: HashSet<ElementId>,
code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
context_menu_selected_text: Option<String>,
}
-struct Options {
- parse_links_only: bool,
+#[derive(Clone, Copy, Default)]
+pub struct MarkdownOptions {
+ pub parse_links_only: bool,
+ pub parse_html: bool,
+ pub render_mermaid_diagrams: bool,
}
pub enum CodeBlockRenderer {
@@ -300,6 +314,22 @@ impl Markdown {
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<LanguageName>,
cx: &mut Context<Self>,
+ ) -> Self {
+ Self::new_with_options(
+ source,
+ language_registry,
+ fallback_code_block_language,
+ MarkdownOptions::default(),
+ cx,
+ )
+ }
+
+ pub fn new_with_options(
+ source: SharedString,
+ language_registry: Option<Arc<LanguageRegistry>>,
+ fallback_code_block_language: Option<LanguageName>,
+ options: MarkdownOptions,
+ cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let mut this = Self {
@@ -307,6 +337,7 @@ impl Markdown {
selection: Selection::default(),
pressed_link: None,
autoscroll_request: None,
+ active_root_block: None,
should_reparse: false,
images_by_source_offset: Default::default(),
parsed_markdown: ParsedMarkdown::default(),
@@ -314,9 +345,8 @@ impl Markdown {
focus_handle,
language_registry,
fallback_code_block_language,
- options: Options {
- parse_links_only: false,
- },
+ options,
+ mermaid_state: MermaidState::default(),
copied_code_blocks: HashSet::default(),
code_block_scroll_handles: BTreeMap::default(),
context_menu_selected_text: None,
@@ -326,28 +356,16 @@ impl Markdown {
}
pub fn new_text(source: SharedString, cx: &mut Context<Self>) -> Self {
- let focus_handle = cx.focus_handle();
- let mut this = Self {
+ Self::new_with_options(
source,
- selection: Selection::default(),
- pressed_link: None,
- autoscroll_request: None,
- should_reparse: false,
- parsed_markdown: ParsedMarkdown::default(),
- images_by_source_offset: Default::default(),
- pending_parse: None,
- focus_handle,
- language_registry: None,
- fallback_code_block_language: None,
- options: Options {
+ None,
+ None,
+ MarkdownOptions {
parse_links_only: true,
+ ..Default::default()
},
- copied_code_blocks: HashSet::default(),
- code_block_scroll_handles: BTreeMap::default(),
- context_menu_selected_text: None,
- };
- this.parse(cx);
- this
+ cx,
+ )
}
fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle {
@@ -410,6 +428,30 @@ impl Markdown {
self.parse(cx);
}
+ pub fn request_autoscroll_to_source_index(
+ &mut self,
+ source_index: usize,
+ cx: &mut Context<Self>,
+ ) {
+ self.autoscroll_request = Some(source_index);
+ cx.refresh_windows();
+ }
+
+ pub fn set_active_root_for_source_index(
+ &mut self,
+ source_index: Option<usize>,
+ cx: &mut Context<Self>,
+ ) {
+ let active_root_block =
+ source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index));
+ if self.active_root_block == active_root_block {
+ return;
+ }
+
+ self.active_root_block = active_root_block;
+ cx.notify();
+ }
+
pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
if source == self.source() {
return;
@@ -489,6 +531,17 @@ impl Markdown {
fn parse(&mut self, cx: &mut Context<Self>) {
if self.source.is_empty() {
+ self.should_reparse = false;
+ self.pending_parse.take();
+ self.parsed_markdown = ParsedMarkdown {
+ source: self.source.clone(),
+ ..Default::default()
+ };
+ self.active_root_block = None;
+ self.images_by_source_offset.clear();
+ self.mermaid_state.clear();
+ cx.notify();
+ cx.refresh_windows();
return;
}
@@ -503,6 +556,8 @@ impl Markdown {
fn start_background_parse(&self, cx: &Context<Self>) -> Task<()> {
let source = self.source.clone();
let should_parse_links_only = self.options.parse_links_only;
+ let should_parse_html = self.options.parse_html;
+ let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
let language_registry = self.language_registry.clone();
let fallback = self.fallback_code_block_language.clone();
@@ -514,12 +569,25 @@ impl Markdown {
source,
languages_by_name: TreeMap::default(),
languages_by_path: TreeMap::default(),
+ root_block_starts: Arc::default(),
+ html_blocks: BTreeMap::default(),
+ mermaid_diagrams: BTreeMap::default(),
},
Default::default(),
);
}
- let (events, language_names, paths) = parse_markdown(&source);
+ let parsed = parse_markdown_with_options(&source, should_parse_html);
+ let events = parsed.events;
+ let language_names = parsed.language_names;
+ let paths = parsed.language_paths;
+ let root_block_starts = parsed.root_block_starts;
+ let html_blocks = parsed.html_blocks;
+ let mermaid_diagrams = if should_render_mermaid_diagrams {
+ extract_mermaid_diagrams(&source, &events)
+ } else {
+ BTreeMap::default()
+ };
let mut images_by_source_offset = HashMap::default();
let mut languages_by_name = TreeMap::default();
let mut languages_by_path = TreeMap::default();
@@ -578,6 +646,9 @@ impl Markdown {
events: Arc::from(events),
languages_by_name,
languages_by_path,
+ root_block_starts: Arc::from(root_block_starts),
+ html_blocks,
+ mermaid_diagrams,
},
images_by_source_offset,
)
@@ -589,10 +660,22 @@ impl Markdown {
this.update(cx, |this, cx| {
this.parsed_markdown = parsed;
this.images_by_source_offset = images_by_source_offset;
+ if this.active_root_block.is_some_and(|block_index| {
+ block_index >= this.parsed_markdown.root_block_starts.len()
+ }) {
+ this.active_root_block = None;
+ }
+ if this.options.render_mermaid_diagrams {
+ let parsed_markdown = this.parsed_markdown.clone();
+ this.mermaid_state.update(&parsed_markdown, cx);
+ } else {
+ this.mermaid_state.clear();
+ }
this.pending_parse.take();
if this.should_reparse {
this.parse(cx);
}
+ cx.notify();
cx.refresh_windows();
})
.ok();
@@ -686,6 +769,9 @@ pub struct ParsedMarkdown {
pub events: Arc<[(Range<usize>, MarkdownEvent)]>,
pub languages_by_name: TreeMap<SharedString, Arc<Language>>,
pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
+ pub root_block_starts: Arc<[usize]>,
+ pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
+ pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>,
}
impl ParsedMarkdown {
@@ -696,6 +782,30 @@ impl ParsedMarkdown {
pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
&self.events
}
+
+ pub fn root_block_starts(&self) -> &Arc<[usize]> {
+ &self.root_block_starts
+ }
+
+ pub fn root_block_for_source_index(&self, source_index: usize) -> Option<usize> {
+ if self.root_block_starts.is_empty() {
+ return None;
+ }
+
+ let partition = self
+ .root_block_starts
+ .partition_point(|block_start| *block_start <= source_index);
+
+ Some(partition.saturating_sub(1))
+ }
+}
+
+pub enum AutoscrollBehavior {
+ /// Propagate the request up the element tree for the nearest
+ /// scrollable ancestor (e.g. `List`) to handle.
+ Propagate,
+ /// Directly control a specific scroll handle.
+ Controlled(ScrollHandle),
}
pub struct MarkdownElement {
@@ -703,6 +813,11 @@ pub struct MarkdownElement {
style: MarkdownStyle,
code_block_renderer: CodeBlockRenderer,
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
+ on_source_click: Option<SourceClickCallback>,
+ on_checkbox_toggle: Option<CheckboxToggleCallback>,
+ image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
+ show_root_block_markers: bool,
+ autoscroll: AutoscrollBehavior,
}
impl MarkdownElement {
@@ -716,6 +831,11 @@ impl MarkdownElement {
border: false,
},
on_url_click: None,
+ on_source_click: None,
+ on_checkbox_toggle: None,
+ image_resolver: None,
+ show_root_block_markers: false,
+ autoscroll: AutoscrollBehavior::Propagate,
}
}
@@ -753,6 +873,147 @@ impl MarkdownElement {
self
}
+ pub fn on_source_click(
+ mut self,
+ handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
+ ) -> Self {
+ self.on_source_click = Some(Box::new(handler));
+ self
+ }
+
+ pub fn on_checkbox_toggle(
+ mut self,
+ handler: impl Fn(Range<usize>, bool, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_checkbox_toggle = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn image_resolver(
+ mut self,
+ resolver: impl Fn(&str) -> Option<ImageSource> + 'static,
+ ) -> Self {
+ self.image_resolver = Some(Box::new(resolver));
+ self
+ }
+
+ pub fn show_root_block_markers(mut self) -> Self {
+ self.show_root_block_markers = true;
+ self
+ }
+
+ pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self {
+ self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle);
+ self
+ }
+
+ fn push_markdown_image(
+ &self,
+ builder: &mut MarkdownElementBuilder,
+ range: &Range<usize>,
+ source: ImageSource,
+ width: Option<DefiniteLength>,
+ height: Option<DefiniteLength>,
+ ) {
+ builder.modify_current_div(|el| {
+ el.items_center().flex().flex_row().child(
+ img(source)
+ .max_w_full()
+ .when_some(height, |this, height| this.h(height))
+ .when_some(width, |this, width| this.w(width)),
+ )
+ });
+ let _ = range;
+ }
+
+ fn push_markdown_paragraph(
+ &self,
+ builder: &mut MarkdownElementBuilder,
+ range: &Range<usize>,
+ markdown_end: usize,
+ ) {
+ builder.push_div(
+ div().when(!self.style.height_is_multiple_of_line_height, |el| {
+ el.mb_2().line_height(rems(1.3))
+ }),
+ range,
+ markdown_end,
+ );
+ }
+
+ fn push_markdown_heading(
+ &self,
+ builder: &mut MarkdownElementBuilder,
+ level: pulldown_cmark::HeadingLevel,
+ range: &Range<usize>,
+ markdown_end: usize,
+ ) {
+ let mut heading = div().mb_2();
+ heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref());
+
+ let mut heading_style = self.style.heading.clone();
+ let heading_text_style = heading_style.text_style().clone();
+ heading.style().refine(&heading_style);
+
+ builder.push_text_style(heading_text_style);
+ builder.push_div(heading, range, markdown_end);
+ }
+
+ fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) {
+ builder.pop_div();
+ builder.pop_text_style();
+ }
+
+ fn push_markdown_block_quote(
+ &self,
+ builder: &mut MarkdownElementBuilder,
+ range: &Range<usize>,
+ markdown_end: usize,
+ ) {
+ builder.push_text_style(self.style.block_quote.clone());
+ builder.push_div(
+ div()
+ .pl_4()
+ .mb_2()
+ .border_l_4()
+ .border_color(self.style.block_quote_border_color),
+ range,
+ markdown_end,
+ );
+ }
+
+ fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) {
+ builder.pop_div();
+ builder.pop_text_style();
+ }
+
+ fn push_markdown_list_item(
+ &self,
+ builder: &mut MarkdownElementBuilder,
+ bullet: AnyElement,
+ range: &Range<usize>,
+ markdown_end: usize,
+ ) {
+ builder.push_div(
+ div()
+ .when(!self.style.height_is_multiple_of_line_height, |el| {
+ el.mb_1().gap_1().line_height(rems(1.3))
+ })
+ .h_flex()
+ .items_start()
+ .child(bullet),
+ range,
+ markdown_end,
+ );
+ // Without `w_0`, text doesn't wrap to the width of the container.
+ builder.push_div(div().flex_1().w_0(), range, markdown_end);
+ }
+
+ fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) {
+ builder.pop_div();
+ builder.pop_div();
+ }
+
fn paint_selection(
&self,
bounds: Bounds<Pixels>,
@@ -846,6 +1107,7 @@ impl MarkdownElement {
}
let on_open_url = self.on_url_click.take();
+ let on_source_click = self.on_source_click.take();
self.on_mouse_event(window, cx, {
let hitbox = hitbox.clone();
@@ -873,6 +1135,16 @@ impl MarkdownElement {
match rendered_text.source_index_for_position(event.position) {
Ok(ix) | Err(ix) => ix,
};
+ if let Some(handler) = on_source_click.as_ref() {
+ let blocked = handler(source_index, event.click_count, window, cx);
+ if blocked {
+ markdown.selection = Selection::default();
+ markdown.pressed_link = None;
+ window.prevent_default();
+ cx.notify();
+ return;
+ }
+ }
let (range, mode) = match event.click_count {
1 => {
let range = source_index..source_index;
@@ -980,14 +1252,38 @@ impl MarkdownElement {
.update(cx, |markdown, _| markdown.autoscroll_request.take())?;
let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?;
- let text_style = self.style.base_text_style.clone();
- let font_id = window.text_system().resolve_font(&text_style.font());
- let font_size = text_style.font_size.to_pixels(window.rem_size());
- let em_width = window.text_system().em_width(font_id, font_size).unwrap();
- window.request_autoscroll(Bounds::from_corners(
- point(position.x - 3. * em_width, position.y - 3. * line_height),
- point(position.x + 3. * em_width, position.y + 3. * line_height),
- ));
+ match &self.autoscroll {
+ AutoscrollBehavior::Controlled(scroll_handle) => {
+ let viewport = scroll_handle.bounds();
+ let margin = line_height * 3.;
+ let top_goal = viewport.top() + margin;
+ let bottom_goal = viewport.bottom() - margin;
+ let current_offset = scroll_handle.offset();
+
+ let new_offset_y = if position.y < top_goal {
+ current_offset.y + (top_goal - position.y)
+ } else if position.y + line_height > bottom_goal {
+ current_offset.y + (bottom_goal - (position.y + line_height))
+ } else {
+ current_offset.y
+ };
+
+ scroll_handle.set_offset(point(
+ current_offset.x,
+ new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO),
+ ));
+ }
+ AutoscrollBehavior::Propagate => {
+ let text_style = self.style.base_text_style.clone();
+ let font_id = window.text_system().resolve_font(&text_style.font());
+ let font_size = text_style.font_size.to_pixels(window.rem_size());
+ let em_width = window.text_system().em_width(font_id, font_size).unwrap();
+ window.request_autoscroll(Bounds::from_corners(
+ point(position.x - 3. * em_width, position.y - 3. * line_height),
+ point(position.x + 3. * em_width, position.y + 3. * line_height),
+ ));
+ }
+ }
Some(())
}
@@ -1039,11 +1335,14 @@ impl Element for MarkdownElement {
self.style.base_text_style.clone(),
self.style.syntax.clone(),
);
- let (parsed_markdown, images) = {
+ let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = {
let markdown = self.markdown.read(cx);
(
markdown.parsed_markdown.clone(),
markdown.images_by_source_offset.clone(),
+ markdown.active_root_block,
+ markdown.options.render_mermaid_diagrams,
+ markdown.mermaid_state.clone(),
)
};
let markdown_end = if let Some(last) = parsed_markdown.events.last() {
@@ -1054,6 +1353,8 @@ impl Element for MarkdownElement {
let mut code_block_ids = HashSet::default();
let mut current_img_block_range: Option<Range<usize>> = None;
+ let mut handled_html_block = false;
+ let mut rendered_mermaid_block = false;
for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
// Skip alt text for images that rendered
if let Some(current_img_block_range) = ¤t_img_block_range
@@ -1062,58 +1363,83 @@ impl Element for MarkdownElement {
continue;
}
+ if handled_html_block {
+ if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event {
+ handled_html_block = false;
+ } else {
+ continue;
+ }
+ }
+
+ if rendered_mermaid_block {
+ if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) {
+ rendered_mermaid_block = false;
+ }
+ continue;
+ }
+
match event {
+ MarkdownEvent::RootStart => {
+ if self.show_root_block_markers {
+ builder.push_root_block(range, markdown_end);
+ }
+ }
+ MarkdownEvent::RootEnd(root_block_index) => {
+ if self.show_root_block_markers {
+ builder.pop_root_block(
+ active_root_block == Some(*root_block_index),
+ cx.theme().colors().border,
+ cx.theme().colors().border_variant,
+ );
+ }
+ }
MarkdownEvent::Start(tag) => {
match tag {
- MarkdownTag::Image { .. } => {
+ MarkdownTag::Image { dest_url, .. } => {
if let Some(image) = images.get(&range.start) {
current_img_block_range = Some(range.clone());
- builder.modify_current_div(|el| {
- el.items_center()
- .flex()
- .flex_row()
- .child(img(image.clone()))
- });
+ self.push_markdown_image(
+ &mut builder,
+ range,
+ image.clone().into(),
+ None,
+ None,
+ );
+ } else if let Some(source) = self
+ .image_resolver
+ .as_ref()
+ .and_then(|resolve| resolve(dest_url.as_ref()))
+ {
+ current_img_block_range = Some(range.clone());
+ self.push_markdown_image(&mut builder, range, source, None, None);
}
}
MarkdownTag::Paragraph => {
- builder.push_div(
- div().when(!self.style.height_is_multiple_of_line_height, |el| {
- el.mb_2().line_height(rems(1.3))
- }),
- range,
- markdown_end,
- );
+ self.push_markdown_paragraph(&mut builder, range, markdown_end);
}
MarkdownTag::Heading { level, .. } => {
- let mut heading = div().mb_2();
-
- heading = apply_heading_style(
- heading,
- *level,
- self.style.heading_level_styles.as_ref(),
- );
-
- heading.style().refine(&self.style.heading);
-
- let text_style = self.style.heading.text_style().clone();
-
- builder.push_text_style(text_style);
- builder.push_div(heading, range, markdown_end);
+ self.push_markdown_heading(&mut builder, *level, range, markdown_end);
}
MarkdownTag::BlockQuote => {
- builder.push_text_style(self.style.block_quote.clone());
- builder.push_div(
- div()
- .pl_4()
- .mb_2()
- .border_l_4()
- .border_color(self.style.block_quote_border_color),
- range,
- markdown_end,
- );
+ self.push_markdown_block_quote(&mut builder, range, markdown_end);
}
MarkdownTag::CodeBlock { kind, .. } => {
+ if render_mermaid_diagrams
+ && let Some(mermaid_diagram) =
+ parsed_markdown.mermaid_diagrams.get(&range.start)
+ {
+ builder.push_sourced_element(
+ mermaid_diagram.content_range.clone(),
+ render_mermaid_diagram(
+ mermaid_diagram,
+ &mermaid_state,
+ &self.style,
+ ),
+ );
+ rendered_mermaid_block = true;
+ continue;
+ }
+
let language = match kind {
CodeBlockKind::Fenced => None,
CodeBlockKind::FencedLang(language) => {
@@ -1197,46 +1523,57 @@ impl Element for MarkdownElement {
(CodeBlockRenderer::Custom { .. }, _) => {}
}
}
- MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end),
+ MarkdownTag::HtmlBlock => {
+ builder.push_div(div(), range, markdown_end);
+ if let Some(block) = parsed_markdown.html_blocks.get(&range.start) {
+ self.render_html_block(block, &mut builder, markdown_end, cx);
+ handled_html_block = true;
+ }
+ }
MarkdownTag::List(bullet_index) => {
builder.push_list(*bullet_index);
builder.push_div(div().pl_2p5(), range, markdown_end);
}
MarkdownTag::Item => {
- let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) =
- parsed_markdown.events.get(index.saturating_add(1))
- {
- let source = &parsed_markdown.source()[range.clone()];
-
- Checkbox::new(
- ElementId::Name(source.to_string().into()),
- if *checked {
+ let bullet =
+ if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) =
+ parsed_markdown.events.get(index.saturating_add(1))
+ {
+ let source = &parsed_markdown.source()[range.clone()];
+ let checked = *checked;
+ let toggle_state = if checked {
ToggleState::Selected
} else {
ToggleState::Unselected
- },
- )
- .fill()
- .visualization_only(true)
- .into_any_element()
- } else if let Some(bullet_index) = builder.next_bullet_index() {
- div().child(format!("{}.", bullet_index)).into_any_element()
- } else {
- div().child("•").into_any_element()
- };
- builder.push_div(
- div()
- .when(!self.style.height_is_multiple_of_line_height, |el| {
- el.mb_1().gap_1().line_height(rems(1.3))
- })
- .h_flex()
- .items_start()
- .child(bullet),
- range,
- markdown_end,
- );
- // Without `w_0`, text doesn't wrap to the width of the container.
- builder.push_div(div().flex_1().w_0(), range, markdown_end);
+ };
+
+ let checkbox = Checkbox::new(
+ ElementId::Name(source.to_string().into()),
+ toggle_state,
+ )
+ .fill();
+
+ if let Some(on_toggle) = self.on_checkbox_toggle.clone() {
+ let task_source_range = task_range.clone();
+ checkbox
+ .on_click(move |_state, window, cx| {
+ on_toggle(
+ task_source_range.clone(),
+ !checked,
+ window,
+ cx,
+ );
+ })
+ .into_any_element()
+ } else {
+ checkbox.visualization_only(true).into_any_element()
+ }
+ } else if let Some(bullet_index) = builder.next_bullet_index() {
+ div().child(format!("{}.", bullet_index)).into_any_element()
+ } else {
+ div().child("•").into_any_element()
+ };
+ self.push_markdown_list_item(&mut builder, bullet, range, markdown_end);
}
MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement {
font_style: Some(FontStyle::Italic),
@@ -1341,12 +1678,10 @@ impl Element for MarkdownElement {
builder.pop_div();
}
MarkdownTagEnd::Heading(_) => {
- builder.pop_div();
- builder.pop_text_style()
+ self.pop_markdown_heading(&mut builder);
}
MarkdownTagEnd::BlockQuote(_kind) => {
- builder.pop_text_style();
- builder.pop_div()
+ self.pop_markdown_block_quote(&mut builder);
}
MarkdownTagEnd::CodeBlock => {
builder.trim_trailing_newline();
@@ -1424,8 +1759,7 @@ impl Element for MarkdownElement {
builder.pop_div();
}
MarkdownTagEnd::Item => {
- builder.pop_div();
- builder.pop_div();
+ self.pop_markdown_list_item(&mut builder);
}
MarkdownTagEnd::Emphasis => builder.pop_text_style(),
MarkdownTagEnd::Strong => builder.pop_text_style(),
@@ -1843,6 +2177,15 @@ impl MarkdownElementBuilder {
self.div_stack.push(div);
}
+ fn push_root_block(&mut self, range: &Range<usize>, markdown_end: usize) {
+ self.push_div(
+ div().group("markdown-root-block").relative(),
+ range,
+ markdown_end,
+ );
+ self.push_div(div().pl_4(), range, markdown_end);
+ }
+
fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) {
self.flush_text();
if let Some(div) = self.div_stack.pop() {
@@ -1850,12 +2193,53 @@ impl MarkdownElementBuilder {
}
}
+ fn pop_root_block(
+ &mut self,
+ is_active: bool,
+ active_gutter_color: Hsla,
+ hovered_gutter_color: Hsla,
+ ) {
+ self.pop_div();
+ self.modify_current_div(|el| {
+ el.child(
+ div()
+ .h_full()
+ .w(px(4.0))
+ .when(is_active, |this| this.bg(active_gutter_color))
+ .group_hover("markdown-root-block", |this| {
+ if is_active {
+ this
+ } else {
+ this.bg(hovered_gutter_color)
+ }
+ })
+ .rounded_xs()
+ .absolute()
+ .left_0()
+ .top_0(),
+ )
+ });
+ self.pop_div();
+ }
+
fn pop_div(&mut self) {
self.flush_text();
let div = self.div_stack.pop().unwrap().into_any_element();
self.div_stack.last_mut().unwrap().extend(iter::once(div));
}
+ fn push_sourced_element(&mut self, source_range: Range<usize>, element: impl Into<AnyElement>) {
+ self.flush_text();
+ let anchor = self.render_source_anchor(source_range);
+ self.div_stack.last_mut().unwrap().extend([{
+ div()
+ .relative()
+ .child(anchor)
+ .child(element.into())
+ .into_any_element()
+ }]);
+ }
+
fn push_list(&mut self, bullet_index: Option<u64>) {
self.list_stack.push(ListStackEntry { bullet_index });
}
@@ -1957,6 +2341,29 @@ impl MarkdownElementBuilder {
}
}
+ fn render_source_anchor(&mut self, source_range: Range<usize>) -> AnyElement {
+ let mut text_style = self.base_text_style.clone();
+ text_style.color = Hsla::transparent_black();
+ let text = "\u{200B}";
+ let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]);
+ self.rendered_lines.push(RenderedLine {
+ layout: styled_text.layout().clone(),
+ source_mappings: vec![SourceMapping {
+ rendered_index: 0,
+ source_index: source_range.start,
+ }],
+ source_end: source_range.end,
+ language: None,
+ });
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .opacity(0.)
+ .child(styled_text)
+ .into_any_element()
+ }
+
fn flush_text(&mut self) {
let line = mem::take(&mut self.pending_line);
if line.text.is_empty() {
@@ -2006,7 +2413,7 @@ impl RenderedLine {
Ok(ix) => &self.source_mappings[ix],
Err(ix) => &self.source_mappings[ix - 1],
};
- mapping.rendered_index + (source_index - mapping.source_index)
+ (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len())
}
fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize {
@@ -2334,6 +2741,15 @@ mod tests {
markdown: &str,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut TestAppContext,
+ ) -> RenderedText {
+ render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx)
+ }
+
+ fn render_markdown_with_options(
+ markdown: &str,
+ language_registry: Option<Arc<LanguageRegistry>>,
+ options: MarkdownOptions,
+ cx: &mut TestAppContext,
) -> RenderedText {
struct TestWindow;
@@ -2346,8 +2762,15 @@ mod tests {
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
- let markdown =
- cx.new(|cx| Markdown::new(markdown.to_string().into(), language_registry, None, cx));
+ let markdown = cx.new(|cx| {
+ Markdown::new_with_options(
+ markdown.to_string().into(),
+ language_registry,
+ None,
+ options,
+ cx,
+ )
+ });
cx.run_until_parked();
let (rendered, _) = cx.draw(
Default::default(),
@@ -2527,7 +2950,7 @@ mod tests {
#[test]
fn test_table_checkbox_detection() {
let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
- let (events, _, _) = crate::parser::parse_markdown(md);
+ let events = crate::parser::parse_markdown_with_options(md, false).events;
let mut in_table = false;
let mut cell_texts: Vec<String> = Vec::new();
@@ -0,0 +1,614 @@
+use collections::HashMap;
+use gpui::{
+ Animation, AnimationExt, AnyElement, Context, ImageSource, RenderImage, StyledText, Task, img,
+ pulsating_between,
+};
+use std::collections::BTreeMap;
+use std::ops::Range;
+use std::sync::{Arc, OnceLock};
+use std::time::Duration;
+use ui::prelude::*;
+
+use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag};
+
+use super::{Markdown, MarkdownStyle, ParsedMarkdown};
+
+type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, Arc<CachedMermaidDiagram>>;
+
+#[derive(Clone, Debug)]
+pub(crate) struct ParsedMarkdownMermaidDiagram {
+ pub(crate) content_range: Range<usize>,
+ pub(crate) contents: ParsedMarkdownMermaidDiagramContents,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub(crate) struct ParsedMarkdownMermaidDiagramContents {
+ pub(crate) contents: SharedString,
+ pub(crate) scale: u32,
+}
+
+#[derive(Default, Clone)]
+pub(crate) struct MermaidState {
+ cache: MermaidDiagramCache,
+ order: Vec<ParsedMarkdownMermaidDiagramContents>,
+}
+
+struct CachedMermaidDiagram {
+ render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
+ fallback_image: Option<Arc<RenderImage>>,
+ _task: Task<()>,
+}
+
+impl MermaidState {
+ pub(crate) fn clear(&mut self) {
+ self.cache.clear();
+ self.order.clear();
+ }
+
+ fn get_fallback_image(
+ idx: usize,
+ old_order: &[ParsedMarkdownMermaidDiagramContents],
+ new_order_len: usize,
+ cache: &MermaidDiagramCache,
+ ) -> Option<Arc<RenderImage>> {
+ if old_order.len() != new_order_len {
+ return None;
+ }
+
+ old_order.get(idx).and_then(|old_content| {
+ cache.get(old_content).and_then(|old_cached| {
+ old_cached
+ .render_image
+ .get()
+ .and_then(|result| result.as_ref().ok().cloned())
+ .or_else(|| old_cached.fallback_image.clone())
+ })
+ })
+ }
+
+ pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context<Markdown>) {
+ let mut new_order = Vec::new();
+ for mermaid_diagram in parsed.mermaid_diagrams.values() {
+ new_order.push(mermaid_diagram.contents.clone());
+ }
+
+ for (idx, new_content) in new_order.iter().enumerate() {
+ if !self.cache.contains_key(new_content) {
+ let fallback =
+ Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
+ self.cache.insert(
+ new_content.clone(),
+ Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)),
+ );
+ }
+ }
+
+ let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect();
+ self.cache
+ .retain(|content, _| new_order_set.contains(content));
+ self.order = new_order;
+ }
+}
+
+impl CachedMermaidDiagram {
+ fn new(
+ contents: ParsedMarkdownMermaidDiagramContents,
+ fallback_image: Option<Arc<RenderImage>>,
+ cx: &mut Context<Markdown>,
+ ) -> Self {
+ let render_image = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
+ let render_image_clone = render_image.clone();
+ let svg_renderer = cx.svg_renderer();
+
+ let task = cx.spawn(async move |this, cx| {
+ let value = cx
+ .background_spawn(async move {
+ let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
+ let scale = contents.scale as f32 / 100.0;
+ svg_renderer
+ .render_single_frame(svg_string.as_bytes(), scale, true)
+ .map_err(|error| anyhow::anyhow!("{error}"))
+ })
+ .await;
+ let _ = render_image_clone.set(value);
+ this.update(cx, |_, cx| {
+ cx.notify();
+ })
+ .ok();
+ });
+
+ Self {
+ render_image,
+ fallback_image,
+ _task: task,
+ }
+ }
+
+ #[cfg(test)]
+ fn new_for_test(
+ render_image: Option<Arc<RenderImage>>,
+ fallback_image: Option<Arc<RenderImage>>,
+ ) -> Self {
+ let result = Arc::new(OnceLock::new());
+ if let Some(render_image) = render_image {
+ let _ = result.set(Ok(render_image));
+ }
+ Self {
+ render_image: result,
+ fallback_image,
+ _task: Task::ready(()),
+ }
+ }
+}
+
+fn parse_mermaid_info(info: &str) -> Option<u32> {
+ let mut parts = info.split_whitespace();
+ if parts.next()? != "mermaid" {
+ return None;
+ }
+
+ Some(
+ parts
+ .next()
+ .and_then(|scale| scale.parse().ok())
+ .unwrap_or(100)
+ .clamp(10, 500),
+ )
+}
+
+pub(crate) fn extract_mermaid_diagrams(
+ source: &str,
+ events: &[(Range<usize>, MarkdownEvent)],
+) -> BTreeMap<usize, ParsedMarkdownMermaidDiagram> {
+ let mut mermaid_diagrams = BTreeMap::default();
+
+ for (source_range, event) in events {
+ let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else {
+ continue;
+ };
+ let CodeBlockKind::FencedLang(info) = kind else {
+ continue;
+ };
+ let Some(scale) = parse_mermaid_info(info.as_ref()) else {
+ continue;
+ };
+
+ let contents = source[metadata.content_range.clone()]
+ .strip_suffix('\n')
+ .unwrap_or(&source[metadata.content_range.clone()])
+ .to_string();
+ mermaid_diagrams.insert(
+ source_range.start,
+ ParsedMarkdownMermaidDiagram {
+ content_range: metadata.content_range.clone(),
+ contents: ParsedMarkdownMermaidDiagramContents {
+ contents: contents.into(),
+ scale,
+ },
+ },
+ );
+ }
+
+ mermaid_diagrams
+}
+
+pub(crate) fn render_mermaid_diagram(
+ parsed: &ParsedMarkdownMermaidDiagram,
+ mermaid_state: &MermaidState,
+ style: &MarkdownStyle,
+) -> AnyElement {
+ let cached = mermaid_state.cache.get(&parsed.contents);
+ let mut container = div().w_full();
+ container.style().refine(&style.code_block);
+
+ if let Some(result) = cached.and_then(|cached| cached.render_image.get()) {
+ match result {
+ Ok(render_image) => container
+ .child(
+ div().w_full().child(
+ img(ImageSource::Render(render_image.clone()))
+ .max_w_full()
+ .with_fallback(|| {
+ div()
+ .child(Label::new("Failed to load mermaid diagram"))
+ .into_any_element()
+ }),
+ ),
+ )
+ .into_any_element(),
+ Err(_) => container
+ .child(StyledText::new(parsed.contents.contents.clone()))
+ .into_any_element(),
+ }
+ } else if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) {
+ container
+ .child(
+ div()
+ .w_full()
+ .child(
+ img(ImageSource::Render(fallback.clone()))
+ .max_w_full()
+ .with_fallback(|| {
+ div()
+ .child(Label::new("Failed to load mermaid diagram"))
+ .into_any_element()
+ }),
+ )
+ .with_animation(
+ "mermaid-fallback-pulse",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.6, 1.0)),
+ |element, delta| element.opacity(delta),
+ ),
+ )
+ .into_any_element()
+ } else {
+ container
+ .child(
+ Label::new("Rendering mermaid diagram...")
+ .color(Color::Muted)
+ .with_animation(
+ "mermaid-loading-pulse",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ ),
+ )
+ .into_any_element()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ CachedMermaidDiagram, MermaidDiagramCache, MermaidState,
+ ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info,
+ };
+ use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
+ use collections::HashMap;
+ use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size};
+ use std::sync::Arc;
+ use ui::prelude::*;
+
+ fn ensure_theme_initialized(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ if !cx.has_global::<settings::SettingsStore>() {
+ settings::init(cx);
+ }
+ if !cx.has_global::<theme::GlobalTheme>() {
+ theme::init(theme::LoadThemes::JustBase, cx);
+ }
+ });
+ }
+
+ fn render_markdown_with_options(
+ markdown: &str,
+ options: MarkdownOptions,
+ cx: &mut TestAppContext,
+ ) -> crate::RenderedText {
+ struct TestWindow;
+
+ impl Render for TestWindow {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ div()
+ }
+ }
+
+ ensure_theme_initialized(cx);
+
+ let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+ let markdown = cx.new(|cx| {
+ Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx)
+ });
+ cx.run_until_parked();
+ let (rendered, _) = cx.draw(
+ Default::default(),
+ size(px(600.0), px(600.0)),
+ |_window, _cx| {
+ MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
+ CodeBlockRenderer::Default {
+ copy_button: false,
+ copy_button_on_hover: false,
+ border: false,
+ },
+ )
+ },
+ );
+ rendered.text
+ }
+
+ fn mock_render_image(cx: &mut TestAppContext) -> Arc<RenderImage> {
+ cx.update(|cx| {
+ cx.svg_renderer()
+ .render_single_frame(
+ br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
+ 1.0,
+ true,
+ )
+ .unwrap()
+ })
+ }
+
+ fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents {
+ ParsedMarkdownMermaidDiagramContents {
+ contents: contents.to_string().into(),
+ scale: 100,
+ }
+ }
+
+ fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
+ diagrams
+ .iter()
+ .map(|diagram| mermaid_contents(diagram))
+ .collect()
+ }
+
+ fn mermaid_fallback(
+ new_diagram: &str,
+ new_full_order: &[ParsedMarkdownMermaidDiagramContents],
+ old_full_order: &[ParsedMarkdownMermaidDiagramContents],
+ cache: &MermaidDiagramCache,
+ ) -> Option<Arc<RenderImage>> {
+ let new_content = mermaid_contents(new_diagram);
+ let idx = new_full_order
+ .iter()
+ .position(|diagram| diagram == &new_content)?;
+ MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
+ }
+
+ #[test]
+ fn test_parse_mermaid_info() {
+ assert_eq!(parse_mermaid_info("mermaid"), Some(100));
+ assert_eq!(parse_mermaid_info("mermaid 150"), Some(150));
+ assert_eq!(parse_mermaid_info("mermaid 5"), Some(10));
+ assert_eq!(parse_mermaid_info("mermaid 999"), Some(500));
+ assert_eq!(parse_mermaid_info("rust"), None);
+ }
+
+ #[test]
+ fn test_extract_mermaid_diagrams_parses_scale() {
+ let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```";
+ let events = crate::parser::parse_markdown_with_options(markdown, false).events;
+ let diagrams = extract_mermaid_diagrams(markdown, &events);
+
+ assert_eq!(diagrams.len(), 1);
+ let diagram = diagrams.values().next().unwrap();
+ assert_eq!(diagram.contents.contents, "graph TD;");
+ assert_eq!(diagram.contents.scale, 150);
+ }
+
+ #[gpui::test]
+ fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) {
+ let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
+ let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
+
+ let svg_b = mock_render_image(cx);
+
+ let mut cache: MermaidDiagramCache = HashMap::default();
+ cache.insert(
+ mermaid_contents("graph A"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+ cache.insert(
+ mermaid_contents("graph B"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(svg_b.clone()),
+ None,
+ )),
+ );
+ cache.insert(
+ mermaid_contents("graph C"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+
+ let fallback =
+ mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
+
+ assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id));
+ }
+
+ #[gpui::test]
+ fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) {
+ let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
+ let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
+
+ let mut cache: MermaidDiagramCache = HashMap::default();
+ cache.insert(
+ mermaid_contents("graph A"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+ cache.insert(
+ mermaid_contents("graph C"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+
+ let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
+
+ assert!(fallback.is_none());
+ }
+
+ #[gpui::test]
+ fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) {
+ let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
+ let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
+
+ let original_svg = mock_render_image(cx);
+
+ let mut cache: MermaidDiagramCache = HashMap::default();
+ cache.insert(
+ mermaid_contents("graph A"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+ cache.insert(
+ mermaid_contents("graph B modified"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ None,
+ Some(original_svg.clone()),
+ )),
+ );
+ cache.insert(
+ mermaid_contents("graph C"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+
+ let fallback = mermaid_fallback(
+ "graph B modified again",
+ &new_full_order,
+ &old_full_order,
+ &cache,
+ );
+
+ assert_eq!(
+ fallback.as_ref().map(|image| image.id),
+ Some(original_svg.id)
+ );
+ }
+
+ #[gpui::test]
+ fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) {
+ let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
+ let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
+
+ let svg_a = mock_render_image(cx);
+
+ let mut cache: MermaidDiagramCache = HashMap::default();
+ cache.insert(
+ mermaid_contents("graph A"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(svg_a.clone()),
+ None,
+ )),
+ );
+ cache.insert(
+ mermaid_contents("graph B"),
+ Arc::new(CachedMermaidDiagram::new_for_test(
+ Some(mock_render_image(cx)),
+ None,
+ )),
+ );
+
+ let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
+
+ assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id));
+ }
+
+ #[gpui::test]
+ fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) {
+ let rendered = render_markdown_with_options(
+ "```mermaid\ngraph TD;\n```",
+ MarkdownOptions {
+ render_mermaid_diagrams: true,
+ ..Default::default()
+ },
+ cx,
+ );
+
+ let text = rendered
+ .lines
+ .iter()
+ .map(|line| line.layout.wrapped_text())
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ assert!(!text.contains("graph TD;"));
+ }
+
+ #[gpui::test]
+ fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) {
+ struct TestWindow;
+
+ impl Render for TestWindow {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ div()
+ }
+ }
+
+ ensure_theme_initialized(cx);
+
+ let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+ let markdown = cx.new(|cx| {
+ Markdown::new_with_options(
+ "```mermaid\ngraph TD;\n```".into(),
+ None,
+ None,
+ MarkdownOptions {
+ render_mermaid_diagrams: true,
+ ..Default::default()
+ },
+ cx,
+ )
+ });
+ cx.run_until_parked();
+ let render_image = mock_render_image(cx);
+ markdown.update(cx, |markdown, _| {
+ let contents = markdown
+ .parsed_markdown
+ .mermaid_diagrams
+ .values()
+ .next()
+ .unwrap()
+ .contents
+ .clone();
+ markdown.mermaid_state.cache.insert(
+ contents.clone(),
+ Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)),
+ );
+ markdown.mermaid_state.order = vec![contents];
+ });
+
+ let (rendered, _) = cx.draw(
+ Default::default(),
+ size(px(600.0), px(600.0)),
+ |_window, _cx| {
+ MarkdownElement::new(markdown.clone(), MarkdownStyle::default())
+ .code_block_renderer(CodeBlockRenderer::Default {
+ copy_button: false,
+ copy_button_on_hover: false,
+ border: false,
+ })
+ },
+ );
+
+ let mermaid_diagram = markdown.update(cx, |markdown, _| {
+ markdown
+ .parsed_markdown
+ .mermaid_diagrams
+ .values()
+ .next()
+ .unwrap()
+ .clone()
+ });
+ assert!(
+ rendered
+ .text
+ .position_for_source_index(mermaid_diagram.content_range.start)
+ .is_some()
+ );
+ assert!(
+ rendered
+ .text
+ .position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1))
+ .is_some()
+ );
+ }
+}
@@ -4,11 +4,11 @@ pub use pulldown_cmark::TagEnd as MarkdownTagEnd;
use pulldown_cmark::{
Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser,
};
-use std::{ops::Range, sync::Arc};
+use std::{collections::BTreeMap, ops::Range, sync::Arc};
use collections::HashSet;
-use crate::path_range::PathWithRange;
+use crate::{html, path_range::PathWithRange};
pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
@@ -22,16 +22,69 @@ pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_SUPERSCRIPT)
.union(Options::ENABLE_SUBSCRIPT);
-pub fn parse_markdown(
- text: &str,
-) -> (
- Vec<(Range<usize>, MarkdownEvent)>,
- HashSet<SharedString>,
- HashSet<Arc<str>>,
-) {
- let mut events = Vec::new();
+#[derive(Default)]
+struct ParseState {
+ events: Vec<(Range<usize>, MarkdownEvent)>,
+ root_block_starts: Vec<usize>,
+ depth: usize,
+}
+
+#[derive(Debug, Default)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedMarkdownData {
+ pub events: Vec<(Range<usize>, MarkdownEvent)>,
+ pub language_names: HashSet<SharedString>,
+ pub language_paths: HashSet<Arc<str>>,
+ pub root_block_starts: Vec<usize>,
+ pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
+}
+
+impl ParseState {
+ fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) {
+ match &event {
+ MarkdownEvent::Start(_) => {
+ if self.depth == 0 {
+ self.root_block_starts.push(range.start);
+ self.events.push((range.clone(), MarkdownEvent::RootStart));
+ }
+ self.depth += 1;
+ self.events.push((range, event));
+ }
+ MarkdownEvent::End(_) => {
+ self.events.push((range.clone(), event));
+ if self.depth > 0 {
+ self.depth -= 1;
+ if self.depth == 0 {
+ let root_block_index = self.root_block_starts.len() - 1;
+ self.events
+ .push((range, MarkdownEvent::RootEnd(root_block_index)));
+ }
+ }
+ }
+ MarkdownEvent::Rule => {
+ if self.depth == 0 && !range.is_empty() {
+ self.root_block_starts.push(range.start);
+ let root_block_index = self.root_block_starts.len() - 1;
+ self.events.push((range.clone(), MarkdownEvent::RootStart));
+ self.events.push((range.clone(), event));
+ self.events
+ .push((range, MarkdownEvent::RootEnd(root_block_index)));
+ } else {
+ self.events.push((range, event));
+ }
+ }
+ _ => {
+ self.events.push((range, event));
+ }
+ }
+ }
+}
+
+pub(crate) fn parse_markdown_with_options(text: &str, parse_html: bool) -> ParsedMarkdownData {
+ let mut state = ParseState::default();
let mut language_names = HashSet::default();
let mut language_paths = HashSet::default();
+ let mut html_blocks = BTreeMap::default();
let mut within_link = false;
let mut within_metadata = false;
let mut parser = Parser::new_ext(text, PARSE_OPTIONS)
@@ -48,6 +101,32 @@ pub fn parse_markdown(
}
match pulldown_event {
pulldown_cmark::Event::Start(tag) => {
+ if let pulldown_cmark::Tag::HtmlBlock = &tag {
+ state.push_event(range.clone(), MarkdownEvent::Start(MarkdownTag::HtmlBlock));
+
+ if parse_html {
+ if let Some(block) =
+ html::html_parser::parse_html_block(&text[range.clone()], range.clone())
+ {
+ html_blocks.insert(range.start, block);
+
+ while let Some((event, end_range)) = parser.next() {
+ if let pulldown_cmark::Event::End(
+ pulldown_cmark::TagEnd::HtmlBlock,
+ ) = event
+ {
+ state.push_event(
+ end_range,
+ MarkdownEvent::End(MarkdownTagEnd::HtmlBlock),
+ );
+ break;
+ }
+ }
+ }
+ }
+ continue;
+ }
+
let tag = match tag {
pulldown_cmark::Tag::Link {
link_type,
@@ -63,9 +142,9 @@ pub fn parse_markdown(
id: SharedString::from(id.into_string()),
}
}
- pulldown_cmark::Tag::MetadataBlock(kind) => {
+ pulldown_cmark::Tag::MetadataBlock(_kind) => {
within_metadata = true;
- MarkdownTag::MetadataBlock(kind)
+ continue;
}
pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => {
MarkdownTag::CodeBlock {
@@ -164,20 +243,20 @@ pub fn parse_markdown(
title: SharedString::from(title.into_string()),
id: SharedString::from(id.into_string()),
},
- pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock,
+ pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, // this is handled above separately
pulldown_cmark::Tag::DefinitionList => MarkdownTag::DefinitionList,
pulldown_cmark::Tag::DefinitionListTitle => MarkdownTag::DefinitionListTitle,
pulldown_cmark::Tag::DefinitionListDefinition => {
MarkdownTag::DefinitionListDefinition
}
};
- events.push((range, MarkdownEvent::Start(tag)))
+ state.push_event(range, MarkdownEvent::Start(tag))
}
pulldown_cmark::Event::End(tag) => {
if let pulldown_cmark::TagEnd::Link = tag {
within_link = false;
}
- events.push((range, MarkdownEvent::End(tag)));
+ state.push_event(range, MarkdownEvent::End(tag));
}
pulldown_cmark::Event::Text(parsed) => {
fn event_for(
@@ -205,16 +284,26 @@ pub fn parse_markdown(
parsed,
}];
- while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) {
- let Some((pulldown_cmark::Event::Text(next_event), next_range)) = parser.next()
- else {
+ while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _)))
+ || (parse_html
+ && matches!(
+ parser.peek(),
+ Some((pulldown_cmark::Event::InlineHtml(_), _))
+ ))
+ {
+ let Some((next_event, next_range)) = parser.next() else {
unreachable!()
};
- let next_len = last_len + next_event.len();
+ let next_text = match next_event {
+ pulldown_cmark::Event::Text(next_event) => next_event,
+ pulldown_cmark::Event::InlineHtml(_) => CowStr::Borrowed(""),
+ _ => unreachable!(),
+ };
+ let next_len = last_len + next_text.len();
ranges.push(TextRange {
source_range: next_range.clone(),
merged_range: last_len..next_len,
- parsed: next_event,
+ parsed: next_text,
});
last_len = next_len;
}
@@ -241,7 +330,8 @@ pub fn parse_markdown(
.is_some_and(|range| range.merged_range.end <= link_start_in_merged)
{
let range = ranges.next().unwrap();
- events.push(event_for(text, range.source_range, &range.parsed));
+ let (range, event) = event_for(text, range.source_range, &range.parsed);
+ state.push_event(range, event);
}
let Some(range) = ranges.peek_mut() else {
@@ -250,11 +340,12 @@ pub fn parse_markdown(
let prefix_len = link_start_in_merged - range.merged_range.start;
if prefix_len > 0 {
let (head, tail) = range.parsed.split_at(prefix_len);
- events.push(event_for(
+ let (event_range, event) = event_for(
text,
range.source_range.start..range.source_range.start + prefix_len,
head,
- ));
+ );
+ state.push_event(event_range, event);
range.parsed = CowStr::Boxed(tail.into());
range.merged_range.start += prefix_len;
range.source_range.start += prefix_len;
@@ -290,7 +381,7 @@ pub fn parse_markdown(
}
let link_range = link_start_in_source..link_end_in_source;
- events.push((
+ state.push_event(
link_range.clone(),
MarkdownEvent::Start(MarkdownTag::Link {
link_type: LinkType::Autolink,
@@ -298,37 +389,52 @@ pub fn parse_markdown(
title: SharedString::default(),
id: SharedString::default(),
}),
- ));
- events.extend(link_events);
- events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link)));
+ );
+ for (range, event) in link_events {
+ state.push_event(range, event);
+ }
+ state.push_event(
+ link_range.clone(),
+ MarkdownEvent::End(MarkdownTagEnd::Link),
+ );
}
}
for range in ranges {
- events.push(event_for(text, range.source_range, &range.parsed));
+ let (range, event) = event_for(text, range.source_range, &range.parsed);
+ state.push_event(range, event);
}
}
pulldown_cmark::Event::Code(_) => {
let content_range = extract_code_content_range(&text[range.clone()]);
let content_range =
content_range.start + range.start..content_range.end + range.start;
- events.push((content_range, MarkdownEvent::Code))
+ state.push_event(content_range, MarkdownEvent::Code)
+ }
+ pulldown_cmark::Event::Html(_) => state.push_event(range, MarkdownEvent::Html),
+ pulldown_cmark::Event::InlineHtml(_) => {
+ state.push_event(range, MarkdownEvent::InlineHtml)
}
- pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)),
- pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)),
pulldown_cmark::Event::FootnoteReference(_) => {
- events.push((range, MarkdownEvent::FootnoteReference))
+ state.push_event(range, MarkdownEvent::FootnoteReference)
}
- pulldown_cmark::Event::SoftBreak => events.push((range, MarkdownEvent::SoftBreak)),
- pulldown_cmark::Event::HardBreak => events.push((range, MarkdownEvent::HardBreak)),
- pulldown_cmark::Event::Rule => events.push((range, MarkdownEvent::Rule)),
+ pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak),
+ pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak),
+ pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule),
pulldown_cmark::Event::TaskListMarker(checked) => {
- events.push((range, MarkdownEvent::TaskListMarker(checked)))
+ state.push_event(range, MarkdownEvent::TaskListMarker(checked))
}
pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {}
}
}
- (events, language_names, language_paths)
+
+ ParsedMarkdownData {
+ events: state.events,
+ language_names,
+ language_paths,
+ root_block_starts: state.root_block_starts,
+ html_blocks,
+ }
}
pub fn parse_links_only(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
@@ -401,6 +507,10 @@ pub enum MarkdownEvent {
Rule,
/// A task list marker, rendered as a checkbox in HTML. Contains a true when it is checked.
TaskListMarker(bool),
+ /// Start of a root-level block (a top-level structural element like a paragraph, heading, list, etc.).
+ RootStart,
+ /// End of a root-level block. Contains the root block index.
+ RootEnd(usize),
}
/// Tags for elements that can contain other elements.
@@ -575,31 +685,39 @@ mod tests {
#[test]
fn test_html_comments() {
assert_eq!(
- parse_markdown(" <!--\nrdoc-file=string.c\n-->\nReturns"),
- (
- vec![
+ parse_markdown_with_options(" <!--\nrdoc-file=string.c\n-->\nReturns", false),
+ ParsedMarkdownData {
+ events: vec![
+ (2..30, RootStart),
(2..30, Start(HtmlBlock)),
(2..2, SubstitutedText(" ".into())),
(2..7, Html),
(7..26, Html),
(26..30, Html),
(2..30, End(MarkdownTagEnd::HtmlBlock)),
+ (2..30, RootEnd(0)),
+ (30..37, RootStart),
(30..37, Start(Paragraph)),
(30..37, Text),
- (30..37, End(MarkdownTagEnd::Paragraph))
+ (30..37, End(MarkdownTagEnd::Paragraph)),
+ (30..37, RootEnd(1)),
],
- HashSet::default(),
- HashSet::default()
- )
+ root_block_starts: vec![2, 30],
+ ..Default::default()
+ }
)
}
#[test]
fn test_plain_urls_and_escaped_text() {
assert_eq!(
- parse_markdown(" https://some.url some \\`►\\` text"),
- (
- vec![
+ parse_markdown_with_options(
+ " https://some.url some \\`►\\` text",
+ false
+ ),
+ ParsedMarkdownData {
+ events: vec![
+ (0..51, RootStart),
(0..51, Start(Paragraph)),
(0..6, SubstitutedText("\u{a0}".into())),
(6..12, SubstitutedText("\u{a0}".into())),
@@ -620,19 +738,25 @@ mod tests {
(37..44, SubstitutedText("►".into())),
(45..46, Text), // Escaped backtick
(46..51, Text),
- (0..51, End(MarkdownTagEnd::Paragraph))
+ (0..51, End(MarkdownTagEnd::Paragraph)),
+ (0..51, RootEnd(0)),
],
- HashSet::default(),
- HashSet::default()
- )
+ root_block_starts: vec![0],
+ ..Default::default()
+ }
);
}
#[test]
fn test_incomplete_link() {
assert_eq!(
- parse_markdown("You can use the [GitHub Search API](https://docs.github.com/en").0,
+ parse_markdown_with_options(
+ "You can use the [GitHub Search API](https://docs.github.com/en",
+ false
+ )
+ .events,
vec![
+ (0..62, RootStart),
(0..62, Start(Paragraph)),
(0..16, Text),
(16..17, Text),
@@ -650,7 +774,8 @@ mod tests {
),
(36..62, Text),
(36..62, End(MarkdownTagEnd::Link)),
- (0..62, End(MarkdownTagEnd::Paragraph))
+ (0..62, End(MarkdownTagEnd::Paragraph)),
+ (0..62, RootEnd(0)),
],
);
}
@@ -658,9 +783,13 @@ mod tests {
#[test]
fn test_smart_punctuation() {
assert_eq!(
- parse_markdown("-- --- ... \"double quoted\" 'single quoted' ----------"),
- (
- vec![
+ parse_markdown_with_options(
+ "-- --- ... \"double quoted\" 'single quoted' ----------",
+ false
+ ),
+ ParsedMarkdownData {
+ events: vec![
+ (0..53, RootStart),
(0..53, Start(Paragraph)),
(0..2, SubstitutedText("–".into())),
(2..3, Text),
@@ -668,29 +797,31 @@ mod tests {
(6..7, Text),
(7..10, SubstitutedText("…".into())),
(10..11, Text),
- (11..12, SubstitutedText("“".into())),
+ (11..12, SubstitutedText("\u{201c}".into())),
(12..25, Text),
- (25..26, SubstitutedText("”".into())),
+ (25..26, SubstitutedText("\u{201d}".into())),
(26..27, Text),
- (27..28, SubstitutedText("‘".into())),
+ (27..28, SubstitutedText("\u{2018}".into())),
(28..41, Text),
- (41..42, SubstitutedText("’".into())),
+ (41..42, SubstitutedText("\u{2019}".into())),
(42..43, Text),
(43..53, SubstitutedText("–––––".into())),
- (0..53, End(MarkdownTagEnd::Paragraph))
+ (0..53, End(MarkdownTagEnd::Paragraph)),
+ (0..53, RootEnd(0)),
],
- HashSet::default(),
- HashSet::default()
- )
+ root_block_starts: vec![0],
+ ..Default::default()
+ }
)
}
#[test]
fn test_code_block_metadata() {
assert_eq!(
- parse_markdown("```rust\nfn main() {\n let a = 1;\n}\n```"),
- (
- vec![
+ parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false),
+ ParsedMarkdownData {
+ events: vec![
+ (0..37, RootStart),
(
0..37,
Start(CodeBlock {
@@ -703,19 +834,22 @@ mod tests {
),
(8..34, Text),
(0..37, End(MarkdownTagEnd::CodeBlock)),
+ (0..37, RootEnd(0)),
],
- {
+ language_names: {
let mut h = HashSet::default();
h.insert("rust".into());
h
},
- HashSet::default()
- )
+ root_block_starts: vec![0],
+ ..Default::default()
+ }
);
assert_eq!(
- parse_markdown(" fn main() {}"),
- (
- vec![
+ parse_markdown_with_options(" fn main() {}", false),
+ ParsedMarkdownData {
+ events: vec![
+ (4..16, RootStart),
(
4..16,
Start(CodeBlock {
@@ -727,14 +861,76 @@ mod tests {
})
),
(4..16, Text),
- (4..16, End(MarkdownTagEnd::CodeBlock))
+ (4..16, End(MarkdownTagEnd::CodeBlock)),
+ (4..16, RootEnd(0)),
],
- HashSet::default(),
- HashSet::default()
- )
+ root_block_starts: vec![4],
+ ..Default::default()
+ }
);
}
+ #[test]
+ fn test_metadata_blocks_do_not_affect_root_blocks() {
+ assert_eq!(
+ parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false),
+ ParsedMarkdownData {
+ events: vec![
+ (27..36, RootStart),
+ (27..36, Start(Paragraph)),
+ (27..36, Text),
+ (27..36, End(MarkdownTagEnd::Paragraph)),
+ (27..36, RootEnd(0)),
+ ],
+ root_block_starts: vec![27],
+ ..Default::default()
+ }
+ );
+ }
+
+ #[test]
+ fn test_table_checkboxes_remain_text_in_cells() {
+ let markdown = "\
+| Done | Task |
+|------|---------|
+| [x] | Fix bug |
+| [ ] | Add feature |";
+ let parsed = parse_markdown_with_options(markdown, false);
+
+ let mut in_table = false;
+ let mut saw_task_list_marker = false;
+ let mut cell_texts = Vec::new();
+ let mut current_cell = String::new();
+
+ for (range, event) in &parsed.events {
+ match event {
+ Start(Table(_)) => in_table = true,
+ End(MarkdownTagEnd::Table) => in_table = false,
+ Start(TableCell) => current_cell.clear(),
+ End(MarkdownTagEnd::TableCell) => {
+ if in_table {
+ cell_texts.push(current_cell.clone());
+ }
+ }
+ Text if in_table => current_cell.push_str(&markdown[range.clone()]),
+ TaskListMarker(_) if in_table => saw_task_list_marker = true,
+ _ => {}
+ }
+ }
+
+ let checkbox_cells: Vec<&str> = cell_texts
+ .iter()
+ .map(|cell| cell.trim())
+ .filter(|cell| *cell == "[x]" || *cell == "[X]" || *cell == "[ ]")
+ .collect();
+
+ assert!(
+ !saw_task_list_marker,
+ "Table checkboxes should remain text, not task-list markers"
+ );
+ assert_eq!(checkbox_cells, vec!["[x]", "[ ]"]);
+ }
+
#[test]
fn test_extract_code_content_range() {
let input = "```let x = 5;```";
@@ -776,8 +972,13 @@ mod tests {
// Note: In real usage, pulldown_cmark creates separate text events for the escaped character
// We're verifying our parser can handle this correctly
assert_eq!(
- parse_markdown("https:/\\/example.com is equivalent to https://example.com!").0,
+ parse_markdown_with_options(
+ "https:/\\/example.com is equivalent to https://example.com!",
+ false
+ )
+ .events,
vec![
+ (0..62, RootStart),
(0..62, Start(Paragraph)),
(
0..20,
@@ -806,13 +1007,19 @@ mod tests {
(58..61, Text),
(38..61, End(MarkdownTagEnd::Link)),
(61..62, Text),
- (0..62, End(MarkdownTagEnd::Paragraph))
+ (0..62, End(MarkdownTagEnd::Paragraph)),
+ (0..62, RootEnd(0)),
],
);
assert_eq!(
- parse_markdown("Visit https://example.com/cat\\/é‍☕ for coffee!").0,
+ parse_markdown_with_options(
+ "Visit https://example.com/cat\\/é‍☕ for coffee!",
+ false
+ )
+ .events,
[
+ (0..55, RootStart),
(0..55, Start(Paragraph)),
(0..6, Text),
(
@@ -830,7 +1037,8 @@ mod tests {
(40..43, Text),
(6..43, End(MarkdownTagEnd::Link)),
(43..55, Text),
- (0..55, End(MarkdownTagEnd::Paragraph))
+ (0..55, End(MarkdownTagEnd::Paragraph)),
+ (0..55, RootEnd(0)),
]
);
}
@@ -16,28 +16,18 @@ test-support = []
[dependencies]
anyhow.workspace = true
-async-recursion.workspace = true
-collections.workspace = true
editor.workspace = true
gpui.workspace = true
-html5ever.workspace = true
language.workspace = true
-linkify.workspace = true
log.workspace = true
markdown.workspace = true
-markup5ever_rcdom.workspace = true
-pretty_assertions.workspace = true
-pulldown-cmark.workspace = true
settings.workspace = true
-stacksafe.workspace = true
theme.workspace = true
ui.workspace = true
urlencoding.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
-mermaid-rs-renderer.workspace = true
[dev-dependencies]
-editor = { workspace = true, features = ["test-support"] }
-language = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true
@@ -1,374 +0,0 @@
-use gpui::{
- DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle,
- UnderlineStyle, px,
-};
-use language::HighlightId;
-
-use std::{fmt::Display, ops::Range, path::PathBuf};
-use urlencoding;
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum ParsedMarkdownElement {
- Heading(ParsedMarkdownHeading),
- ListItem(ParsedMarkdownListItem),
- Table(ParsedMarkdownTable),
- BlockQuote(ParsedMarkdownBlockQuote),
- CodeBlock(ParsedMarkdownCodeBlock),
- MermaidDiagram(ParsedMarkdownMermaidDiagram),
- /// A paragraph of text and other inline elements.
- Paragraph(MarkdownParagraph),
- HorizontalRule(Range<usize>),
- Image(Image),
-}
-
-impl ParsedMarkdownElement {
- pub fn source_range(&self) -> Option<Range<usize>> {
- Some(match self {
- Self::Heading(heading) => heading.source_range.clone(),
- Self::ListItem(list_item) => list_item.source_range.clone(),
- Self::Table(table) => table.source_range.clone(),
- Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
- Self::CodeBlock(code_block) => code_block.source_range.clone(),
- Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(),
- Self::Paragraph(text) => match text.get(0)? {
- MarkdownParagraphChunk::Text(t) => t.source_range.clone(),
- MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
- },
- Self::HorizontalRule(range) => range.clone(),
- Self::Image(image) => image.source_range.clone(),
- })
- }
-
- pub fn is_list_item(&self) -> bool {
- matches!(self, Self::ListItem(_))
- }
-}
-
-pub type MarkdownParagraph = Vec<MarkdownParagraphChunk>;
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum MarkdownParagraphChunk {
- Text(ParsedMarkdownText),
- Image(Image),
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdown {
- pub children: Vec<ParsedMarkdownElement>,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownListItem {
- pub source_range: Range<usize>,
- /// How many indentations deep this item is.
- pub depth: u16,
- pub item_type: ParsedMarkdownListItemType,
- pub content: Vec<ParsedMarkdownElement>,
- /// Whether we can expect nested list items inside of this items `content`.
- pub nested: bool,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum ParsedMarkdownListItemType {
- Ordered(u64),
- Task(bool, Range<usize>),
- Unordered,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownCodeBlock {
- pub source_range: Range<usize>,
- pub language: Option<String>,
- pub contents: SharedString,
- pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownMermaidDiagram {
- pub source_range: Range<usize>,
- pub contents: ParsedMarkdownMermaidDiagramContents,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Hash)]
-pub struct ParsedMarkdownMermaidDiagramContents {
- pub contents: SharedString,
- pub scale: u32,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownHeading {
- pub source_range: Range<usize>,
- pub level: HeadingLevel,
- pub contents: MarkdownParagraph,
-}
-
-#[derive(Debug, PartialEq)]
-pub enum HeadingLevel {
- H1,
- H2,
- H3,
- H4,
- H5,
- H6,
-}
-
-#[derive(Debug)]
-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)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum ParsedMarkdownTableAlignment {
- #[default]
- None,
- Left,
- Center,
- Right,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownTableColumn {
- pub col_span: usize,
- pub row_span: usize,
- pub is_header: bool,
- pub children: MarkdownParagraph,
- pub alignment: ParsedMarkdownTableAlignment,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownTableRow {
- pub columns: Vec<ParsedMarkdownTableColumn>,
-}
-
-impl Default for ParsedMarkdownTableRow {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl ParsedMarkdownTableRow {
- pub fn new() -> Self {
- Self {
- columns: Vec::new(),
- }
- }
-
- pub fn with_columns(columns: Vec<ParsedMarkdownTableColumn>) -> Self {
- Self { columns }
- }
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownBlockQuote {
- pub source_range: Range<usize>,
- pub children: Vec<ParsedMarkdownElement>,
-}
-
-#[derive(Debug, Clone)]
-pub struct ParsedMarkdownText {
- /// Where the text is located in the source Markdown document.
- pub source_range: Range<usize>,
- /// The text content stripped of any formatting symbols.
- pub contents: SharedString,
- /// The list of highlights contained in the Markdown document.
- pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
- /// The regions of the Markdown document.
- pub regions: Vec<(Range<usize>, ParsedRegion)>,
-}
-
-/// A run of highlighted Markdown text.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum MarkdownHighlight {
- /// A styled Markdown highlight.
- Style(MarkdownHighlightStyle),
- /// A highlighted code block.
- Code(HighlightId),
-}
-
-impl MarkdownHighlight {
- /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`].
- pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option<HighlightStyle> {
- match self {
- MarkdownHighlight::Style(style) => {
- let mut highlight = HighlightStyle::default();
-
- if style.italic {
- highlight.font_style = Some(FontStyle::Italic);
- }
-
- if style.underline {
- highlight.underline = Some(UnderlineStyle {
- thickness: px(1.),
- ..Default::default()
- });
- }
-
- if style.strikethrough {
- highlight.strikethrough = Some(StrikethroughStyle {
- thickness: px(1.),
- ..Default::default()
- });
- }
-
- if style.weight != FontWeight::default() {
- highlight.font_weight = Some(style.weight);
- }
-
- if style.link {
- highlight.underline = Some(UnderlineStyle {
- thickness: px(1.),
- ..Default::default()
- });
- }
-
- if style.oblique {
- highlight.font_style = Some(FontStyle::Oblique)
- }
-
- Some(highlight)
- }
-
- MarkdownHighlight::Code(id) => theme.get(*id).cloned(),
- }
- }
-}
-
-/// The style for a Markdown highlight.
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct MarkdownHighlightStyle {
- /// Whether the text should be italicized.
- pub italic: bool,
- /// Whether the text should be underlined.
- pub underline: bool,
- /// Whether the text should be struck through.
- pub strikethrough: bool,
- /// The weight of the text.
- pub weight: FontWeight,
- /// Whether the text should be stylized as link.
- pub link: bool,
- // Whether the text should be obliqued.
- pub oblique: bool,
-}
-
-/// A parsed region in a Markdown document.
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedRegion {
- /// Whether the region is a code block.
- pub code: bool,
- /// The link contained in this region, if it has one.
- pub link: Option<Link>,
-}
-
-/// A Markdown link.
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum Link {
- /// A link to a webpage.
- Web {
- /// The URL of the webpage.
- url: String,
- },
- /// A link to a path on the filesystem.
- Path {
- /// The path as provided in the Markdown document.
- display_path: PathBuf,
- /// The absolute path to the item.
- path: PathBuf,
- },
-}
-
-impl Link {
- pub fn identify(file_location_directory: Option<PathBuf>, text: String) -> Option<Link> {
- if text.starts_with("http") {
- return Some(Link::Web { url: text });
- }
-
- // URL decode the text to handle spaces and other special characters
- let decoded_text = urlencoding::decode(&text)
- .map(|s| s.into_owned())
- .unwrap_or(text);
-
- let path = PathBuf::from(&decoded_text);
- if path.is_absolute() && path.exists() {
- return Some(Link::Path {
- display_path: path.clone(),
- path,
- });
- }
-
- if let Some(file_location_directory) = file_location_directory {
- let display_path = path;
- let path = file_location_directory.join(decoded_text);
- if path.exists() {
- return Some(Link::Path { display_path, path });
- }
- }
-
- None
- }
-}
-
-impl Display for Link {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Link::Web { url } => write!(f, "{}", url),
- Link::Path { display_path, .. } => write!(f, "{}", display_path.display()),
- }
- }
-}
-
-/// A Markdown Image
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct Image {
- pub link: Link,
- pub source_range: Range<usize>,
- pub alt_text: Option<SharedString>,
- pub width: Option<DefiniteLength>,
- pub height: Option<DefiniteLength>,
-}
-
-impl Image {
- pub fn identify(
- text: String,
- source_range: Range<usize>,
- file_location_directory: Option<PathBuf>,
- ) -> Option<Self> {
- let link = Link::identify(file_location_directory, text)?;
- Some(Self {
- source_range,
- link,
- alt_text: None,
- width: None,
- height: None,
- })
- }
-
- pub fn set_alt_text(&mut self, alt_text: SharedString) {
- self.alt_text = Some(alt_text);
- }
-
- pub fn set_width(&mut self, width: DefiniteLength) {
- self.width = Some(width);
- }
-
- pub fn set_height(&mut self, height: DefiniteLength) {
- self.height = Some(height);
- }
-}
@@ -1,3320 +0,0 @@
-use crate::{
- markdown_elements::*,
- markdown_minifier::{Minifier, MinifierOptions},
-};
-use async_recursion::async_recursion;
-use collections::FxHashMap;
-use gpui::{DefiniteLength, FontWeight, px, relative};
-use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink};
-use language::LanguageRegistry;
-use markdown::parser::PARSE_OPTIONS;
-use markup5ever_rcdom::RcDom;
-use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd};
-use stacksafe::stacksafe;
-use std::{
- cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec,
-};
-use ui::SharedString;
-
-pub async fn parse_markdown(
- markdown_input: &str,
- file_location_directory: Option<PathBuf>,
- language_registry: Option<Arc<LanguageRegistry>>,
-) -> ParsedMarkdown {
- let parser = Parser::new_ext(markdown_input, PARSE_OPTIONS);
- let parser = MarkdownParser::new(
- parser.into_offset_iter().collect(),
- file_location_directory,
- language_registry,
- );
- let renderer = parser.parse_document().await;
- ParsedMarkdown {
- children: renderer.parsed,
- }
-}
-
-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
- cursor: usize,
- /// The blocks that we have successfully parsed so far
- parsed: Vec<ParsedMarkdownElement>,
- file_location_directory: Option<PathBuf>,
- language_registry: Option<Arc<LanguageRegistry>>,
-}
-
-#[derive(Debug)]
-struct ParseHtmlNodeContext {
- list_item_depth: u16,
-}
-
-impl Default for ParseHtmlNodeContext {
- fn default() -> Self {
- Self { list_item_depth: 1 }
- }
-}
-
-struct MarkdownListItem {
- content: Vec<ParsedMarkdownElement>,
- item_type: ParsedMarkdownListItemType,
-}
-
-impl Default for MarkdownListItem {
- fn default() -> Self {
- Self {
- content: Vec::new(),
- item_type: ParsedMarkdownListItemType::Unordered,
- }
- }
-}
-
-impl<'a> MarkdownParser<'a> {
- fn new(
- tokens: Vec<(Event<'a>, Range<usize>)>,
- file_location_directory: Option<PathBuf>,
- language_registry: Option<Arc<LanguageRegistry>>,
- ) -> Self {
- Self {
- tokens,
- file_location_directory,
- language_registry,
- cursor: 0,
- parsed: vec![],
- }
- }
-
- fn eof(&self) -> bool {
- if self.tokens.is_empty() {
- return true;
- }
- self.cursor >= self.tokens.len() - 1
- }
-
- fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range<usize>)> {
- if self.eof() || (steps + self.cursor) >= self.tokens.len() {
- return self.tokens.last();
- }
- self.tokens.get(self.cursor + steps)
- }
-
- fn previous(&self) -> Option<&(Event<'_>, Range<usize>)> {
- if self.cursor == 0 || self.cursor > self.tokens.len() {
- return None;
- }
- self.tokens.get(self.cursor - 1)
- }
-
- fn current(&self) -> Option<&(Event<'_>, Range<usize>)> {
- self.peek(0)
- }
-
- fn current_event(&self) -> Option<&Event<'_>> {
- self.current().map(|(event, _)| event)
- }
-
- fn is_text_like(event: &Event) -> bool {
- match event {
- Event::Text(_)
- // Represent an inline code block
- | Event::Code(_)
- | Event::Html(_)
- | Event::InlineHtml(_)
- | Event::FootnoteReference(_)
- | Event::Start(Tag::Link { .. })
- | Event::Start(Tag::Emphasis)
- | Event::Start(Tag::Strong)
- | Event::Start(Tag::Strikethrough)
- | Event::Start(Tag::Image { .. }) => {
- true
- }
- _ => false,
- }
- }
-
- async fn parse_document(mut self) -> Self {
- while !self.eof() {
- if let Some(block) = self.parse_block().await {
- self.parsed.extend(block);
- } else {
- self.cursor += 1;
- }
- }
- self
- }
-
- #[async_recursion]
- async fn parse_block(&mut self) -> Option<Vec<ParsedMarkdownElement>> {
- let (current, source_range) = self.current().unwrap();
- let source_range = source_range.clone();
- match current {
- Event::Start(tag) => match tag {
- Tag::Paragraph => {
- self.cursor += 1;
- let text = self.parse_text(false, Some(source_range));
- Some(vec![ParsedMarkdownElement::Paragraph(text)])
- }
- Tag::Heading { level, .. } => {
- let level = *level;
- self.cursor += 1;
- let heading = self.parse_heading(level);
- Some(vec![ParsedMarkdownElement::Heading(heading)])
- }
- Tag::Table(alignment) => {
- let alignment = alignment.clone();
- self.cursor += 1;
- let table = self.parse_table(alignment);
- Some(vec![ParsedMarkdownElement::Table(table)])
- }
- Tag::List(order) => {
- let order = *order;
- self.cursor += 1;
- let list = self.parse_list(order).await;
- Some(list)
- }
- Tag::BlockQuote(_kind) => {
- self.cursor += 1;
- let block_quote = self.parse_block_quote().await;
- Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)])
- }
- Tag::CodeBlock(kind) => {
- let (language, scale) = match kind {
- pulldown_cmark::CodeBlockKind::Indented => (None, None),
- pulldown_cmark::CodeBlockKind::Fenced(language) => {
- if language.is_empty() {
- (None, None)
- } else {
- let parts: Vec<&str> = language.split_whitespace().collect();
- let lang = parts.first().map(|s| s.to_string());
- let scale = parts.get(1).and_then(|s| s.parse::<u32>().ok());
- (lang, scale)
- }
- }
- };
-
- self.cursor += 1;
-
- if language.as_deref() == Some("mermaid") {
- let mermaid_diagram = self.parse_mermaid_diagram(scale).await?;
- Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)])
- } else {
- let code_block = self.parse_code_block(language).await?;
- Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
- }
- }
- Tag::HtmlBlock => {
- self.cursor += 1;
-
- Some(self.parse_html_block().await)
- }
- _ => None,
- },
- Event::Rule => {
- self.cursor += 1;
- Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)])
- }
- _ => None,
- }
- }
-
- fn parse_text(
- &mut self,
- should_complete_on_soft_break: bool,
- source_range: Option<Range<usize>>,
- ) -> MarkdownParagraph {
- let source_range = source_range.unwrap_or_else(|| {
- self.current()
- .map(|(_, range)| range.clone())
- .unwrap_or_default()
- });
-
- let mut markdown_text_like = Vec::new();
- let mut text = String::new();
- let mut bold_depth = 0;
- let mut italic_depth = 0;
- let mut strikethrough_depth = 0;
- let mut link: Option<Link> = None;
- let mut image: Option<Image> = None;
- let mut regions: Vec<(Range<usize>, ParsedRegion)> = vec![];
- let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
- let mut link_urls: Vec<String> = vec![];
- let mut link_ranges: Vec<Range<usize>> = vec![];
-
- loop {
- if self.eof() {
- break;
- }
-
- let (current, _) = self.current().unwrap();
- let prev_len = text.len();
- match current {
- Event::SoftBreak => {
- if should_complete_on_soft_break {
- break;
- }
- text.push(' ');
- }
-
- Event::HardBreak => {
- text.push('\n');
- }
-
- // We want to ignore any inline HTML tags in the text but keep
- // the text between them
- Event::InlineHtml(_) => {}
-
- Event::Text(t) => {
- text.push_str(t.as_ref());
- let mut style = MarkdownHighlightStyle::default();
-
- if bold_depth > 0 {
- style.weight = FontWeight::BOLD;
- }
-
- if italic_depth > 0 {
- style.italic = true;
- }
-
- if strikethrough_depth > 0 {
- style.strikethrough = true;
- }
-
- let last_run_len = if let Some(link) = link.clone() {
- regions.push((
- prev_len..text.len(),
- ParsedRegion {
- code: false,
- link: Some(link),
- },
- ));
- style.link = true;
- prev_len
- } else {
- // Manually scan for links
- let mut finder = linkify::LinkFinder::new();
- finder.kinds(&[linkify::LinkKind::Url]);
- let mut last_link_len = prev_len;
- for link in finder.links(t) {
- let start = prev_len + link.start();
- let end = prev_len + link.end();
- let range = start..end;
- link_ranges.push(range.clone());
- link_urls.push(link.as_str().to_string());
-
- // If there is a style before we match a link, we have to add this to the highlighted ranges
- if style != MarkdownHighlightStyle::default() && last_link_len < start {
- highlights.push((
- last_link_len..start,
- MarkdownHighlight::Style(style.clone()),
- ));
- }
-
- highlights.push((
- range.clone(),
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- underline: true,
- ..style
- }),
- ));
-
- regions.push((
- range.clone(),
- ParsedRegion {
- code: false,
- link: Some(Link::Web {
- url: link.as_str().to_string(),
- }),
- },
- ));
- last_link_len = end;
- }
- last_link_len
- };
-
- if style != MarkdownHighlightStyle::default() && last_run_len < text.len() {
- let mut new_highlight = true;
- if let Some((last_range, last_style)) = highlights.last_mut()
- && last_range.end == last_run_len
- && last_style == &MarkdownHighlight::Style(style.clone())
- {
- last_range.end = text.len();
- new_highlight = false;
- }
- if new_highlight {
- highlights.push((
- last_run_len..text.len(),
- MarkdownHighlight::Style(style.clone()),
- ));
- }
- }
- }
- Event::Code(t) => {
- text.push_str(t.as_ref());
- let range = prev_len..text.len();
-
- if link.is_some() {
- highlights.push((
- range.clone(),
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- link: true,
- ..Default::default()
- }),
- ));
- }
- regions.push((
- range,
- ParsedRegion {
- code: true,
- link: link.clone(),
- },
- ));
- }
- Event::Start(tag) => match tag {
- Tag::Emphasis => italic_depth += 1,
- Tag::Strong => bold_depth += 1,
- Tag::Strikethrough => strikethrough_depth += 1,
- Tag::Link { dest_url, .. } => {
- link = Link::identify(
- self.file_location_directory.clone(),
- dest_url.to_string(),
- );
- }
- Tag::Image { dest_url, .. } => {
- if !text.is_empty() {
- let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range: source_range.clone(),
- contents: mem::take(&mut text).into(),
- highlights: mem::take(&mut highlights),
- regions: mem::take(&mut regions),
- });
- markdown_text_like.push(parsed_regions);
- }
- image = Image::identify(
- dest_url.to_string(),
- source_range.clone(),
- self.file_location_directory.clone(),
- );
- }
- _ => {
- break;
- }
- },
-
- Event::End(tag) => match tag {
- TagEnd::Emphasis => italic_depth -= 1,
- TagEnd::Strong => bold_depth -= 1,
- TagEnd::Strikethrough => strikethrough_depth -= 1,
- TagEnd::Link => {
- link = None;
- }
- TagEnd::Image => {
- if let Some(mut image) = image.take() {
- if !text.is_empty() {
- image.set_alt_text(std::mem::take(&mut text).into());
- mem::take(&mut highlights);
- mem::take(&mut regions);
- }
- markdown_text_like.push(MarkdownParagraphChunk::Image(image));
- }
- }
- TagEnd::Paragraph => {
- self.cursor += 1;
- break;
- }
- _ => {
- break;
- }
- },
- _ => {
- break;
- }
- }
-
- self.cursor += 1;
- }
- if !text.is_empty() {
- markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range,
- contents: text.into(),
- highlights,
- regions,
- }));
- }
- markdown_text_like
- }
-
- fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading {
- let (_event, source_range) = self.previous().unwrap();
- let source_range = source_range.clone();
- let text = self.parse_text(true, None);
-
- // Advance past the heading end tag
- self.cursor += 1;
-
- ParsedMarkdownHeading {
- source_range,
- level: match level {
- pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
- pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
- pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
- pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
- pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
- pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
- },
- contents: text,
- }
- }
-
- fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
- let (_event, source_range) = self.previous().unwrap();
- let source_range = source_range.clone();
- let mut header = vec![];
- let mut body = vec![];
- let mut row_columns = vec![];
- let mut in_header = true;
- let column_alignments = alignment
- .iter()
- .map(Self::convert_alignment)
- .collect::<Vec<_>>();
-
- loop {
- if self.eof() {
- break;
- }
-
- let (current, source_range) = self.current().unwrap();
- let source_range = source_range.clone();
- match current {
- Event::Start(Tag::TableHead)
- | Event::Start(Tag::TableRow)
- | Event::End(TagEnd::TableCell) => {
- self.cursor += 1;
- }
- Event::Start(Tag::TableCell) => {
- self.cursor += 1;
- let cell_contents = self.parse_text(false, Some(source_range));
- row_columns.push(ParsedMarkdownTableColumn {
- col_span: 1,
- row_span: 1,
- is_header: in_header,
- children: cell_contents,
- alignment: column_alignments
- .get(row_columns.len())
- .copied()
- .unwrap_or_default(),
- });
- }
- Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
- self.cursor += 1;
- let columns = std::mem::take(&mut row_columns);
- if in_header {
- header.push(ParsedMarkdownTableRow { columns: columns });
- in_header = false;
- } else {
- body.push(ParsedMarkdownTableRow::with_columns(columns));
- }
- }
- Event::End(TagEnd::Table) => {
- self.cursor += 1;
- break;
- }
- _ => {
- break;
- }
- }
- }
-
- ParsedMarkdownTable {
- source_range,
- header,
- body,
- caption: None,
- }
- }
-
- fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment {
- match alignment {
- Alignment::None => ParsedMarkdownTableAlignment::None,
- Alignment::Left => ParsedMarkdownTableAlignment::Left,
- Alignment::Center => ParsedMarkdownTableAlignment::Center,
- Alignment::Right => ParsedMarkdownTableAlignment::Right,
- }
- }
-
- async fn parse_list(&mut self, order: Option<u64>) -> Vec<ParsedMarkdownElement> {
- let (_, list_source_range) = self.previous().unwrap();
-
- let mut items = Vec::new();
- let mut items_stack = vec![MarkdownListItem::default()];
- let mut depth = 1;
- let mut order = order;
- let mut order_stack = Vec::new();
-
- let mut insertion_indices = FxHashMap::default();
- let mut source_ranges = FxHashMap::default();
- let mut start_item_range = list_source_range.clone();
-
- while !self.eof() {
- let (current, source_range) = self.current().unwrap();
- match current {
- Event::Start(Tag::List(new_order)) => {
- if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) {
- insertion_indices.insert(depth, items.len());
- }
-
- // We will use the start of the nested list as the end for the current item's range,
- // because we don't care about the hierarchy of list items
- if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) {
- e.insert(start_item_range.start..source_range.start);
- }
-
- order_stack.push(order);
- order = *new_order;
- self.cursor += 1;
- depth += 1;
- }
- Event::End(TagEnd::List(_)) => {
- order = order_stack.pop().flatten();
- self.cursor += 1;
- depth -= 1;
-
- if depth == 0 {
- break;
- }
- }
- Event::Start(Tag::Item) => {
- start_item_range = source_range.clone();
-
- self.cursor += 1;
- items_stack.push(MarkdownListItem::default());
-
- let mut task_list = None;
- // Check for task list marker (`- [ ]` or `- [x]`)
- if let Some(event) = self.current_event() {
- // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
- if event == &Event::Start(Tag::Paragraph) {
- self.cursor += 1;
- }
-
- if let Some((Event::TaskListMarker(checked), range)) = self.current() {
- task_list = Some((*checked, range.clone()));
- self.cursor += 1;
- }
- }
-
- if let Some((event, range)) = self.current() {
- // This is a plain list item.
- // For example `- some text` or `1. [Docs](./docs.md)`
- if MarkdownParser::is_text_like(event) {
- let text = self.parse_text(false, Some(range.clone()));
- let block = ParsedMarkdownElement::Paragraph(text);
- if let Some(content) = items_stack.last_mut() {
- let item_type = if let Some((checked, range)) = task_list {
- ParsedMarkdownListItemType::Task(checked, range)
- } else if let Some(order) = order {
- ParsedMarkdownListItemType::Ordered(order)
- } else {
- ParsedMarkdownListItemType::Unordered
- };
- content.item_type = item_type;
- content.content.push(block);
- }
- } else {
- let block = self.parse_block().await;
- if let Some(block) = block
- && let Some(list_item) = items_stack.last_mut()
- {
- list_item.content.extend(block);
- }
- }
- }
-
- // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
- if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) {
- self.cursor += 1;
- }
- }
- Event::End(TagEnd::Item) => {
- self.cursor += 1;
-
- if let Some(current) = order {
- order = Some(current + 1);
- }
-
- if let Some(list_item) = items_stack.pop() {
- let source_range = source_ranges
- .remove(&depth)
- .unwrap_or(start_item_range.clone());
-
- // We need to remove the last character of the source range, because it includes the newline character
- let source_range = source_range.start..source_range.end - 1;
- let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
- source_range,
- content: list_item.content,
- depth,
- item_type: list_item.item_type,
- nested: false,
- });
-
- if let Some(index) = insertion_indices.get(&depth) {
- items.insert(*index, item);
- insertion_indices.remove(&depth);
- } else {
- items.push(item);
- }
- }
- }
- _ => {
- if depth == 0 {
- break;
- }
- // This can only happen if a list item starts with more then one paragraph,
- // or the list item contains blocks that should be rendered after the nested list items
- let block = self.parse_block().await;
- if let Some(block) = block {
- if let Some(list_item) = items_stack.last_mut() {
- // If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item
- if !insertion_indices.contains_key(&depth) {
- list_item.content.extend(block);
- continue;
- }
- }
-
- // Otherwise we need to insert the block after all the nested items
- // that have been parsed so far
- items.extend(block);
- } else {
- self.cursor += 1;
- }
- }
- }
- }
-
- items
- }
-
- #[async_recursion]
- async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote {
- let (_event, source_range) = self.previous().unwrap();
- let source_range = source_range.clone();
- let mut nested_depth = 1;
-
- let mut children: Vec<ParsedMarkdownElement> = vec![];
-
- while !self.eof() {
- let block = self.parse_block().await;
-
- if let Some(block) = block {
- children.extend(block);
- } else {
- break;
- }
-
- if self.eof() {
- break;
- }
-
- let (current, _source_range) = self.current().unwrap();
- match current {
- // This is a nested block quote.
- // Record that we're in a nested block quote and continue parsing.
- // We don't need to advance the cursor since the next
- // call to `parse_block` will handle it.
- Event::Start(Tag::BlockQuote(_kind)) => {
- nested_depth += 1;
- }
- Event::End(TagEnd::BlockQuote(_kind)) => {
- nested_depth -= 1;
- if nested_depth == 0 {
- self.cursor += 1;
- break;
- }
- }
- _ => {}
- };
- }
-
- ParsedMarkdownBlockQuote {
- source_range,
- children,
- }
- }
-
- async fn parse_code_block(
- &mut self,
- language: Option<String>,
- ) -> Option<ParsedMarkdownCodeBlock> {
- let Some((_event, source_range)) = self.previous() else {
- return None;
- };
-
- let source_range = source_range.clone();
- let mut code = String::new();
-
- while !self.eof() {
- let Some((current, _source_range)) = self.current() else {
- break;
- };
-
- match current {
- Event::Text(text) => {
- code.push_str(text);
- self.cursor += 1;
- }
- Event::End(TagEnd::CodeBlock) => {
- self.cursor += 1;
- break;
- }
- _ => {
- break;
- }
- }
- }
-
- code = code.strip_suffix('\n').unwrap_or(&code).to_string();
-
- let highlights = if let Some(language) = &language {
- if let Some(registry) = &self.language_registry {
- let rope: language::Rope = code.as_str().into();
- registry
- .language_for_name_or_extension(language)
- .await
- .map(|l| l.highlight_text(&rope, 0..code.len()))
- .ok()
- } else {
- None
- }
- } else {
- None
- };
-
- Some(ParsedMarkdownCodeBlock {
- source_range,
- contents: code.into(),
- language,
- highlights,
- })
- }
-
- async fn parse_mermaid_diagram(
- &mut self,
- scale: Option<u32>,
- ) -> Option<ParsedMarkdownMermaidDiagram> {
- let Some((_event, source_range)) = self.previous() else {
- return None;
- };
-
- let source_range = source_range.clone();
- let mut code = String::new();
-
- while !self.eof() {
- let Some((current, _source_range)) = self.current() else {
- break;
- };
-
- match current {
- Event::Text(text) => {
- code.push_str(text);
- self.cursor += 1;
- }
- Event::End(TagEnd::CodeBlock) => {
- self.cursor += 1;
- break;
- }
- _ => {
- break;
- }
- }
- }
-
- code = code.strip_suffix('\n').unwrap_or(&code).to_string();
-
- let scale = scale.unwrap_or(100).clamp(10, 500);
-
- Some(ParsedMarkdownMermaidDiagram {
- source_range,
- contents: ParsedMarkdownMermaidDiagramContents {
- contents: code.into(),
- scale,
- },
- })
- }
-
- async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
- let mut elements = Vec::new();
- let Some((_event, _source_range)) = self.previous() else {
- 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;
- };
- let source_range = source_range.clone();
- match current {
- Event::Html(html) => {
- 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;
- }
- Event::End(TagEnd::CodeBlock) => {
- self.cursor += 1;
- break;
- }
- _ => {
- break;
- }
- }
- }
-
- 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,
- &ParseHtmlNodeContext::default(),
- );
- }
-
- elements
- }
-
- #[stacksafe]
- fn parse_html_node(
- &self,
- source_range: Range<usize>,
- node: &Rc<markup5ever_rcdom::Node>,
- elements: &mut Vec<ParsedMarkdownElement>,
- context: &ParseHtmlNodeContext,
- ) {
- match &node.data {
- markup5ever_rcdom::NodeData::Document => {
- self.consume_children(source_range, node, elements, context);
- }
- markup5ever_rcdom::NodeData::Text { contents } => {
- elements.push(ParsedMarkdownElement::Paragraph(vec![
- MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range,
- regions: Vec::default(),
- highlights: Vec::default(),
- contents: contents.borrow().to_string().into(),
- }),
- ]));
- }
- markup5ever_rcdom::NodeData::Comment { .. } => {}
- markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
- let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles(
- Self::extract_styles_from_attributes(attrs),
- ) {
- vec![MarkdownHighlight::Style(styles)]
- } else {
- Vec::default()
- };
-
- if local_name!("img") == name.local {
- if let Some(image) = self.extract_image(source_range, attrs) {
- elements.push(ParsedMarkdownElement::Image(image));
- }
- } else if local_name!("p") == name.local {
- let mut paragraph = MarkdownParagraph::new();
- self.parse_paragraph(
- source_range,
- node,
- &mut paragraph,
- &mut styles,
- &mut Vec::new(),
- );
-
- if !paragraph.is_empty() {
- elements.push(ParsedMarkdownElement::Paragraph(paragraph));
- }
- } 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,
- &mut styles,
- &mut Vec::new(),
- );
-
- 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 if local_name!("ul") == name.local || local_name!("ol") == name.local {
- if let Some(list_items) = self.extract_html_list(
- node,
- local_name!("ol") == name.local,
- context.list_item_depth,
- source_range,
- ) {
- elements.extend(list_items);
- }
- } else if local_name!("blockquote") == name.local {
- if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
- elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
- }
- } 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, context);
- }
- }
- _ => {}
- }
- }
-
- #[stacksafe]
- fn parse_paragraph(
- &self,
- source_range: Range<usize>,
- node: &Rc<markup5ever_rcdom::Node>,
- paragraph: &mut MarkdownParagraph,
- highlights: &mut Vec<MarkdownHighlight>,
- regions: &mut Vec<(Range<usize>, ParsedRegion)>,
- ) {
- fn items_with_range<T>(
- range: Range<usize>,
- items: impl IntoIterator<Item = T>,
- ) -> Vec<(Range<usize>, T)> {
- items
- .into_iter()
- .map(|item| (range.clone(), item))
- .collect()
- }
-
- match &node.data {
- markup5ever_rcdom::NodeData::Text { contents } => {
- // append the text to the last chunk, so we can have a hacky version
- // of inline text with highlighting
- if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p {
- MarkdownParagraphChunk::Text(text) => Some(text),
- _ => None,
- }) {
- let mut new_text = text.contents.to_string();
- new_text.push_str(&contents.borrow());
-
- text.highlights.extend(items_with_range(
- text.contents.len()..new_text.len(),
- std::mem::take(highlights),
- ));
- text.regions.extend(items_with_range(
- text.contents.len()..new_text.len(),
- std::mem::take(regions)
- .into_iter()
- .map(|(_, region)| region),
- ));
- text.contents = SharedString::from(new_text);
- } else {
- let contents = contents.borrow().to_string();
- paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range,
- highlights: items_with_range(0..contents.len(), std::mem::take(highlights)),
- regions: items_with_range(
- 0..contents.len(),
- std::mem::take(regions)
- .into_iter()
- .map(|(_, region)| region),
- ),
- contents: contents.into(),
- }));
- }
- }
- markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
- if local_name!("img") == name.local {
- if let Some(image) = self.extract_image(source_range, attrs) {
- paragraph.push(MarkdownParagraphChunk::Image(image));
- }
- } else if local_name!("b") == name.local || local_name!("strong") == name.local {
- highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight::BOLD,
- ..Default::default()
- }));
-
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- } else if local_name!("i") == name.local {
- highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
- italic: true,
- ..Default::default()
- }));
-
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- } else if local_name!("em") == name.local {
- highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
- oblique: true,
- ..Default::default()
- }));
-
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- } else if local_name!("del") == name.local {
- highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
- strikethrough: true,
- ..Default::default()
- }));
-
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- } else if local_name!("ins") == name.local {
- highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
- underline: true,
- ..Default::default()
- }));
-
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- } else if local_name!("a") == name.local {
- if let Some(url) = Self::attr_value(attrs, local_name!("href"))
- && let Some(link) =
- Link::identify(self.file_location_directory.clone(), url)
- {
- highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
- link: true,
- ..Default::default()
- }));
-
- regions.push((
- source_range.clone(),
- ParsedRegion {
- code: false,
- link: Some(link),
- },
- ));
- }
-
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- } else {
- self.consume_paragraph(source_range, node, paragraph, highlights, regions);
- }
- }
- _ => {}
- }
- }
-
- fn consume_paragraph(
- &self,
- source_range: Range<usize>,
- node: &Rc<markup5ever_rcdom::Node>,
- paragraph: &mut MarkdownParagraph,
- highlights: &mut Vec<MarkdownHighlight>,
- regions: &mut Vec<(Range<usize>, ParsedRegion)>,
- ) {
- for node in node.children.borrow().iter() {
- self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions);
- }
- }
-
- fn parse_table_row(
- &self,
- source_range: Range<usize>,
- node: &Rc<markup5ever_rcdom::Node>,
- ) -> Option<ParsedMarkdownTableRow> {
- let mut columns = Vec::new();
-
- match &node.data {
- markup5ever_rcdom::NodeData::Element { name, .. } => {
- if local_name!("tr") != name.local {
- return None;
- }
-
- for node in node.children.borrow().iter() {
- if let Some(column) = self.parse_table_column(source_range.clone(), node) {
- columns.push(column);
- }
- }
- }
- _ => {}
- }
-
- if columns.is_empty() {
- None
- } else {
- Some(ParsedMarkdownTableRow { columns })
- }
- }
-
- fn parse_table_column(
- &self,
- source_range: Range<usize>,
- node: &Rc<markup5ever_rcdom::Node>,
- ) -> Option<ParsedMarkdownTableColumn> {
- match &node.data {
- markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
- if !matches!(name.local, local_name!("th") | local_name!("td")) {
- return None;
- }
-
- let mut children = MarkdownParagraph::new();
- self.consume_paragraph(
- source_range,
- node,
- &mut children,
- &mut Vec::new(),
- &mut Vec::new(),
- );
-
- let is_header = matches!(name.local, local_name!("th"));
-
- Some(ParsedMarkdownTableColumn {
- col_span: std::cmp::max(
- Self::attr_value(attrs, local_name!("colspan"))
- .and_then(|span| span.parse().ok())
- .unwrap_or(1),
- 1,
- ),
- row_span: std::cmp::max(
- Self::attr_value(attrs, local_name!("rowspan"))
- .and_then(|span| span.parse().ok())
- .unwrap_or(1),
- 1,
- ),
- is_header,
- children,
- alignment: Self::attr_value(attrs, local_name!("align"))
- .and_then(|align| match align.as_str() {
- "left" => Some(ParsedMarkdownTableAlignment::Left),
- "center" => Some(ParsedMarkdownTableAlignment::Center),
- "right" => Some(ParsedMarkdownTableAlignment::Right),
- _ => None,
- })
- .unwrap_or_else(|| {
- if is_header {
- ParsedMarkdownTableAlignment::Center
- } else {
- ParsedMarkdownTableAlignment::default()
- }
- }),
- })
- }
- _ => None,
- }
- }
-
- fn consume_children(
- &self,
- source_range: Range<usize>,
- node: &Rc<markup5ever_rcdom::Node>,
- elements: &mut Vec<ParsedMarkdownElement>,
- context: &ParseHtmlNodeContext,
- ) {
- for node in node.children.borrow().iter() {
- self.parse_html_node(source_range.clone(), node, elements, context);
- }
- }
-
- fn attr_value(
- attrs: &RefCell<Vec<html5ever::Attribute>>,
- name: html5ever::LocalName,
- ) -> Option<String> {
- attrs.borrow().iter().find_map(|attr| {
- if attr.name.local == name {
- Some(attr.value.to_string())
- } else {
- None
- }
- })
- }
-
- fn markdown_style_from_html_styles(
- styles: HashMap<String, String>,
- ) -> Option<MarkdownHighlightStyle> {
- let mut markdown_style = MarkdownHighlightStyle::default();
-
- if let Some(text_decoration) = styles.get("text-decoration") {
- match text_decoration.to_lowercase().as_str() {
- "underline" => {
- markdown_style.underline = true;
- }
- "line-through" => {
- markdown_style.strikethrough = true;
- }
- _ => {}
- }
- }
-
- if let Some(font_style) = styles.get("font-style") {
- match font_style.to_lowercase().as_str() {
- "italic" => {
- markdown_style.italic = true;
- }
- "oblique" => {
- markdown_style.oblique = true;
- }
- _ => {}
- }
- }
-
- if let Some(font_weight) = styles.get("font-weight") {
- match font_weight.to_lowercase().as_str() {
- "bold" => {
- markdown_style.weight = FontWeight::BOLD;
- }
- "lighter" => {
- markdown_style.weight = FontWeight::THIN;
- }
- _ => {
- if let Some(weight) = font_weight.parse::<f32>().ok() {
- markdown_style.weight = FontWeight(weight);
- }
- }
- }
- }
-
- if markdown_style != MarkdownHighlightStyle::default() {
- Some(markdown_style)
- } else {
- None
- }
- }
-
- fn extract_styles_from_attributes(
- attrs: &RefCell<Vec<html5ever::Attribute>>,
- ) -> HashMap<String, String> {
- let mut styles = HashMap::new();
-
- if let Some(style) = Self::attr_value(attrs, local_name!("style")) {
- for decl in style.split(';') {
- let mut parts = decl.splitn(2, ':');
- if let Some((key, value)) = parts.next().zip(parts.next()) {
- styles.insert(
- key.trim().to_lowercase().to_string(),
- value.trim().to_string(),
- );
- }
- }
- }
-
- styles
- }
-
- fn extract_image(
- &self,
- source_range: Range<usize>,
- attrs: &RefCell<Vec<html5ever::Attribute>>,
- ) -> Option<Image> {
- let src = Self::attr_value(attrs, local_name!("src"))?;
-
- let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?;
-
- if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) {
- image.set_alt_text(alt.into());
- }
-
- let styles = Self::extract_styles_from_attributes(attrs);
-
- if let Some(width) = Self::attr_value(attrs, local_name!("width"))
- .or_else(|| styles.get("width").cloned())
- .and_then(|width| Self::parse_html_element_dimension(&width))
- {
- image.set_width(width);
- }
-
- if let Some(height) = Self::attr_value(attrs, local_name!("height"))
- .or_else(|| styles.get("height").cloned())
- .and_then(|height| Self::parse_html_element_dimension(&height))
- {
- image.set_height(height);
- }
-
- Some(image)
- }
-
- fn extract_html_list(
- &self,
- node: &Rc<markup5ever_rcdom::Node>,
- ordered: bool,
- depth: u16,
- source_range: Range<usize>,
- ) -> Option<Vec<ParsedMarkdownElement>> {
- let mut list_items = Vec::with_capacity(node.children.borrow().len());
-
- for (index, node) in node.children.borrow().iter().enumerate() {
- match &node.data {
- markup5ever_rcdom::NodeData::Element { name, .. } => {
- if local_name!("li") != name.local {
- continue;
- }
-
- let mut content = Vec::new();
- self.consume_children(
- source_range.clone(),
- node,
- &mut content,
- &ParseHtmlNodeContext {
- list_item_depth: depth + 1,
- },
- );
-
- if !content.is_empty() {
- list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
- depth,
- source_range: source_range.clone(),
- item_type: if ordered {
- ParsedMarkdownListItemType::Ordered(index as u64 + 1)
- } else {
- ParsedMarkdownListItemType::Unordered
- },
- content,
- nested: true,
- }));
- }
- }
- _ => {}
- }
- }
-
- if list_items.is_empty() {
- None
- } else {
- Some(list_items)
- }
- }
-
- fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
- if value.ends_with("%") {
- value
- .trim_end_matches("%")
- .parse::<f32>()
- .ok()
- .map(|value| relative(value / 100.))
- } else {
- value
- .trim_end_matches("px")
- .parse()
- .ok()
- .map(|value| px(value).into())
- }
- }
-
- fn extract_html_blockquote(
- &self,
- node: &Rc<markup5ever_rcdom::Node>,
- source_range: Range<usize>,
- ) -> Option<ParsedMarkdownBlockQuote> {
- let mut children = Vec::new();
- self.consume_children(
- source_range.clone(),
- node,
- &mut children,
- &ParseHtmlNodeContext::default(),
- );
-
- if children.is_empty() {
- None
- } else {
- Some(ParsedMarkdownBlockQuote {
- children,
- source_range,
- })
- }
- }
-
- fn extract_html_table(
- &self,
- node: &Rc<markup5ever_rcdom::Node>,
- source_range: Range<usize>,
- ) -> Option<ParsedMarkdownTable> {
- let mut header_rows = Vec::new();
- let mut body_rows = Vec::new();
- let mut caption = None;
-
- // 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,
- &mut Vec::new(),
- &mut Vec::new(),
- );
- caption = Some(paragraph);
- }
- if local_name!("thead") == name.local {
- // node should be a tr element
- for node in node.children.borrow().iter() {
- if let Some(row) = self.parse_table_row(source_range.clone(), node) {
- header_rows.push(row);
- }
- }
- } else if local_name!("tbody") == name.local {
- // node should be a tr element
- for node in node.children.borrow().iter() {
- if let Some(row) = self.parse_table_row(source_range.clone(), node) {
- body_rows.push(row);
- }
- }
- }
- }
- _ => {}
- }
- }
-
- if !header_rows.is_empty() || !body_rows.is_empty() {
- Some(ParsedMarkdownTable {
- source_range,
- body: body_rows,
- header: header_rows,
- caption,
- })
- } else {
- None
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use ParsedMarkdownListItemType::*;
- use core::panic;
- use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength};
- use language::{HighlightId, LanguageRegistry};
- use pretty_assertions::assert_eq;
-
- async fn parse(input: &str) -> ParsedMarkdown {
- parse_markdown(input, None, None).await
- }
-
- #[gpui::test]
- async fn test_headings() {
- let parsed = parse("# Heading one\n## Heading two\n### Heading three").await;
-
- assert_eq!(
- parsed.children,
- vec![
- h1(text("Heading one", 2..13), 0..14),
- h2(text("Heading two", 17..28), 14..29),
- h3(text("Heading three", 33..46), 29..46),
- ]
- );
- }
-
- #[gpui::test]
- async fn test_newlines_dont_new_paragraphs() {
- let parsed = parse("Some text **that is bolded**\n and *italicized*").await;
-
- assert_eq!(
- parsed.children,
- vec![p("Some text that is bolded and italicized", 0..46)]
- );
- }
-
- #[gpui::test]
- async fn test_heading_with_paragraph() {
- let parsed = parse("# Zed\nThe editor").await;
-
- assert_eq!(
- parsed.children,
- vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),]
- );
- }
-
- #[gpui::test]
- async fn test_double_newlines_do_new_paragraphs() {
- let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await;
-
- assert_eq!(
- parsed.children,
- vec![
- p("Some text that is bolded", 0..29),
- p("and italicized", 31..47),
- ]
- );
- }
-
- #[gpui::test]
- async fn test_bold_italic_text() {
- let parsed = parse("Some text **that is bolded** and *italicized*").await;
-
- assert_eq!(
- parsed.children,
- vec![p("Some text that is bolded and italicized", 0..45)]
- );
- }
-
- #[gpui::test]
- async fn test_nested_bold_strikethrough_text() {
- let parsed = parse("Some **bo~~strikethrough~~ld** text").await;
-
- assert_eq!(parsed.children.len(), 1);
- assert_eq!(
- parsed.children[0],
- ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text(
- ParsedMarkdownText {
- source_range: 0..35,
- contents: "Some bostrikethroughld text".into(),
- highlights: Vec::new(),
- regions: Vec::new(),
- }
- )])
- );
-
- let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
-
- let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] {
- text
- } else {
- panic!("Expected a text");
- };
-
- assert_eq!(
- paragraph.highlights,
- vec![
- (
- 5..7,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight::BOLD,
- ..Default::default()
- }),
- ),
- (
- 7..20,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight::BOLD,
- strikethrough: true,
- ..Default::default()
- }),
- ),
- (
- 20..22,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight::BOLD,
- ..Default::default()
- }),
- ),
- ]
- );
- }
-
- #[gpui::test]
- async fn test_html_inline_style_elements() {
- let parsed =
- parse("<p>Some text <strong>strong text</strong> more text <b>bold text</b> more text <i>italic text</i> more text <em>emphasized text</em> more text <del>deleted text</del> more text <ins>inserted text</ins></p>").await;
-
- assert_eq!(1, parsed.children.len());
- let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
- chunks
- } else {
- panic!("Expected a paragraph");
- };
-
- assert_eq!(1, chunks.len());
- let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
-
- assert_eq!(0..205, text.source_range);
- assert_eq!(
- "Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text",
- text.contents.as_str(),
- );
- assert_eq!(
- vec![
- (
- 10..21,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight(700.0),
- ..Default::default()
- },),
- ),
- (
- 32..41,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight(700.0),
- ..Default::default()
- },),
- ),
- (
- 52..63,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- italic: true,
- weight: FontWeight(400.0),
- ..Default::default()
- },),
- ),
- (
- 74..89,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- weight: FontWeight(400.0),
- oblique: true,
- ..Default::default()
- },),
- ),
- (
- 100..112,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- strikethrough: true,
- weight: FontWeight(400.0),
- ..Default::default()
- },),
- ),
- (
- 123..136,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- underline: true,
- weight: FontWeight(400.0,),
- ..Default::default()
- },),
- ),
- ],
- text.highlights
- );
- }
-
- #[gpui::test]
- async fn test_html_href_element() {
- let parsed =
- parse("<p>Some text <a href=\"https://example.com\">link</a> more text</p>").await;
-
- assert_eq!(1, parsed.children.len());
- let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
- chunks
- } else {
- panic!("Expected a paragraph");
- };
-
- assert_eq!(1, chunks.len());
- let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
-
- assert_eq!(0..65, text.source_range);
- assert_eq!("Some text link more text", text.contents.as_str(),);
- assert_eq!(
- vec![(
- 10..14,
- MarkdownHighlight::Style(MarkdownHighlightStyle {
- link: true,
- ..Default::default()
- },),
- )],
- text.highlights
- );
- assert_eq!(
- vec![(
- 10..14,
- ParsedRegion {
- code: false,
- link: Some(Link::Web {
- url: "https://example.com".into()
- })
- }
- )],
- text.regions
- )
- }
-
- #[gpui::test]
- async fn test_text_with_inline_html() {
- let parsed = parse("This is a paragraph with an inline HTML <sometag>tag</sometag>.").await;
-
- assert_eq!(
- parsed.children,
- vec![p("This is a paragraph with an inline HTML tag.", 0..63),],
- );
- }
-
- #[gpui::test]
- async fn test_raw_links_detection() {
- let parsed = parse("Checkout this https://zed.dev link").await;
-
- assert_eq!(
- parsed.children,
- vec![p("Checkout this https://zed.dev link", 0..34)]
- );
- }
-
- #[gpui::test]
- async fn test_empty_image() {
- let parsed = parse("![]()").await;
-
- let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
- assert_eq!(paragraph.len(), 0);
- }
-
- #[gpui::test]
- async fn test_image_links_detection() {
- let parsed = parse("").await;
-
- let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
- assert_eq!(
- paragraph[0],
- MarkdownParagraphChunk::Image(Image {
- source_range: 0..111,
- link: Link::Web {
- url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(),
- },
- alt_text: Some("test".into()),
- height: None,
- width: None,
- },)
- );
- }
-
- #[gpui::test]
- async fn test_image_alt_text() {
- let parsed = parse("[](https://zed.dev)\n ").await;
-
- let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
- assert_eq!(
- paragraph[0],
- MarkdownParagraphChunk::Image(Image {
- source_range: 0..142,
- link: Link::Web {
- url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(),
- },
- alt_text: Some("Zed".into()),
- height: None,
- width: None,
- },)
- );
- }
-
- #[gpui::test]
- async fn test_image_without_alt_text() {
- let parsed = parse("").await;
-
- let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
- assert_eq!(
- paragraph[0],
- MarkdownParagraphChunk::Image(Image {
- source_range: 0..31,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: None,
- height: None,
- width: None,
- },)
- );
- }
-
- #[gpui::test]
- async fn test_image_with_alt_text_containing_formatting() {
- let parsed = parse("").await;
-
- let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] else {
- panic!("Expected a paragraph");
- };
- assert_eq!(
- chunks,
- &[MarkdownParagraphChunk::Image(Image {
- source_range: 0..44,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: Some("foo bar baz".into()),
- height: None,
- width: None,
- }),],
- );
- }
-
- #[gpui::test]
- async fn test_images_with_text_in_between() {
- let parsed = parse(
- "\nLorem Ipsum\n",
- )
- .await;
-
- let chunks = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
- text
- } else {
- panic!("Expected a paragraph");
- };
- assert_eq!(
- chunks,
- &vec![
- MarkdownParagraphChunk::Image(Image {
- source_range: 0..81,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: Some("foo".into()),
- height: None,
- width: None,
- }),
- MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range: 0..81,
- contents: " Lorem Ipsum ".into(),
- highlights: Vec::new(),
- regions: Vec::new(),
- }),
- MarkdownParagraphChunk::Image(Image {
- source_range: 0..81,
- link: Link::Web {
- url: "http://example.com/bar.png".to_string(),
- },
- alt_text: Some("bar".into()),
- height: None,
- width: None,
- })
- ]
- );
- }
-
- #[test]
- fn test_parse_html_element_dimension() {
- // Test percentage values
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("50%"),
- Some(DefiniteLength::Fraction(0.5))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("100%"),
- Some(DefiniteLength::Fraction(1.0))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("25%"),
- Some(DefiniteLength::Fraction(0.25))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("0%"),
- Some(DefiniteLength::Fraction(0.0))
- );
-
- // Test pixel values
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("100px"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("50px"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0))))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("0px"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0))))
- );
-
- // Test values without units (should be treated as pixels)
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("100"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("42"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
- );
-
- // Test invalid values
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("invalid"),
- None
- );
- assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None);
- assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None);
- assert_eq!(MarkdownParser::parse_html_element_dimension(""), None);
- assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None);
- assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None);
-
- // Test decimal values
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("50.5%"),
- Some(DefiniteLength::Fraction(0.505))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("100.25px"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25))))
- );
- assert_eq!(
- MarkdownParser::parse_html_element_dimension("42.0"),
- Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
- );
- }
-
- #[gpui::test]
- async fn test_html_unordered_list() {
- let parsed = parse(
- "<ul>
- <li>Item 1</li>
- <li>Item 2</li>
- </ul>",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![
- nested_list_item(
- 0..82,
- 1,
- ParsedMarkdownListItemType::Unordered,
- vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
- ),
- nested_list_item(
- 0..82,
- 1,
- ParsedMarkdownListItemType::Unordered,
- vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
- ),
- ]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_ordered_list() {
- let parsed = parse(
- "<ol>
- <li>Item 1</li>
- <li>Item 2</li>
- </ol>",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![
- nested_list_item(
- 0..82,
- 1,
- ParsedMarkdownListItemType::Ordered(1),
- vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
- ),
- nested_list_item(
- 0..82,
- 1,
- ParsedMarkdownListItemType::Ordered(2),
- vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
- ),
- ]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_nested_ordered_list() {
- let parsed = parse(
- "<ol>
- <li>Item 1</li>
- <li>Item 2
- <ol>
- <li>Sub-Item 1</li>
- <li>Sub-Item 2</li>
- </ol>
- </li>
- </ol>",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![
- nested_list_item(
- 0..216,
- 1,
- ParsedMarkdownListItemType::Ordered(1),
- vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
- ),
- nested_list_item(
- 0..216,
- 1,
- ParsedMarkdownListItemType::Ordered(2),
- vec![
- ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
- nested_list_item(
- 0..216,
- 2,
- ParsedMarkdownListItemType::Ordered(1),
- vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
- ),
- nested_list_item(
- 0..216,
- 2,
- ParsedMarkdownListItemType::Ordered(2),
- vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
- ),
- ]
- ),
- ]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_nested_unordered_list() {
- let parsed = parse(
- "<ul>
- <li>Item 1</li>
- <li>Item 2
- <ul>
- <li>Sub-Item 1</li>
- <li>Sub-Item 2</li>
- </ul>
- </li>
- </ul>",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![
- nested_list_item(
- 0..216,
- 1,
- ParsedMarkdownListItemType::Unordered,
- vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
- ),
- nested_list_item(
- 0..216,
- 1,
- ParsedMarkdownListItemType::Unordered,
- vec![
- ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
- nested_list_item(
- 0..216,
- 2,
- ParsedMarkdownListItemType::Unordered,
- vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
- ),
- nested_list_item(
- 0..216,
- 2,
- ParsedMarkdownListItemType::Unordered,
- vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
- ),
- ]
- ),
- ]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_inline_html_image_tag() {
- let parsed =
- parse("<p>Some text<img src=\"http://example.com/foo.png\" /> some more text</p>")
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![ParsedMarkdownElement::Paragraph(vec![
- MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range: 0..71,
- contents: "Some text".into(),
- highlights: Default::default(),
- regions: Default::default()
- }),
- MarkdownParagraphChunk::Image(Image {
- source_range: 0..71,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: None,
- height: None,
- width: None,
- }),
- MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range: 0..71,
- contents: " some more text".into(),
- highlights: Default::default(),
- regions: Default::default()
- }),
- ])]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_block_quote() {
- let parsed = parse(
- "<blockquote>
- <p>some description</p>
- </blockquote>",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![block_quote(
- vec![ParsedMarkdownElement::Paragraph(text(
- "some description",
- 0..78
- ))],
- 0..78,
- )]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_nested_block_quote() {
- let parsed = parse(
- "<blockquote>
- <p>some description</p>
- <blockquote>
- <p>second description</p>
- </blockquote>
- </blockquote>",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![block_quote(
- vec![
- ParsedMarkdownElement::Paragraph(text("some description", 0..179)),
- block_quote(
- vec![ParsedMarkdownElement::Paragraph(text(
- "second description",
- 0..179
- ))],
- 0..179,
- )
- ],
- 0..179,
- )]
- },
- parsed
- );
- }
-
- #[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,
- None,
- vec![row(vec![
- column(
- 1,
- 1,
- true,
- text("Id", 0..366),
- ParsedMarkdownTableAlignment::Center
- ),
- column(
- 1,
- 1,
- true,
- text("Name ", 0..366),
- ParsedMarkdownTableAlignment::Center
- )
- ])],
- vec![
- row(vec![
- column(
- 1,
- 1,
- false,
- text("1", 0..366),
- ParsedMarkdownTableAlignment::None
- ),
- column(
- 1,
- 1,
- false,
- text("Chris", 0..366),
- ParsedMarkdownTableAlignment::None
- )
- ]),
- row(vec![
- column(
- 1,
- 1,
- false,
- text("2", 0..366),
- ParsedMarkdownTableAlignment::None
- ),
- column(
- 1,
- 1,
- false,
- text("Dennis", 0..366),
- ParsedMarkdownTableAlignment::None
- )
- ]),
- ],
- ))],
- },
- parsed
- );
- }
-
- #[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(),
- 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(
- "<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,
- None,
- vec![],
- vec![
- row(vec![
- column(
- 1,
- 1,
- false,
- text("1", 0..240),
- ParsedMarkdownTableAlignment::None
- ),
- column(
- 1,
- 1,
- false,
- text("Chris", 0..240),
- ParsedMarkdownTableAlignment::None
- )
- ]),
- row(vec![
- column(
- 1,
- 1,
- false,
- text("2", 0..240),
- ParsedMarkdownTableAlignment::None
- ),
- column(
- 1,
- 1,
- false,
- text("Dennis", 0..240),
- ParsedMarkdownTableAlignment::None
- )
- ]),
- ],
- ))],
- },
- 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,
- None,
- vec![row(vec![
- column(
- 1,
- 1,
- true,
- text("Id", 0..150),
- ParsedMarkdownTableAlignment::Center
- ),
- column(
- 1,
- 1,
- true,
- text("Name", 0..150),
- ParsedMarkdownTableAlignment::Center
- )
- ])],
- 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;
-
- 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(),
- 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(),
- 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(),
- 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(),
- 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(),
- 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(),
- regions: Vec::default()
- })],
- }),
- ],
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_image_tag() {
- let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![ParsedMarkdownElement::Image(Image {
- source_range: 0..40,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: None,
- height: None,
- width: None,
- })]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_image_tag_with_alt_text() {
- let parsed = parse("<img src=\"http://example.com/foo.png\" alt=\"Foo\" />").await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![ParsedMarkdownElement::Image(Image {
- source_range: 0..50,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: Some("Foo".into()),
- height: None,
- width: None,
- })]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_image_tag_with_height_and_width() {
- let parsed =
- parse("<img src=\"http://example.com/foo.png\" height=\"100\" width=\"200\" />").await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![ParsedMarkdownElement::Image(Image {
- source_range: 0..65,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: None,
- height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
- width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
- })]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_html_image_style_tag_with_height_and_width() {
- let parsed = parse(
- "<img src=\"http://example.com/foo.png\" style=\"height:100px; width:200px;\" />",
- )
- .await;
-
- assert_eq!(
- ParsedMarkdown {
- children: vec![ParsedMarkdownElement::Image(Image {
- source_range: 0..75,
- link: Link::Web {
- url: "http://example.com/foo.png".to_string(),
- },
- alt_text: None,
- height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
- width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
- })]
- },
- parsed
- );
- }
-
- #[gpui::test]
- async fn test_header_only_table() {
- let markdown = "\
-| Header 1 | Header 2 |
-|----------|----------|
-
-Some other content
-";
-
- let expected_table = table(
- 0..48,
- None,
- vec![row(vec![
- column(
- 1,
- 1,
- true,
- text("Header 1", 1..11),
- ParsedMarkdownTableAlignment::None,
- ),
- column(
- 1,
- 1,
- true,
- text("Header 2", 12..22),
- ParsedMarkdownTableAlignment::None,
- ),
- ])],
- vec![],
- );
-
- assert_eq!(
- parse(markdown).await.children[0],
- ParsedMarkdownElement::Table(expected_table)
- );
- }
-
- #[gpui::test]
- async fn test_basic_table() {
- let markdown = "\
-| Header 1 | Header 2 |
-|----------|----------|
-| Cell 1 | Cell 2 |
-| Cell 3 | Cell 4 |";
-
- let expected_table = table(
- 0..95,
- None,
- vec![row(vec![
- column(
- 1,
- 1,
- true,
- text("Header 1", 1..11),
- ParsedMarkdownTableAlignment::None,
- ),
- column(
- 1,
- 1,
- true,
- text("Header 2", 12..22),
- ParsedMarkdownTableAlignment::None,
- ),
- ])],
- vec![
- row(vec![
- column(
- 1,
- 1,
- false,
- text("Cell 1", 49..59),
- ParsedMarkdownTableAlignment::None,
- ),
- column(
- 1,
- 1,
- false,
- text("Cell 2", 60..70),
- ParsedMarkdownTableAlignment::None,
- ),
- ]),
- row(vec![
- column(
- 1,
- 1,
- false,
- text("Cell 3", 73..83),
- ParsedMarkdownTableAlignment::None,
- ),
- column(
- 1,
- 1,
- false,
- text("Cell 4", 84..94),
- ParsedMarkdownTableAlignment::None,
- ),
- ]),
- ],
- );
-
- assert_eq!(
- parse(markdown).await.children[0],
- ParsedMarkdownElement::Table(expected_table)
- );
- }
-
- #[gpui::test]
- async fn test_table_with_checkboxes() {
- let markdown = "\
-| Done | Task |
-|------|---------|
-| [x] | Fix bug |
-| [ ] | Add feature |";
-
- let parsed = parse(markdown).await;
- let table = match &parsed.children[0] {
- ParsedMarkdownElement::Table(table) => table,
- other => panic!("Expected table, got: {:?}", other),
- };
-
- let first_cell = &table.body[0].columns[0];
- let first_cell_text = match &first_cell.children[0] {
- MarkdownParagraphChunk::Text(t) => t.contents.to_string(),
- other => panic!("Expected text chunk, got: {:?}", other),
- };
- assert_eq!(first_cell_text.trim(), "[x]");
-
- let second_cell = &table.body[1].columns[0];
- let second_cell_text = match &second_cell.children[0] {
- MarkdownParagraphChunk::Text(t) => t.contents.to_string(),
- other => panic!("Expected text chunk, got: {:?}", other),
- };
- assert_eq!(second_cell_text.trim(), "[ ]");
- }
-
- #[gpui::test]
- async fn test_list_basic() {
- let parsed = parse(
- "\
-* Item 1
-* Item 2
-* Item 3
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
- list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
- list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]),
- ],
- );
- }
-
- #[gpui::test]
- async fn test_list_with_tasks() {
- let parsed = parse(
- "\
-- [ ] TODO
-- [x] Checked
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
- list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]),
- ],
- );
- }
-
- #[gpui::test]
- async fn test_list_with_indented_task() {
- let parsed = parse(
- "\
-- [ ] TODO
- - [x] Checked
- - Unordered
- 1. Number 1
- 1. Number 2
-1. Number A
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..12, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
- list_item(13..26, 2, Task(true, 15..18), vec![p("Checked", 19..26)]),
- list_item(29..40, 2, Unordered, vec![p("Unordered", 31..40)]),
- list_item(43..54, 2, Ordered(1), vec![p("Number 1", 46..54)]),
- list_item(57..68, 2, Ordered(2), vec![p("Number 2", 60..68)]),
- list_item(69..80, 1, Ordered(1), vec![p("Number A", 72..80)]),
- ],
- );
- }
-
- #[gpui::test]
- async fn test_list_with_linebreak_is_handled_correctly() {
- let parsed = parse(
- "\
-- [ ] Task 1
-
-- [x] Task 2
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]),
- list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]),
- ],
- );
- }
-
- #[gpui::test]
- async fn test_list_nested() {
- let parsed = parse(
- "\
-* Item 1
-* Item 2
-* Item 3
-
-1. Hello
-1. Two
- 1. Three
-2. Four
-3. Five
-
-* First
- 1. Hello
- 1. Goodbyte
- - Inner
- - Inner
- 2. Goodbyte
- - Next item empty
- -
-* Last
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
- list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
- list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]),
- list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]),
- list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]),
- list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]),
- list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]),
- list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]),
- list_item(73..82, 1, Unordered, vec![p("First", 75..80)]),
- list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]),
- list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]),
- list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]),
- list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]),
- list_item(143..159, 2, Ordered(2), vec![p("Goodbyte", 146..154)]),
- list_item(160..180, 3, Unordered, vec![p("Next item empty", 165..180)]),
- list_item(186..190, 3, Unordered, vec![]),
- list_item(191..197, 1, Unordered, vec![p("Last", 193..197)]),
- ]
- );
- }
-
- #[gpui::test]
- async fn test_list_with_nested_content() {
- let parsed = parse(
- "\
-* This is a list item with two paragraphs.
-
- This is the second paragraph in the list item.
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![list_item(
- 0..96,
- 1,
- Unordered,
- vec![
- p("This is a list item with two paragraphs.", 4..44),
- p("This is the second paragraph in the list item.", 50..97)
- ],
- ),],
- );
- }
-
- #[gpui::test]
- async fn test_list_item_with_inline_html() {
- let parsed = parse(
- "\
-* This is a list item with an inline HTML <sometag>tag</sometag>.
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![list_item(
- 0..67,
- 1,
- Unordered,
- vec![p("This is a list item with an inline HTML tag.", 4..44),],
- ),],
- );
- }
-
- #[gpui::test]
- async fn test_nested_list_with_paragraph_inside() {
- let parsed = parse(
- "\
-1. a
- 1. b
- 1. c
-
- text
-
- 1. d
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],),
- list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],),
- list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],),
- p("text", 32..37),
- list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],),
- ],
- );
- }
-
- #[gpui::test]
- async fn test_list_with_leading_text() {
- let parsed = parse(
- "\
-* `code`
-* **bold**
-* [link](https://example.com)
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..8, 1, Unordered, vec![p("code", 2..8)]),
- list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]),
- list_item(20..49, 1, Unordered, vec![p("link", 22..49)],),
- ],
- );
- }
-
- #[gpui::test]
- async fn test_simple_block_quote() {
- let parsed = parse("> Simple block quote with **styled text**").await;
-
- assert_eq!(
- parsed.children,
- vec![block_quote(
- vec![p("Simple block quote with styled text", 2..41)],
- 0..41
- )]
- );
- }
-
- #[gpui::test]
- async fn test_simple_block_quote_with_multiple_lines() {
- let parsed = parse(
- "\
-> # Heading
-> More
-> text
->
-> More text
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![block_quote(
- vec![
- h1(text("Heading", 4..11), 2..12),
- p("More text", 14..26),
- p("More text", 30..40)
- ],
- 0..40
- )]
- );
- }
-
- #[gpui::test]
- async fn test_nested_block_quote() {
- let parsed = parse(
- "\
-> A
->
-> > # B
->
-> C
-
-More text
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![
- block_quote(
- vec![
- p("A", 2..4),
- block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14),
- p("C", 18..20)
- ],
- 0..20
- ),
- p("More text", 21..31)
- ]
- );
- }
-
- #[gpui::test]
- async fn test_dollar_signs_are_plain_text() {
- // Dollar signs should be preserved as plain text, not treated as math delimiters.
- // Regression test for https://github.com/zed-industries/zed/issues/50170
- let parsed = parse("$100$ per unit").await;
- assert_eq!(parsed.children, vec![p("$100$ per unit", 0..14)]);
- }
-
- #[gpui::test]
- async fn test_dollar_signs_in_list_items() {
- let parsed = parse("- $18,000 budget\n- $20,000 budget\n").await;
- assert_eq!(
- parsed.children,
- vec![
- list_item(0..16, 1, Unordered, vec![p("$18,000 budget", 2..16)]),
- list_item(17..33, 1, Unordered, vec![p("$20,000 budget", 19..33)]),
- ]
- );
- }
-
- #[gpui::test]
- async fn test_code_block() {
- let parsed = parse(
- "\
-```
-fn main() {
- return 0;
-}
-```
-",
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![code_block(
- None,
- "fn main() {\n return 0;\n}",
- 0..35,
- None
- )]
- );
- }
-
- #[gpui::test]
- async fn test_code_block_with_language(executor: BackgroundExecutor) {
- let language_registry = Arc::new(LanguageRegistry::test(executor.clone()));
- language_registry.add(language::rust_lang());
-
- let parsed = parse_markdown(
- "\
-```rust
-fn main() {
- return 0;
-}
-```
-",
- None,
- Some(language_registry),
- )
- .await;
-
- assert_eq!(
- parsed.children,
- vec![code_block(
- Some("rust".to_string()),
- "fn main() {\n return 0;\n}",
- 0..39,
- Some(vec![])
- )]
- );
- }
-
- fn h1(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
- ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
- source_range,
- level: HeadingLevel::H1,
- contents,
- })
- }
-
- fn h2(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
- ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
- source_range,
- level: HeadingLevel::H2,
- contents,
- })
- }
-
- fn h3(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
- ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
- source_range,
- level: HeadingLevel::H3,
- contents,
- })
- }
-
- fn p(contents: &str, source_range: Range<usize>) -> ParsedMarkdownElement {
- ParsedMarkdownElement::Paragraph(text(contents, source_range))
- }
-
- fn text(contents: &str, source_range: Range<usize>) -> MarkdownParagraph {
- vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
- highlights: Vec::new(),
- regions: Vec::new(),
- source_range,
- contents: contents.to_string().into(),
- })]
- }
-
- fn block_quote(
- children: Vec<ParsedMarkdownElement>,
- source_range: Range<usize>,
- ) -> ParsedMarkdownElement {
- ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote {
- source_range,
- children,
- })
- }
-
- fn code_block(
- language: Option<String>,
- code: &str,
- source_range: Range<usize>,
- highlights: Option<Vec<(Range<usize>, HighlightId)>>,
- ) -> ParsedMarkdownElement {
- ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
- source_range,
- language,
- contents: code.to_string().into(),
- highlights,
- })
- }
-
- fn list_item(
- source_range: Range<usize>,
- depth: u16,
- item_type: ParsedMarkdownListItemType,
- content: Vec<ParsedMarkdownElement>,
- ) -> ParsedMarkdownElement {
- ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
- source_range,
- item_type,
- depth,
- content,
- nested: false,
- })
- }
-
- fn nested_list_item(
- source_range: Range<usize>,
- depth: u16,
- item_type: ParsedMarkdownListItemType,
- content: Vec<ParsedMarkdownElement>,
- ) -> ParsedMarkdownElement {
- ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
- source_range,
- item_type,
- depth,
- content,
- nested: true,
- })
- }
-
- fn table(
- source_range: Range<usize>,
- caption: Option<MarkdownParagraph>,
- header: Vec<ParsedMarkdownTableRow>,
- body: Vec<ParsedMarkdownTableRow>,
- ) -> ParsedMarkdownTable {
- ParsedMarkdownTable {
- source_range,
- header,
- body,
- caption,
- }
- }
-
- fn row(columns: Vec<ParsedMarkdownTableColumn>) -> ParsedMarkdownTableRow {
- ParsedMarkdownTableRow { columns }
- }
-
- fn column(
- col_span: usize,
- row_span: usize,
- is_header: bool,
- children: MarkdownParagraph,
- alignment: ParsedMarkdownTableAlignment,
- ) -> ParsedMarkdownTableColumn {
- ParsedMarkdownTableColumn {
- col_span,
- row_span,
- is_header,
- children,
- alignment,
- }
- }
-
- impl PartialEq for ParsedMarkdownTable {
- fn eq(&self, other: &Self) -> bool {
- self.source_range == other.source_range
- && self.header == other.header
- && self.body == other.body
- }
- }
-
- impl PartialEq for ParsedMarkdownText {
- fn eq(&self, other: &Self) -> bool {
- self.source_range == other.source_range && self.contents == other.contents
- }
- }
-}
@@ -1,11 +1,7 @@
use gpui::{App, actions};
use workspace::Workspace;
-pub mod markdown_elements;
-mod markdown_minifier;
-pub mod markdown_parser;
pub mod markdown_preview_view;
-pub mod markdown_renderer;
pub use zed_actions::preview::markdown::{OpenPreview, OpenPreviewToTheSide};
@@ -1,46 +1,45 @@
use std::cmp::min;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
-use std::{ops::Range, path::PathBuf};
use anyhow::Result;
use editor::scroll::Autoscroll;
use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
use gpui::{
- App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
- IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled,
- Subscription, Task, WeakEntity, Window, list,
+ App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement,
+ IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString,
+ SharedUri, Subscription, Task, WeakEntity, Window, point,
};
use language::LanguageRegistry;
+use markdown::{
+ CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle,
+};
use settings::Settings;
use theme::ThemeSettings;
use ui::{WithScrollbar, prelude::*};
+use util::normalize_path;
use workspace::item::{Item, ItemHandle};
-use workspace::{Pane, Workspace};
+use workspace::{OpenOptions, OpenVisible, Pane, Workspace};
-use crate::markdown_elements::ParsedMarkdownElement;
-use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState};
use crate::{
- OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
- markdown_elements::ParsedMarkdown,
- markdown_parser::parse_markdown,
- markdown_renderer::{RenderContext, render_markdown_block},
+ OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem,
};
-use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
+use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
pub struct MarkdownPreviewView {
workspace: WeakEntity<Workspace>,
- image_cache: Entity<RetainAllImageCache>,
active_editor: Option<EditorState>,
focus_handle: FocusHandle,
- contents: Option<ParsedMarkdown>,
- selected_block: usize,
- list_state: ListState,
- language_registry: Arc<LanguageRegistry>,
- mermaid_state: MermaidState,
- parsing_markdown_task: Option<Task<Result<()>>>,
+ markdown: Entity<Markdown>,
+ _markdown_subscription: Subscription,
+ active_source_index: Option<usize>,
+ scroll_handle: ScrollHandle,
+ image_cache: Entity<RetainAllImageCache>,
+ base_directory: Option<PathBuf>,
+ pending_update_task: Option<Task<Result<()>>>,
mode: MarkdownPreviewMode,
}
@@ -205,19 +204,35 @@ impl MarkdownPreviewView {
cx: &mut Context<Workspace>,
) -> Entity<Self> {
cx.new(|cx| {
- let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
-
+ let markdown = cx.new(|cx| {
+ Markdown::new_with_options(
+ SharedString::default(),
+ Some(language_registry),
+ None,
+ MarkdownOptions {
+ parse_html: true,
+ render_mermaid_diagrams: true,
+ ..Default::default()
+ },
+ cx,
+ )
+ });
let mut this = Self {
- selected_block: 0,
active_editor: None,
focus_handle: cx.focus_handle(),
workspace: workspace.clone(),
- contents: None,
- list_state,
- language_registry,
- mermaid_state: Default::default(),
- parsing_markdown_task: None,
+ _markdown_subscription: cx.observe(
+ &markdown,
+ |this: &mut Self, _: Entity<Markdown>, cx| {
+ this.sync_active_root_block(cx);
+ },
+ ),
+ markdown,
+ active_source_index: None,
+ scroll_handle: ScrollHandle::new(),
image_cache: RetainAllImageCache::new(cx),
+ base_directory: None,
+ pending_update_task: None,
mode,
};
@@ -280,17 +295,16 @@ impl MarkdownPreviewView {
| EditorEvent::BufferEdited { .. }
| EditorEvent::DirtyChanged
| EditorEvent::ExcerptsEdited { .. } => {
- this.parse_markdown_from_active_editor(true, window, cx);
+ this.update_markdown_from_active_editor(true, false, window, cx);
}
EditorEvent::SelectionsChanged { .. } => {
- let selection_range = editor.update(cx, |editor, cx| {
- editor
- .selections
- .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
- .range()
- });
- this.selected_block = this.get_block_index_under_cursor(selection_range);
- this.list_state.scroll_to_reveal_item(this.selected_block);
+ let (selection_start, editor_is_focused) =
+ editor.update(cx, |editor, cx| {
+ let index = Self::selected_source_index(editor, cx);
+ let focused = editor.focus_handle(cx).is_focused(window);
+ (index, focused)
+ });
+ this.sync_preview_to_source_index(selection_start, editor_is_focused, cx);
cx.notify();
}
_ => {}
@@ -298,27 +312,30 @@ impl MarkdownPreviewView {
},
);
+ self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx);
self.active_editor = Some(EditorState {
editor,
_subscription: subscription,
});
- self.parse_markdown_from_active_editor(false, window, cx);
+ self.update_markdown_from_active_editor(false, true, window, cx);
}
- fn parse_markdown_from_active_editor(
+ fn update_markdown_from_active_editor(
&mut self,
wait_for_debounce: bool,
+ should_reveal: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(state) = &self.active_editor {
// if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
- if wait_for_debounce && self.parsing_markdown_task.is_some() {
+ if wait_for_debounce && self.pending_update_task.is_some() {
return;
}
- self.parsing_markdown_task = Some(self.parse_markdown_in_background(
+ self.pending_update_task = Some(self.schedule_markdown_update(
wait_for_debounce,
+ should_reveal,
state.editor.clone(),
window,
cx,
@@ -326,63 +343,97 @@ impl MarkdownPreviewView {
}
}
- fn parse_markdown_in_background(
+ fn schedule_markdown_update(
&mut self,
wait_for_debounce: bool,
+ should_reveal_selection: bool,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
- let language_registry = self.language_registry.clone();
-
cx.spawn_in(window, async move |view, cx| {
if wait_for_debounce {
// Wait for the user to stop typing
cx.background_executor().timer(REPARSE_DEBOUNCE).await;
}
- let (contents, file_location) = view.update(cx, |_, cx| {
- let editor = editor.read(cx);
- let contents = editor.buffer().read(cx).snapshot(cx).text();
- let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
- (contents, file_location)
- })?;
+ let editor_clone = editor.clone();
+ let update = view.update(cx, |view, cx| {
+ let is_active_editor = view
+ .active_editor
+ .as_ref()
+ .is_some_and(|active_editor| active_editor.editor == editor_clone);
+ if !is_active_editor {
+ return None;
+ }
- let parsing_task = cx.background_spawn(async move {
- parse_markdown(&contents, file_location, Some(language_registry)).await
- });
- let contents = parsing_task.await;
+ let (contents, selection_start) = editor_clone.update(cx, |editor, cx| {
+ let contents = editor.buffer().read(cx).snapshot(cx).text();
+ let selection_start = Self::selected_source_index(editor, cx);
+ (contents, selection_start)
+ });
+ Some((SharedString::from(contents), selection_start))
+ })?;
view.update(cx, move |view, cx| {
- view.mermaid_state.update(&contents, cx);
- let markdown_blocks_count = contents.children.len();
- view.contents = Some(contents);
- let scroll_top = view.list_state.logical_scroll_top();
- view.list_state.reset(markdown_blocks_count);
- view.list_state.scroll_to(scroll_top);
- view.parsing_markdown_task = None;
+ if let Some((contents, selection_start)) = update {
+ view.markdown.update(cx, |markdown, cx| {
+ markdown.reset(contents, cx);
+ });
+ view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx);
+ }
+ view.pending_update_task = None;
cx.notify();
})
})
}
- fn move_cursor_to_block(
- &self,
- window: &mut Window,
+ fn selected_source_index(editor: &Editor, cx: &mut App) -> usize {
+ editor
+ .selections
+ .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
+ .range()
+ .start
+ .0
+ }
+
+ fn sync_preview_to_source_index(
+ &mut self,
+ source_index: usize,
+ reveal: bool,
cx: &mut Context<Self>,
- selection: Range<MultiBufferOffset>,
) {
- if let Some(state) = &self.active_editor {
- state.editor.update(cx, |editor, cx| {
- editor.change_selections(
- SelectionEffects::scroll(Autoscroll::center()),
- window,
- cx,
- |selections| selections.select_ranges(vec![selection]),
- );
- window.focus(&editor.focus_handle(cx), cx);
- });
- }
+ self.active_source_index = Some(source_index);
+ self.sync_active_root_block(cx);
+ self.markdown.update(cx, |markdown, cx| {
+ if reveal {
+ markdown.request_autoscroll_to_source_index(source_index, cx);
+ }
+ });
+ }
+
+ fn sync_active_root_block(&mut self, cx: &mut Context<Self>) {
+ self.markdown.update(cx, |markdown, cx| {
+ markdown.set_active_root_for_source_index(self.active_source_index, cx);
+ });
+ }
+
+ fn move_cursor_to_source_index(
+ editor: &Entity<Editor>,
+ source_index: usize,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ editor.update(cx, |editor, cx| {
+ let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index);
+ editor.change_selections(
+ SelectionEffects::scroll(Autoscroll::center()),
+ window,
+ cx,
+ |selections| selections.select_ranges(vec![selection]),
+ );
+ window.focus(&editor.focus_handle(cx), cx);
+ });
}
/// The absolute path of the file that is currently being previewed.
@@ -398,52 +449,24 @@ impl MarkdownPreviewView {
}
}
- fn get_block_index_under_cursor(&self, selection_range: Range<MultiBufferOffset>) -> usize {
- let mut block_index = None;
- let cursor = selection_range.start.0;
-
- let mut last_end = 0;
- if let Some(content) = &self.contents {
- for (i, block) in content.children.iter().enumerate() {
- let Some(Range { start, end }) = block.source_range() else {
- continue;
- };
-
- // Check if the cursor is between the last block and the current block
- if last_end <= cursor && cursor < start {
- block_index = Some(i.saturating_sub(1));
- break;
- }
-
- if start <= cursor && end >= cursor {
- block_index = Some(i);
- break;
- }
- last_end = end;
- }
-
- if block_index.is_none() && last_end < cursor {
- block_index = Some(content.children.len().saturating_sub(1));
- }
- }
-
- block_index.unwrap_or_default()
+ fn line_scroll_amount(&self, cx: &App) -> Pixels {
+ let settings = ThemeSettings::get_global(cx);
+ settings.buffer_font_size(cx) * settings.buffer_line_height.value()
}
- fn should_apply_padding_between(
- current_block: &ParsedMarkdownElement,
- next_block: Option<&ParsedMarkdownElement>,
- ) -> bool {
- !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false))
+ fn scroll_by_amount(&self, distance: Pixels) {
+ let offset = self.scroll_handle.offset();
+ self.scroll_handle
+ .set_offset(point(offset.x, offset.y - distance));
}
fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
- let viewport_height = self.list_state.viewport_bounds().size.height;
+ let viewport_height = self.scroll_handle.bounds().size.height;
if viewport_height.is_zero() {
return;
}
- self.list_state.scroll_by(-viewport_height);
+ self.scroll_by_amount(-viewport_height);
cx.notify();
}
@@ -453,35 +476,49 @@ impl MarkdownPreviewView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- let viewport_height = self.list_state.viewport_bounds().size.height;
+ let viewport_height = self.scroll_handle.bounds().size.height;
if viewport_height.is_zero() {
return;
}
- self.list_state.scroll_by(viewport_height);
+ self.scroll_by_amount(viewport_height);
cx.notify();
}
fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
- let scroll_top = self.list_state.logical_scroll_top();
- if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+ if let Some(bounds) = self
+ .scroll_handle
+ .bounds_for_item(self.scroll_handle.top_item())
+ {
let item_height = bounds.size.height;
// Scroll no more than the rough equivalent of a large headline
let max_height = window.rem_size() * 2;
let scroll_height = min(item_height, max_height);
- self.list_state.scroll_by(-scroll_height);
+ self.scroll_by_amount(-scroll_height);
+ } else {
+ let scroll_height = self.line_scroll_amount(cx);
+ if !scroll_height.is_zero() {
+ self.scroll_by_amount(-scroll_height);
+ }
}
cx.notify();
}
fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
- let scroll_top = self.list_state.logical_scroll_top();
- if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+ if let Some(bounds) = self
+ .scroll_handle
+ .bounds_for_item(self.scroll_handle.top_item())
+ {
let item_height = bounds.size.height;
// Scroll no more than the rough equivalent of a large headline
let max_height = window.rem_size() * 2;
let scroll_height = min(item_height, max_height);
- self.list_state.scroll_by(scroll_height);
+ self.scroll_by_amount(scroll_height);
+ } else {
+ let scroll_height = self.line_scroll_amount(cx);
+ if !scroll_height.is_zero() {
+ self.scroll_by_amount(scroll_height);
+ }
}
cx.notify();
}
@@ -492,9 +529,11 @@ impl MarkdownPreviewView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- let scroll_top = self.list_state.logical_scroll_top();
- if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
- self.list_state.scroll_by(-bounds.size.height);
+ if let Some(bounds) = self
+ .scroll_handle
+ .bounds_for_item(self.scroll_handle.top_item())
+ {
+ self.scroll_by_amount(-bounds.size.height);
}
cx.notify();
}
@@ -505,18 +544,17 @@ impl MarkdownPreviewView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- let scroll_top = self.list_state.logical_scroll_top();
- if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
- self.list_state.scroll_by(bounds.size.height);
+ if let Some(bounds) = self
+ .scroll_handle
+ .bounds_for_item(self.scroll_handle.top_item())
+ {
+ self.scroll_by_amount(bounds.size.height);
}
cx.notify();
}
fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context<Self>) {
- self.list_state.scroll_to(ListOffset {
- item_ix: 0,
- offset_in_item: px(0.),
- });
+ self.scroll_handle.scroll_to_item(0);
cx.notify();
}
@@ -526,19 +564,157 @@ impl MarkdownPreviewView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
- let count = self.list_state.item_count();
- if count > 0 {
- self.list_state.scroll_to(ListOffset {
- item_ix: count - 1,
- offset_in_item: px(0.),
- });
- }
+ self.scroll_handle.scroll_to_bottom();
cx.notify();
}
+
+ fn render_markdown_element(
+ &self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> MarkdownElement {
+ let workspace = self.workspace.clone();
+ let base_directory = self.base_directory.clone();
+ let active_editor = self
+ .active_editor
+ .as_ref()
+ .map(|state| state.editor.clone());
+
+ let mut markdown_element = MarkdownElement::new(
+ self.markdown.clone(),
+ MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
+ )
+ .code_block_renderer(CodeBlockRenderer::Default {
+ copy_button: false,
+ copy_button_on_hover: true,
+ border: false,
+ })
+ .scroll_handle(self.scroll_handle.clone())
+ .show_root_block_markers()
+ .image_resolver({
+ let base_directory = self.base_directory.clone();
+ move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref())
+ })
+ .on_url_click(move |url, window, cx| {
+ open_preview_url(url, base_directory.clone(), &workspace, window, cx);
+ });
+
+ if let Some(active_editor) = active_editor {
+ let editor_for_checkbox = active_editor.clone();
+ let view_handle = cx.entity().downgrade();
+ markdown_element = markdown_element
+ .on_source_click(move |source_index, click_count, window, cx| {
+ if click_count == 2 {
+ Self::move_cursor_to_source_index(&active_editor, source_index, window, cx);
+ true
+ } else {
+ false
+ }
+ })
+ .on_checkbox_toggle(move |source_range, new_checked, window, cx| {
+ let task_marker = if new_checked { "[x]" } else { "[ ]" };
+ editor_for_checkbox.update(cx, |editor, cx| {
+ editor.edit(
+ [(
+ MultiBufferOffset(source_range.start)
+ ..MultiBufferOffset(source_range.end),
+ task_marker,
+ )],
+ cx,
+ );
+ });
+ if let Some(view) = view_handle.upgrade() {
+ cx.update_entity(&view, |this, cx| {
+ this.update_markdown_from_active_editor(false, false, window, cx);
+ });
+ }
+ });
+ }
+
+ markdown_element
+ }
+}
+
+fn open_preview_url(
+ url: SharedString,
+ base_directory: Option<PathBuf>,
+ workspace: &WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ if let Some(path) = resolve_preview_path(url.as_ref(), base_directory.as_deref())
+ && let Some(workspace) = workspace.upgrade()
+ {
+ let _ = workspace.update(cx, |workspace, cx| {
+ workspace
+ .open_abs_path(
+ normalize_path(path.as_path()),
+ OpenOptions {
+ visible: Some(OpenVisible::None),
+ ..Default::default()
+ },
+ window,
+ cx,
+ )
+ .detach();
+ });
+ return;
+ }
+
+ cx.open_url(url.as_ref());
+}
+
+fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option<PathBuf> {
+ if url.starts_with("http://") || url.starts_with("https://") {
+ return None;
+ }
+
+ let decoded_url = urlencoding::decode(url)
+ .map(|decoded| decoded.into_owned())
+ .unwrap_or_else(|_| url.to_string());
+ let candidate = PathBuf::from(&decoded_url);
+
+ if candidate.is_absolute() && candidate.exists() {
+ return Some(candidate);
+ }
+
+ let base_directory = base_directory?;
+ let resolved = base_directory.join(decoded_url);
+ if resolved.exists() {
+ Some(resolved)
+ } else {
+ None
+ }
+}
+
+fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option<ImageSource> {
+ if dest_url.starts_with("data:") {
+ return None;
+ }
+
+ if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
+ return Some(ImageSource::Resource(Resource::Uri(SharedUri::from(
+ dest_url.to_string(),
+ ))));
+ }
+
+ let decoded = urlencoding::decode(dest_url)
+ .map(|decoded| decoded.into_owned())
+ .unwrap_or_else(|_| dest_url.to_string());
+
+ let path = if Path::new(&decoded).is_absolute() {
+ PathBuf::from(decoded)
+ } else {
+ base_directory?.join(decoded)
+ };
+
+ Some(ImageSource::Resource(Resource::Path(Arc::from(
+ path.as_path(),
+ ))))
}
impl Focusable for MarkdownPreviewView {
- fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
@@ -572,10 +748,7 @@ impl Item for MarkdownPreviewView {
impl Render for MarkdownPreviewView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx);
- let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height;
-
- v_flex()
+ div()
.image_cache(self.image_cache.clone())
.id("MarkdownPreview")
.key_context("MarkdownPreview")
@@ -590,113 +763,65 @@ impl Render for MarkdownPreviewView {
.on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
.size_full()
.bg(cx.theme().colors().editor_background)
- .p_4()
- .text_size(buffer_size)
- .line_height(relative(buffer_line_height.value()))
- .child(div().flex_grow().map(|this| {
- this.child(
- list(
- self.list_state.clone(),
- cx.processor(|this, ix, window, cx| {
- let Some(contents) = &this.contents else {
- return div().into_any();
- };
-
- let mut render_cx = RenderContext::new(
- Some(this.workspace.clone()),
- &this.mermaid_state,
- window,
- cx,
- )
- .with_checkbox_clicked_callback(cx.listener(
- move |this, e: &CheckboxClickedEvent, window, cx| {
- if let Some(editor) =
- this.active_editor.as_ref().map(|s| s.editor.clone())
- {
- editor.update(cx, |editor, cx| {
- let task_marker =
- if e.checked() { "[x]" } else { "[ ]" };
-
- editor.edit(
- [(
- MultiBufferOffset(e.source_range().start)
- ..MultiBufferOffset(e.source_range().end),
- task_marker,
- )],
- cx,
- );
- });
- this.parse_markdown_from_active_editor(false, window, cx);
- cx.notify();
- }
- },
- ));
-
- let block = contents.children.get(ix).unwrap();
- let rendered_block = render_markdown_block(block, &mut render_cx);
-
- let should_apply_padding = Self::should_apply_padding_between(
- block,
- contents.children.get(ix + 1),
- );
-
- let selected_block = this.selected_block;
- let scaled_rems = render_cx.scaled_rems(1.0);
- div()
- .id(ix)
- .when(should_apply_padding, |this| {
- this.pb(render_cx.scaled_rems(0.75))
- })
- .group("markdown-block")
- .on_click(cx.listener(
- move |this, event: &ClickEvent, window, cx| {
- if event.click_count() == 2
- && let Some(source_range) = this
- .contents
- .as_ref()
- .and_then(|c| c.children.get(ix))
- .and_then(|block: &ParsedMarkdownElement| {
- block.source_range()
- })
- {
- this.move_cursor_to_block(
- window,
- cx,
- MultiBufferOffset(source_range.start)
- ..MultiBufferOffset(source_range.start),
- );
- }
- },
- ))
- .map(move |container| {
- let indicator = div()
- .h_full()
- .w(px(4.0))
- .when(ix == selected_block, |this| {
- this.bg(cx.theme().colors().border)
- })
- .group_hover("markdown-block", |s| {
- if ix == selected_block {
- s
- } else {
- s.bg(cx.theme().colors().border_variant)
- }
- })
- .rounded_xs();
-
- container.child(
- div()
- .relative()
- .child(div().pl(scaled_rems).child(rendered_block))
- .child(indicator.absolute().left_0().top_0()),
- )
- })
- .into_any()
- }),
- )
- .size_full(),
- )
- }))
- .vertical_scrollbar_for(&self.list_state, window, cx)
+ .child(
+ div()
+ .id("markdown-preview-scroll-container")
+ .size_full()
+ .overflow_y_scroll()
+ .track_scroll(&self.scroll_handle)
+ .p_4()
+ .child(self.render_markdown_element(window, cx)),
+ )
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use anyhow::Result;
+ use std::fs;
+ use tempfile::TempDir;
+
+ use super::resolve_preview_path;
+
+ #[test]
+ fn resolves_relative_preview_paths() -> Result<()> {
+ let temp_dir = TempDir::new()?;
+ let base_directory = temp_dir.path();
+ let file = base_directory.join("notes.md");
+ fs::write(&file, "# Notes")?;
+
+ assert_eq!(
+ resolve_preview_path("notes.md", Some(base_directory)),
+ Some(file)
+ );
+ assert_eq!(
+ resolve_preview_path("nonexistent.md", Some(base_directory)),
+ None
+ );
+ assert_eq!(resolve_preview_path("notes.md", None), None);
+
+ Ok(())
+ }
+
+ #[test]
+ fn resolves_urlencoded_preview_paths() -> Result<()> {
+ let temp_dir = TempDir::new()?;
+ let base_directory = temp_dir.path();
+ let file = base_directory.join("release notes.md");
+ fs::write(&file, "# Release Notes")?;
+
+ assert_eq!(
+ resolve_preview_path("release%20notes.md", Some(base_directory)),
+ Some(file)
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn does_not_treat_web_links_as_preview_paths() {
+ assert_eq!(resolve_preview_path("https://zed.dev", None), None);
+ assert_eq!(resolve_preview_path("http://example.com", None), None);
}
}
@@ -1,1517 +0,0 @@
-use crate::{
- markdown_elements::{
- HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
- ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
- ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType,
- ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable,
- ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
- },
- markdown_preview_view::MarkdownPreviewView,
-};
-use collections::HashMap;
-use gpui::{
- AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
- Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
- Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled,
- StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems,
-};
-
-use settings::Settings;
-use std::{
- ops::{Mul, Range},
- sync::{Arc, OnceLock},
- time::Duration,
- vec,
-};
-use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
-use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container};
-use util::normalize_path;
-use workspace::{OpenOptions, OpenVisible, Workspace};
-
-pub struct CheckboxClickedEvent {
- pub checked: bool,
- pub source_range: Range<usize>,
-}
-
-impl CheckboxClickedEvent {
- pub fn source_range(&self) -> Range<usize> {
- self.source_range.clone()
- }
-
- pub fn checked(&self) -> bool {
- self.checked
- }
-}
-
-type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
-
-type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, CachedMermaidDiagram>;
-
-#[derive(Default)]
-pub(crate) struct MermaidState {
- cache: MermaidDiagramCache,
- order: Vec<ParsedMarkdownMermaidDiagramContents>,
-}
-
-impl MermaidState {
- fn get_fallback_image(
- idx: usize,
- old_order: &[ParsedMarkdownMermaidDiagramContents],
- new_order_len: usize,
- cache: &MermaidDiagramCache,
- ) -> Option<Arc<RenderImage>> {
- // When the diagram count changes e.g. addition or removal, positional matching
- // is unreliable since a new diagram at index i likely doesn't correspond to the
- // old diagram at index i. We only allow fallbacks when counts match, which covers
- // the common case of editing a diagram in-place.
- //
- // Swapping two diagrams would briefly show the stale fallback, but that's an edge
- // case we don't handle.
- if old_order.len() != new_order_len {
- return None;
- }
- old_order.get(idx).and_then(|old_content| {
- cache.get(old_content).and_then(|old_cached| {
- old_cached
- .render_image
- .get()
- .and_then(|result| result.as_ref().ok().cloned())
- // Chain fallbacks for rapid edits.
- .or_else(|| old_cached.fallback_image.clone())
- })
- })
- }
-
- pub(crate) fn update(
- &mut self,
- parsed: &ParsedMarkdown,
- cx: &mut Context<MarkdownPreviewView>,
- ) {
- use crate::markdown_elements::ParsedMarkdownElement;
- use std::collections::HashSet;
-
- let mut new_order = Vec::new();
- for element in parsed.children.iter() {
- if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element {
- new_order.push(mermaid_diagram.contents.clone());
- }
- }
-
- for (idx, new_content) in new_order.iter().enumerate() {
- if !self.cache.contains_key(new_content) {
- let fallback =
- Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
- self.cache.insert(
- new_content.clone(),
- CachedMermaidDiagram::new(new_content.clone(), fallback, cx),
- );
- }
- }
-
- let new_order_set: HashSet<_> = new_order.iter().cloned().collect();
- self.cache
- .retain(|content, _| new_order_set.contains(content));
- self.order = new_order;
- }
-}
-
-pub(crate) struct CachedMermaidDiagram {
- pub(crate) render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
- pub(crate) fallback_image: Option<Arc<RenderImage>>,
- _task: Task<()>,
-}
-
-impl CachedMermaidDiagram {
- pub(crate) fn new(
- contents: ParsedMarkdownMermaidDiagramContents,
- fallback_image: Option<Arc<RenderImage>>,
- cx: &mut Context<MarkdownPreviewView>,
- ) -> Self {
- let result = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
- let result_clone = result.clone();
- let svg_renderer = cx.svg_renderer();
-
- let _task = cx.spawn(async move |this, cx| {
- let value = cx
- .background_spawn(async move {
- let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
- let scale = contents.scale as f32 / 100.0;
- svg_renderer
- .render_single_frame(svg_string.as_bytes(), scale, true)
- .map_err(|e| anyhow::anyhow!("{}", e))
- })
- .await;
- let _ = result_clone.set(value);
- this.update(cx, |_, cx| {
- cx.notify();
- })
- .ok();
- });
-
- Self {
- render_image: result,
- fallback_image,
- _task,
- }
- }
-
- #[cfg(test)]
- fn new_for_test(
- render_image: Option<Arc<RenderImage>>,
- fallback_image: Option<Arc<RenderImage>>,
- ) -> Self {
- let result = Arc::new(OnceLock::new());
- if let Some(img) = render_image {
- let _ = result.set(Ok(img));
- }
- Self {
- render_image: result,
- fallback_image,
- _task: Task::ready(()),
- }
- }
-}
-#[derive(Clone)]
-pub struct RenderContext<'a> {
- workspace: Option<WeakEntity<Workspace>>,
- next_id: usize,
- buffer_font_family: SharedString,
- buffer_text_style: TextStyle,
- text_style: TextStyle,
- border_color: Hsla,
- title_bar_background_color: Hsla,
- panel_background_color: Hsla,
- text_color: Hsla,
- link_color: Hsla,
- window_rem_size: Pixels,
- text_muted_color: Hsla,
- code_block_background_color: Hsla,
- code_span_background_color: Hsla,
- syntax_theme: Arc<SyntaxTheme>,
- indent: usize,
- checkbox_clicked_callback: Option<CheckboxClickedCallback>,
- is_last_child: bool,
- mermaid_state: &'a MermaidState,
-}
-
-impl<'a> RenderContext<'a> {
- pub(crate) fn new(
- workspace: Option<WeakEntity<Workspace>>,
- mermaid_state: &'a MermaidState,
- window: &mut Window,
- cx: &mut App,
- ) -> Self {
- let theme = cx.theme().clone();
-
- let settings = ThemeSettings::get_global(cx);
- let buffer_font_family = settings.buffer_font.family.clone();
- let buffer_font_features = settings.buffer_font.features.clone();
- let mut buffer_text_style = window.text_style();
- buffer_text_style.font_family = buffer_font_family.clone();
- buffer_text_style.font_features = buffer_font_features;
- buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
-
- RenderContext {
- workspace,
- next_id: 0,
- indent: 0,
- buffer_font_family,
- buffer_text_style,
- text_style: window.text_style(),
- syntax_theme: theme.syntax().clone(),
- border_color: theme.colors().border,
- title_bar_background_color: theme.colors().title_bar_background,
- panel_background_color: theme.colors().panel_background,
- text_color: theme.colors().text,
- link_color: theme.colors().text_accent,
- window_rem_size: window.rem_size(),
- text_muted_color: theme.colors().text_muted,
- code_block_background_color: theme.colors().surface_background,
- code_span_background_color: theme.colors().editor_document_highlight_read_background,
- checkbox_clicked_callback: None,
- is_last_child: false,
- mermaid_state,
- }
- }
-
- pub fn with_checkbox_clicked_callback(
- mut self,
- callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static,
- ) -> Self {
- self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
- self
- }
-
- fn next_id(&mut self, span: &Range<usize>) -> ElementId {
- let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
- self.next_id += 1;
- ElementId::from(SharedString::from(id))
- }
-
- /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as
- /// buffer font size changes. The callees of this function should be reimplemented to use real
- /// relative sizing once that is implemented in GPUI
- pub fn scaled_rems(&self, rems: f32) -> Rems {
- self.buffer_text_style
- .font_size
- .to_rems(self.window_rem_size)
- .mul(rems)
- }
-
- /// This ensures that children inside of block quotes
- /// have padding between them.
- ///
- /// For example, for this markdown:
- ///
- /// ```markdown
- /// > This is a block quote.
- /// >
- /// > And this is the next paragraph.
- /// ```
- ///
- /// We give padding between "This is a block quote."
- /// and "And this is the next paragraph."
- fn with_common_p(&self, element: Div) -> Div {
- if self.indent > 0 && !self.is_last_child {
- element.pb(self.scaled_rems(0.75))
- } else {
- element
- }
- }
-
- /// The is used to indicate that the current element is the last child or not of its parent.
- ///
- /// Then we can avoid adding padding to the bottom of the last child.
- fn with_last_child<R>(&mut self, is_last: bool, render: R) -> AnyElement
- where
- R: FnOnce(&mut Self) -> AnyElement,
- {
- self.is_last_child = is_last;
- let element = render(self);
- self.is_last_child = false;
- element
- }
-}
-
-pub fn render_parsed_markdown(
- parsed: &ParsedMarkdown,
- workspace: Option<WeakEntity<Workspace>>,
- window: &mut Window,
- cx: &mut App,
-) -> Div {
- let cache = Default::default();
- let mut cx = RenderContext::new(workspace, &cache, window, cx);
-
- v_flex().gap_3().children(
- parsed
- .children
- .iter()
- .map(|block| render_markdown_block(block, &mut cx)),
- )
-}
-pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
- use ParsedMarkdownElement::*;
- match block {
- Paragraph(text) => render_markdown_paragraph(text, cx),
- Heading(heading) => render_markdown_heading(heading, cx),
- ListItem(list_item) => render_markdown_list_item(list_item, cx),
- Table(table) => render_markdown_table(table, cx),
- BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
- CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
- MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx),
- HorizontalRule(_) => render_markdown_rule(cx),
- Image(image) => render_markdown_image(image, cx),
- }
-}
-
-fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
- let size = match parsed.level {
- HeadingLevel::H1 => 2.,
- HeadingLevel::H2 => 1.5,
- HeadingLevel::H3 => 1.25,
- HeadingLevel::H4 => 1.,
- HeadingLevel::H5 => 0.875,
- HeadingLevel::H6 => 0.85,
- };
-
- let text_size = cx.scaled_rems(size);
-
- // was `DefiniteLength::from(text_size.mul(1.25))`
- // let line_height = DefiniteLength::from(text_size.mul(1.25));
- let line_height = text_size * 1.25;
-
- // was `rems(0.15)`
- // let padding_top = cx.scaled_rems(0.15);
- let padding_top = rems(0.15);
-
- // was `.pb_1()` = `rems(0.25)`
- // let padding_bottom = cx.scaled_rems(0.25);
- let padding_bottom = rems(0.25);
-
- let color = match parsed.level {
- HeadingLevel::H6 => cx.text_muted_color,
- _ => cx.text_color,
- };
- div()
- .line_height(line_height)
- .text_size(text_size)
- .text_color(color)
- .pt(padding_top)
- .pb(padding_bottom)
- .children(render_markdown_text(&parsed.contents, cx))
- .whitespace_normal()
- .into_any()
-}
-
-fn render_markdown_list_item(
- parsed: &ParsedMarkdownListItem,
- cx: &mut RenderContext,
-) -> AnyElement {
- use ParsedMarkdownListItemType::*;
- let depth = parsed.depth.saturating_sub(1) as usize;
-
- let bullet = match &parsed.item_type {
- Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(),
- Unordered => list_item_prefix(1, false, depth).into_any_element(),
- Task(checked, range) => div()
- .id(cx.next_id(range))
- .mt(cx.scaled_rems(3.0 / 16.0))
- .child(
- MarkdownCheckbox::new(
- "checkbox",
- if *checked {
- ToggleState::Selected
- } else {
- ToggleState::Unselected
- },
- cx.clone(),
- )
- .when_some(
- cx.checkbox_clicked_callback.clone(),
- |this, callback| {
- this.on_click({
- let range = range.clone();
- move |selection, window, cx| {
- let checked = match selection {
- ToggleState::Selected => true,
- ToggleState::Unselected => false,
- _ => return,
- };
-
- if window.modifiers().secondary() {
- callback(
- &CheckboxClickedEvent {
- checked,
- source_range: range.clone(),
- },
- window,
- cx,
- );
- }
- }
- })
- },
- ),
- )
- .hover(|s| s.cursor_pointer())
- .tooltip(|_, cx| {
- InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
- })
- .into_any_element(),
- };
- let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet);
-
- let contents: Vec<AnyElement> = parsed
- .content
- .iter()
- .map(|c| render_markdown_block(c, cx))
- .collect();
-
- let item = h_flex()
- .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32)))
- .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5())
- .items_start()
- .children(vec![
- bullet,
- v_flex()
- .children(contents)
- .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0)))
- .pr(cx.scaled_rems(1.0))
- .w_full(),
- ]);
-
- cx.with_common_p(item).into_any()
-}
-
-/// # MarkdownCheckbox ///
-/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview
-/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the
-/// app are not visually affected
-#[derive(gpui::IntoElement)]
-struct MarkdownCheckbox {
- id: ElementId,
- toggle_state: ToggleState,
- disabled: bool,
- placeholder: bool,
- on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
- filled: bool,
- style: ui::ToggleStyle,
- tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
- label: Option<SharedString>,
- base_rem: Rems,
-}
-
-impl MarkdownCheckbox {
- /// Creates a new [`Checkbox`].
- fn new(id: impl Into<ElementId>, checked: ToggleState, render_cx: RenderContext) -> Self {
- Self {
- id: id.into(),
- toggle_state: checked,
- disabled: false,
- on_click: None,
- filled: false,
- style: ui::ToggleStyle::default(),
- tooltip: None,
- label: None,
- placeholder: false,
- base_rem: render_cx.scaled_rems(1.0),
- }
- }
-
- /// Binds a handler to the [`Checkbox`] that will be called when clicked.
- fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self {
- self.on_click = Some(Box::new(handler));
- self
- }
-
- fn bg_color(&self, cx: &App) -> Hsla {
- let style = self.style.clone();
- match (style, self.filled) {
- (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
- (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
- (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
- (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
- (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(),
- (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2),
- }
- }
-
- fn border_color(&self, cx: &App) -> Hsla {
- if self.disabled {
- return cx.theme().colors().border_variant;
- }
-
- match self.style.clone() {
- ui::ToggleStyle::Ghost => cx.theme().colors().border,
- ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border,
- ui::ToggleStyle::Custom(color) => color.opacity(0.3),
- }
- }
-}
-
-impl gpui::RenderOnce for MarkdownCheckbox {
- fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
- let group_id = format!("checkbox_group_{:?}", self.id);
- let color = if self.disabled {
- Color::Disabled
- } else {
- Color::Selected
- };
- let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small
- let icon = match self.toggle_state {
- ToggleState::Selected => {
- if self.placeholder {
- None
- } else {
- Some(
- ui::Icon::new(IconName::Check)
- .size(icon_size_small)
- .color(color),
- )
- }
- }
- ToggleState::Indeterminate => Some(
- ui::Icon::new(IconName::Dash)
- .size(icon_size_small)
- .color(color),
- ),
- ToggleState::Unselected => None,
- };
-
- let bg_color = self.bg_color(cx);
- let border_color = self.border_color(cx);
- let hover_border_color = border_color.alpha(0.7);
-
- let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px)
-
- let checkbox = h_flex()
- .id(self.id.clone())
- .justify_center()
- .items_center()
- .size(size)
- .group(group_id.clone())
- .child(
- div()
- .flex()
- .flex_none()
- .justify_center()
- .items_center()
- .m(self.base_rem.mul(0.25)) // was .m_1
- .size(self.base_rem.mul(1.0)) // was .size_4
- .rounded(self.base_rem.mul(0.125)) // was .rounded_xs
- .border_1()
- .bg(bg_color)
- .border_color(border_color)
- .when(self.disabled, |this| this.cursor_not_allowed())
- .when(self.disabled, |this| {
- this.bg(cx.theme().colors().element_disabled.opacity(0.6))
- })
- .when(!self.disabled, |this| {
- this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
- })
- .when(self.placeholder, |this| {
- this.child(
- div()
- .flex_none()
- .rounded_full()
- .bg(color.color(cx).alpha(0.5))
- .size(self.base_rem.mul(0.25)), // was .size_1
- )
- })
- .children(icon),
- );
-
- h_flex()
- .id(self.id)
- .gap(ui::DynamicSpacing::Base06.rems(cx))
- .child(checkbox)
- .when_some(
- self.on_click.filter(|_| !self.disabled),
- |this, on_click| {
- this.on_click(move |_, window, cx| {
- on_click(&self.toggle_state.inverse(), window, cx)
- })
- },
- )
- // TODO: Allow label size to be different from default.
- // TODO: Allow label color to be different from muted.
- .when_some(self.label, |this, label| {
- this.child(Label::new(label).color(Color::Muted))
- })
- .when_some(self.tooltip, |this, tooltip| {
- this.tooltip(move |window, cx| tooltip(window, cx))
- })
- }
-}
-
-fn calculate_table_columns_count(rows: &Vec<ParsedMarkdownTableRow>) -> usize {
- let mut actual_column_count = 0;
- for row in rows {
- actual_column_count = actual_column_count.max(
- row.columns
- .iter()
- .map(|column| column.col_span)
- .sum::<usize>(),
- );
- }
- actual_column_count
-}
-
-fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
- let actual_header_column_count = calculate_table_columns_count(&parsed.header);
- let actual_body_column_count = calculate_table_columns_count(&parsed.body);
- let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count);
-
- let total_rows = parsed.header.len() + parsed.body.len();
-
- // Track which grid cells are occupied by spanning cells
- let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
-
- let mut cells = Vec::with_capacity(total_rows * max_column_count);
-
- for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() {
- let mut col_idx = 0;
-
- for cell in row.columns.iter() {
- // Skip columns occupied by row-spanning cells from previous rows
- while col_idx < max_column_count && grid_occupied[row_idx][col_idx] {
- col_idx += 1;
- }
-
- if col_idx >= max_column_count {
- break;
- }
-
- let container = match cell.alignment {
- ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
- ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
- ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
- };
-
- let cell_element = container
- .col_span(cell.col_span.min(max_column_count - col_idx) as u16)
- .row_span(cell.row_span.min(total_rows - row_idx) as u16)
- .children(render_markdown_text(&cell.children, cx))
- .px_2()
- .py_1()
- .when(col_idx > 0, |this| this.border_l_1())
- .when(row_idx > 0, |this| this.border_t_1())
- .border_color(cx.border_color)
- .when(cell.is_header, |this| {
- this.bg(cx.title_bar_background_color)
- })
- .when(cell.row_span > 1, |this| this.justify_center())
- .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
-
- cells.push(cell_element);
-
- // Mark grid positions as occupied for row-spanning cells
- for r in 0..cell.row_span {
- for c in 0..cell.col_span {
- if row_idx + r < total_rows && col_idx + c < max_column_count {
- grid_occupied[row_idx + r][col_idx + c] = true;
- }
- }
- }
-
- col_idx += cell.col_span;
- }
-
- // Fill remaining columns with empty cells if needed
- while col_idx < max_column_count {
- if grid_occupied[row_idx][col_idx] {
- col_idx += 1;
- continue;
- }
-
- let empty_cell = div()
- .when(col_idx > 0, |this| this.border_l_1())
- .when(row_idx > 0, |this| this.border_t_1())
- .border_color(cx.border_color)
- .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
-
- cells.push(empty_cell);
- col_idx += 1;
- }
- }
-
- cx.with_common_p(v_flex().items_start())
- .when_some(parsed.caption.as_ref(), |this, caption| {
- this.children(render_markdown_text(caption, cx))
- })
- .child(
- div()
- .rounded_sm()
- .overflow_hidden()
- .border_1()
- .border_color(cx.border_color)
- .min_w_0()
- .grid()
- .grid_cols_max_content(max_column_count as u16)
- .children(cells),
- )
- .into_any()
-}
-
-fn render_markdown_block_quote(
- parsed: &ParsedMarkdownBlockQuote,
- cx: &mut RenderContext,
-) -> AnyElement {
- cx.indent += 1;
-
- let children: Vec<AnyElement> = parsed
- .children
- .iter()
- .enumerate()
- .map(|(ix, child)| {
- cx.with_last_child(ix + 1 == parsed.children.len(), |cx| {
- render_markdown_block(child, cx)
- })
- })
- .collect();
-
- cx.indent -= 1;
-
- cx.with_common_p(div())
- .child(
- div()
- .border_l_4()
- .border_color(cx.border_color)
- .pl_3()
- .children(children),
- )
- .into_any()
-}
-
-fn render_markdown_code_block(
- parsed: &ParsedMarkdownCodeBlock,
- cx: &mut RenderContext,
-) -> AnyElement {
- let body = if let Some(highlights) = parsed.highlights.as_ref() {
- StyledText::new(parsed.contents.clone()).with_default_highlights(
- &cx.buffer_text_style,
- highlights.iter().filter_map(|(range, highlight_id)| {
- cx.syntax_theme
- .get(*highlight_id)
- .cloned()
- .map(|style| (range.clone(), style))
- }),
- )
- } else {
- StyledText::new(parsed.contents.clone())
- };
-
- let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone())
- .tooltip_label("Copy Codeblock")
- .visible_on_hover("markdown-block");
-
- let font = gpui::Font {
- family: cx.buffer_font_family.clone(),
- features: cx.buffer_text_style.font_features.clone(),
- ..Default::default()
- };
-
- cx.with_common_p(div())
- .font(font)
- .px_3()
- .py_3()
- .bg(cx.code_block_background_color)
- .rounded_sm()
- .child(body)
- .child(
- div()
- .h_flex()
- .absolute()
- .right_1()
- .top_1()
- .child(copy_block_button),
- )
- .into_any()
-}
-
-fn render_mermaid_diagram(
- parsed: &ParsedMarkdownMermaidDiagram,
- cx: &mut RenderContext,
-) -> AnyElement {
- let cached = cx.mermaid_state.cache.get(&parsed.contents);
-
- if let Some(result) = cached.and_then(|c| c.render_image.get()) {
- match result {
- Ok(render_image) => cx
- .with_common_p(div())
- .px_3()
- .py_3()
- .bg(cx.code_block_background_color)
- .rounded_sm()
- .child(
- div().w_full().child(
- img(ImageSource::Render(render_image.clone()))
- .max_w_full()
- .with_fallback(|| {
- div()
- .child(Label::new("Failed to load mermaid diagram"))
- .into_any_element()
- }),
- ),
- )
- .into_any(),
- Err(_) => cx
- .with_common_p(div())
- .px_3()
- .py_3()
- .bg(cx.code_block_background_color)
- .rounded_sm()
- .child(StyledText::new(parsed.contents.contents.clone()))
- .into_any(),
- }
- } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) {
- cx.with_common_p(div())
- .px_3()
- .py_3()
- .bg(cx.code_block_background_color)
- .rounded_sm()
- .child(
- div()
- .w_full()
- .child(
- img(ImageSource::Render(fallback.clone()))
- .max_w_full()
- .with_fallback(|| {
- div()
- .child(Label::new("Failed to load mermaid diagram"))
- .into_any_element()
- }),
- )
- .with_animation(
- "mermaid-fallback-pulse",
- Animation::new(Duration::from_secs(2))
- .repeat()
- .with_easing(pulsating_between(0.6, 1.0)),
- |el, delta| el.opacity(delta),
- ),
- )
- .into_any()
- } else {
- cx.with_common_p(div())
- .px_3()
- .py_3()
- .bg(cx.code_block_background_color)
- .rounded_sm()
- .child(
- Label::new("Rendering mermaid diagram...")
- .color(Color::Muted)
- .with_animation(
- "mermaid-loading-pulse",
- Animation::new(Duration::from_secs(2))
- .repeat()
- .with_easing(pulsating_between(0.4, 0.8)),
- |label, delta| label.alpha(delta),
- ),
- )
- .into_any()
- }
-}
-
-fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
- cx.with_common_p(div())
- .children(render_markdown_text(parsed, cx))
- .flex()
- .flex_col()
- .into_any_element()
-}
-
-fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
- let mut any_element = Vec::with_capacity(parsed_new.len());
- // these values are cloned in-order satisfy borrow checker
- let syntax_theme = cx.syntax_theme.clone();
- let workspace_clone = cx.workspace.clone();
- let code_span_bg_color = cx.code_span_background_color;
- let text_style = cx.text_style.clone();
- let link_color = cx.link_color;
-
- for parsed_region in parsed_new {
- match parsed_region {
- MarkdownParagraphChunk::Text(parsed) => {
- let trimmed = parsed.contents.trim();
- if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
- let checked = trimmed != "[ ]";
- let element = div()
- .child(MarkdownCheckbox::new(
- cx.next_id(&parsed.source_range),
- if checked {
- ToggleState::Selected
- } else {
- ToggleState::Unselected
- },
- cx.clone(),
- ))
- .into_any();
- any_element.push(element);
- continue;
- }
-
- let element_id = cx.next_id(&parsed.source_range);
-
- let highlights = gpui::combine_highlights(
- parsed.highlights.iter().filter_map(|(range, highlight)| {
- highlight
- .to_highlight_style(&syntax_theme)
- .map(|style| (range.clone(), style))
- }),
- parsed.regions.iter().filter_map(|(range, region)| {
- if region.code {
- Some((
- range.clone(),
- HighlightStyle {
- background_color: Some(code_span_bg_color),
- ..Default::default()
- },
- ))
- } else if region.link.is_some() {
- Some((
- range.clone(),
- HighlightStyle {
- color: Some(link_color),
- ..Default::default()
- },
- ))
- } else {
- None
- }
- }),
- );
- let mut links = Vec::new();
- let mut link_ranges = Vec::new();
- for (range, region) in parsed.regions.iter() {
- if let Some(link) = region.link.clone() {
- links.push(link);
- link_ranges.push(range.clone());
- }
- }
- let workspace = workspace_clone.clone();
- let element = div()
- .child(
- InteractiveText::new(
- element_id,
- StyledText::new(parsed.contents.clone())
- .with_default_highlights(&text_style, highlights),
- )
- .tooltip({
- let links = links.clone();
- let link_ranges = link_ranges.clone();
- move |idx, _, cx| {
- for (ix, range) in link_ranges.iter().enumerate() {
- if range.contains(&idx) {
- return Some(LinkPreview::new(&links[ix].to_string(), cx));
- }
- }
- None
- }
- })
- .on_click(
- link_ranges,
- move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
- Link::Web { url } => cx.open_url(url),
- Link::Path { path, .. } => {
- if let Some(workspace) = &workspace {
- _ = workspace.update(cx, |workspace, cx| {
- workspace
- .open_abs_path(
- normalize_path(path.clone().as_path()),
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- window,
- cx,
- )
- .detach();
- });
- }
- }
- },
- ),
- )
- .into_any();
- any_element.push(element);
- }
-
- MarkdownParagraphChunk::Image(image) => {
- any_element.push(render_markdown_image(image, cx));
- }
- }
- }
-
- any_element
-}
-
-fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
- let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
- div().py(cx.scaled_rems(0.5)).child(rule).into_any()
-}
-
-fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
- let image_resource = match image.link.clone() {
- Link::Web { url } => Resource::Uri(url.into()),
- Link::Path { path, .. } => Resource::Path(Arc::from(path)),
- };
-
- let element_id = cx.next_id(&image.source_range);
- let workspace = cx.workspace.clone();
-
- div()
- .id(element_id)
- .cursor_pointer()
- .child(
- img(ImageSource::Resource(image_resource))
- .max_w_full()
- .with_fallback({
- let alt_text = image.alt_text.clone();
- move || div().children(alt_text.clone()).into_any_element()
- })
- .when_some(image.height, |this, height| this.h(height))
- .when_some(image.width, |this, width| this.w(width)),
- )
- .tooltip({
- let link = image.link.clone();
- let alt_text = image.alt_text.clone();
- move |_, cx| {
- InteractiveMarkdownElementTooltip::new(
- Some(alt_text.clone().unwrap_or(link.to_string().into())),
- "open image",
- cx,
- )
- .into()
- }
- })
- .on_click({
- let link = image.link.clone();
- move |_, window, cx| {
- if window.modifiers().secondary() {
- match &link {
- Link::Web { url } => cx.open_url(url),
- Link::Path { path, .. } => {
- if let Some(workspace) = &workspace {
- _ = workspace.update(cx, |workspace, cx| {
- workspace
- .open_abs_path(
- path.clone(),
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- window,
- cx,
- )
- .detach();
- });
- }
- }
- }
- }
- }
- })
- .into_any()
-}
-
-struct InteractiveMarkdownElementTooltip {
- tooltip_text: Option<SharedString>,
- action_text: SharedString,
-}
-
-impl InteractiveMarkdownElementTooltip {
- pub fn new(
- tooltip_text: Option<SharedString>,
- action_text: impl Into<SharedString>,
- cx: &mut App,
- ) -> Entity<Self> {
- let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
-
- cx.new(|_cx| Self {
- tooltip_text,
- action_text: action_text.into(),
- })
- }
-}
-
-impl Render for InteractiveMarkdownElementTooltip {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- tooltip_container(cx, |el, _| {
- let secondary_modifier = Keystroke {
- modifiers: Modifiers::secondary_key(),
- ..Default::default()
- };
-
- el.child(
- v_flex()
- .gap_1()
- .when_some(self.tooltip_text.clone(), |this, text| {
- this.child(Label::new(text).size(LabelSize::Small))
- })
- .child(
- Label::new(format!(
- "{}-click to {}",
- secondary_modifier, self.action_text
- ))
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- })
- }
-}
-
-/// Returns the prefix for a list item.
-fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
- let ix = order.saturating_sub(1);
- const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
- const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
- const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"];
-
- if ordered {
- match depth {
- 0 => format!("{}. ", order),
- 1 => format!(
- "{}. ",
- NUMBERED_PREFIXES_1
- .chars()
- .nth(ix % NUMBERED_PREFIXES_1.len())
- .unwrap()
- ),
- _ => format!(
- "{}. ",
- NUMBERED_PREFIXES_2
- .chars()
- .nth(ix % NUMBERED_PREFIXES_2.len())
- .unwrap()
- ),
- }
- } else {
- let depth = depth.min(BULLETS.len() - 1);
- let bullet = BULLETS[depth];
- return format!("{} ", bullet);
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents;
- use crate::markdown_elements::ParsedMarkdownTableColumn;
- use crate::markdown_elements::ParsedMarkdownText;
-
- fn text(text: &str) -> MarkdownParagraphChunk {
- MarkdownParagraphChunk::Text(ParsedMarkdownText {
- source_range: 0..text.len(),
- contents: SharedString::new(text),
- highlights: Default::default(),
- regions: Default::default(),
- })
- }
-
- fn column(
- col_span: usize,
- row_span: usize,
- children: Vec<MarkdownParagraphChunk>,
- ) -> ParsedMarkdownTableColumn {
- ParsedMarkdownTableColumn {
- col_span,
- row_span,
- is_header: false,
- children,
- alignment: ParsedMarkdownTableAlignment::None,
- }
- }
-
- fn column_with_row_span(
- col_span: usize,
- row_span: usize,
- children: Vec<MarkdownParagraphChunk>,
- ) -> ParsedMarkdownTableColumn {
- ParsedMarkdownTableColumn {
- col_span,
- row_span,
- is_header: false,
- children,
- alignment: ParsedMarkdownTableAlignment::None,
- }
- }
-
- #[test]
- fn test_calculate_table_columns_count() {
- assert_eq!(0, calculate_table_columns_count(&vec![]));
-
- assert_eq!(
- 1,
- calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
- column(1, 1, vec![text("column1")])
- ])])
- );
-
- assert_eq!(
- 2,
- calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
- column(1, 1, vec![text("column1")]),
- column(1, 1, vec![text("column2")]),
- ])])
- );
-
- assert_eq!(
- 2,
- calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
- column(2, 1, vec![text("column1")])
- ])])
- );
-
- assert_eq!(
- 3,
- calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
- column(1, 1, vec![text("column1")]),
- column(2, 1, vec![text("column2")]),
- ])])
- );
-
- assert_eq!(
- 2,
- calculate_table_columns_count(&vec![
- ParsedMarkdownTableRow::with_columns(vec![
- column(1, 1, vec![text("column1")]),
- column(1, 1, vec![text("column2")]),
- ]),
- ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),])
- ])
- );
-
- assert_eq!(
- 3,
- calculate_table_columns_count(&vec![
- ParsedMarkdownTableRow::with_columns(vec![
- column(1, 1, vec![text("column1")]),
- column(1, 1, vec![text("column2")]),
- ]),
- ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),])
- ])
- );
- }
-
- #[test]
- fn test_row_span_support() {
- assert_eq!(
- 3,
- calculate_table_columns_count(&vec![
- ParsedMarkdownTableRow::with_columns(vec![
- column_with_row_span(1, 2, vec![text("spans 2 rows")]),
- column(1, 1, vec![text("column2")]),
- column(1, 1, vec![text("column3")]),
- ]),
- ParsedMarkdownTableRow::with_columns(vec![
- // First column is covered by row span from above
- column(1, 1, vec![text("column2 row2")]),
- column(1, 1, vec![text("column3 row2")]),
- ])
- ])
- );
-
- assert_eq!(
- 4,
- calculate_table_columns_count(&vec![
- ParsedMarkdownTableRow::with_columns(vec![
- column_with_row_span(1, 3, vec![text("spans 3 rows")]),
- column_with_row_span(2, 1, vec![text("spans 2 cols")]),
- column(1, 1, vec![text("column4")]),
- ]),
- ParsedMarkdownTableRow::with_columns(vec![
- // First column covered by row span
- column(1, 1, vec![text("column2")]),
- column(1, 1, vec![text("column3")]),
- column(1, 1, vec![text("column4")]),
- ]),
- ParsedMarkdownTableRow::with_columns(vec![
- // First column still covered by row span
- column(3, 1, vec![text("spans 3 cols")]),
- ])
- ])
- );
- }
-
- #[test]
- fn test_list_item_prefix() {
- assert_eq!(list_item_prefix(1, true, 0), "1. ");
- assert_eq!(list_item_prefix(2, true, 0), "2. ");
- assert_eq!(list_item_prefix(3, true, 0), "3. ");
- assert_eq!(list_item_prefix(11, true, 0), "11. ");
- assert_eq!(list_item_prefix(1, true, 1), "A. ");
- assert_eq!(list_item_prefix(2, true, 1), "B. ");
- assert_eq!(list_item_prefix(3, true, 1), "C. ");
- assert_eq!(list_item_prefix(1, true, 2), "a. ");
- assert_eq!(list_item_prefix(2, true, 2), "b. ");
- assert_eq!(list_item_prefix(7, true, 2), "g. ");
- assert_eq!(list_item_prefix(1, true, 1), "A. ");
- assert_eq!(list_item_prefix(1, true, 2), "a. ");
- assert_eq!(list_item_prefix(1, false, 0), "• ");
- assert_eq!(list_item_prefix(1, false, 1), "◦ ");
- assert_eq!(list_item_prefix(1, false, 2), "▪ ");
- assert_eq!(list_item_prefix(1, false, 3), "‣ ");
- assert_eq!(list_item_prefix(1, false, 4), "⁃ ");
- }
-
- fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents {
- ParsedMarkdownMermaidDiagramContents {
- contents: SharedString::from(s.to_string()),
- scale: 1,
- }
- }
-
- fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
- diagrams
- .iter()
- .map(|diagram| mermaid_contents(diagram))
- .collect()
- }
-
- fn mermaid_fallback(
- new_diagram: &str,
- new_full_order: &[ParsedMarkdownMermaidDiagramContents],
- old_full_order: &[ParsedMarkdownMermaidDiagramContents],
- cache: &MermaidDiagramCache,
- ) -> Option<Arc<RenderImage>> {
- let new_content = mermaid_contents(new_diagram);
- let idx = new_full_order
- .iter()
- .position(|content| content == &new_content)?;
- MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
- }
-
- fn mock_render_image() -> Arc<RenderImage> {
- Arc::new(RenderImage::new(Vec::new()))
- }
-
- #[test]
- fn test_mermaid_fallback_on_edit() {
- let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
- let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
-
- let svg_b = mock_render_image();
- let mut cache: MermaidDiagramCache = HashMap::default();
- cache.insert(
- mermaid_contents("graph A"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
- cache.insert(
- mermaid_contents("graph B"),
- CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None),
- );
- cache.insert(
- mermaid_contents("graph C"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
-
- let fallback =
- mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
-
- assert!(
- fallback.is_some(),
- "Should use old diagram as fallback when editing"
- );
- assert!(
- Arc::ptr_eq(&fallback.unwrap(), &svg_b),
- "Fallback should be the old diagram's SVG"
- );
- }
-
- #[test]
- fn test_mermaid_no_fallback_on_add_in_middle() {
- let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
- let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
-
- let mut cache: MermaidDiagramCache = HashMap::default();
- cache.insert(
- mermaid_contents("graph A"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
- cache.insert(
- mermaid_contents("graph C"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
-
- let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
-
- assert!(
- fallback.is_none(),
- "Should NOT use fallback when adding new diagram"
- );
- }
-
- #[test]
- fn test_mermaid_fallback_chains_on_rapid_edits() {
- let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
- let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
-
- let original_svg = mock_render_image();
- let mut cache: MermaidDiagramCache = HashMap::default();
- cache.insert(
- mermaid_contents("graph A"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
- cache.insert(
- mermaid_contents("graph B modified"),
- // Still rendering, but has fallback from original "graph B"
- CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())),
- );
- cache.insert(
- mermaid_contents("graph C"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
-
- let fallback = mermaid_fallback(
- "graph B modified again",
- &new_full_order,
- &old_full_order,
- &cache,
- );
-
- assert!(
- fallback.is_some(),
- "Should chain fallback when previous render not complete"
- );
- assert!(
- Arc::ptr_eq(&fallback.unwrap(), &original_svg),
- "Fallback should chain through to the original SVG"
- );
- }
-
- #[test]
- fn test_mermaid_no_fallback_when_no_old_diagram_at_index() {
- let old_full_order = mermaid_sequence(&["graph A"]);
- let new_full_order = mermaid_sequence(&["graph A", "graph B"]);
-
- let mut cache: MermaidDiagramCache = HashMap::default();
- cache.insert(
- mermaid_contents("graph A"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
-
- let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache);
-
- assert!(
- fallback.is_none(),
- "Should NOT have fallback when adding diagram at end"
- );
- }
-
- #[test]
- fn test_mermaid_fallback_with_duplicate_blocks_edit_first() {
- let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
- let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]);
-
- let svg_a = mock_render_image();
- let mut cache: MermaidDiagramCache = HashMap::default();
- cache.insert(
- mermaid_contents("graph A"),
- CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
- );
- cache.insert(
- mermaid_contents("graph B"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
-
- let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
-
- assert!(
- fallback.is_some(),
- "Should use old diagram as fallback when editing one of duplicate blocks"
- );
- assert!(
- Arc::ptr_eq(&fallback.unwrap(), &svg_a),
- "Fallback should be the old duplicate diagram's image"
- );
- }
-
- #[test]
- fn test_mermaid_fallback_with_duplicate_blocks_edit_second() {
- let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
- let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
-
- let svg_a = mock_render_image();
- let mut cache: MermaidDiagramCache = HashMap::default();
- cache.insert(
- mermaid_contents("graph A"),
- CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
- );
- cache.insert(
- mermaid_contents("graph B"),
- CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
- );
-
- let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
-
- assert!(
- fallback.is_some(),
- "Should use old diagram as fallback when editing the second duplicate block"
- );
- assert!(
- Arc::ptr_eq(&fallback.unwrap(), &svg_a),
- "Fallback should be the old duplicate diagram's image"
- );
- }
-}