zng_wgt_markdown/
lib.rs

1#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo.png")]
3//!
4//! Markdown widget, properties and nodes.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12zng_wgt::enable_widget_macros!();
13
14use std::mem;
15
16pub use pulldown_cmark::HeadingLevel;
17
18use zng_ext_font::WhiteSpace;
19use zng_wgt::prelude::*;
20
21#[doc(hidden)]
22pub use zng_wgt_text::__formatx;
23
24use zng_wgt_text as text;
25
26mod resolvers;
27mod view_fn;
28
29pub use resolvers::*;
30pub use view_fn::*;
31
32/// Render markdown styled text.
33#[widget($crate::Markdown {
34    ($txt:literal) => {
35        txt = $crate::__formatx!($txt);
36    };
37    ($txt:expr) => {
38        txt = $txt;
39    };
40    ($txt:tt, $($format:tt)*) => {
41        txt = $crate::__formatx!($txt, $($format)*);
42    };
43})]
44#[rustfmt::skip]
45pub struct Markdown(
46    text::FontMix<
47    text::TextSpacingMix<
48    text::ParagraphMix<
49    text::LangMix<
50    WidgetBase
51    >>>>
52);
53impl Markdown {
54    fn widget_intrinsic(&mut self) {
55        widget_set! {
56            self;
57            on_link = hn!(|args: &LinkArgs| {
58                try_default_link_action(args);
59            });
60        };
61
62        self.widget_builder().push_build_action(|wgt| {
63            let md = wgt.capture_var_or_default(property_id!(text::txt));
64            let child = markdown_node(md);
65            wgt.set_child(child.boxed());
66        });
67    }
68
69    widget_impl! {
70        /// Markdown text.
71        pub text::txt(txt: impl IntoVar<Txt>);
72    }
73}
74
75/// Implements the markdown parsing and view generation, configured by contextual properties.
76pub fn markdown_node(md: impl IntoVar<Txt>) -> impl UiNode {
77    let md = md.into_var();
78    match_node(NilUiNode.boxed(), move |c, op| match op {
79        UiNodeOp::Init => {
80            WIDGET
81                .sub_var(&md)
82                .sub_var(&TEXT_FN_VAR)
83                .sub_var(&LINK_FN_VAR)
84                .sub_var(&CODE_INLINE_FN_VAR)
85                .sub_var(&CODE_BLOCK_FN_VAR)
86                .sub_var(&PARAGRAPH_FN_VAR)
87                .sub_var(&HEADING_FN_VAR)
88                .sub_var(&LIST_FN_VAR)
89                .sub_var(&LIST_ITEM_BULLET_FN_VAR)
90                .sub_var(&LIST_ITEM_FN_VAR)
91                .sub_var(&IMAGE_FN_VAR)
92                .sub_var(&RULE_FN_VAR)
93                .sub_var(&BLOCK_QUOTE_FN_VAR)
94                .sub_var(&TABLE_FN_VAR)
95                .sub_var(&TABLE_CELL_FN_VAR)
96                .sub_var(&PANEL_FN_VAR)
97                .sub_var(&IMAGE_RESOLVER_VAR)
98                .sub_var(&LINK_RESOLVER_VAR);
99
100            *c.child() = md.with(|md| markdown_view_fn(md.as_str())).boxed();
101        }
102        UiNodeOp::Deinit => {
103            c.deinit();
104            *c.child() = NilUiNode.boxed();
105        }
106        UiNodeOp::Info { info } => {
107            info.flag_meta(*MARKDOWN_INFO_ID);
108        }
109        UiNodeOp::Update { .. } => {
110            use resolvers::*;
111            use view_fn::*;
112
113            if md.is_new()
114                || TEXT_FN_VAR.is_new()
115                || LINK_FN_VAR.is_new()
116                || CODE_INLINE_FN_VAR.is_new()
117                || CODE_BLOCK_FN_VAR.is_new()
118                || PARAGRAPH_FN_VAR.is_new()
119                || HEADING_FN_VAR.is_new()
120                || LIST_FN_VAR.is_new()
121                || LIST_ITEM_BULLET_FN_VAR.is_new()
122                || LIST_ITEM_FN_VAR.is_new()
123                || IMAGE_FN_VAR.is_new()
124                || RULE_FN_VAR.is_new()
125                || BLOCK_QUOTE_FN_VAR.is_new()
126                || TABLE_FN_VAR.is_new()
127                || TABLE_CELL_FN_VAR.is_new()
128                || PANEL_FN_VAR.is_new()
129                || IMAGE_RESOLVER_VAR.is_new()
130                || LINK_RESOLVER_VAR.is_new()
131            {
132                c.delegated();
133                c.child().deinit();
134                *c.child() = md.with(|md| markdown_view_fn(md.as_str())).boxed();
135                c.child().init();
136                WIDGET.update_info().layout().render();
137            }
138        }
139        _ => {}
140    })
141}
142
143fn markdown_view_fn<'a>(md: &'a str) -> impl UiNode + use<> {
144    use pulldown_cmark::*;
145    use resolvers::*;
146    use view_fn::*;
147
148    let mut strong = 0;
149    let mut emphasis = 0;
150    let mut strikethrough = 0;
151
152    let text_view = TEXT_FN_VAR.get();
153    let link_view = LINK_FN_VAR.get();
154    let code_inline_view = CODE_INLINE_FN_VAR.get();
155    let code_block_view = CODE_BLOCK_FN_VAR.get();
156    let heading_view = HEADING_FN_VAR.get();
157    let paragraph_view = PARAGRAPH_FN_VAR.get();
158    let list_view = LIST_FN_VAR.get();
159    let definition_list_view = DEF_LIST_FN_VAR.get();
160    let list_item_bullet_view = LIST_ITEM_BULLET_FN_VAR.get();
161    let list_item_view = LIST_ITEM_FN_VAR.get();
162    let image_view = IMAGE_FN_VAR.get();
163    let rule_view = RULE_FN_VAR.get();
164    let block_quote_view = BLOCK_QUOTE_FN_VAR.get();
165    let footnote_ref_view = FOOTNOTE_REF_FN_VAR.get();
166    let footnote_def_view = FOOTNOTE_DEF_FN_VAR.get();
167    let def_list_item_title_view = DEF_LIST_ITEM_TITLE_FN_VAR.get();
168    let def_list_item_definition_view = DEF_LIST_ITEM_DEFINITION_FN_VAR.get();
169    let table_view = TABLE_FN_VAR.get();
170    let table_cell_view = TABLE_CELL_FN_VAR.get();
171
172    let image_resolver = IMAGE_RESOLVER_VAR.get();
173    let link_resolver = LINK_RESOLVER_VAR.get();
174
175    struct ListInfo {
176        block_start: usize,
177        inline_start: usize,
178        first_num: Option<u64>,
179        item_num: Option<u64>,
180        item_checked: Option<bool>,
181    }
182    let mut blocks = vec![];
183    let mut inlines = vec![];
184
185    let mut link = None;
186    let mut list_info = vec![];
187    let mut list_items = vec![];
188    let mut block_quote_start = vec![];
189    let mut code_block = None;
190    let mut image = None;
191    let mut heading_text = None;
192    let mut footnote_def = None;
193    let mut table_cells = vec![];
194    let mut table_cols = vec![];
195    let mut table_col = 0;
196    let mut table_head = false;
197
198    let mut last_txt_end = '\0';
199
200    for item in Parser::new_with_broken_link_callback(md, Options::all(), Some(&mut |b: BrokenLink<'a>| Some((b.reference, "".into())))) {
201        let item = match item {
202            Event::SoftBreak => Event::Text(pulldown_cmark::CowStr::Borrowed(" ")),
203            Event::HardBreak => Event::Text(pulldown_cmark::CowStr::Borrowed("\n")),
204            item => item,
205        };
206        match item {
207            Event::Start(tag) => match tag {
208                Tag::Paragraph => {
209                    // close unbalanced HTML tags
210                    (strong, emphasis, strikethrough) = (0, 0, 0);
211                    last_txt_end = '\0';
212                }
213                Tag::Heading { .. } => {
214                    (strong, emphasis, strikethrough) = (0, 0, 0);
215                    last_txt_end = '\0';
216                    heading_text = Some(String::new());
217                }
218                Tag::BlockQuote(_) => {
219                    (strong, emphasis, strikethrough) = (0, 0, 0);
220                    last_txt_end = '\0';
221                    block_quote_start.push(blocks.len());
222                }
223                Tag::CodeBlock(kind) => {
224                    (strong, emphasis, strikethrough) = (0, 0, 0);
225                    last_txt_end = '\0';
226                    code_block = Some((String::new(), kind));
227                }
228                Tag::List(n) => {
229                    (strong, emphasis, strikethrough) = (0, 0, 0);
230                    last_txt_end = '\0';
231                    list_info.push(ListInfo {
232                        block_start: blocks.len(),
233                        inline_start: inlines.len(),
234                        first_num: n,
235                        item_num: n,
236                        item_checked: None,
237                    });
238                }
239                Tag::DefinitionList => {
240                    (strong, emphasis, strikethrough) = (0, 0, 0);
241                    last_txt_end = '\0';
242                    list_info.push(ListInfo {
243                        block_start: blocks.len(),
244                        inline_start: inlines.len(),
245                        first_num: None,
246                        item_num: None,
247                        item_checked: None,
248                    });
249                }
250                Tag::Item | Tag::DefinitionListTitle | Tag::DefinitionListDefinition => {
251                    (strong, emphasis, strikethrough) = (0, 0, 0);
252                    last_txt_end = '\0';
253                    if let Some(list) = list_info.last_mut() {
254                        list.block_start = blocks.len();
255                    }
256                }
257                Tag::FootnoteDefinition(label) => {
258                    (strong, emphasis, strikethrough) = (0, 0, 0);
259                    last_txt_end = '\0';
260                    footnote_def = Some((blocks.len(), label));
261                }
262                Tag::Table(columns) => {
263                    (strong, emphasis, strikethrough) = (0, 0, 0);
264                    last_txt_end = '\0';
265                    table_cols = columns
266                        .into_iter()
267                        .map(|c| match c {
268                            Alignment::None => Align::START,
269                            Alignment::Left => Align::LEFT,
270                            Alignment::Center => Align::CENTER,
271                            Alignment::Right => Align::RIGHT,
272                        })
273                        .collect()
274                }
275                Tag::TableHead => {
276                    (strong, emphasis, strikethrough) = (0, 0, 0);
277                    last_txt_end = '\0';
278                    table_head = true;
279                    table_col = 0;
280                }
281                Tag::TableRow => {
282                    (strong, emphasis, strikethrough) = (0, 0, 0);
283                    last_txt_end = '\0';
284                    table_col = 0;
285                }
286                Tag::TableCell => {
287                    (strong, emphasis, strikethrough) = (0, 0, 0);
288                    last_txt_end = '\0';
289                }
290                Tag::Emphasis => {
291                    emphasis += 1;
292                }
293                Tag::Strong => {
294                    strong += 1;
295                }
296                Tag::Strikethrough => {
297                    strong += 1;
298                }
299                Tag::Link {
300                    link_type,
301                    dest_url,
302                    title,
303                    id,
304                } => {
305                    link = Some((inlines.len(), link_type, dest_url, title, id));
306                }
307                Tag::Image { dest_url, title, .. } => {
308                    last_txt_end = '\0';
309                    image = Some((String::new(), dest_url, title));
310                }
311                Tag::HtmlBlock => {}
312                Tag::MetadataBlock(_) => {}
313            },
314            Event::End(tag) => match tag {
315                TagEnd::Paragraph => {
316                    if !inlines.is_empty() {
317                        blocks.push(paragraph_view(ParagraphFnArgs {
318                            index: blocks.len() as u32,
319                            items: mem::take(&mut inlines).into(),
320                        }));
321                    }
322                }
323                TagEnd::Heading(level) => {
324                    if !inlines.is_empty() {
325                        blocks.push(heading_view(HeadingFnArgs {
326                            level,
327                            anchor: heading_anchor(heading_text.take().unwrap_or_default().as_str()),
328                            items: mem::take(&mut inlines).into(),
329                        }));
330                    }
331                }
332                TagEnd::BlockQuote(_) => {
333                    if let Some(start) = block_quote_start.pop() {
334                        let items: UiVec = blocks.drain(start..).collect();
335                        if !items.is_empty() {
336                            blocks.push(block_quote_view(BlockQuoteFnArgs {
337                                level: block_quote_start.len() as u32,
338                                items,
339                            }));
340                        }
341                    }
342                }
343                TagEnd::CodeBlock => {
344                    let (mut txt, kind) = code_block.take().unwrap();
345                    if txt.ends_with('\n') {
346                        txt.pop();
347                    }
348                    blocks.push(code_block_view(CodeBlockFnArgs {
349                        lang: match kind {
350                            CodeBlockKind::Indented => Txt::from_str(""),
351                            CodeBlockKind::Fenced(l) => l.to_txt(),
352                        },
353                        txt: txt.into(),
354                    }))
355                }
356                TagEnd::List(_) => {
357                    if let Some(list) = list_info.pop() {
358                        blocks.push(list_view(ListFnArgs {
359                            depth: list_info.len() as u32,
360                            first_num: list.first_num,
361                            items: mem::take(&mut list_items).into(),
362                        }));
363                    }
364                }
365                TagEnd::DefinitionList => {
366                    if list_info.pop().is_some() {
367                        blocks.push(definition_list_view(DefListArgs {
368                            items: mem::take(&mut list_items).into(),
369                        }));
370                    }
371                }
372                TagEnd::Item => {
373                    let depth = list_info.len().saturating_sub(1);
374                    if let Some(list) = list_info.last_mut() {
375                        let num = match &mut list.item_num {
376                            Some(n) => {
377                                let r = *n;
378                                *n += 1;
379                                Some(r)
380                            }
381                            None => None,
382                        };
383
384                        let bullet_args = ListItemBulletFnArgs {
385                            depth: depth as u32,
386                            num,
387                            checked: list.item_checked.take(),
388                        };
389                        list_items.push(list_item_bullet_view(bullet_args));
390                        list_items.push(list_item_view(ListItemFnArgs {
391                            bullet: bullet_args,
392                            items: inlines.drain(list.inline_start..).collect(),
393                            blocks: blocks.drain(list.block_start..).collect(),
394                        }));
395                    }
396                }
397                TagEnd::DefinitionListTitle => {
398                    if let Some(list) = list_info.last_mut() {
399                        list_items.push(def_list_item_title_view(DefListItemTitleArgs {
400                            items: inlines.drain(list.inline_start..).collect(),
401                        }));
402                    }
403                }
404                TagEnd::DefinitionListDefinition => {
405                    if let Some(list) = list_info.last_mut() {
406                        list_items.push(def_list_item_definition_view(DefListItemDefinitionArgs {
407                            items: inlines.drain(list.inline_start..).collect(),
408                        }));
409                    }
410                }
411                TagEnd::FootnoteDefinition => {
412                    if let Some((i, label)) = footnote_def.take() {
413                        let label = html_escape::decode_html_entities(label.as_ref());
414                        let items = blocks.drain(i..).collect();
415                        blocks.push(footnote_def_view(FootnoteDefFnArgs {
416                            label: label.to_txt(),
417                            items,
418                        }));
419                    }
420                }
421                TagEnd::Table => {
422                    if !table_cells.is_empty() {
423                        blocks.push(table_view(TableFnArgs {
424                            columns: mem::take(&mut table_cols),
425                            cells: mem::take(&mut table_cells).into(),
426                        }));
427                    }
428                }
429                TagEnd::TableHead => {
430                    table_head = false;
431                }
432                TagEnd::TableRow => {}
433                TagEnd::TableCell => {
434                    table_cells.push(table_cell_view(TableCellFnArgs {
435                        is_heading: table_head,
436                        col_align: table_cols[table_col],
437                        items: mem::take(&mut inlines).into(),
438                    }));
439                    table_col += 1;
440                }
441                TagEnd::Emphasis => {
442                    emphasis -= 1;
443                }
444                TagEnd::Strong => {
445                    strong -= 1;
446                }
447                TagEnd::Strikethrough => {
448                    strikethrough -= 1;
449                }
450                TagEnd::Link => {
451                    let (inlines_start, kind, url, title, _id) = link.take().unwrap();
452                    let title = html_escape::decode_html_entities(title.as_ref());
453                    let url = link_resolver.resolve(url.as_ref());
454                    match kind {
455                        LinkType::Inline => {}
456                        LinkType::Reference => {}
457                        LinkType::ReferenceUnknown => {}
458                        LinkType::Collapsed => {}
459                        LinkType::CollapsedUnknown => {}
460                        LinkType::Shortcut => {}
461                        LinkType::ShortcutUnknown => {}
462                        LinkType::Autolink | LinkType::Email => {
463                            let url = html_escape::decode_html_entities(&url);
464                            if let Some(txt) = text_view.call_checked(TextFnArgs {
465                                txt: url.to_txt(),
466                                style: MarkdownStyle {
467                                    strong: strong > 0,
468                                    emphasis: emphasis > 0,
469                                    strikethrough: strikethrough > 0,
470                                },
471                            }) {
472                                inlines.push(txt);
473                            }
474                        }
475                    }
476                    if !inlines.is_empty() {
477                        let items = inlines.drain(inlines_start..).collect();
478                        if let Some(lnk) = link_view.call_checked(LinkFnArgs {
479                            url,
480                            title: title.to_txt(),
481                            items,
482                        }) {
483                            inlines.push(lnk);
484                        }
485                    }
486                }
487                TagEnd::Image => {
488                    let (alt_txt, url, title) = image.take().unwrap();
489                    let title = html_escape::decode_html_entities(title.as_ref());
490                    blocks.push(image_view(ImageFnArgs {
491                        source: image_resolver.resolve(&url),
492                        title: title.to_txt(),
493                        alt_items: mem::take(&mut inlines).into(),
494                        alt_txt: alt_txt.into(),
495                    }));
496                }
497                TagEnd::HtmlBlock => {}
498                TagEnd::MetadataBlock(_) => {}
499            },
500            Event::Text(txt) => {
501                let txt = html_escape::decode_html_entities(txt.as_ref());
502                if let Some((t, _)) = &mut code_block {
503                    t.push_str(&txt);
504                } else if !txt.is_empty() {
505                    let mut txt = Txt::from_string(txt.into_owned());
506
507                    // apply `WhiteSpace::MergeAll` across texts.
508                    let txt_end = txt.chars().next_back().unwrap();
509
510                    if txt != " " && txt != "\n" {
511                        // not Soft/HardBreak
512                        let starts_with_space = txt.chars().next().unwrap().is_whitespace();
513                        match WhiteSpace::MergeAll.transform(&txt) {
514                            std::borrow::Cow::Borrowed(_) => {
515                                if starts_with_space && last_txt_end != '\0' || !txt.is_empty() && last_txt_end.is_whitespace() {
516                                    txt.to_mut().insert(0, ' ');
517                                }
518                                txt.end_mut();
519                                last_txt_end = txt_end;
520                            }
521                            std::borrow::Cow::Owned(t) => {
522                                txt = t;
523                                if !txt.is_empty() {
524                                    if starts_with_space && last_txt_end != '\0' || !txt.is_empty() && last_txt_end.is_whitespace() {
525                                        txt.to_mut().insert(0, ' ');
526                                        txt.end_mut();
527                                    }
528                                    last_txt_end = txt_end;
529                                }
530                            }
531                        }
532                    }
533
534                    if let Some(t) = &mut heading_text {
535                        t.push_str(&txt);
536                    }
537                    if let Some((t, _, _)) = &mut image {
538                        t.push_str(&txt);
539                    }
540                    if let Some(txt) = text_view.call_checked(TextFnArgs {
541                        txt,
542                        style: MarkdownStyle {
543                            strong: strong > 0,
544                            emphasis: emphasis > 0,
545                            strikethrough: strikethrough > 0,
546                        },
547                    }) {
548                        inlines.push(txt);
549                    }
550                }
551            }
552            Event::Code(txt) => {
553                let txt = html_escape::decode_html_entities(txt.as_ref());
554
555                let style = MarkdownStyle {
556                    strong: strong > 0,
557                    emphasis: emphasis > 0,
558                    strikethrough: strikethrough > 0,
559                };
560
561                if last_txt_end.is_whitespace() {
562                    if let Some(txt) = text_view.call_checked(TextFnArgs {
563                        txt: ' '.into(),
564                        style: style.clone(),
565                    }) {
566                        inlines.push(txt);
567                    }
568                }
569
570                if let Some(txt) = code_inline_view.call_checked(CodeInlineFnArgs { txt: txt.to_txt(), style }) {
571                    inlines.push(txt);
572                }
573            }
574            Event::Html(tag) | Event::InlineHtml(tag) => match tag.as_ref() {
575                "<b>" => strong += 1,
576                "</b>" => strong -= 1,
577                "<em>" => emphasis += 1,
578                "</em>" => emphasis -= 1,
579                "<s>" => strikethrough += 1,
580                "</s>" => strikethrough -= 1,
581                _ => {}
582            },
583            Event::FootnoteReference(label) => {
584                let label = html_escape::decode_html_entities(label.as_ref());
585                if let Some(txt) = footnote_ref_view.call_checked(FootnoteRefFnArgs { label: label.to_txt() }) {
586                    inlines.push(txt);
587                }
588            }
589            Event::Rule => {
590                blocks.push(rule_view(RuleFnArgs {}));
591            }
592            Event::TaskListMarker(c) => {
593                if let Some(l) = &mut list_info.last_mut() {
594                    l.item_checked = Some(c);
595                }
596            }
597            Event::InlineMath(_) => {}
598            Event::DisplayMath(_) => {}
599            // handled early
600            Event::SoftBreak | Event::HardBreak => unreachable!(),
601        }
602    }
603
604    PANEL_FN_VAR.get()(PanelFnArgs { items: blocks.into() })
605}