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