freya_components/
markdown.rs

1use std::borrow::Cow;
2
3use freya_core::prelude::*;
4use pulldown_cmark::{
5    Event,
6    HeadingLevel,
7    Options,
8    Parser,
9    Tag,
10    TagEnd,
11};
12use torin::prelude::*;
13
14#[cfg(feature = "remote-asset")]
15use crate::Uri;
16#[cfg(feature = "remote-asset")]
17use crate::image_viewer::{
18    ImageSource,
19    ImageViewer,
20};
21#[cfg(feature = "router")]
22use crate::link::{
23    Link,
24    LinkTooltip,
25};
26use crate::{
27    table::{
28        Table,
29        TableBody,
30        TableCell,
31        TableHead,
32        TableRow,
33    },
34    theming::component_themes::MarkdownViewerTheme,
35};
36
37/// Markdown viewer component.
38///
39/// Renders markdown content with support for:
40/// - Headings (h1-h6)
41/// - Paragraphs
42/// - Bold, italic, and strikethrough text
43/// - Code (inline and blocks)
44/// - Lists (ordered and unordered)
45/// - Tables
46/// - Images
47/// - Links
48/// - Blockquotes
49/// - Horizontal rules
50///
51/// # Example
52///
53/// ```rust
54/// # use freya::prelude::*;
55/// fn app() -> impl IntoElement {
56///     MarkdownViewer::new("# Hello World\n\nThis is **bold** and *italic* text.")
57/// }
58/// ```
59#[derive(PartialEq)]
60pub struct MarkdownViewer {
61    content: Cow<'static, str>,
62    layout: LayoutData,
63    key: DiffKey,
64    pub(crate) theme: Option<crate::theming::component_themes::MarkdownViewerThemePartial>,
65}
66
67impl MarkdownViewer {
68    pub fn new(content: impl Into<Cow<'static, str>>) -> Self {
69        Self {
70            content: content.into(),
71            layout: LayoutData::default(),
72            key: DiffKey::None,
73            theme: None,
74        }
75    }
76
77    pub fn key(mut self, key: impl Into<DiffKey>) -> Self {
78        self.key = key.into();
79        self
80    }
81}
82
83impl KeyExt for MarkdownViewer {
84    fn write_key(&mut self) -> &mut DiffKey {
85        &mut self.key
86    }
87}
88
89impl LayoutExt for MarkdownViewer {
90    fn get_layout(&mut self) -> &mut LayoutData {
91        &mut self.layout
92    }
93}
94
95impl ContainerExt for MarkdownViewer {}
96
97/// Represents different markdown elements for rendering.
98#[derive(Clone)]
99enum MarkdownElement {
100    Heading {
101        level: HeadingLevel,
102        spans: Vec<TextSpan>,
103    },
104    Paragraph {
105        spans: Vec<TextSpan>,
106    },
107    CodeBlock {
108        code: String,
109        #[allow(dead_code)]
110        language: Option<String>,
111    },
112    UnorderedList {
113        items: Vec<Vec<TextSpan>>,
114    },
115    OrderedList {
116        start: u64,
117        items: Vec<Vec<TextSpan>>,
118    },
119    Image {
120        #[cfg_attr(not(feature = "remote-asset"), allow(dead_code))]
121        url: String,
122        alt: String,
123    },
124    Link {
125        url: String,
126        title: Option<String>,
127        text: Vec<TextSpan>,
128    },
129    Blockquote {
130        spans: Vec<TextSpan>,
131    },
132    Table {
133        headers: Vec<Vec<TextSpan>>,
134        rows: Vec<Vec<Vec<TextSpan>>>,
135    },
136    HorizontalRule,
137}
138
139/// Represents styled text spans within markdown.
140#[derive(Clone, Debug)]
141struct TextSpan {
142    text: String,
143    bold: bool,
144    italic: bool,
145    #[allow(dead_code)]
146    strikethrough: bool,
147    code: bool,
148}
149
150impl TextSpan {
151    fn new(text: impl Into<String>) -> Self {
152        Self {
153            text: text.into(),
154            bold: false,
155            italic: false,
156            strikethrough: false,
157            code: false,
158        }
159    }
160}
161
162/// Parse markdown content into a list of elements.
163fn parse_markdown(content: &str) -> Vec<MarkdownElement> {
164    let mut options = Options::empty();
165    options.insert(Options::ENABLE_STRIKETHROUGH);
166    options.insert(Options::ENABLE_TABLES);
167
168    let parser = Parser::new_ext(content, options);
169    let mut elements = Vec::new();
170    let mut current_spans: Vec<TextSpan> = Vec::new();
171    let mut list_items: Vec<Vec<TextSpan>> = Vec::new();
172    let mut current_list_item: Vec<TextSpan> = Vec::new();
173
174    let mut in_heading: Option<HeadingLevel> = None;
175    let mut in_paragraph = false;
176    let mut in_code_block = false;
177    let mut code_block_content = String::new();
178    let mut code_block_language: Option<String> = None;
179    let mut ordered_list_start: Option<u64> = None;
180    let mut in_list_item = false;
181    let mut in_blockquote = false;
182    let mut blockquote_spans: Vec<TextSpan> = Vec::new();
183
184    let mut in_table_cell = false;
185    let mut table_headers: Vec<Vec<TextSpan>> = Vec::new();
186    let mut table_rows: Vec<Vec<Vec<TextSpan>>> = Vec::new();
187    let mut current_table_row: Vec<Vec<TextSpan>> = Vec::new();
188    let mut current_cell_spans: Vec<TextSpan> = Vec::new();
189
190    let mut in_link = false;
191    let mut link_url: Option<String> = None;
192    let mut link_title: Option<String> = None;
193    let mut link_spans: Vec<TextSpan> = Vec::new();
194
195    let mut bold = false;
196    let mut italic = false;
197    let mut strikethrough = false;
198
199    for event in parser {
200        match event {
201            Event::Start(tag) => match tag {
202                Tag::Heading { level, .. } => {
203                    in_heading = Some(level);
204                    current_spans.clear();
205                }
206                Tag::Paragraph => {
207                    if in_blockquote {
208                        // Paragraphs inside blockquotes
209                    } else if in_list_item {
210                        // Paragraphs inside list items
211                    } else {
212                        in_paragraph = true;
213                        current_spans.clear();
214                    }
215                }
216                Tag::CodeBlock(kind) => {
217                    in_code_block = true;
218                    code_block_content.clear();
219                    code_block_language = match kind {
220                        pulldown_cmark::CodeBlockKind::Fenced(lang) => {
221                            let lang_str = lang.to_string();
222                            if lang_str.is_empty() {
223                                None
224                            } else {
225                                Some(lang_str)
226                            }
227                        }
228                        pulldown_cmark::CodeBlockKind::Indented => None,
229                    };
230                }
231                Tag::List(start) => {
232                    ordered_list_start = start;
233                    list_items.clear();
234                }
235                Tag::Item => {
236                    in_list_item = true;
237                    current_list_item.clear();
238                }
239                Tag::Strong => bold = true,
240                Tag::Emphasis => italic = true,
241                Tag::Strikethrough => strikethrough = true,
242                Tag::BlockQuote(_) => {
243                    in_blockquote = true;
244                    blockquote_spans.clear();
245                }
246                Tag::Image {
247                    dest_url, title, ..
248                } => {
249                    elements.push(MarkdownElement::Image {
250                        url: dest_url.to_string(),
251                        alt: title.to_string(),
252                    });
253                }
254                Tag::Link {
255                    dest_url, title, ..
256                } => {
257                    in_link = true;
258                    link_url = Some(dest_url.to_string());
259                    link_title = Some(title.to_string());
260                    link_spans.clear();
261                }
262                Tag::Table(_) => {
263                    table_headers.clear();
264                    table_rows.clear();
265                    current_table_row.clear();
266                }
267                Tag::TableHead => {}
268                Tag::TableRow => {
269                    current_table_row.clear();
270                }
271                Tag::TableCell => {
272                    in_table_cell = true;
273                    current_cell_spans.clear();
274                }
275                _ => {}
276            },
277            Event::End(tag_end) => match tag_end {
278                TagEnd::Heading(_) => {
279                    if let Some(level) = in_heading.take() {
280                        elements.push(MarkdownElement::Heading {
281                            level,
282                            spans: current_spans.clone(),
283                        });
284                    }
285                }
286                TagEnd::Paragraph => {
287                    if in_blockquote {
288                        blockquote_spans.append(&mut current_spans)
289                    } else if in_list_item {
290                        current_list_item.append(&mut current_spans)
291                    } else if in_paragraph {
292                        in_paragraph = false;
293                        elements.push(MarkdownElement::Paragraph {
294                            spans: current_spans.clone(),
295                        });
296                    }
297                }
298                TagEnd::CodeBlock => {
299                    in_code_block = false;
300                    elements.push(MarkdownElement::CodeBlock {
301                        code: code_block_content.clone(),
302                        language: code_block_language.take(),
303                    });
304                }
305                TagEnd::List(_) => {
306                    if let Some(start) = ordered_list_start.take() {
307                        elements.push(MarkdownElement::OrderedList {
308                            start,
309                            items: list_items.clone(),
310                        });
311                    } else {
312                        elements.push(MarkdownElement::UnorderedList {
313                            items: list_items.clone(),
314                        });
315                    }
316                }
317                TagEnd::Item => {
318                    in_list_item = false;
319                    list_items.push(current_list_item.clone());
320                }
321                TagEnd::Strong => bold = false,
322                TagEnd::Emphasis => italic = false,
323                TagEnd::Strikethrough => strikethrough = false,
324                TagEnd::BlockQuote(_) => {
325                    in_blockquote = false;
326                    elements.push(MarkdownElement::Blockquote {
327                        spans: blockquote_spans.clone(),
328                    });
329                }
330                TagEnd::Table => {
331                    elements.push(MarkdownElement::Table {
332                        headers: table_headers.clone(),
333                        rows: table_rows.clone(),
334                    });
335                }
336                TagEnd::TableHead => {
337                    // TableHead contains cells directly (no TableRow), so save headers here
338                    table_headers = current_table_row.clone();
339                    current_table_row.clear();
340                }
341                TagEnd::TableRow => {
342                    // TableRow only appears in body rows, not in TableHead
343                    table_rows.push(current_table_row.clone());
344                    current_table_row.clear();
345                }
346                TagEnd::TableCell => {
347                    in_table_cell = false;
348                    current_table_row.push(current_cell_spans.clone());
349                }
350                TagEnd::Link => {
351                    in_link = false;
352                    if let Some(url) = link_url.take() {
353                        elements.push(MarkdownElement::Link {
354                            url,
355                            title: link_title.take(),
356                            text: link_spans.clone(),
357                        });
358                    }
359                }
360                _ => {}
361            },
362            Event::Text(text) => {
363                if in_code_block {
364                    code_block_content.push_str(text.trim());
365                } else if in_table_cell {
366                    let span = TextSpan {
367                        text: text.to_string(),
368                        bold,
369                        italic,
370                        strikethrough,
371                        code: false,
372                    };
373                    current_cell_spans.push(span);
374                } else {
375                    let span = TextSpan {
376                        text: text.to_string(),
377                        bold,
378                        italic,
379                        strikethrough,
380                        code: false,
381                    };
382                    if in_blockquote && !in_paragraph {
383                        blockquote_spans.push(span);
384                    } else if in_list_item && !in_paragraph {
385                        current_list_item.push(span);
386                    } else if in_link {
387                        link_spans.push(span);
388                    } else {
389                        current_spans.push(span);
390                    }
391                }
392            }
393            Event::Code(code) => {
394                let span = TextSpan {
395                    text: code.to_string(),
396                    bold,
397                    italic,
398                    strikethrough,
399                    code: true,
400                };
401                if in_table_cell {
402                    current_cell_spans.push(span);
403                } else if in_blockquote {
404                    blockquote_spans.push(span);
405                } else if in_list_item {
406                    current_list_item.push(span);
407                } else if in_link {
408                    link_spans.push(span);
409                } else {
410                    current_spans.push(span);
411                }
412            }
413            Event::SoftBreak | Event::HardBreak => {
414                let span = TextSpan::new(" ");
415                if in_blockquote {
416                    blockquote_spans.push(span);
417                } else if in_list_item {
418                    current_list_item.push(span);
419                } else if in_link {
420                    link_spans.push(span);
421                } else {
422                    current_spans.push(span);
423                }
424            }
425            Event::Rule => {
426                elements.push(MarkdownElement::HorizontalRule);
427            }
428            _ => {}
429        }
430    }
431
432    elements
433}
434
435/// Render text spans as a paragraph element.
436fn render_spans(spans: &[TextSpan], base_font_size: f32, code_color: Option<Color>) -> Paragraph {
437    let mut p = paragraph().font_size(base_font_size);
438
439    for span in spans {
440        let mut s = Span::new(span.text.clone());
441
442        if span.bold {
443            s = s.font_weight(FontWeight::BOLD);
444        }
445
446        if span.italic {
447            s = s.font_slant(FontSlant::Italic);
448        }
449
450        if span.code {
451            s = s.font_family("monospace");
452            if let Some(c) = code_color {
453                s = s.color(c);
454            }
455        }
456
457        p = p.span(s);
458    }
459
460    p
461}
462
463impl Component for MarkdownViewer {
464    fn render(&self) -> impl IntoElement {
465        let elements = parse_markdown(&self.content);
466
467        let MarkdownViewerTheme {
468            color,
469            background_code,
470            color_code,
471            background_blockquote,
472            border_blockquote,
473            background_divider,
474            heading_h1,
475            heading_h2,
476            heading_h3,
477            heading_h4,
478            heading_h5,
479            heading_h6,
480            paragraph_size,
481            code_font_size,
482            table_font_size,
483        } = crate::get_theme!(&self.theme, markdown_viewer);
484
485        let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.);
486
487        for (idx, element) in elements.into_iter().enumerate() {
488            let child: Element = match element {
489                MarkdownElement::Heading { level, spans } => {
490                    let font_size = match level {
491                        HeadingLevel::H1 => heading_h1,
492                        HeadingLevel::H2 => heading_h2,
493                        HeadingLevel::H3 => heading_h3,
494                        HeadingLevel::H4 => heading_h4,
495                        HeadingLevel::H5 => heading_h5,
496                        HeadingLevel::H6 => heading_h6,
497                    };
498                    render_spans(&spans, font_size, Some(color))
499                        .font_weight(FontWeight::BOLD)
500                        .key(idx)
501                        .into()
502                }
503                MarkdownElement::Paragraph { spans } => {
504                    render_spans(&spans, paragraph_size, Some(color))
505                        .key(idx)
506                        .into()
507                }
508                MarkdownElement::CodeBlock { code, .. } => rect()
509                    .key(idx)
510                    .width(Size::fill())
511                    .background(background_code)
512                    .corner_radius(6.)
513                    .padding(Gaps::new_all(12.))
514                    .child(
515                        label()
516                            .text(code)
517                            .font_family("monospace")
518                            .font_size(code_font_size)
519                            .color(color_code),
520                    )
521                    .into(),
522                MarkdownElement::UnorderedList { items } => {
523                    let mut list = rect()
524                        .key(idx)
525                        .vertical()
526                        .spacing(4.)
527                        .padding(Gaps::new(0., 0., 0., 20.));
528
529                    for (item_idx, item_spans) in items.into_iter().enumerate() {
530                        let item_content = rect()
531                            .key(item_idx)
532                            .horizontal()
533                            .cross_align(Alignment::Start)
534                            .spacing(8.)
535                            .child(label().text("•").font_size(paragraph_size))
536                            .child(render_spans(&item_spans, paragraph_size, Some(color_code)));
537
538                        list = list.child(item_content);
539                    }
540
541                    list.into()
542                }
543                MarkdownElement::OrderedList { start, items } => {
544                    let mut list = rect()
545                        .key(idx)
546                        .vertical()
547                        .spacing(4.)
548                        .padding(Gaps::new(0., 0., 0., 20.));
549
550                    for (item_idx, item_spans) in items.into_iter().enumerate() {
551                        let number = start + item_idx as u64;
552                        let item_content = rect()
553                            .key(item_idx)
554                            .horizontal()
555                            .cross_align(Alignment::Start)
556                            .spacing(8.)
557                            .child(
558                                label()
559                                    .text(format!("{}.", number))
560                                    .font_size(paragraph_size),
561                            )
562                            .child(render_spans(&item_spans, paragraph_size, Some(color_code)));
563
564                        list = list.child(item_content);
565                    }
566
567                    list.into()
568                }
569                #[cfg(feature = "remote-asset")]
570                MarkdownElement::Image { url, alt } => match url.parse::<Uri>() {
571                    Ok(uri) => {
572                        let source: ImageSource = uri.into();
573                        ImageViewer::new(source)
574                            .a11y_alt(alt)
575                            .key(idx)
576                            .width(Size::fill())
577                            .height(Size::px(300.))
578                            .into()
579                    }
580                    Err(_) => label()
581                        .key(idx)
582                        .text(format!("[Invalid image URL: {}]", url))
583                        .into(),
584                },
585                #[cfg(not(feature = "remote-asset"))]
586                MarkdownElement::Image { alt, .. } => {
587                    label().key(idx).text(format!("[Image: {}]", alt)).into()
588                }
589                MarkdownElement::Link { url, title, text } => {
590                    #[cfg(feature = "router")]
591                    {
592                        let mut tooltip = LinkTooltip::Default;
593                        if let Some(title) = title
594                            && !title.is_empty()
595                        {
596                            tooltip = LinkTooltip::Custom(title);
597                        }
598
599                        Link::new(url)
600                            .tooltip(tooltip)
601                            .child(render_spans(&text, paragraph_size, Some(color)))
602                            .key(idx)
603                            .into()
604                    }
605                    #[cfg(not(feature = "router"))]
606                    {
607                        render_spans(&text, paragraph_size, Some(color))
608                            .key(idx)
609                            .into()
610                    }
611                }
612                MarkdownElement::Blockquote { spans } => rect()
613                    .key(idx)
614                    .width(Size::fill())
615                    .padding(Gaps::new(12., 12., 12., 16.))
616                    .border(
617                        Border::new()
618                            .width(4.)
619                            .fill(border_blockquote)
620                            .alignment(BorderAlignment::Inner),
621                    )
622                    .background(background_blockquote)
623                    .child(
624                        render_spans(&spans, paragraph_size, Some(color_code))
625                            .font_slant(FontSlant::Italic),
626                    )
627                    .into(),
628                MarkdownElement::HorizontalRule => rect()
629                    .key(idx)
630                    .width(Size::fill())
631                    .height(Size::px(1.))
632                    .background(background_divider)
633                    .into(),
634                MarkdownElement::Table { headers, rows } => {
635                    let columns = headers.len();
636
637                    let mut head = TableHead::new();
638                    let mut header_row = TableRow::new();
639                    for (col_idx, header_spans) in headers.into_iter().enumerate() {
640                        header_row = header_row.child(
641                            TableCell::new().key(col_idx).child(
642                                render_spans(&header_spans, table_font_size, Some(color_code))
643                                    .font_weight(FontWeight::BOLD),
644                            ),
645                        );
646                    }
647                    head = head.child(header_row);
648
649                    let mut body = TableBody::new();
650                    for (row_idx, row) in rows.into_iter().enumerate() {
651                        let mut table_row = TableRow::new().key(row_idx);
652                        for (col_idx, cell_spans) in row.into_iter().enumerate() {
653                            table_row = table_row.child(TableCell::new().key(col_idx).child(
654                                render_spans(&cell_spans, table_font_size, Some(color_code)),
655                            ));
656                        }
657                        body = body.child(table_row);
658                    }
659
660                    Table::new(columns).key(idx).child(head).child(body).into()
661                }
662            };
663
664            container = container.child(child);
665        }
666
667        container
668    }
669
670    fn render_key(&self) -> DiffKey {
671        self.key.clone().or(self.default_key())
672    }
673}