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(
452 spans: &[TextSpan],
453 base_font_size: f32,
454 text_color: Color,
455 code_color: Color,
456) -> Paragraph {
457 paragraph()
458 .font_size(base_font_size)
459 .spans_iter(spans.iter().map(|span| {
460 let mut styled = Span::new(span.text.clone());
461 if span.bold {
462 styled = styled.font_weight(FontWeight::BOLD);
463 }
464 if span.italic {
465 styled = styled.font_slant(FontSlant::Italic);
466 }
467 if span.code {
468 styled.font_family("monospace").color(code_color)
469 } else {
470 styled.color(text_color)
471 }
472 }))
473}
474
475impl Component for MarkdownViewer {
476 fn render(&self) -> impl IntoElement {
477 let elements = parse_markdown(&self.content);
478
479 let MarkdownViewerTheme {
480 color,
481 background_code,
482 color_code,
483 background_blockquote,
484 border_blockquote,
485 background_divider,
486 heading_h1,
487 heading_h2,
488 heading_h3,
489 heading_h4,
490 heading_h5,
491 heading_h6,
492 paragraph_size,
493 code_font_size,
494 table_font_size,
495 } = crate::get_theme!(
496 &self.theme,
497 MarkdownViewerThemePreference,
498 "markdown_viewer"
499 );
500
501 let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.);
502
503 for (idx, element) in elements.into_iter().enumerate() {
504 let child: Element = match element {
505 MarkdownElement::Heading { level, spans } => {
506 let font_size = match level {
507 HeadingLevel::H1 => heading_h1,
508 HeadingLevel::H2 => heading_h2,
509 HeadingLevel::H3 => heading_h3,
510 HeadingLevel::H4 => heading_h4,
511 HeadingLevel::H5 => heading_h5,
512 HeadingLevel::H6 => heading_h6,
513 };
514 render_spans(&spans, font_size, color, color_code)
515 .font_weight(FontWeight::BOLD)
516 .key(idx)
517 .into()
518 }
519 MarkdownElement::Paragraph { spans } => {
520 render_spans(&spans, paragraph_size, color, color_code)
521 .key(idx)
522 .into()
523 }
524 MarkdownElement::CodeBlock { code, .. } => rect()
525 .key(idx)
526 .width(Size::fill())
527 .background(background_code)
528 .corner_radius(6.)
529 .padding(Gaps::new_all(12.))
530 .child(
531 label()
532 .text(code)
533 .font_family("monospace")
534 .font_size(code_font_size)
535 .color(color_code),
536 )
537 .into(),
538 MarkdownElement::UnorderedList { items } => {
539 let mut list = rect()
540 .key(idx)
541 .vertical()
542 .spacing(4.)
543 .padding(Gaps::new(0., 0., 0., 20.));
544
545 for (item_idx, item_spans) in items.into_iter().enumerate() {
546 let item_content = rect()
547 .key(item_idx)
548 .horizontal()
549 .cross_align(Alignment::Start)
550 .spacing(8.)
551 .child(label().text("•").font_size(paragraph_size).color(color))
552 .child(render_spans(&item_spans, paragraph_size, color, color_code));
553
554 list = list.child(item_content);
555 }
556
557 list.into()
558 }
559 MarkdownElement::OrderedList { start, items } => {
560 let mut list = rect()
561 .key(idx)
562 .vertical()
563 .spacing(4.)
564 .padding(Gaps::new(0., 0., 0., 20.));
565
566 for (item_idx, item_spans) in items.into_iter().enumerate() {
567 let number = start + item_idx as u64;
568 let item_content = rect()
569 .key(item_idx)
570 .horizontal()
571 .cross_align(Alignment::Start)
572 .spacing(8.)
573 .child(
574 label()
575 .text(format!("{}.", number))
576 .font_size(paragraph_size)
577 .color(color),
578 )
579 .child(render_spans(&item_spans, paragraph_size, color, color_code));
580
581 list = list.child(item_content);
582 }
583
584 list.into()
585 }
586 #[cfg(feature = "remote-asset")]
587 MarkdownElement::Image { url, alt } => match url.parse::<Uri>() {
588 Ok(uri) => {
589 let source: ImageSource = uri.into();
590 ImageViewer::new(source)
591 .a11y_alt(alt)
592 .key(idx)
593 .width(Size::fill())
594 .height(Size::px(300.))
595 .into()
596 }
597 Err(_) => label()
598 .key(idx)
599 .text(format!("[Invalid image URL: {}]", url))
600 .color(color)
601 .into(),
602 },
603 #[cfg(not(feature = "remote-asset"))]
604 MarkdownElement::Image { alt, .. } => label()
605 .key(idx)
606 .text(format!("[Image: {}]", alt))
607 .color(color)
608 .into(),
609 #[cfg(feature = "router")]
610 MarkdownElement::Link { url, title, text } => {
611 let mut tooltip = LinkTooltip::Default;
612 if let Some(title) = title
613 && !title.is_empty()
614 {
615 tooltip = LinkTooltip::Custom(title);
616 }
617
618 Link::new(url)
619 .tooltip(tooltip)
620 .child(render_spans(&text, paragraph_size, color, color_code))
621 .key(idx)
622 .into()
623 }
624 #[cfg(not(feature = "router"))]
625 MarkdownElement::Link { text, .. } => {
626 render_spans(&text, paragraph_size, color, color_code)
627 .key(idx)
628 .into()
629 }
630 MarkdownElement::Blockquote { spans } => rect()
631 .key(idx)
632 .width(Size::fill())
633 .padding(Gaps::new(12., 12., 12., 16.))
634 .border(
635 Border::new()
636 .width(4.)
637 .fill(border_blockquote)
638 .alignment(BorderAlignment::Inner),
639 )
640 .background(background_blockquote)
641 .child(
642 render_spans(&spans, paragraph_size, color, color_code)
643 .font_slant(FontSlant::Italic),
644 )
645 .into(),
646 MarkdownElement::HorizontalRule => rect()
647 .key(idx)
648 .width(Size::fill())
649 .height(Size::px(1.))
650 .background(background_divider)
651 .into(),
652 MarkdownElement::Table { headers, rows } => {
653 let mut head = TableHead::new();
654 let mut header_row = TableRow::new();
655 for (col_idx, header_spans) in headers.into_iter().enumerate() {
656 header_row = header_row.child(
657 TableCell::new().key(col_idx).child(
658 render_spans(&header_spans, table_font_size, color, color_code)
659 .font_weight(FontWeight::BOLD),
660 ),
661 );
662 }
663 head = head.child(header_row);
664
665 let mut body = TableBody::new();
666 for (row_idx, row) in rows.into_iter().enumerate() {
667 let mut table_row = TableRow::new().key(row_idx);
668 for (col_idx, cell_spans) in row.into_iter().enumerate() {
669 table_row = table_row.child(TableCell::new().key(col_idx).child(
670 render_spans(&cell_spans, table_font_size, color, color_code),
671 ));
672 }
673 body = body.child(table_row);
674 }
675
676 Table::new().key(idx).child(head).child(body).into()
677 }
678 };
679
680 container = container.child(child);
681 }
682
683 container
684 }
685
686 fn render_key(&self) -> DiffKey {
687 self.key.clone().or(self.default_key())
688 }
689}