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#![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#[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 pub text::txt(txt: impl IntoVar<Txt>);
72 }
73}
74
75pub 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 (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 let txt_end = txt.chars().next_back().unwrap();
509
510 if txt != " " && txt != "\n" {
511 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 Event::SoftBreak | Event::HardBreak => unreachable!(),
601 }
602 }
603
604 PANEL_FN_VAR.get()(PanelFnArgs { items: blocks.into() })
605}