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