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