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::Url;
8#[cfg(feature = "remote-asset")]
9use freya_components::image_viewer::ImageViewer;
10#[cfg(feature = "router")]
11use freya_components::link::{
12    Link,
13    LinkTooltip,
14};
15use freya_components::{
16    define_theme,
17    get_theme_or_default,
18    table::{
19        Table,
20        TableBody,
21        TableCell,
22        TableHead,
23        TableRow,
24    },
25    theming::macros::Preference,
26};
27use freya_core::{
28    elements::rect::Rect,
29    prelude::*,
30};
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    List(List),
212    Image {
213        url: String,
214        alt: String,
215    },
216    Link {
217        url: String,
218        title: Option<String>,
219        content: Vec<Inline>,
220    },
221    Blockquote {
222        content: Vec<Inline>,
223    },
224    Table {
225        headers: Vec<Vec<TextSpan>>,
226        rows: Vec<Vec<Vec<TextSpan>>>,
227    },
228    HorizontalRule,
229}
230
231/// A markdown list, ordered when `start` is present.
232#[derive(Clone)]
233struct List {
234    start: Option<u64>,
235    items: Vec<ListItem>,
236}
237
238/// A list item's inline content plus the lists nested under it.
239#[derive(Clone)]
240struct ListItem {
241    content: Vec<Inline>,
242    nested_lists: Vec<List>,
243}
244
245/// A piece of a paragraph's content: styled text, an image or an inline link flowing within the text.
246#[derive(Clone)]
247enum Inline {
248    Span(TextSpan),
249    Image {
250        url: String,
251        alt: String,
252    },
253    #[cfg_attr(not(feature = "router"), allow(dead_code))]
254    Link {
255        url: String,
256        title: Option<String>,
257        content: Vec<Inline>,
258    },
259    /// A raw inline HTML tag, resolved at render time by [`MarkdownViewer::inline_element`].
260    Html(String),
261}
262
263/// Represents styled text spans within markdown.
264#[derive(Clone, Debug)]
265struct TextSpan {
266    text: String,
267    bold: bool,
268    italic: bool,
269    #[allow(dead_code)]
270    strikethrough: bool,
271    code: bool,
272}
273
274impl TextSpan {
275    fn new(text: impl Into<String>) -> Self {
276        Self {
277            text: text.into(),
278            bold: false,
279            italic: false,
280            strikethrough: false,
281            code: false,
282        }
283    }
284}
285
286fn parse_markdown(content: &str) -> Vec<MarkdownElement> {
287    let mut options = Options::empty();
288    options.insert(Options::ENABLE_STRIKETHROUGH);
289    options.insert(Options::ENABLE_TABLES);
290
291    let parser = Parser::new_ext(content, options);
292    let mut elements = Vec::new();
293    let mut current_spans: Vec<TextSpan> = Vec::new();
294    let mut current_content: Vec<Inline> = Vec::new();
295    let mut list_stack: Vec<List> = Vec::new();
296    let mut item_stack: Vec<ListItem> = Vec::new();
297
298    let mut in_heading: Option<HeadingLevel> = None;
299    let mut in_paragraph = false;
300    let mut in_code_block = false;
301    let mut code_block_content = String::new();
302    let mut code_block_language: Option<String> = None;
303    let mut in_blockquote = false;
304    let mut blockquote_content: Vec<Inline> = Vec::new();
305
306    let mut in_table_cell = false;
307    let mut table_headers: Vec<Vec<TextSpan>> = Vec::new();
308    let mut table_rows: Vec<Vec<Vec<TextSpan>>> = Vec::new();
309    let mut current_table_row: Vec<Vec<TextSpan>> = Vec::new();
310    let mut current_cell_spans: Vec<TextSpan> = Vec::new();
311
312    let mut in_link = false;
313    let mut link_url: Option<String> = None;
314    let mut link_title: Option<String> = None;
315    let mut link_content: Vec<Inline> = Vec::new();
316
317    let mut in_image = false;
318    let mut image_url = String::new();
319    let mut image_title = String::new();
320    let mut image_alt = String::new();
321
322    let mut bold = false;
323    let mut italic = false;
324    let mut strikethrough = false;
325
326    for event in parser {
327        match event {
328            Event::Start(tag) => match tag {
329                Tag::Heading { level, .. } => {
330                    in_heading = Some(level);
331                    current_spans.clear();
332                }
333                Tag::Paragraph => {
334                    if in_blockquote {
335                        // Paragraphs inside blockquotes
336                    } else if !item_stack.is_empty() {
337                        // Paragraphs inside list items
338                    } else {
339                        in_paragraph = true;
340                        current_spans.clear();
341                        current_content.clear();
342                    }
343                }
344                Tag::CodeBlock(kind) => {
345                    in_code_block = true;
346                    code_block_content.clear();
347                    code_block_language = match kind {
348                        pulldown_cmark::CodeBlockKind::Fenced(lang) => {
349                            let lang_str = lang.to_string();
350                            if lang_str.is_empty() {
351                                None
352                            } else {
353                                Some(lang_str)
354                            }
355                        }
356                        pulldown_cmark::CodeBlockKind::Indented => None,
357                    };
358                }
359                Tag::List(start) => {
360                    list_stack.push(List {
361                        start,
362                        items: Vec::new(),
363                    });
364                }
365                Tag::Item => {
366                    item_stack.push(ListItem {
367                        content: Vec::new(),
368                        nested_lists: Vec::new(),
369                    });
370                }
371                Tag::Strong => bold = true,
372                Tag::Emphasis => italic = true,
373                Tag::Strikethrough => strikethrough = true,
374                Tag::BlockQuote(_) => {
375                    in_blockquote = true;
376                    blockquote_content.clear();
377                }
378                Tag::Image {
379                    dest_url, title, ..
380                } => {
381                    in_image = true;
382                    image_url = dest_url.to_string();
383                    image_title = title.to_string();
384                    image_alt.clear();
385                }
386                Tag::Link {
387                    dest_url, title, ..
388                } => {
389                    in_link = true;
390                    link_url = Some(dest_url.to_string());
391                    link_title = Some(title.to_string());
392                    link_content.clear();
393                }
394                Tag::Table(_) => {
395                    table_headers.clear();
396                    table_rows.clear();
397                    current_table_row.clear();
398                }
399                Tag::TableHead => {}
400                Tag::TableRow => {
401                    current_table_row.clear();
402                }
403                Tag::TableCell => {
404                    in_table_cell = true;
405                    current_cell_spans.clear();
406                }
407                _ => {}
408            },
409            Event::End(tag_end) => match tag_end {
410                TagEnd::Heading(_) => {
411                    if let Some(level) = in_heading.take() {
412                        elements.push(MarkdownElement::Heading {
413                            level,
414                            spans: mem::take(&mut current_spans),
415                        });
416                    }
417                }
418                TagEnd::Paragraph => {
419                    if in_blockquote {
420                        blockquote_content.extend(current_spans.drain(..).map(Inline::Span))
421                    } else if let Some(item) = item_stack.last_mut() {
422                        item.content
423                            .extend(current_spans.drain(..).map(Inline::Span))
424                    } else if in_paragraph {
425                        in_paragraph = false;
426                        current_content.extend(current_spans.drain(..).map(Inline::Span));
427                        elements.push(MarkdownElement::Paragraph {
428                            content: mem::take(&mut current_content),
429                        });
430                    }
431                }
432                TagEnd::CodeBlock => {
433                    in_code_block = false;
434                    elements.push(MarkdownElement::CodeBlock {
435                        code: mem::take(&mut code_block_content),
436                        language: code_block_language.take(),
437                    });
438                }
439                TagEnd::List(_) => {
440                    if let Some(list) = list_stack.pop() {
441                        if let Some(item) = item_stack.last_mut() {
442                            item.nested_lists.push(list);
443                        } else {
444                            elements.push(MarkdownElement::List(list));
445                        }
446                    }
447                }
448                TagEnd::Item => {
449                    if let (Some(item), Some(list)) = (item_stack.pop(), list_stack.last_mut()) {
450                        list.items.push(item);
451                    }
452                }
453                TagEnd::Strong => bold = false,
454                TagEnd::Emphasis => italic = false,
455                TagEnd::Strikethrough => strikethrough = false,
456                TagEnd::BlockQuote(_) => {
457                    in_blockquote = false;
458                    elements.push(MarkdownElement::Blockquote {
459                        content: mem::take(&mut blockquote_content),
460                    });
461                }
462                TagEnd::Table => {
463                    elements.push(MarkdownElement::Table {
464                        headers: mem::take(&mut table_headers),
465                        rows: mem::take(&mut table_rows),
466                    });
467                }
468                TagEnd::TableHead => {
469                    // TableHead contains cells directly (no TableRow), so save headers here
470                    table_headers = mem::take(&mut current_table_row);
471                }
472                TagEnd::TableRow => {
473                    // TableRow only appears in body rows, not in TableHead
474                    table_rows.push(mem::take(&mut current_table_row));
475                }
476                TagEnd::TableCell => {
477                    in_table_cell = false;
478                    current_table_row.push(mem::take(&mut current_cell_spans));
479                }
480                TagEnd::Image => {
481                    in_image = false;
482                    let url = mem::take(&mut image_url);
483                    let alt = if image_alt.is_empty() {
484                        mem::take(&mut image_title)
485                    } else {
486                        mem::take(&mut image_alt)
487                    };
488                    if in_link {
489                        link_content.push(Inline::Image { url, alt });
490                    } else if in_blockquote {
491                        blockquote_content.push(Inline::Image { url, alt });
492                    } else if let Some(item) = item_stack.last_mut() {
493                        item.content.push(Inline::Image { url, alt });
494                    } else if in_paragraph {
495                        current_content.extend(current_spans.drain(..).map(Inline::Span));
496                        current_content.push(Inline::Image { url, alt });
497                    } else {
498                        elements.push(MarkdownElement::Image { url, alt });
499                    }
500                }
501                TagEnd::Link => {
502                    in_link = false;
503                    if let Some(url) = link_url.take() {
504                        let title = link_title.take();
505                        let content = mem::take(&mut link_content);
506                        if in_blockquote {
507                            blockquote_content.push(Inline::Link {
508                                url,
509                                title,
510                                content,
511                            });
512                        } else if let Some(item) = item_stack.last_mut() {
513                            item.content.push(Inline::Link {
514                                url,
515                                title,
516                                content,
517                            });
518                        } else if in_paragraph {
519                            current_content.extend(current_spans.drain(..).map(Inline::Span));
520                            current_content.push(Inline::Link {
521                                url,
522                                title,
523                                content,
524                            });
525                        } else {
526                            elements.push(MarkdownElement::Link {
527                                url,
528                                title,
529                                content,
530                            });
531                        }
532                    }
533                }
534                _ => {}
535            },
536            Event::Text(text) => {
537                if in_code_block {
538                    code_block_content.push_str(text.trim());
539                } else if in_image {
540                    image_alt.push_str(&text);
541                } else if in_table_cell {
542                    let span = TextSpan {
543                        text: text.to_string(),
544                        bold,
545                        italic,
546                        strikethrough,
547                        code: false,
548                    };
549                    current_cell_spans.push(span);
550                } else {
551                    let span = TextSpan {
552                        text: text.to_string(),
553                        bold,
554                        italic,
555                        strikethrough,
556                        code: false,
557                    };
558                    if in_link {
559                        link_content.push(Inline::Span(span));
560                    } else if in_blockquote && !in_paragraph {
561                        blockquote_content.push(Inline::Span(span));
562                    } else if let Some(item) = item_stack.last_mut()
563                        && !in_paragraph
564                    {
565                        item.content.push(Inline::Span(span));
566                    } else {
567                        current_spans.push(span);
568                    }
569                }
570            }
571            Event::Code(code) => {
572                if in_image {
573                    image_alt.push_str(&code);
574                    continue;
575                }
576                let span = TextSpan {
577                    text: code.to_string(),
578                    bold,
579                    italic,
580                    strikethrough,
581                    code: true,
582                };
583                if in_table_cell {
584                    current_cell_spans.push(span);
585                } else if in_link {
586                    link_content.push(Inline::Span(span));
587                } else if in_blockquote {
588                    blockquote_content.push(Inline::Span(span));
589                } else if let Some(item) = item_stack.last_mut() {
590                    item.content.push(Inline::Span(span));
591                } else {
592                    current_spans.push(span);
593                }
594            }
595            Event::SoftBreak | Event::HardBreak => {
596                if in_image {
597                    image_alt.push(' ');
598                    continue;
599                }
600                let span = TextSpan::new(" ");
601                if in_link {
602                    link_content.push(Inline::Span(span));
603                } else if in_blockquote {
604                    blockquote_content.push(Inline::Span(span));
605                } else if let Some(item) = item_stack.last_mut() {
606                    item.content.push(Inline::Span(span));
607                } else {
608                    current_spans.push(span);
609                }
610            }
611            Event::InlineHtml(html) => {
612                if in_paragraph && !in_link {
613                    current_content.extend(current_spans.drain(..).map(Inline::Span));
614                    current_content.push(Inline::Html(html.to_string()));
615                }
616            }
617            Event::Rule => {
618                elements.push(MarkdownElement::HorizontalRule);
619            }
620            _ => {}
621        }
622    }
623
624    elements
625}
626
627/// Build a styled [Span] from a markdown text span.
628fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'static> {
629    let mut styled = Span::new(span.text.clone());
630    if span.bold {
631        styled = styled.font_weight(FontWeight::BOLD);
632    }
633    if span.italic {
634        styled = styled.font_slant(FontSlant::Italic);
635    }
636    if span.code {
637        styled.font_family("monospace").color(code_color)
638    } else {
639        styled.color(text_color)
640    }
641}
642
643/// Render text spans as a paragraph element.
644fn render_spans(
645    spans: &[TextSpan],
646    base_font_size: f32,
647    text_color: Color,
648    code_color: Color,
649) -> Paragraph {
650    paragraph().font_size(base_font_size).spans_iter(
651        spans
652            .iter()
653            .map(|span| styled_span(span, text_color, code_color)),
654    )
655}
656
657/// Render a list and, recursively, the lists nested under its items.
658fn render_list(
659    list: &List,
660    paragraph_size: f32,
661    color: Color,
662    color_link: Color,
663    color_code: Color,
664    inline_element: Option<&Callback<String, Option<Element>>>,
665) -> Rect {
666    rect()
667        .vertical()
668        .spacing(4.)
669        .padding(Gaps::new(0., 0., 0., 20.))
670        .children(list.items.iter().enumerate().map(|(item_idx, item)| {
671            rect()
672                .key(item_idx)
673                .horizontal()
674                .cross_align(Alignment::Start)
675                .spacing(8.)
676                .child(
677                    label()
678                        .text(match list.start {
679                            Some(start) => format!("{}.", start + item_idx as u64),
680                            None => "•".to_string(),
681                        })
682                        .font_size(paragraph_size)
683                        .color(color),
684                )
685                .child(
686                    rect()
687                        .vertical()
688                        .spacing(4.)
689                        .child(render_content(
690                            &item.content,
691                            paragraph_size,
692                            color,
693                            color_link,
694                            color_code,
695                            inline_element,
696                        ))
697                        .children(item.nested_lists.iter().map(|nested_list| {
698                            render_list(
699                                nested_list,
700                                paragraph_size,
701                                color,
702                                color_link,
703                                color_code,
704                                inline_element,
705                            )
706                            .into()
707                        })),
708                )
709                .into()
710        }))
711}
712
713/// Render a markdown image.
714#[cfg(feature = "remote-asset")]
715fn render_image(url: &str, alt: &str, text_color: Color) -> Element {
716    match url.parse::<Url>() {
717        Ok(uri) => ImageViewer::new(uri)
718            .a11y_alt(alt)
719            .aspect_ratio(AspectRatio::Fit)
720            .into(),
721        Err(_) => label()
722            .text(format!("[Invalid image URL: {}]", url))
723            .color(text_color)
724            .into(),
725    }
726}
727
728/// Render a markdown image as its alt text when remote assets are disabled.
729#[cfg(not(feature = "remote-asset"))]
730fn render_image(_url: &str, alt: &str, text_color: Color) -> Element {
731    label()
732        .text(format!("[Image: {}]", alt))
733        .color(text_color)
734        .into()
735}
736
737/// Render a paragraph's content, flowing inline links (colored with `link_color`) and images
738/// between the text.
739fn render_content(
740    content: &[Inline],
741    base_font_size: f32,
742    text_color: Color,
743    link_color: Color,
744    code_color: Color,
745    inline_element: Option<&Callback<String, Option<Element>>>,
746) -> Paragraph {
747    let mut result = paragraph().font_size(base_font_size);
748    for item in content {
749        result = match item {
750            Inline::Span(span) => result.span(styled_span(span, text_color, code_color)),
751            Inline::Image { url, alt } => result.child(render_image(url, alt, text_color)),
752            Inline::Html(raw) => {
753                match inline_element.and_then(|handler| handler.call(raw.clone())) {
754                    Some(element) => result.child(element),
755                    None => result.span(Span::new(raw.clone()).color(text_color)),
756                }
757            }
758            #[cfg(feature = "router")]
759            Inline::Link {
760                url,
761                title,
762                content,
763            } => {
764                let mut tooltip = LinkTooltip::Default;
765                if let Some(title) = title
766                    && !title.is_empty()
767                {
768                    tooltip = LinkTooltip::Custom(title.clone());
769                }
770                result.child(
771                    Link::new(url.clone())
772                        .tooltip(tooltip)
773                        .child(render_content(
774                            content,
775                            base_font_size,
776                            link_color,
777                            link_color,
778                            code_color,
779                            inline_element,
780                        )),
781                )
782            }
783            #[cfg(not(feature = "router"))]
784            Inline::Link { content, .. } => {
785                content.iter().fold(result, |paragraph, item| match item {
786                    Inline::Span(span) => paragraph.span(styled_span(span, link_color, code_color)),
787                    Inline::Image { url, alt } => {
788                        paragraph.child(render_image(url, alt, text_color))
789                    }
790                    _ => paragraph,
791                })
792            }
793        };
794    }
795    result
796}
797
798impl Component for MarkdownViewer {
799    fn render(&self) -> impl IntoElement {
800        let elements = parse_markdown(&self.content);
801
802        let MarkdownViewerTheme {
803            color,
804            color_link,
805            #[cfg(not(feature = "code-editor"))]
806            background_code,
807            #[cfg(feature = "code-editor")]
808                background_code: _,
809            color_code,
810            background_blockquote,
811            border_blockquote,
812            background_divider,
813            heading_h1,
814            heading_h2,
815            heading_h3,
816            heading_h4,
817            heading_h5,
818            heading_h6,
819            paragraph_size,
820            code_font_size,
821            table_font_size,
822        } = get_theme_or_default!(
823            &self.theme,
824            MarkdownViewerThemePreference,
825            "markdown_viewer",
826            markdown_theme_preference
827        );
828
829        let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.);
830
831        for (idx, element) in elements.into_iter().enumerate() {
832            let child: Element = match element {
833                MarkdownElement::Heading { level, spans } => {
834                    let font_size = match level {
835                        HeadingLevel::H1 => heading_h1,
836                        HeadingLevel::H2 => heading_h2,
837                        HeadingLevel::H3 => heading_h3,
838                        HeadingLevel::H4 => heading_h4,
839                        HeadingLevel::H5 => heading_h5,
840                        HeadingLevel::H6 => heading_h6,
841                    };
842                    render_spans(&spans, font_size, color, color_code)
843                        .font_weight(FontWeight::BOLD)
844                        .key(idx)
845                        .into()
846                }
847                MarkdownElement::Paragraph { content } => render_content(
848                    &content,
849                    paragraph_size,
850                    color,
851                    color_link,
852                    color_code,
853                    self.inline_element.as_ref(),
854                )
855                .key(idx)
856                .into(),
857                MarkdownElement::CodeBlock {
858                    code,
859                    #[cfg(feature = "code-editor")]
860                    language,
861                    #[cfg(not(feature = "code-editor"))]
862                        language: _,
863                } => {
864                    #[cfg(feature = "code-editor")]
865                    let element = CodeBlockEditor::new(
866                        move || Cow::Owned(code.clone()),
867                        language,
868                        self.language_resolver.clone(),
869                        code_font_size,
870                        self.code_editor_font_family.clone(),
871                    )
872                    .key(idx)
873                    .into();
874
875                    #[cfg(not(feature = "code-editor"))]
876                    let element = rect()
877                        .key(idx)
878                        .width(Size::fill())
879                        .background(background_code)
880                        .corner_radius(6.)
881                        .padding(Gaps::new_all(12.))
882                        .child(
883                            label()
884                                .text(code)
885                                .font_family(self.code_editor_font_family.clone())
886                                .font_size(code_font_size)
887                                .color(color_code),
888                        )
889                        .into();
890
891                    element
892                }
893                MarkdownElement::List(list) => render_list(
894                    &list,
895                    paragraph_size,
896                    color,
897                    color_link,
898                    color_code,
899                    self.inline_element.as_ref(),
900                )
901                .key(idx)
902                .into(),
903                MarkdownElement::Image { url, alt } => rect()
904                    .key(idx)
905                    .child(render_image(&url, &alt, color))
906                    .into(),
907                #[cfg(feature = "router")]
908                MarkdownElement::Link {
909                    url,
910                    title,
911                    content,
912                } => {
913                    let mut tooltip = LinkTooltip::Default;
914                    if let Some(title) = title
915                        && !title.is_empty()
916                    {
917                        tooltip = LinkTooltip::Custom(title);
918                    }
919
920                    Link::new(url)
921                        .tooltip(tooltip)
922                        .child(render_content(
923                            &content,
924                            paragraph_size,
925                            color_link,
926                            color_link,
927                            color_code,
928                            self.inline_element.as_ref(),
929                        ))
930                        .key(idx)
931                        .into()
932                }
933                #[cfg(not(feature = "router"))]
934                MarkdownElement::Link { content, .. } => render_content(
935                    &content,
936                    paragraph_size,
937                    color,
938                    color_link,
939                    color_code,
940                    self.inline_element.as_ref(),
941                )
942                .key(idx)
943                .into(),
944                MarkdownElement::Blockquote { content } => rect()
945                    .key(idx)
946                    .width(Size::fill())
947                    .padding(Gaps::new(12., 12., 12., 16.))
948                    .border(
949                        Border::new()
950                            .width(4.)
951                            .fill(border_blockquote)
952                            .alignment(BorderAlignment::Inner),
953                    )
954                    .background(background_blockquote)
955                    .child(
956                        render_content(
957                            &content,
958                            paragraph_size,
959                            color,
960                            color_link,
961                            color_code,
962                            self.inline_element.as_ref(),
963                        )
964                        .font_slant(FontSlant::Italic),
965                    )
966                    .into(),
967                MarkdownElement::HorizontalRule => rect()
968                    .key(idx)
969                    .width(Size::fill())
970                    .height(Size::px(1.))
971                    .background(background_divider)
972                    .into(),
973                MarkdownElement::Table { headers, rows } => {
974                    let mut head = TableHead::new();
975                    let mut header_row = TableRow::new();
976                    for (col_idx, header_spans) in headers.into_iter().enumerate() {
977                        header_row = header_row.child(
978                            TableCell::new().key(col_idx).child(
979                                render_spans(&header_spans, table_font_size, color, color_code)
980                                    .font_weight(FontWeight::BOLD),
981                            ),
982                        );
983                    }
984                    head = head.child(header_row);
985
986                    let mut body = TableBody::new();
987                    for (row_idx, row) in rows.into_iter().enumerate() {
988                        let mut table_row = TableRow::new().key(row_idx);
989                        for (col_idx, cell_spans) in row.into_iter().enumerate() {
990                            table_row = table_row.child(TableCell::new().key(col_idx).child(
991                                render_spans(&cell_spans, table_font_size, color, color_code),
992                            ));
993                        }
994                        body = body.child(table_row);
995                    }
996
997                    Table::new().key(idx).child(head).child(body).into()
998                }
999            };
1000
1001            container = container.child(child);
1002        }
1003
1004        container
1005    }
1006
1007    fn render_key(&self) -> DiffKey {
1008        self.key.clone().or(self.default_key())
1009    }
1010}