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#[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#[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#[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
163fn 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 } else if in_list_item {
211 } 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 table_headers = current_table_row.clone();
340 current_table_row.clear();
341 }
342 TagEnd::TableRow => {
343 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
436fn 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}