Skip to main content

freya_markdown/
lib.rs

1use std::{
2    borrow::Cow,
3    mem,
4};
5
6#[cfg(feature = "remote-asset")]
7use freya_components::Uri;
8#[cfg(feature = "remote-asset")]
9use freya_components::image_viewer::{
10    ImageSource,
11    ImageViewer,
12};
13#[cfg(feature = "router")]
14use freya_components::link::{
15    Link,
16    LinkTooltip,
17};
18use freya_components::{
19    define_theme,
20    get_theme_or_default,
21    table::{
22        Table,
23        TableBody,
24        TableCell,
25        TableHead,
26        TableRow,
27    },
28    theming::macros::Preference,
29};
30use freya_core::prelude::*;
31use pulldown_cmark::{
32    Event,
33    HeadingLevel,
34    Options,
35    Parser,
36    Tag,
37    TagEnd,
38};
39use torin::prelude::*;
40
41#[cfg(feature = "code-editor")]
42mod code_editor;
43#[cfg(feature = "code-editor")]
44use code_editor::CodeBlockEditor;
45
46define_theme! {
47    %[component]
48    pub MarkdownViewer {
49        %[fields]
50        color: Color,
51        color_link: Color,
52        background_code: Color,
53        color_code: Color,
54        background_blockquote: Color,
55        border_blockquote: Color,
56        background_divider: Color,
57        heading_h1: f32,
58        heading_h2: f32,
59        heading_h3: f32,
60        heading_h4: f32,
61        heading_h5: f32,
62        heading_h6: f32,
63        paragraph_size: f32,
64        code_font_size: f32,
65        table_font_size: f32,
66    }
67}
68
69fn markdown_theme_preference() -> MarkdownViewerThemePreference {
70    MarkdownViewerThemePreference {
71        color: Preference::Reference("text_primary"),
72        color_link: Preference::Reference("text_highlight"),
73        background_code: Preference::Reference("surface_tertiary"),
74        color_code: Preference::Reference("text_primary"),
75        background_blockquote: Preference::Reference("surface_tertiary"),
76        border_blockquote: Preference::Reference("surface_primary"),
77        background_divider: Preference::Reference("border"),
78        heading_h1: Preference::Specific(32.0),
79        heading_h2: Preference::Specific(28.0),
80        heading_h3: Preference::Specific(24.0),
81        heading_h4: Preference::Specific(20.0),
82        heading_h5: Preference::Specific(18.0),
83        heading_h6: Preference::Specific(16.0),
84        paragraph_size: Preference::Specific(16.0),
85        code_font_size: Preference::Specific(14.0),
86        table_font_size: Preference::Specific(14.0),
87    }
88}
89
90/// Markdown viewer component.
91///
92/// Renders markdown content with support for:
93/// - Headings (h1-h6)
94/// - Paragraphs
95/// - Bold, italic, and strikethrough text
96/// - Code (inline and blocks)
97/// - Lists (ordered and unordered)
98/// - Tables
99/// - Images
100/// - Links
101/// - Blockquotes
102/// - Horizontal rules
103/// - Custom inline elements (see [`MarkdownViewer::inline_element`])
104///
105/// With the `code-editor` feature enabled, code blocks are rendered with the
106/// `CodeEditor` component for syntax highlighting. Otherwise they fall back to
107/// plain monospace text.
108///
109/// # Example
110///
111/// ```rust
112/// # use freya::prelude::*;
113/// fn app() -> impl IntoElement {
114///     MarkdownViewer::new("# Hello World\n\nThis is **bold** and *italic* text.")
115/// }
116/// ```
117#[derive(PartialEq)]
118pub struct MarkdownViewer {
119    content: Cow<'static, str>,
120    layout: LayoutData,
121    key: DiffKey,
122    pub(crate) theme: Option<MarkdownViewerThemePartial>,
123    inline_element: Option<Callback<String, Option<Element>>>,
124    code_editor_font_family: Cow<'static, str>,
125    #[cfg(feature = "code-editor")]
126    language_resolver: Option<code_editor::LanguageResolver>,
127}
128
129impl MarkdownViewer {
130    pub fn new(content: impl Into<Cow<'static, str>>) -> Self {
131        Self {
132            content: content.into(),
133            layout: LayoutData::default(),
134            key: DiffKey::None,
135            theme: None,
136            inline_element: None,
137            code_editor_font_family: Cow::Borrowed("Jetbrains Mono"),
138            #[cfg(feature = "code-editor")]
139            language_resolver: None,
140        }
141    }
142
143    /// Set a handler for custom inline elements.
144    ///
145    /// Each raw inline HTML tag in a paragraph (for example `<rust-logo/>`) is passed to the
146    /// `handler`, which returns the element to inline, or `None` to keep the tag as plain text.
147    ///
148    /// ```rust
149    /// # use freya::prelude::*;
150    /// fn app() -> impl IntoElement {
151    ///     MarkdownViewer::new("Made with Rust <rust-logo/> btw")
152    ///         .inline_element(|html: String| html.starts_with("<rust-logo").then(|| "🦀"))
153    /// }
154    /// ```
155    pub fn inline_element<ReturnedElement: IntoElement + 'static>(
156        mut self,
157        handler: impl Into<Callback<String, Option<ReturnedElement>>>,
158    ) -> Self {
159        let handler = handler.into();
160        self.inline_element = Some(Callback::new(move |html| {
161            handler.call(html).map(IntoElement::into_element)
162        }));
163        self
164    }
165
166    /// Sets the font family used for code blocks. Defaults to `"Jetbrains Mono"`.
167    pub fn code_editor_font_family(mut self, font_family: impl Into<Cow<'static, str>>) -> Self {
168        self.code_editor_font_family = font_family.into();
169        self
170    }
171
172    /// Sets a resolver mapping a code block's language to an `EditorLanguage` for highlighting.
173    #[cfg(feature = "code-editor")]
174    pub fn code_editor_language(
175        mut self,
176        resolver: impl Into<code_editor::LanguageResolver>,
177    ) -> Self {
178        self.language_resolver = Some(resolver.into());
179        self
180    }
181}
182
183impl KeyExt for MarkdownViewer {
184    fn write_key(&mut self) -> &mut DiffKey {
185        &mut self.key
186    }
187}
188
189impl LayoutExt for MarkdownViewer {
190    fn get_layout(&mut self) -> &mut LayoutData {
191        &mut self.layout
192    }
193}
194
195impl ContainerExt for MarkdownViewer {}
196
197#[allow(dead_code)]
198#[derive(Clone)]
199enum MarkdownElement {
200    Heading {
201        level: HeadingLevel,
202        spans: Vec<TextSpan>,
203    },
204    Paragraph {
205        content: Vec<Inline>,
206    },
207    CodeBlock {
208        code: String,
209        language: Option<String>,
210    },
211    UnorderedList {
212        items: Vec<Vec<TextSpan>>,
213    },
214    OrderedList {
215        start: u64,
216        items: Vec<Vec<TextSpan>>,
217    },
218    Image {
219        #[cfg_attr(not(feature = "remote-asset"), allow(dead_code))]
220        url: String,
221        alt: String,
222    },
223    Link {
224        url: String,
225        title: Option<String>,
226        text: Vec<TextSpan>,
227    },
228    Blockquote {
229        spans: Vec<TextSpan>,
230    },
231    Table {
232        headers: Vec<Vec<TextSpan>>,
233        rows: Vec<Vec<Vec<TextSpan>>>,
234    },
235    HorizontalRule,
236}
237
238/// A piece of a paragraph's content: styled text or an inline link flowing within the text.
239#[derive(Clone)]
240enum Inline {
241    Span(TextSpan),
242    #[cfg_attr(not(feature = "router"), allow(dead_code))]
243    Link {
244        url: String,
245        title: Option<String>,
246        text: Vec<TextSpan>,
247    },
248    /// A raw inline HTML tag, resolved at render time by [`MarkdownViewer::inline_element`].
249    Html(String),
250}
251
252/// Represents styled text spans within markdown.
253#[derive(Clone, Debug)]
254struct TextSpan {
255    text: String,
256    bold: bool,
257    italic: bool,
258    #[allow(dead_code)]
259    strikethrough: bool,
260    code: bool,
261}
262
263impl TextSpan {
264    fn new(text: impl Into<String>) -> Self {
265        Self {
266            text: text.into(),
267            bold: false,
268            italic: false,
269            strikethrough: false,
270            code: false,
271        }
272    }
273}
274
275fn parse_markdown(content: &str) -> Vec<MarkdownElement> {
276    let mut options = Options::empty();
277    options.insert(Options::ENABLE_STRIKETHROUGH);
278    options.insert(Options::ENABLE_TABLES);
279
280    let parser = Parser::new_ext(content, options);
281    let mut elements = Vec::new();
282    let mut current_spans: Vec<TextSpan> = Vec::new();
283    let mut current_content: Vec<Inline> = Vec::new();
284    let mut list_items: Vec<Vec<TextSpan>> = Vec::new();
285    let mut current_list_item: Vec<TextSpan> = Vec::new();
286
287    let mut in_heading: Option<HeadingLevel> = None;
288    let mut in_paragraph = false;
289    let mut in_code_block = false;
290    let mut code_block_content = String::new();
291    let mut code_block_language: Option<String> = None;
292    let mut ordered_list_start: Option<u64> = None;
293    let mut in_list_item = false;
294    let mut in_blockquote = false;
295    let mut blockquote_spans: Vec<TextSpan> = Vec::new();
296
297    let mut in_table_cell = false;
298    let mut table_headers: Vec<Vec<TextSpan>> = Vec::new();
299    let mut table_rows: Vec<Vec<Vec<TextSpan>>> = Vec::new();
300    let mut current_table_row: Vec<Vec<TextSpan>> = Vec::new();
301    let mut current_cell_spans: Vec<TextSpan> = Vec::new();
302
303    let mut in_link = false;
304    let mut link_url: Option<String> = None;
305    let mut link_title: Option<String> = None;
306    let mut link_spans: Vec<TextSpan> = Vec::new();
307
308    let mut bold = false;
309    let mut italic = false;
310    let mut strikethrough = false;
311
312    for event in parser {
313        match event {
314            Event::Start(tag) => match tag {
315                Tag::Heading { level, .. } => {
316                    in_heading = Some(level);
317                    current_spans.clear();
318                }
319                Tag::Paragraph => {
320                    if in_blockquote {
321                        // Paragraphs inside blockquotes
322                    } else if in_list_item {
323                        // Paragraphs inside list items
324                    } else {
325                        in_paragraph = true;
326                        current_spans.clear();
327                        current_content.clear();
328                    }
329                }
330                Tag::CodeBlock(kind) => {
331                    in_code_block = true;
332                    code_block_content.clear();
333                    code_block_language = match kind {
334                        pulldown_cmark::CodeBlockKind::Fenced(lang) => {
335                            let lang_str = lang.to_string();
336                            if lang_str.is_empty() {
337                                None
338                            } else {
339                                Some(lang_str)
340                            }
341                        }
342                        pulldown_cmark::CodeBlockKind::Indented => None,
343                    };
344                }
345                Tag::List(start) => {
346                    ordered_list_start = start;
347                    list_items.clear();
348                }
349                Tag::Item => {
350                    in_list_item = true;
351                    current_list_item.clear();
352                }
353                Tag::Strong => bold = true,
354                Tag::Emphasis => italic = true,
355                Tag::Strikethrough => strikethrough = true,
356                Tag::BlockQuote(_) => {
357                    in_blockquote = true;
358                    blockquote_spans.clear();
359                }
360                Tag::Image {
361                    dest_url, title, ..
362                } => {
363                    elements.push(MarkdownElement::Image {
364                        url: dest_url.to_string(),
365                        alt: title.to_string(),
366                    });
367                }
368                Tag::Link {
369                    dest_url, title, ..
370                } => {
371                    in_link = true;
372                    link_url = Some(dest_url.to_string());
373                    link_title = Some(title.to_string());
374                    link_spans.clear();
375                }
376                Tag::Table(_) => {
377                    table_headers.clear();
378                    table_rows.clear();
379                    current_table_row.clear();
380                }
381                Tag::TableHead => {}
382                Tag::TableRow => {
383                    current_table_row.clear();
384                }
385                Tag::TableCell => {
386                    in_table_cell = true;
387                    current_cell_spans.clear();
388                }
389                _ => {}
390            },
391            Event::End(tag_end) => match tag_end {
392                TagEnd::Heading(_) => {
393                    if let Some(level) = in_heading.take() {
394                        elements.push(MarkdownElement::Heading {
395                            level,
396                            spans: mem::take(&mut current_spans),
397                        });
398                    }
399                }
400                TagEnd::Paragraph => {
401                    if in_blockquote {
402                        blockquote_spans.append(&mut current_spans)
403                    } else if in_list_item {
404                        current_list_item.append(&mut current_spans)
405                    } else if in_paragraph {
406                        in_paragraph = false;
407                        current_content.extend(current_spans.drain(..).map(Inline::Span));
408                        elements.push(MarkdownElement::Paragraph {
409                            content: mem::take(&mut current_content),
410                        });
411                    }
412                }
413                TagEnd::CodeBlock => {
414                    in_code_block = false;
415                    elements.push(MarkdownElement::CodeBlock {
416                        code: mem::take(&mut code_block_content),
417                        language: code_block_language.take(),
418                    });
419                }
420                TagEnd::List(_) => {
421                    let items = mem::take(&mut list_items);
422                    if let Some(start) = ordered_list_start.take() {
423                        elements.push(MarkdownElement::OrderedList { start, items });
424                    } else {
425                        elements.push(MarkdownElement::UnorderedList { items });
426                    }
427                }
428                TagEnd::Item => {
429                    in_list_item = false;
430                    list_items.push(mem::take(&mut current_list_item));
431                }
432                TagEnd::Strong => bold = false,
433                TagEnd::Emphasis => italic = false,
434                TagEnd::Strikethrough => strikethrough = false,
435                TagEnd::BlockQuote(_) => {
436                    in_blockquote = false;
437                    elements.push(MarkdownElement::Blockquote {
438                        spans: mem::take(&mut blockquote_spans),
439                    });
440                }
441                TagEnd::Table => {
442                    elements.push(MarkdownElement::Table {
443                        headers: mem::take(&mut table_headers),
444                        rows: mem::take(&mut table_rows),
445                    });
446                }
447                TagEnd::TableHead => {
448                    // TableHead contains cells directly (no TableRow), so save headers here
449                    table_headers = mem::take(&mut current_table_row);
450                }
451                TagEnd::TableRow => {
452                    // TableRow only appears in body rows, not in TableHead
453                    table_rows.push(mem::take(&mut current_table_row));
454                }
455                TagEnd::TableCell => {
456                    in_table_cell = false;
457                    current_table_row.push(mem::take(&mut current_cell_spans));
458                }
459                TagEnd::Link => {
460                    in_link = false;
461                    if let Some(url) = link_url.take() {
462                        let title = link_title.take();
463                        let text = mem::take(&mut link_spans);
464                        if in_paragraph {
465                            current_content.extend(current_spans.drain(..).map(Inline::Span));
466                            current_content.push(Inline::Link { url, title, text });
467                        } else {
468                            elements.push(MarkdownElement::Link { url, title, text });
469                        }
470                    }
471                }
472                _ => {}
473            },
474            Event::Text(text) => {
475                if in_code_block {
476                    code_block_content.push_str(text.trim());
477                } else if in_table_cell {
478                    let span = TextSpan {
479                        text: text.to_string(),
480                        bold,
481                        italic,
482                        strikethrough,
483                        code: false,
484                    };
485                    current_cell_spans.push(span);
486                } else {
487                    let span = TextSpan {
488                        text: text.to_string(),
489                        bold,
490                        italic,
491                        strikethrough,
492                        code: false,
493                    };
494                    if in_blockquote && !in_paragraph {
495                        blockquote_spans.push(span);
496                    } else if in_list_item && !in_paragraph {
497                        current_list_item.push(span);
498                    } else if in_link {
499                        link_spans.push(span);
500                    } else {
501                        current_spans.push(span);
502                    }
503                }
504            }
505            Event::Code(code) => {
506                let span = TextSpan {
507                    text: code.to_string(),
508                    bold,
509                    italic,
510                    strikethrough,
511                    code: true,
512                };
513                if in_table_cell {
514                    current_cell_spans.push(span);
515                } else if in_blockquote {
516                    blockquote_spans.push(span);
517                } else if in_list_item {
518                    current_list_item.push(span);
519                } else if in_link {
520                    link_spans.push(span);
521                } else {
522                    current_spans.push(span);
523                }
524            }
525            Event::SoftBreak | Event::HardBreak => {
526                let span = TextSpan::new(" ");
527                if in_blockquote {
528                    blockquote_spans.push(span);
529                } else if in_list_item {
530                    current_list_item.push(span);
531                } else if in_link {
532                    link_spans.push(span);
533                } else {
534                    current_spans.push(span);
535                }
536            }
537            Event::InlineHtml(html) => {
538                if in_paragraph && !in_link {
539                    current_content.extend(current_spans.drain(..).map(Inline::Span));
540                    current_content.push(Inline::Html(html.to_string()));
541                }
542            }
543            Event::Rule => {
544                elements.push(MarkdownElement::HorizontalRule);
545            }
546            _ => {}
547        }
548    }
549
550    elements
551}
552
553/// Build a styled [Span] from a markdown text span.
554fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'static> {
555    let mut styled = Span::new(span.text.clone());
556    if span.bold {
557        styled = styled.font_weight(FontWeight::BOLD);
558    }
559    if span.italic {
560        styled = styled.font_slant(FontSlant::Italic);
561    }
562    if span.code {
563        styled.font_family("monospace").color(code_color)
564    } else {
565        styled.color(text_color)
566    }
567}
568
569/// Render text spans as a paragraph element.
570fn render_spans(
571    spans: &[TextSpan],
572    base_font_size: f32,
573    text_color: Color,
574    code_color: Color,
575) -> Paragraph {
576    paragraph().font_size(base_font_size).spans_iter(
577        spans
578            .iter()
579            .map(|span| styled_span(span, text_color, code_color)),
580    )
581}
582
583/// Render a paragraph's content, flowing inline links (colored with `link_color`) between the text.
584fn render_content(
585    content: &[Inline],
586    base_font_size: f32,
587    text_color: Color,
588    link_color: Color,
589    code_color: Color,
590    inline_element: Option<&Callback<String, Option<Element>>>,
591) -> Paragraph {
592    let mut result = paragraph().font_size(base_font_size);
593    for item in content {
594        result = match item {
595            Inline::Span(span) => result.span(styled_span(span, text_color, code_color)),
596            Inline::Html(raw) => {
597                match inline_element.and_then(|handler| handler.call(raw.clone())) {
598                    Some(element) => result.child(element),
599                    None => result.span(Span::new(raw.clone()).color(text_color)),
600                }
601            }
602            #[cfg(feature = "router")]
603            Inline::Link { url, title, text } => {
604                let mut tooltip = LinkTooltip::Default;
605                if let Some(title) = title
606                    && !title.is_empty()
607                {
608                    tooltip = LinkTooltip::Custom(title.clone());
609                }
610                result.child(Link::new(url.clone()).tooltip(tooltip).child(render_spans(
611                    text,
612                    base_font_size,
613                    link_color,
614                    code_color,
615                )))
616            }
617            #[cfg(not(feature = "router"))]
618            Inline::Link { text, .. } => text.iter().fold(result, |paragraph, span| {
619                paragraph.span(styled_span(span, link_color, code_color))
620            }),
621        };
622    }
623    result
624}
625
626impl Component for MarkdownViewer {
627    fn render(&self) -> impl IntoElement {
628        let elements = parse_markdown(&self.content);
629
630        let MarkdownViewerTheme {
631            color,
632            color_link,
633            #[cfg(not(feature = "code-editor"))]
634            background_code,
635            #[cfg(feature = "code-editor")]
636                background_code: _,
637            color_code,
638            background_blockquote,
639            border_blockquote,
640            background_divider,
641            heading_h1,
642            heading_h2,
643            heading_h3,
644            heading_h4,
645            heading_h5,
646            heading_h6,
647            paragraph_size,
648            code_font_size,
649            table_font_size,
650        } = get_theme_or_default!(
651            &self.theme,
652            MarkdownViewerThemePreference,
653            "markdown_viewer",
654            markdown_theme_preference
655        );
656
657        let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.);
658
659        for (idx, element) in elements.into_iter().enumerate() {
660            let child: Element = match element {
661                MarkdownElement::Heading { level, spans } => {
662                    let font_size = match level {
663                        HeadingLevel::H1 => heading_h1,
664                        HeadingLevel::H2 => heading_h2,
665                        HeadingLevel::H3 => heading_h3,
666                        HeadingLevel::H4 => heading_h4,
667                        HeadingLevel::H5 => heading_h5,
668                        HeadingLevel::H6 => heading_h6,
669                    };
670                    render_spans(&spans, font_size, color, color_code)
671                        .font_weight(FontWeight::BOLD)
672                        .key(idx)
673                        .into()
674                }
675                MarkdownElement::Paragraph { content } => render_content(
676                    &content,
677                    paragraph_size,
678                    color,
679                    color_link,
680                    color_code,
681                    self.inline_element.as_ref(),
682                )
683                .key(idx)
684                .into(),
685                MarkdownElement::CodeBlock {
686                    code,
687                    #[cfg(feature = "code-editor")]
688                    language,
689                    #[cfg(not(feature = "code-editor"))]
690                        language: _,
691                } => {
692                    #[cfg(feature = "code-editor")]
693                    let element = CodeBlockEditor::new(
694                        move || Cow::Owned(code.clone()),
695                        language,
696                        self.language_resolver.clone(),
697                        code_font_size,
698                        self.code_editor_font_family.clone(),
699                    )
700                    .key(idx)
701                    .into();
702
703                    #[cfg(not(feature = "code-editor"))]
704                    let element = rect()
705                        .key(idx)
706                        .width(Size::fill())
707                        .background(background_code)
708                        .corner_radius(6.)
709                        .padding(Gaps::new_all(12.))
710                        .child(
711                            label()
712                                .text(code)
713                                .font_family(self.code_editor_font_family.clone())
714                                .font_size(code_font_size)
715                                .color(color_code),
716                        )
717                        .into();
718
719                    element
720                }
721                MarkdownElement::UnorderedList { items } => {
722                    let mut list = rect()
723                        .key(idx)
724                        .vertical()
725                        .spacing(4.)
726                        .padding(Gaps::new(0., 0., 0., 20.));
727
728                    for (item_idx, item_spans) in items.into_iter().enumerate() {
729                        let item_content = rect()
730                            .key(item_idx)
731                            .horizontal()
732                            .cross_align(Alignment::Start)
733                            .spacing(8.)
734                            .child(label().text("•").font_size(paragraph_size).color(color))
735                            .child(render_spans(&item_spans, paragraph_size, color, color_code));
736
737                        list = list.child(item_content);
738                    }
739
740                    list.into()
741                }
742                MarkdownElement::OrderedList { start, items } => {
743                    let mut list = rect()
744                        .key(idx)
745                        .vertical()
746                        .spacing(4.)
747                        .padding(Gaps::new(0., 0., 0., 20.));
748
749                    for (item_idx, item_spans) in items.into_iter().enumerate() {
750                        let number = start + item_idx as u64;
751                        let item_content = rect()
752                            .key(item_idx)
753                            .horizontal()
754                            .cross_align(Alignment::Start)
755                            .spacing(8.)
756                            .child(
757                                label()
758                                    .text(format!("{}.", number))
759                                    .font_size(paragraph_size)
760                                    .color(color),
761                            )
762                            .child(render_spans(&item_spans, paragraph_size, color, color_code));
763
764                        list = list.child(item_content);
765                    }
766
767                    list.into()
768                }
769                #[cfg(feature = "remote-asset")]
770                MarkdownElement::Image { url, alt } => match url.parse::<Uri>() {
771                    Ok(uri) => {
772                        let source: ImageSource = uri.into();
773                        ImageViewer::new(source)
774                            .a11y_alt(alt)
775                            .key(idx)
776                            .width(Size::fill())
777                            .height(Size::px(300.))
778                            .into()
779                    }
780                    Err(_) => label()
781                        .key(idx)
782                        .text(format!("[Invalid image URL: {}]", url))
783                        .color(color)
784                        .into(),
785                },
786                #[cfg(not(feature = "remote-asset"))]
787                MarkdownElement::Image { alt, .. } => label()
788                    .key(idx)
789                    .text(format!("[Image: {}]", alt))
790                    .color(color)
791                    .into(),
792                #[cfg(feature = "router")]
793                MarkdownElement::Link { url, title, text } => {
794                    let mut tooltip = LinkTooltip::Default;
795                    if let Some(title) = title
796                        && !title.is_empty()
797                    {
798                        tooltip = LinkTooltip::Custom(title);
799                    }
800
801                    Link::new(url)
802                        .tooltip(tooltip)
803                        .child(render_spans(&text, paragraph_size, color_link, color_code))
804                        .key(idx)
805                        .into()
806                }
807                #[cfg(not(feature = "router"))]
808                MarkdownElement::Link { text, .. } => {
809                    render_spans(&text, paragraph_size, color, color_code)
810                        .key(idx)
811                        .into()
812                }
813                MarkdownElement::Blockquote { spans } => rect()
814                    .key(idx)
815                    .width(Size::fill())
816                    .padding(Gaps::new(12., 12., 12., 16.))
817                    .border(
818                        Border::new()
819                            .width(4.)
820                            .fill(border_blockquote)
821                            .alignment(BorderAlignment::Inner),
822                    )
823                    .background(background_blockquote)
824                    .child(
825                        render_spans(&spans, paragraph_size, color, color_code)
826                            .font_slant(FontSlant::Italic),
827                    )
828                    .into(),
829                MarkdownElement::HorizontalRule => rect()
830                    .key(idx)
831                    .width(Size::fill())
832                    .height(Size::px(1.))
833                    .background(background_divider)
834                    .into(),
835                MarkdownElement::Table { headers, rows } => {
836                    let mut head = TableHead::new();
837                    let mut header_row = TableRow::new();
838                    for (col_idx, header_spans) in headers.into_iter().enumerate() {
839                        header_row = header_row.child(
840                            TableCell::new().key(col_idx).child(
841                                render_spans(&header_spans, table_font_size, color, color_code)
842                                    .font_weight(FontWeight::BOLD),
843                            ),
844                        );
845                    }
846                    head = head.child(header_row);
847
848                    let mut body = TableBody::new();
849                    for (row_idx, row) in rows.into_iter().enumerate() {
850                        let mut table_row = TableRow::new().key(row_idx);
851                        for (col_idx, cell_spans) in row.into_iter().enumerate() {
852                            table_row = table_row.child(TableCell::new().key(col_idx).child(
853                                render_spans(&cell_spans, table_font_size, color, color_code),
854                            ));
855                        }
856                        body = body.child(table_row);
857                    }
858
859                    Table::new().key(idx).child(head).child(body).into()
860                }
861            };
862
863            container = container.child(child);
864        }
865
866        container
867    }
868
869    fn render_key(&self) -> DiffKey {
870        self.key.clone().or(self.default_key())
871    }
872}