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