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