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