Skip to main content

freya_components/
markdown.rs

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