1use std::ops::Range;
4
5use damascene_core::prelude::*;
6use damascene_core::selection::SelectionSource;
7use pulldown_cmark::{
8 Alignment, BlockQuoteKind, CodeBlockKind, Event, HeadingLevel, Options as CmarkOptions, Parser,
9 Tag, TagEnd,
10};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub struct MarkdownOptions {
19 pub smart_punctuation: bool,
22 pub gfm_alerts: bool,
25 pub math: bool,
28 #[cfg(feature = "html")]
34 pub html: damascene_html::HtmlOptions,
35}
36
37impl MarkdownOptions {
38 pub fn smart_punctuation(mut self, enabled: bool) -> Self {
39 self.smart_punctuation = enabled;
40 self
41 }
42
43 pub fn gfm_alerts(mut self, enabled: bool) -> Self {
44 self.gfm_alerts = enabled;
45 self
46 }
47
48 pub fn math(mut self, enabled: bool) -> Self {
49 self.math = enabled;
50 self
51 }
52
53 #[cfg(feature = "html")]
55 pub fn html_options(mut self, html: damascene_html::HtmlOptions) -> Self {
56 self.html = html;
57 self
58 }
59}
60
61pub fn md(input: &str) -> El {
67 md_with_options(input, MarkdownOptions::default())
68}
69
70pub fn md_with_options(input: &str, options: MarkdownOptions) -> El {
72 walk(input, options).finish()
73}
74
75#[cfg(feature = "html")]
81pub fn md_with_lints(input: &str, options: MarkdownOptions) -> (El, Vec<damascene_html::Finding>) {
82 walk(input, options).finish_with_lints()
83}
84
85fn walk(input: &str, options: MarkdownOptions) -> Walker {
86 let mut parser_options = CmarkOptions::ENABLE_TABLES
90 | CmarkOptions::ENABLE_STRIKETHROUGH
91 | CmarkOptions::ENABLE_TASKLISTS;
92 if options.smart_punctuation {
93 parser_options |= CmarkOptions::ENABLE_SMART_PUNCTUATION;
94 }
95 if options.gfm_alerts {
96 parser_options |= CmarkOptions::ENABLE_GFM;
97 }
98 if options.math {
99 parser_options |= CmarkOptions::ENABLE_MATH;
100 }
101
102 let parser = Parser::new_ext(input, parser_options);
103 let mut walker = Walker::new(input, options);
104 for (event, range) in parser.into_offset_iter() {
105 walker.handle(event, range);
106 }
107 walker
108}
109
110enum Frame {
114 Paragraph(InlineBuffer),
116 Heading(HeadingLevel, InlineBuffer),
118 BlockQuote {
120 kind: Option<BlockQuoteKind>,
121 blocks: Vec<El>,
122 },
123 List {
125 start: Option<u64>,
128 items: Vec<ListItem>,
129 },
130 Item {
133 blocks: Vec<El>,
134 task_checked: Option<bool>,
135 },
136 CodeBlock {
143 lang: Option<String>,
144 text: String,
145 text_source: Option<Range<usize>>,
146 indented: bool,
147 },
148 Link(String, InlineBuffer),
153 Image {
157 alt: String,
158 dest_url: String,
159 title: String,
160 },
161 Table {
164 alignments: Vec<Alignment>,
167 head: Option<Vec<El>>,
171 body: Vec<Vec<El>>,
173 },
174 TableHead(Vec<El>),
176 TableRow(Vec<El>),
178 TableCell {
182 runs: InlineBuffer,
183 in_header: bool,
184 alignment: Alignment,
185 },
186}
187
188#[derive(Clone, Debug, Default)]
189struct InlineBuffer {
190 runs: Vec<El>,
191 visible: String,
192 spans: Vec<InlineSourceSpan>,
193}
194
195#[derive(Clone, Debug)]
196struct InlineSourceSpan {
197 visible: Range<usize>,
198 source: Range<usize>,
199 source_full: Range<usize>,
200 atomic: bool,
201}
202
203impl InlineBuffer {
204 fn is_empty(&self) -> bool {
205 self.runs.is_empty()
206 }
207
208 fn visible_len(&self) -> usize {
209 self.visible.len()
210 }
211
212 fn push(
213 &mut self,
214 el: El,
215 visible: &str,
216 source: Range<usize>,
217 source_full: Range<usize>,
218 atomic: bool,
219 ) {
220 let start = self.visible.len();
221 self.visible.push_str(visible);
222 let end = self.visible.len();
223 if start < end {
224 self.spans.push(InlineSourceSpan {
225 visible: start..end,
226 source,
227 source_full,
228 atomic,
229 });
230 }
231 self.runs.push(el);
232 }
233
234 fn append(&mut self, mut other: InlineBuffer) {
235 let offset = self.visible.len();
236 self.visible.push_str(&other.visible);
237 for span in other.spans.drain(..) {
238 self.spans.push(InlineSourceSpan {
239 visible: (span.visible.start + offset)..(span.visible.end + offset),
240 source: span.source,
241 source_full: span.source_full,
242 atomic: span.atomic,
243 });
244 }
245 self.runs.append(&mut other.runs);
246 }
247
248 fn mark_full_source(&mut self, visible: Range<usize>, source_full: Range<usize>) {
249 for span in &mut self.spans {
250 if span.visible.start >= visible.start && span.visible.end <= visible.end {
251 span.source_full = source_full.clone();
252 }
253 }
254 }
255
256 fn mark_all_full_source(&mut self, source_full: Range<usize>) {
257 self.mark_full_source(0..self.visible_len(), source_full);
258 }
259
260 fn into_runs(self) -> Vec<El> {
261 self.runs
262 }
263
264 fn selection_source(
265 &self,
266 input: &str,
267 source_range: Option<Range<usize>>,
268 ) -> Option<SelectionSource> {
269 let source_range = source_range?;
270 let source_text = input.get(source_range.clone())?.to_string();
271 let mut source = SelectionSource::new(source_text, self.visible.clone());
272 for span in &self.spans {
273 let start = span.source.start.saturating_sub(source_range.start);
274 let end = span.source.end.saturating_sub(source_range.start);
275 let full_start = span.source_full.start.saturating_sub(source_range.start);
276 let full_end = span.source_full.end.saturating_sub(source_range.start);
277 source.push_span_with_full_source(
278 span.visible.clone(),
279 start..end,
280 full_start..full_end,
281 span.atomic,
282 );
283 }
284 Some(source)
285 }
286}
287
288struct ListItem {
289 content: El,
290 task_checked: Option<bool>,
291}
292
293#[derive(Default)]
301struct InlineState {
302 italic_depth: u32,
303 bold_depth: u32,
304 strike_depth: u32,
305}
306
307impl InlineState {
308 fn apply(&self, mut el: El) -> El {
309 if self.bold_depth > 0 {
310 el = el.bold();
311 }
312 if self.italic_depth > 0 {
313 el = el.italic();
314 }
315 if self.strike_depth > 0 {
316 el = el.strikethrough();
317 }
318 el
319 }
320}
321
322struct InlineSourceMarker {
323 visible_start: usize,
324 source_start: usize,
325}
326
327#[cfg(feature = "html")]
332const STATEFUL_INLINE_TAGS: &[&str] = &[
333 "a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "del", "dfn", "em", "i", "kbd", "mark",
334 "q", "s", "samp", "small", "span", "strike", "strong", "sub", "sup", "time", "u", "var",
335];
336
337#[cfg(feature = "html")]
340const MAX_OPEN_INLINE_HTML_TAGS: usize = 16;
341
342#[cfg(feature = "html")]
344enum InlineHtmlFragment {
345 Open(String),
347 Close(String),
349 Other,
352}
353
354#[cfg(feature = "html")]
355fn classify_inline_html_fragment(s: &str) -> InlineHtmlFragment {
356 let trimmed = s.trim();
357 let Some(inner) = trimmed
358 .strip_prefix('<')
359 .and_then(|rest| rest.strip_suffix('>'))
360 else {
361 return InlineHtmlFragment::Other;
362 };
363 if inner.contains('<') || inner.contains('>') {
364 return InlineHtmlFragment::Other;
365 }
366 if let Some(rest) = inner.strip_prefix('/') {
367 let name = rest.trim().to_ascii_lowercase();
368 if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric()) {
369 return InlineHtmlFragment::Close(name);
370 }
371 return InlineHtmlFragment::Other;
372 }
373 if inner.trim_end().ends_with('/') {
374 return InlineHtmlFragment::Other;
376 }
377 let name: String = inner
378 .chars()
379 .take_while(|c| c.is_ascii_alphanumeric())
380 .collect::<String>()
381 .to_ascii_lowercase();
382 if name.is_empty() {
383 return InlineHtmlFragment::Other;
385 }
386 let after = &inner[name.len()..];
389 if !(after.is_empty() || after.starts_with(char::is_whitespace)) {
390 return InlineHtmlFragment::Other;
391 }
392 InlineHtmlFragment::Open(name)
393}
394
395struct Walker {
396 input: String,
397 options: MarkdownOptions,
398 stack: Vec<Frame>,
404 source_stack: Vec<Option<Range<usize>>>,
405 inline: InlineState,
407 #[cfg(feature = "html")]
410 html_findings: Vec<damascene_html::Finding>,
411 #[cfg(feature = "html")]
417 open_inline_html: Vec<(String, String)>,
418 #[cfg(feature = "html")]
422 html_template: Option<El>,
423 inline_source_stack: Vec<InlineSourceMarker>,
424 root: Vec<El>,
426}
427
428impl Walker {
429 fn new(input: &str, options: MarkdownOptions) -> Self {
430 Self {
431 input: input.to_string(),
432 options,
433 stack: Vec::new(),
434 source_stack: Vec::new(),
435 inline: InlineState::default(),
436 #[cfg(feature = "html")]
437 html_findings: Vec::new(),
438 #[cfg(feature = "html")]
439 open_inline_html: Vec::new(),
440 #[cfg(feature = "html")]
441 html_template: None,
442 inline_source_stack: Vec::new(),
443 root: Vec::new(),
444 }
445 }
446
447 fn handle(&mut self, event: Event<'_>, range: Range<usize>) {
448 match event {
449 Event::Start(tag) => {
450 self.extend_top_source(range.clone());
451 self.start(tag, range);
452 }
453 Event::End(end) => self.end(end, range),
454 Event::Text(text) => self.text(text.into_string(), range),
455 Event::Code(text) => self.code_span(text.into_string(), range),
456 Event::SoftBreak => self.text(" ".to_string(), range),
457 Event::HardBreak => {
458 self.ensure_inline_frame(range.clone());
459 self.extend_top_source(range.clone());
460 self.push_inline_mapped(hard_break(), "\n", range.clone(), range, false);
461 }
462 Event::Rule => {
463 self.extend_top_source(range);
464 self.push_block(divider());
465 }
466 Event::InlineMath(text) => self.inline_math(text.into_string(), range),
467 Event::DisplayMath(text) => self.display_math(text.into_string(), range),
468 #[cfg(feature = "html")]
469 Event::Html(s) => self.block_html(s.into_string(), range),
470 #[cfg(feature = "html")]
471 Event::InlineHtml(s) => self.inline_html(s.into_string(), range),
472 #[cfg(not(feature = "html"))]
473 Event::Html(_) | Event::InlineHtml(_) => {}
474 Event::FootnoteReference(_) => {}
475 Event::TaskListMarker(checked) => {
476 self.extend_top_source(range);
477 self.task_list_marker(checked);
478 }
479 }
480 }
481
482 fn push_frame(&mut self, frame: Frame, range: Range<usize>) {
483 self.stack.push(frame);
484 self.source_stack.push(Some(range));
485 }
486
487 fn pop_frame(&mut self) -> Option<(Frame, Option<Range<usize>>)> {
488 let frame = self.stack.pop()?;
489 let range = self.source_stack.pop().flatten();
490 Some((frame, range))
491 }
492
493 fn parent_item_source_range(&self) -> Option<Range<usize>> {
494 let frame_index = self
495 .stack
496 .iter()
497 .rposition(|frame| matches!(frame, Frame::Item { .. }))?;
498 self.source_stack.get(frame_index).cloned().flatten()
499 }
500
501 fn parent_table_line_source_range(&self) -> Option<Range<usize>> {
502 let frame_index = self
503 .stack
504 .iter()
505 .rposition(|frame| matches!(frame, Frame::TableHead(_) | Frame::TableRow(_)))?;
506 self.source_stack.get(frame_index).cloned().flatten()
507 }
508
509 fn extend_top_source(&mut self, range: Range<usize>) {
510 if range.start >= range.end {
511 return;
512 }
513 if let Some(slot) = self.source_stack.last_mut() {
514 match slot {
515 Some(existing) => {
516 existing.start = existing.start.min(range.start);
517 existing.end = existing.end.max(range.end);
518 }
519 None => *slot = Some(range),
520 }
521 }
522 }
523
524 fn open_inline_source(&mut self, range: Range<usize>) {
525 self.ensure_inline_frame(range.clone());
526 self.extend_top_source(range.clone());
527 let visible_start = self.current_inline_visible_len().unwrap_or(0);
528 self.inline_source_stack.push(InlineSourceMarker {
529 visible_start,
530 source_start: range.start,
531 });
532 }
533
534 fn close_inline_source(&mut self, range: Range<usize>) {
535 let Some(marker) = self.inline_source_stack.pop() else {
536 return;
537 };
538 let Some(visible_end) = self.current_inline_visible_len() else {
539 return;
540 };
541 if visible_end <= marker.visible_start {
542 return;
543 }
544 if let Some(buffer) = self.current_inline_buffer_mut() {
545 buffer.mark_full_source(
546 marker.visible_start..visible_end,
547 marker.source_start..range.end,
548 );
549 }
550 }
551
552 fn start(&mut self, tag: Tag<'_>, range: Range<usize>) {
553 match tag {
554 Tag::Paragraph => self.push_frame(Frame::Paragraph(InlineBuffer::default()), range),
555 Tag::Heading { level, .. } => {
556 self.push_frame(Frame::Heading(level, InlineBuffer::default()), range)
557 }
558 Tag::BlockQuote(kind) => self.push_frame(
559 Frame::BlockQuote {
560 kind: kind.filter(|_| self.options.gfm_alerts),
561 blocks: Vec::new(),
562 },
563 range,
564 ),
565 Tag::List(start) => self.push_frame(
566 Frame::List {
567 start,
568 items: Vec::new(),
569 },
570 range,
571 ),
572 Tag::Item => self.push_frame(
573 Frame::Item {
574 blocks: Vec::new(),
575 task_checked: None,
576 },
577 range,
578 ),
579 Tag::CodeBlock(kind) => {
580 let (lang, indented) = match kind {
581 CodeBlockKind::Fenced(info) => {
582 let token = info.split_whitespace().next().unwrap_or("");
586 if token.is_empty() {
587 (None, false)
588 } else {
589 (Some(token.to_string()), false)
590 }
591 }
592 CodeBlockKind::Indented => (None, true),
593 };
594 self.push_frame(
595 Frame::CodeBlock {
596 lang,
597 text: String::new(),
598 text_source: None,
599 indented,
600 },
601 range,
602 );
603 }
604 Tag::Emphasis => {
605 self.inline.italic_depth += 1;
606 self.open_inline_source(range);
607 }
608 Tag::Strong => {
609 self.inline.bold_depth += 1;
610 self.open_inline_source(range);
611 }
612 Tag::Strikethrough => {
613 self.inline.strike_depth += 1;
614 self.open_inline_source(range);
615 }
616 Tag::Link { dest_url, .. } => {
617 self.push_frame(
618 Frame::Link(dest_url.into_string(), InlineBuffer::default()),
619 range,
620 );
621 }
622 Tag::Image {
623 dest_url, title, ..
624 } => {
625 self.push_frame(
628 Frame::Image {
629 alt: String::new(),
630 dest_url: dest_url.into_string(),
631 title: title.into_string(),
632 },
633 range,
634 );
635 }
636 Tag::Table(alignments) => {
637 self.push_frame(
638 Frame::Table {
639 alignments,
640 head: None,
641 body: Vec::new(),
642 },
643 range,
644 );
645 }
646 Tag::TableHead => self.push_frame(Frame::TableHead(Vec::new()), range),
647 Tag::TableRow => self.push_frame(Frame::TableRow(Vec::new()), range),
648 Tag::TableCell => {
649 let in_header = self
654 .stack
655 .iter()
656 .rev()
657 .any(|frame| matches!(frame, Frame::TableHead(_)));
658 let alignment = self.next_table_cell_alignment();
659 self.push_frame(
660 Frame::TableCell {
661 runs: InlineBuffer::default(),
662 in_header,
663 alignment,
664 },
665 range,
666 );
667 }
668 Tag::Subscript | Tag::Superscript => {}
672 Tag::FootnoteDefinition(_)
677 | Tag::DefinitionList
678 | Tag::DefinitionListTitle
679 | Tag::DefinitionListDefinition => {
680 self.push_frame(Frame::Paragraph(InlineBuffer::default()), range);
681 }
682 Tag::HtmlBlock | Tag::MetadataBlock(_) => {
687 self.push_frame(Frame::Paragraph(InlineBuffer::default()), range);
688 }
689 }
690 }
691
692 fn end(&mut self, end: TagEnd, range: Range<usize>) {
693 match end {
694 TagEnd::Paragraph => {
695 self.clear_open_inline_html();
697 let inside_blockquote = self.inside_blockquote();
698 if let Some((Frame::Paragraph(inlines), source_range)) = self.pop_frame() {
699 if inlines.is_empty() {
706 return;
707 }
708 let source_range = if inside_blockquote {
709 expand_source_range_start_to_line_start(&self.input, source_range)
710 .map(|range| trim_source_range_end(&self.input, range))
711 } else {
712 source_range
713 };
714 let block = build_paragraph(inlines, &self.input, source_range);
715 self.push_block(block);
716 }
717 }
718 TagEnd::Heading(_) => {
719 self.clear_open_inline_html();
720 let inside_blockquote = self.inside_blockquote();
721 if let Some((Frame::Heading(level, inlines), source_range)) = self.pop_frame() {
722 let source_range = if inside_blockquote {
723 expand_source_range_start_to_line_start(&self.input, source_range)
724 .map(|range| trim_source_range_end(&self.input, range))
725 } else {
726 source_range
727 };
728 let block = build_heading(level, inlines, &self.input, source_range);
729 self.push_block(block);
730 }
731 }
732 TagEnd::BlockQuote(_) => {
733 if let Some((Frame::BlockQuote { kind, blocks }, _)) = self.pop_frame() {
734 self.push_block(build_blockquote(kind, blocks));
735 }
736 }
737 TagEnd::List(_) => {
738 if let Some((Frame::List { start, items }, _)) = self.pop_frame() {
739 let block = build_list(start, items);
740 self.push_block(block);
741 }
742 }
743 TagEnd::Item => {
744 while matches!(self.stack.last(), Some(Frame::Paragraph(_))) {
751 let item_source_range = self.parent_item_source_range();
752 if let Some((Frame::Paragraph(mut inlines), source_range)) = self.pop_frame()
753 && !inlines.is_empty()
754 {
755 if let Some(item_source_range) = item_source_range {
756 let item_source_range =
757 trim_source_range_end(&self.input, item_source_range);
758 let item_source_range = if self.inside_blockquote() {
759 expand_source_range_start_to_line_start(
760 &self.input,
761 Some(item_source_range.clone()),
762 )
763 .unwrap_or(item_source_range)
764 } else {
765 item_source_range
766 };
767 let source_range =
768 union_source_ranges(source_range, Some(item_source_range.clone()));
769 inlines.mark_all_full_source(item_source_range);
770 let block = build_paragraph(inlines, &self.input, source_range);
771 self.push_block(block);
772 continue;
773 }
774 let block = build_paragraph(inlines, &self.input, source_range);
775 self.push_block(block);
776 }
777 }
778 if let Some((
779 Frame::Item {
780 blocks,
781 task_checked,
782 },
783 _,
784 )) = self.pop_frame()
785 {
786 let item_el = build_list_item(blocks);
787 if let Some(Frame::List { items, .. }) = self.stack.last_mut() {
788 items.push(ListItem {
789 content: item_el,
790 task_checked,
791 });
792 }
793 }
794 }
795 TagEnd::CodeBlock => {
796 let inside_blockquote = self.inside_blockquote();
797 if let Some((
798 Frame::CodeBlock {
799 lang,
800 text,
801 text_source,
802 indented,
803 },
804 source_range,
805 )) = self.pop_frame()
806 {
807 let source_range = if indented || inside_blockquote {
808 expand_source_range_start_to_line_start(&self.input, source_range)
809 } else {
810 source_range
811 };
812 self.push_block(build_code_block(
813 lang.as_deref(),
814 text,
815 &self.input,
816 source_range,
817 text_source,
818 ));
819 }
820 }
821 TagEnd::Emphasis => {
822 self.close_inline_source(range);
823 self.inline.italic_depth = self.inline.italic_depth.saturating_sub(1)
824 }
825 TagEnd::Strong => {
826 self.close_inline_source(range);
827 self.inline.bold_depth = self.inline.bold_depth.saturating_sub(1);
828 }
829 TagEnd::Strikethrough => {
830 self.close_inline_source(range);
831 self.inline.strike_depth = self.inline.strike_depth.saturating_sub(1);
832 }
833 TagEnd::Link => {
834 if let Some((Frame::Link(url, mut inlines), source_range)) = self.pop_frame() {
835 if let Some(source_range) = source_range {
836 inlines.mark_full_source(0..inlines.visible_len(), source_range);
837 }
838 for run in &mut inlines.runs {
839 *run = std::mem::take(run).link(url.clone());
843 }
844 self.push_inline_buffer(inlines);
845 }
846 }
847 TagEnd::Image => {
848 if let Some((
849 Frame::Image {
850 alt,
851 dest_url,
852 title,
853 },
854 source_range,
855 )) = self.pop_frame()
856 {
857 let placeholder = build_image_placeholder(&alt, &dest_url, &title);
858 let visible = image_placeholder_label(&alt, &dest_url, &title);
859 if self.in_inline_container() {
860 if let Some(source_range) = source_range {
861 self.push_inline_mapped(
862 placeholder,
863 &visible,
864 source_range.clone(),
865 source_range,
866 true,
867 );
868 } else {
869 self.push_inline_mapped(placeholder, &visible, 0..0, 0..0, false);
870 }
871 } else {
872 let block = with_atomic_source_selection(
873 placeholder,
874 "img",
875 &self.input,
876 source_range,
877 visible,
878 );
879 self.push_block(block);
880 }
881 }
882 }
883 TagEnd::Table => {
884 if let Some((Frame::Table { head, body, .. }, _)) = self.pop_frame() {
885 self.push_block(build_table(head, body));
886 }
887 }
888 TagEnd::TableHead => {
889 if let Some((Frame::TableHead(items), _)) = self.pop_frame() {
890 let rows = normalize_table_head_rows(items);
891 if let Some(Frame::Table { head, .. }) = self.stack.last_mut() {
892 *head = Some(rows);
893 }
894 }
895 }
896 TagEnd::TableRow => {
897 if let Some((Frame::TableRow(cells), _)) = self.pop_frame() {
898 let row = table_row(cells);
899 match self.stack.last_mut() {
900 Some(Frame::TableHead(rows)) => rows.push(row),
901 Some(Frame::Table { body, .. }) => body.push(vec![row]),
902 _ => {}
903 }
904 }
905 }
906 TagEnd::TableCell => {
907 let raw_row_source_range = self
908 .parent_table_line_source_range()
909 .map(|range| trim_source_range_end(&self.input, range));
910 if let Some((
911 Frame::TableCell {
912 runs,
913 in_header,
914 alignment,
915 },
916 source_range,
917 )) = self.pop_frame()
918 {
919 let row_source_range = raw_row_source_range.map(|range| {
920 if in_header {
921 expand_table_header_source_to_delimiter(&self.input, range)
922 } else {
923 range
924 }
925 });
926 let cell = build_table_cell(
927 runs,
928 in_header,
929 alignment,
930 &self.input,
931 source_range,
932 row_source_range,
933 );
934 match self.stack.last_mut() {
935 Some(Frame::TableHead(cells)) | Some(Frame::TableRow(cells)) => {
936 cells.push(cell);
937 }
938 _ => {}
939 }
940 }
941 }
942 TagEnd::Subscript | TagEnd::Superscript => {}
945 TagEnd::FootnoteDefinition
949 | TagEnd::DefinitionList
950 | TagEnd::DefinitionListTitle
951 | TagEnd::DefinitionListDefinition => {
952 if let Some((Frame::Paragraph(inlines), source_range)) = self.pop_frame()
953 && !inlines.is_empty()
954 {
955 let block = build_paragraph(inlines, &self.input, source_range);
956 self.push_block(block);
957 }
958 }
959 TagEnd::HtmlBlock | TagEnd::MetadataBlock(_) => {
960 self.pop_frame();
962 }
963 }
964 }
965
966 fn text(&mut self, s: String, range: Range<usize>) {
967 if let Some(Frame::CodeBlock {
970 text: buf,
971 text_source,
972 ..
973 }) = self.stack.last_mut()
974 {
975 buf.push_str(&s);
976 *text_source = union_source_ranges(text_source.take(), Some(range));
977 return;
978 }
979 if let Some(Frame::Image { alt, .. }) = self.stack.last_mut() {
980 alt.push_str(&s);
981 return;
982 }
983 self.ensure_inline_frame(range.clone());
984 self.extend_top_source(range.clone());
985 let run = self.inline.apply(self.html_styled_text(&s));
986 let source = self.source_range_for_visible(range.clone(), &s);
987 self.push_inline_mapped(run, &s, source, range, false);
988 }
989
990 #[cfg(feature = "html")]
994 fn html_styled_text(&self, s: &str) -> El {
995 match &self.html_template {
996 Some(template) => {
997 let mut el = template.clone();
998 el.text = Some(s.to_string());
999 el
1000 }
1001 None => text(s.to_string()),
1002 }
1003 }
1004
1005 #[cfg(not(feature = "html"))]
1006 fn html_styled_text(&self, s: &str) -> El {
1007 text(s.to_string())
1008 }
1009
1010 fn code_span(&mut self, s: String, range: Range<usize>) {
1011 if matches!(self.stack.last(), Some(Frame::CodeBlock { .. })) {
1016 if let Some(Frame::CodeBlock {
1019 text: buf,
1020 text_source,
1021 ..
1022 }) = self.stack.last_mut()
1023 {
1024 buf.push_str(&s);
1025 *text_source = union_source_ranges(text_source.take(), Some(range));
1026 }
1027 return;
1028 }
1029 if let Some(Frame::Image { alt, .. }) = self.stack.last_mut() {
1030 alt.push_str(&s);
1031 return;
1032 }
1033 self.ensure_inline_frame(range.clone());
1034 self.extend_top_source(range.clone());
1035 let run = self.inline.apply(text(s.clone()).code());
1036 let source = self.source_range_for_visible(range.clone(), &s);
1037 self.push_inline_mapped(run, &s, source, range, false);
1038 }
1039
1040 fn inline_math(&mut self, source: String, range: Range<usize>) {
1041 let expr = parse_tex_or_error(&source);
1042 self.ensure_inline_frame(range.clone());
1043 self.extend_top_source(range.clone());
1044 self.push_inline_mapped(math_inline(expr), "\u{fffc}", range.clone(), range, true);
1045 }
1046
1047 #[cfg(feature = "html")]
1058 fn block_html(&mut self, s: String, range: Range<usize>) {
1059 let _ = range;
1060 if matches!(self.stack.last(), Some(Frame::CodeBlock { .. })) {
1061 if let Some(Frame::CodeBlock {
1064 text: buf,
1065 text_source,
1066 ..
1067 }) = self.stack.last_mut()
1068 {
1069 buf.push_str(&s);
1070 *text_source = union_source_ranges(text_source.take(), None);
1071 }
1072 return;
1073 }
1074 let (blocks, findings) = damascene_html::html_blocks_with_lints(&s, self.options.html);
1075 self.html_findings.extend(findings);
1076 for block in blocks {
1077 self.push_block(block);
1078 }
1079 }
1080
1081 #[cfg(feature = "html")]
1098 fn inline_html(&mut self, s: String, range: Range<usize>) {
1099 if matches!(self.stack.last(), Some(Frame::CodeBlock { .. })) {
1100 if let Some(Frame::CodeBlock {
1101 text: buf,
1102 text_source,
1103 ..
1104 }) = self.stack.last_mut()
1105 {
1106 buf.push_str(&s);
1107 *text_source = union_source_ranges(text_source.take(), Some(range));
1108 }
1109 return;
1110 }
1111 if let Some(Frame::Image { alt, .. }) = self.stack.last_mut() {
1112 alt.push_str(&s);
1113 return;
1114 }
1115 match classify_inline_html_fragment(&s) {
1116 InlineHtmlFragment::Open(name) if STATEFUL_INLINE_TAGS.contains(&name.as_str()) => {
1117 if self.open_inline_html.len() >= MAX_OPEN_INLINE_HTML_TAGS {
1118 self.html_findings.push(damascene_html::Finding {
1119 kind: damascene_html::FindingKind::UnsupportedTag,
1120 detail: format!(
1121 "<{name}> dropped (more than {MAX_OPEN_INLINE_HTML_TAGS} \
1122 unclosed inline tags)"
1123 ),
1124 });
1125 return;
1126 }
1127 let (_, findings) =
1131 damascene_html::html_fragment_inline_with_lints(&s, self.options.html);
1132 self.html_findings.extend(findings);
1133 self.open_inline_html.push((name, s));
1134 self.recompute_html_template();
1135 return;
1136 }
1137 InlineHtmlFragment::Close(name) => {
1138 if let Some(pos) = self
1141 .open_inline_html
1142 .iter()
1143 .rposition(|(open, _)| *open == name)
1144 {
1145 self.open_inline_html.remove(pos);
1146 self.recompute_html_template();
1147 return;
1148 }
1149 }
1150 _ => {}
1151 }
1152 self.ensure_inline_frame(range.clone());
1153 self.extend_top_source(range.clone());
1154 let (runs, findings) =
1155 damascene_html::html_fragment_inline_with_lints(&s, self.options.html);
1156 self.html_findings.extend(findings);
1157 for run in runs {
1158 let styled = self.inline.apply(run);
1159 let visible = styled.text.clone().unwrap_or_default();
1162 self.push_inline_mapped(styled, &visible, range.clone(), range.clone(), false);
1163 }
1164 }
1165
1166 #[cfg(feature = "html")]
1172 fn recompute_html_template(&mut self) {
1173 if self.open_inline_html.is_empty() {
1174 self.html_template = None;
1175 return;
1176 }
1177 let mut src: String = self
1178 .open_inline_html
1179 .iter()
1180 .map(|(_, raw)| raw.as_str())
1181 .collect();
1182 src.push('X');
1183 let (runs, _) = damascene_html::html_fragment_inline_with_lints(&src, self.options.html);
1184 self.html_template = runs
1185 .into_iter()
1186 .find(|run| run.text.as_deref() == Some("X"));
1187 }
1188
1189 #[cfg(feature = "html")]
1192 fn clear_open_inline_html(&mut self) {
1193 self.open_inline_html.clear();
1194 self.html_template = None;
1195 }
1196
1197 #[cfg(not(feature = "html"))]
1198 fn clear_open_inline_html(&mut self) {}
1199
1200 fn display_math(&mut self, source: String, range: Range<usize>) {
1201 let expr = parse_tex_or_error(&source);
1202 let source_text = self.input.get(range.clone()).unwrap_or(&source).to_string();
1203 let visible = "\u{fffc}".to_string();
1204 let mut selection_source = SelectionSource::new(source_text.clone(), visible);
1205 selection_source.push_span(0.."\u{fffc}".len(), 0..source_text.len(), true);
1206 self.push_block(
1207 math_block(expr)
1208 .key(markdown_key("math", &range))
1209 .selectable()
1210 .selection_source(selection_source),
1211 );
1212 }
1213
1214 fn ensure_inline_frame(&mut self, source_range: Range<usize>) {
1221 match self.stack.last() {
1222 Some(
1223 Frame::Paragraph(_)
1224 | Frame::Heading(_, _)
1225 | Frame::Link(_, _)
1226 | Frame::TableCell { .. },
1227 ) => {}
1228 Some(Frame::Item { .. }) => {
1229 self.push_frame(Frame::Paragraph(InlineBuffer::default()), source_range)
1230 }
1231 _ => {}
1232 }
1233 }
1234
1235 fn push_block(&mut self, el: El) {
1238 for frame in self.stack.iter_mut().rev() {
1239 match frame {
1240 Frame::BlockQuote { blocks, .. } | Frame::Item { blocks, .. } => {
1241 blocks.push(el);
1242 return;
1243 }
1244 _ => {}
1245 }
1246 }
1247 self.root.push(el);
1248 }
1249
1250 fn current_inline_visible_len(&self) -> Option<usize> {
1255 self.stack.iter().rev().find_map(|frame| match frame {
1256 Frame::Paragraph(runs)
1257 | Frame::Heading(_, runs)
1258 | Frame::Link(_, runs)
1259 | Frame::TableCell { runs, .. } => Some(runs.visible_len()),
1260 _ => None,
1261 })
1262 }
1263
1264 fn current_inline_buffer_mut(&mut self) -> Option<&mut InlineBuffer> {
1265 self.stack.iter_mut().rev().find_map(|frame| match frame {
1266 Frame::Paragraph(runs)
1267 | Frame::Heading(_, runs)
1268 | Frame::Link(_, runs)
1269 | Frame::TableCell { runs, .. } => Some(runs),
1270 _ => None,
1271 })
1272 }
1273
1274 fn push_inline_mapped(
1275 &mut self,
1276 el: El,
1277 visible: &str,
1278 source: Range<usize>,
1279 source_full: Range<usize>,
1280 atomic: bool,
1281 ) {
1282 for frame in self.stack.iter_mut().rev() {
1283 match frame {
1284 Frame::Paragraph(runs)
1285 | Frame::Heading(_, runs)
1286 | Frame::Link(_, runs)
1287 | Frame::TableCell { runs, .. } => {
1288 runs.push(el, visible, source, source_full, atomic);
1289 return;
1290 }
1291 _ => {}
1292 }
1293 }
1294 }
1295
1296 fn push_inline_buffer(&mut self, buffer: InlineBuffer) {
1297 for frame in self.stack.iter_mut().rev() {
1298 match frame {
1299 Frame::Paragraph(runs)
1300 | Frame::Heading(_, runs)
1301 | Frame::Link(_, runs)
1302 | Frame::TableCell { runs, .. } => {
1303 runs.append(buffer);
1304 return;
1305 }
1306 _ => {}
1307 }
1308 }
1309 }
1310
1311 fn source_range_for_visible(&self, range: Range<usize>, visible: &str) -> Range<usize> {
1312 let Some(fragment) = self.input.get(range.clone()) else {
1313 return range;
1314 };
1315 let Some(start) = fragment.find(visible) else {
1316 return range;
1317 };
1318 (range.start + start)..(range.start + start + visible.len())
1319 }
1320
1321 #[cfg(feature = "html")]
1324 fn finish_with_lints(mut self) -> (El, Vec<damascene_html::Finding>) {
1325 let findings = std::mem::take(&mut self.html_findings);
1326 (self.finish(), findings)
1327 }
1328
1329 fn finish(mut self) -> El {
1330 while let Some((frame, source_range)) = self.pop_frame() {
1334 match frame {
1335 Frame::Paragraph(runs) => {
1336 self.root
1337 .push(build_paragraph(runs, &self.input, source_range))
1338 }
1339 Frame::Heading(level, runs) => {
1340 self.root
1341 .push(build_heading(level, runs, &self.input, source_range))
1342 }
1343 Frame::BlockQuote { kind, blocks } => {
1344 self.root.push(build_blockquote(kind, blocks))
1345 }
1346 Frame::List { start, items } => self.root.push(build_list(start, items)),
1347 Frame::Item { blocks, .. } => self.root.push(build_list_item(blocks)),
1348 Frame::CodeBlock {
1349 lang,
1350 text,
1351 text_source,
1352 indented,
1353 } => {
1354 let source_range = if indented {
1355 expand_source_range_start_to_line_start(&self.input, source_range)
1356 } else {
1357 source_range
1358 };
1359 self.root.push(build_code_block(
1360 lang.as_deref(),
1361 text,
1362 &self.input,
1363 source_range,
1364 text_source,
1365 ))
1366 }
1367 Frame::Link(_, runs) => {
1368 for run in runs.into_runs() {
1369 self.root.push(run);
1370 }
1371 }
1372 Frame::Image {
1373 alt,
1374 dest_url,
1375 title,
1376 } => self
1377 .root
1378 .push(build_image_placeholder(&alt, &dest_url, &title)),
1379 Frame::Table { head, body, .. } => self.root.push(build_table(head, body)),
1380 Frame::TableHead(_) | Frame::TableRow(_) | Frame::TableCell { .. } => {
1381 }
1384 }
1385 }
1386 column(self.root)
1387 .gap(tokens::SPACE_4)
1388 .width(Size::Fill(1.0))
1389 .height(Size::Hug)
1390 }
1391
1392 fn task_list_marker(&mut self, checked: bool) {
1393 for frame in self.stack.iter_mut().rev() {
1394 if let Frame::Item { task_checked, .. } = frame {
1395 *task_checked = Some(checked);
1396 return;
1397 }
1398 }
1399 }
1400
1401 fn in_inline_container(&self) -> bool {
1402 matches!(
1403 self.stack.last(),
1404 Some(
1405 Frame::Paragraph(_)
1406 | Frame::Heading(_, _)
1407 | Frame::Link(_, _)
1408 | Frame::TableCell { .. }
1409 )
1410 )
1411 }
1412
1413 fn inside_blockquote(&self) -> bool {
1414 self.stack
1415 .iter()
1416 .rev()
1417 .skip(1)
1418 .any(|frame| matches!(frame, Frame::BlockQuote { .. }))
1419 }
1420
1421 fn next_table_cell_alignment(&self) -> Alignment {
1422 let index = match self.stack.last() {
1423 Some(Frame::TableHead(cells)) | Some(Frame::TableRow(cells)) => cells.len(),
1424 _ => 0,
1425 };
1426 self.stack
1427 .iter()
1428 .rev()
1429 .find_map(|frame| {
1430 if let Frame::Table { alignments, .. } = frame {
1431 alignments.get(index).copied()
1432 } else {
1433 None
1434 }
1435 })
1436 .unwrap_or(Alignment::None)
1437 }
1438}
1439
1440fn build_code_block(
1448 lang: Option<&str>,
1449 raw_text: String,
1450 input: &str,
1451 source_range: Option<Range<usize>>,
1452 text_source: Option<Range<usize>>,
1453) -> El {
1454 let body = strip_trailing_newline(raw_text);
1455 let body_selection =
1456 code_block_selection_source(input, source_range.clone(), text_source, &body);
1457 let body_key_range = source_range.clone();
1458 #[cfg(feature = "highlighting")]
1459 if let Some(lang) = lang
1460 && let Some(syntax) = crate::highlight::find_syntax(lang)
1461 {
1462 let runs = crate::highlight::highlight_to_runs(&body, syntax);
1463 if !runs.is_empty() {
1464 let mut body = text_runs(runs)
1465 .mono()
1466 .nowrap_text()
1467 .font_size(tokens::TEXT_SM.size)
1468 .width(Size::Hug)
1469 .height(Size::Hug);
1470 if let Some(source) = body_selection.clone() {
1471 body = body
1472 .key(markdown_key(
1473 "code",
1474 &body_key_range.clone().unwrap_or(0..source.source.len()),
1475 ))
1476 .selectable()
1477 .selection_source(source);
1478 }
1479 return code_block_chrome(body);
1480 }
1481 }
1482 #[cfg(not(feature = "highlighting"))]
1483 let _ = lang;
1484 let mut body_el = text(body.clone())
1485 .mono()
1486 .font_size(tokens::TEXT_SM.size)
1487 .nowrap_text()
1488 .width(Size::Hug)
1489 .height(Size::Hug);
1490 if let Some(source) = body_selection {
1491 body_el = body_el
1492 .key(markdown_key(
1493 "code",
1494 &body_key_range.unwrap_or(0..source.source.len()),
1495 ))
1496 .selectable()
1497 .selection_source(source);
1498 }
1499 code_block_chrome(body_el)
1500}
1501
1502fn code_block_selection_source(
1503 input: &str,
1504 source_range: Option<Range<usize>>,
1505 text_source: Option<Range<usize>>,
1506 visible: &str,
1507) -> Option<SelectionSource> {
1508 let source_range = source_range?;
1509 let source_text = input.get(source_range.clone())?.to_string();
1510 let mut source = SelectionSource::new(source_text.clone(), visible.to_string());
1511 if visible.is_empty() {
1512 return Some(source);
1513 }
1514
1515 let search_range = text_source
1516 .map(|range| trim_source_range_end(input, range))
1517 .and_then(|range| {
1518 (range.start >= source_range.start && range.end <= source_range.end)
1519 .then_some((range.start - source_range.start)..(range.end - source_range.start))
1520 })
1521 .unwrap_or(0..source_text.len());
1522
1523 let mut visible_start = 0;
1524 let mut source_cursor = search_range.start;
1525 for segment in visible.split_inclusive('\n') {
1526 if segment.is_empty() {
1527 continue;
1528 }
1529 let search = &source.source[source_cursor..search_range.end];
1530 let found = search.find(segment)?;
1531 let source_start = source_cursor + found;
1532 let source_end = source_start + segment.len();
1533 let visible_end = visible_start + segment.len();
1534 source.push_span_with_full_source(
1535 visible_start..visible_end,
1536 source_start..source_end,
1537 source_start..source_end,
1538 false,
1539 );
1540 visible_start = visible_end;
1541 source_cursor = source_end;
1542 }
1543 Some(source)
1544}
1545
1546fn union_source_ranges(a: Option<Range<usize>>, b: Option<Range<usize>>) -> Option<Range<usize>> {
1547 match (a, b) {
1548 (Some(a), Some(b)) => Some(a.start.min(b.start)..a.end.max(b.end)),
1549 (Some(a), None) => Some(a),
1550 (None, Some(b)) => Some(b),
1551 (None, None) => None,
1552 }
1553}
1554
1555fn trim_source_range_end(input: &str, mut range: Range<usize>) -> Range<usize> {
1556 while range.end > range.start {
1557 let Some((idx, ch)) = input[..range.end].char_indices().next_back() else {
1558 break;
1559 };
1560 if matches!(ch, '\n' | '\r') {
1561 range.end = idx;
1562 } else {
1563 break;
1564 }
1565 }
1566 range
1567}
1568
1569fn expand_table_header_source_to_delimiter(input: &str, mut range: Range<usize>) -> Range<usize> {
1570 let mut cursor = range.end;
1571 if input.as_bytes().get(cursor) == Some(&b'\n') {
1572 cursor += 1;
1573 }
1574
1575 let delimiter_start = cursor;
1576 let delimiter_end = input[delimiter_start..]
1577 .find('\n')
1578 .map(|end| delimiter_start + end)
1579 .unwrap_or(input.len());
1580 let delimiter = input[delimiter_start..delimiter_end].trim();
1581 if is_table_delimiter_row(delimiter) {
1582 range.end = delimiter_end;
1583 }
1584 range
1585}
1586
1587fn is_table_delimiter_row(line: &str) -> bool {
1588 let mut saw_dash = false;
1589 for ch in line.chars() {
1590 match ch {
1591 '|' | ':' | '-' | ' ' | '\t' => {
1592 saw_dash |= ch == '-';
1593 }
1594 _ => return false,
1595 }
1596 }
1597 saw_dash
1598}
1599
1600fn expand_source_range_start_to_line_start(
1601 input: &str,
1602 range: Option<Range<usize>>,
1603) -> Option<Range<usize>> {
1604 let mut range = range?;
1605 while range.start > 0 && input.as_bytes().get(range.start - 1) != Some(&b'\n') {
1606 range.start -= 1;
1607 }
1608 Some(range)
1609}
1610
1611fn parse_tex_or_error(source: &str) -> MathExpr {
1612 match parse_tex(source) {
1613 Ok(expr) => expr,
1614 Err(err) => MathExpr::Error(format!("math parse error at {}: {}", err.byte, err.message)),
1615 }
1616}
1617
1618fn build_paragraph(inlines: InlineBuffer, input: &str, source_range: Option<Range<usize>>) -> El {
1622 let key_range = source_range.clone();
1623 let selection_source = inlines.selection_source(input, source_range);
1624 let runs = inlines.into_runs();
1625 let mut el = if let Some(plain) = single_plain_text(&runs) {
1626 paragraph(plain)
1627 } else {
1628 text_runs(runs)
1629 .wrap_text()
1630 .width(Size::Fill(1.0))
1631 .height(Size::Hug)
1632 };
1633 if let Some(source) = selection_source {
1634 el = el
1635 .key(markdown_key(
1636 "p",
1637 &key_range.unwrap_or(0..source.source.len()),
1638 ))
1639 .selectable()
1640 .selection_source(source);
1641 }
1642 el
1643}
1644
1645fn with_source_selection(
1646 mut el: El,
1647 kind: &str,
1648 source: Option<SelectionSource>,
1649 key_range: Option<Range<usize>>,
1650) -> El {
1651 if let Some(source) = source {
1652 el = el
1653 .key(markdown_key(
1654 kind,
1655 &key_range.unwrap_or(0..source.source.len()),
1656 ))
1657 .selectable()
1658 .selection_source(source);
1659 }
1660 el
1661}
1662
1663fn with_atomic_source_selection(
1664 mut el: El,
1665 kind: &str,
1666 input: &str,
1667 source_range: Option<Range<usize>>,
1668 visible: String,
1669) -> El {
1670 let Some(source_range) = source_range else {
1671 return el;
1672 };
1673 let Some(source_text) = input.get(source_range.clone()) else {
1674 return el;
1675 };
1676 let mut source = SelectionSource::new(source_text.to_string(), visible.clone());
1677 source.push_span(0..visible.len(), 0..source_text.len(), true);
1678 el = el
1679 .key(markdown_key(kind, &source_range))
1680 .selectable()
1681 .selection_source(source);
1682 el
1683}
1684
1685fn markdown_key(kind: &str, range: &Range<usize>) -> String {
1686 format!("md:{kind}:{}..{}", range.start, range.end)
1687}
1688
1689fn build_heading(
1694 level: HeadingLevel,
1695 inlines: InlineBuffer,
1696 input: &str,
1697 source_range: Option<Range<usize>>,
1698) -> El {
1699 let key_range = source_range.clone();
1700 let selection_source = inlines.selection_source(input, source_range);
1701 let runs = inlines.into_runs();
1702 if let Some(plain) = single_plain_text(&runs) {
1703 let el = match level {
1704 HeadingLevel::H1 => h1(plain),
1705 HeadingLevel::H2 => h2(plain),
1706 _ => h3(plain),
1709 };
1710 return with_source_selection(el, "h", selection_source, key_range);
1711 }
1712 let role = match level {
1713 HeadingLevel::H1 => TextRole::Display,
1714 HeadingLevel::H2 => TextRole::Heading,
1715 _ => TextRole::Title,
1716 };
1717 let el = text_runs(runs)
1718 .text_role(role)
1719 .wrap_text()
1720 .width(Size::Fill(1.0))
1721 .height(Size::Hug);
1722 with_source_selection(el, "h", selection_source, key_range)
1723}
1724
1725fn build_blockquote(kind: Option<BlockQuoteKind>, blocks: Vec<El>) -> El {
1726 let Some(kind) = kind else {
1727 return blockquote(blocks);
1728 };
1729
1730 let title = match kind {
1731 BlockQuoteKind::Note => "Note",
1732 BlockQuoteKind::Tip => "Tip",
1733 BlockQuoteKind::Important => "Important",
1734 BlockQuoteKind::Warning => "Warning",
1735 BlockQuoteKind::Caution => "Caution",
1736 };
1737 let body = match blocks.len() {
1738 0 => column(Vec::<El>::new()),
1739 1 => blocks.into_iter().next().unwrap(),
1740 _ => column(blocks)
1741 .gap(tokens::SPACE_2)
1742 .width(Size::Fill(1.0))
1743 .height(Size::Hug),
1744 };
1745 let alert = alert([alert_title(title), body]);
1746 match kind {
1747 BlockQuoteKind::Note | BlockQuoteKind::Important => alert.info(),
1748 BlockQuoteKind::Tip => alert.success(),
1749 BlockQuoteKind::Warning => alert.warning(),
1750 BlockQuoteKind::Caution => alert.destructive(),
1751 }
1752}
1753
1754fn build_list(start: Option<u64>, items: Vec<ListItem>) -> El {
1755 match start {
1756 None if !items.is_empty() && items.iter().all(|item| item.task_checked.is_some()) => {
1757 task_list(
1758 items
1759 .into_iter()
1760 .map(|item| (item.task_checked.unwrap_or(false), item.content)),
1761 )
1762 }
1763 None => bullet_list(items.into_iter().map(|item| item.content)),
1764 Some(start) => numbered_list_from(start, items.into_iter().map(|item| item.content)),
1765 }
1766}
1767
1768fn build_list_item(mut blocks: Vec<El>) -> El {
1771 if blocks.len() == 1 {
1772 blocks.pop().unwrap()
1773 } else {
1774 column(blocks)
1775 .gap(tokens::SPACE_2)
1776 .width(Size::Fill(1.0))
1777 .height(Size::Hug)
1778 }
1779}
1780
1781fn build_table(head: Option<Vec<El>>, body: Vec<Vec<El>>) -> El {
1784 let body_rows: Vec<El> = body.into_iter().flatten().collect();
1788 match head {
1789 Some(header_rows) => table([table_header(header_rows), table_body(body_rows)]),
1790 None => table([table_body(body_rows)]),
1791 }
1792}
1793
1794fn normalize_table_head_rows(items: Vec<El>) -> Vec<El> {
1795 if items.is_empty()
1796 || items
1797 .iter()
1798 .all(|item| item.metrics_role == Some(MetricsRole::TableRow))
1799 {
1800 items
1801 } else {
1802 vec![table_row(items)]
1803 }
1804}
1805
1806fn build_table_cell(
1811 inlines: InlineBuffer,
1812 in_header: bool,
1813 alignment: Alignment,
1814 input: &str,
1815 source_range: Option<Range<usize>>,
1816 row_source_range: Option<Range<usize>>,
1817) -> El {
1818 let key_range = source_range.clone().or_else(|| row_source_range.clone());
1819 let row_group = row_source_range
1820 .as_ref()
1821 .map(|range| format!("md:table-row:{}..{}", range.start, range.end));
1822 let selection_source_range = row_source_range.or(source_range);
1823 let selection_source = inlines
1824 .selection_source(input, selection_source_range)
1825 .map(|source| {
1826 if let Some(group) = row_group {
1827 source.full_selection_group(group)
1828 } else {
1829 source
1830 }
1831 });
1832 let runs = inlines.into_runs();
1833 if in_header {
1834 let cell = if let Some(plain) = single_plain_text(&runs) {
1835 table_head(plain)
1836 } else if runs.is_empty() {
1837 table_head("")
1838 } else {
1839 table_head_el(text_runs(runs).width(Size::Fill(1.0)))
1840 };
1841 let cell = with_source_selection(cell, "th", selection_source, key_range);
1842 return apply_table_alignment(cell, alignment);
1843 }
1844 let cell = if let Some(plain) = single_plain_text(&runs) {
1845 table_cell(text(plain))
1846 } else if runs.is_empty() {
1847 table_cell(text(""))
1848 } else {
1849 table_cell(text_runs(runs).width(Size::Fill(1.0)))
1850 };
1851 let cell = with_source_selection(cell, "td", selection_source, key_range);
1852 apply_table_alignment(cell, alignment)
1853}
1854
1855fn apply_table_alignment(mut el: El, alignment: Alignment) -> El {
1856 let text_align = match alignment {
1857 Alignment::None | Alignment::Left => TextAlign::Start,
1858 Alignment::Center => TextAlign::Center,
1859 Alignment::Right => TextAlign::End,
1860 };
1861 apply_text_align(&mut el, text_align);
1862 el
1863}
1864
1865fn apply_text_align(el: &mut El, text_align: TextAlign) {
1866 el.text_align = text_align;
1867 for child in &mut el.children {
1868 apply_text_align(child, text_align);
1869 }
1870}
1871
1872fn build_image_placeholder(alt: &str, dest_url: &str, title: &str) -> El {
1873 let label = image_placeholder_label(alt, dest_url, title);
1877 let mut el = text(label).muted().italic();
1878 if !dest_url.is_empty() {
1879 el = el.link(dest_url.to_string());
1880 }
1881 el
1882}
1883
1884fn image_placeholder_label(alt: &str, dest_url: &str, title: &str) -> String {
1885 let mut label = match (alt.is_empty(), dest_url.is_empty()) {
1886 (true, true) => "[image]".to_string(),
1887 (false, true) => format!("[image: {alt}]"),
1888 (true, false) => format!("[image: {dest_url}]"),
1889 (false, false) => format!("[image: {alt}] {dest_url}"),
1890 };
1891 if !title.is_empty() {
1892 label.push_str(" \"");
1893 label.push_str(title);
1894 label.push('"');
1895 }
1896 label
1897}
1898
1899fn single_plain_text(runs: &[El]) -> Option<String> {
1906 let mut out = String::new();
1907 for run in runs {
1908 if !matches!(run.kind, Kind::Text) {
1909 return None;
1910 }
1911 if run.font_weight != FontWeight::Regular
1912 || run.text_italic
1913 || run.text_strikethrough
1914 || run.text_underline
1915 || run.text_link.is_some()
1916 || run.text_role != TextRole::Body
1917 {
1918 return None;
1919 }
1920 if let Some(c) = run.text_color
1924 && c != tokens::FOREGROUND
1925 {
1926 return None;
1927 }
1928 let s = run.text.as_deref()?;
1929 out.push_str(s);
1930 }
1931 Some(out)
1932}
1933
1934fn strip_trailing_newline(mut s: String) -> String {
1937 if s.ends_with('\n') {
1938 s.pop();
1939 }
1940 s
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945 use super::*;
1946 use damascene_core::draw_ops::draw_ops;
1947 use damascene_core::ir::DrawOp;
1948 use damascene_core::layout::layout;
1949 use damascene_core::selection::{Selection, SelectionPoint, SelectionRange, selected_text};
1950 use damascene_core::state::UiState;
1951
1952 fn blocks(input: &str) -> Vec<El> {
1955 match md(input) {
1956 el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
1957 other => panic!("expected outer column, got {:?}", other.kind),
1958 }
1959 }
1960
1961 fn blocks_with_options(input: &str, options: MarkdownOptions) -> Vec<El> {
1962 match md_with_options(input, options) {
1963 el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
1964 other => panic!("expected outer column, got {:?}", other.kind),
1965 }
1966 }
1967
1968 fn first_source_backed(el: &El) -> Option<&El> {
1969 if el.selection_source.is_some() {
1970 return Some(el);
1971 }
1972 el.children.iter().find_map(first_source_backed)
1973 }
1974
1975 fn collect_source_backed<'a>(el: &'a El, out: &mut Vec<&'a El>) {
1976 if el.selection_source.is_some() {
1977 out.push(el);
1978 }
1979 for child in &el.children {
1980 collect_source_backed(child, out);
1981 }
1982 }
1983
1984 fn selection(key: &str, start: usize, end: usize) -> Selection {
1985 Selection {
1986 range: Some(SelectionRange {
1987 anchor: SelectionPoint::new(key, start),
1988 head: SelectionPoint::new(key, end),
1989 }),
1990 }
1991 }
1992
1993 #[test]
1994 fn empty_document_yields_an_empty_column() {
1995 let bs = blocks("");
1996 assert!(bs.is_empty());
1997 }
1998
1999 #[test]
2000 fn h1_h2_h3_map_to_heading_constructors() {
2001 let bs = blocks("# Title\n\n## Subtitle\n\n### Section");
2002 assert_eq!(bs.len(), 3);
2003 assert_eq!(bs[0].kind, Kind::Heading);
2004 assert_eq!(bs[0].text.as_deref(), Some("Title"));
2005 assert_eq!(bs[0].text_role, TextRole::Display);
2006 assert_eq!(bs[1].text_role, TextRole::Heading);
2007 assert_eq!(bs[2].text_role, TextRole::Title);
2008 }
2009
2010 #[test]
2011 fn h4_h5_h6_clamp_to_h3() {
2012 let bs = blocks("#### Four\n\n##### Five\n\n###### Six");
2013 for b in &bs {
2014 assert_eq!(b.kind, Kind::Heading);
2015 assert_eq!(b.text_role, TextRole::Title);
2016 }
2017 }
2018
2019 #[test]
2020 fn markdown_heading_selection_copies_heading_marker_when_whole_heading_selected() {
2021 let input = "## Subtitle";
2022 let doc = md(input);
2023 let node = first_source_backed(&doc).expect("source-backed heading");
2024 let source = node.selection_source.as_ref().unwrap();
2025 let key = node.key.as_deref().unwrap();
2026
2027 assert_eq!(
2028 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2029 Some(input)
2030 );
2031 assert_eq!(
2032 selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2033 Some("ubtitl")
2034 );
2035 }
2036
2037 #[test]
2038 fn markdown_blockquote_selection_copies_quote_marker_when_whole_line_selected() {
2039 let input = "> Quoted text";
2040 let doc = md(input);
2041 let node = first_source_backed(&doc).expect("source-backed quote paragraph");
2042 let source = node.selection_source.as_ref().unwrap();
2043 let key = node.key.as_deref().unwrap();
2044
2045 assert_eq!(
2046 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2047 Some(input)
2048 );
2049 assert_eq!(
2050 selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2051 Some("uoted tex")
2052 );
2053 }
2054
2055 #[test]
2056 fn markdown_blockquote_selection_preserves_markers_for_heading_and_list_items() {
2057 let input = "> ## Quoted heading\n>\n> - quoted item";
2058 let doc = md(input);
2059 let mut nodes = Vec::new();
2060 collect_source_backed(&doc, &mut nodes);
2061
2062 let selected_whole = |visible: &str| {
2063 let node = nodes
2064 .iter()
2065 .find(|node| {
2066 node.selection_source
2067 .as_ref()
2068 .is_some_and(|source| source.visible == visible)
2069 })
2070 .expect("source-backed quoted node");
2071 let source = node.selection_source.as_ref().unwrap();
2072 let key = node.key.as_deref().unwrap();
2073 selected_text(&doc, &selection(key, 0, source.visible_len()))
2074 };
2075
2076 assert_eq!(
2077 selected_whole("Quoted heading").as_deref(),
2078 Some("> ## Quoted heading")
2079 );
2080 assert_eq!(
2081 selected_whole("quoted item").as_deref(),
2082 Some("> - quoted item")
2083 );
2084 }
2085
2086 #[test]
2087 fn plain_paragraph_collapses_to_paragraph_widget() {
2088 let bs = blocks("Just some prose.");
2089 assert_eq!(bs.len(), 1);
2090 assert_eq!(bs[0].kind, Kind::Text);
2091 assert_eq!(bs[0].text.as_deref(), Some("Just some prose."));
2092 assert_eq!(bs[0].text_wrap, TextWrap::Wrap);
2093 }
2094
2095 #[test]
2096 fn paragraph_with_inline_styling_uses_text_runs() {
2097 let bs = blocks("Hello **world** and *italic* and `code`.");
2098 assert_eq!(bs.len(), 1);
2099 assert_eq!(bs[0].kind, Kind::Inlines);
2100 let runs: Vec<&El> = bs[0].children.iter().collect();
2101 assert!(
2104 runs.iter()
2105 .any(|r| r.font_weight == FontWeight::Bold && r.text.as_deref() == Some("world"))
2106 );
2107 assert!(
2108 runs.iter()
2109 .any(|r| r.text_italic && r.text.as_deref() == Some("italic"))
2110 );
2111 assert!(
2112 runs.iter()
2113 .any(|r| r.text_role == TextRole::Code && r.text.as_deref() == Some("code"))
2114 );
2115 }
2116
2117 #[test]
2118 fn markdown_selection_copies_paragraph_source() {
2119 let input = "This is **bold**.";
2120 let doc = md(input);
2121 let node = first_source_backed(&doc).expect("source-backed paragraph");
2122 let source = node.selection_source.as_ref().unwrap();
2123 let key = node.key.as_deref().unwrap();
2124
2125 let bold_start = source.visible.find("bold").unwrap();
2126 let bold_end = bold_start + "bold".len();
2127 assert_eq!(
2128 selected_text(&doc, &selection(key, bold_start, bold_end)).as_deref(),
2129 Some("**bold**")
2130 );
2131 assert_eq!(
2132 selected_text(&doc, &selection(key, bold_start + 1, bold_end - 1)).as_deref(),
2133 Some("ol")
2134 );
2135 assert_eq!(
2136 selected_text(&doc, &selection(key, 0, bold_end)).as_deref(),
2137 Some("This is **bold**")
2138 );
2139 assert_eq!(
2140 selected_text(&doc, &selection(key, bold_start, source.visible_len())).as_deref(),
2141 Some("**bold**.")
2142 );
2143 assert_eq!(
2144 selected_text(&doc, &selection(key, bold_start + 1, source.visible_len())).as_deref(),
2145 Some("old.")
2146 );
2147 assert_eq!(
2148 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2149 Some(input)
2150 );
2151 }
2152
2153 #[test]
2154 fn markdown_selection_copies_all_delimiters_when_styled_text_fills_paragraph() {
2155 let input = "**bold**";
2156 let doc = md(input);
2157 let node = first_source_backed(&doc).expect("source-backed paragraph");
2158 let source = node.selection_source.as_ref().unwrap();
2159 let key = node.key.as_deref().unwrap();
2160
2161 assert_eq!(source.visible, "bold");
2162 assert_eq!(
2163 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2164 Some(input)
2165 );
2166 }
2167
2168 #[test]
2169 fn markdown_selection_copies_full_inline_construct_source() {
2170 let input = "Use `code` and [site](https://damascene.dev).";
2171 let doc = md(input);
2172 let node = first_source_backed(&doc).expect("source-backed paragraph");
2173 let source = node.selection_source.as_ref().unwrap();
2174 let key = node.key.as_deref().unwrap();
2175
2176 let code_start = source.visible.find("code").unwrap();
2177 let code_end = code_start + "code".len();
2178 assert_eq!(
2179 selected_text(&doc, &selection(key, code_start, code_end)).as_deref(),
2180 Some("`code`")
2181 );
2182 assert_eq!(
2183 selected_text(&doc, &selection(key, code_start + 1, code_end - 1)).as_deref(),
2184 Some("od")
2185 );
2186
2187 let site_start = source.visible.find("site").unwrap();
2188 let site_end = site_start + "site".len();
2189 assert_eq!(
2190 selected_text(&doc, &selection(key, site_start, site_end)).as_deref(),
2191 Some("[site](https://damascene.dev)")
2192 );
2193 assert_eq!(
2194 selected_text(&doc, &selection(key, site_start + 1, site_end - 1)).as_deref(),
2195 Some("it")
2196 );
2197 }
2198
2199 #[test]
2200 fn markdown_list_selection_does_not_pull_in_previous_document_source() {
2201 let input = "Intro paragraph.\n\n- first item\n- second item\n- third item";
2202 let doc = md(input);
2203 let mut nodes = Vec::new();
2204 collect_source_backed(&doc, &mut nodes);
2205 let list_nodes: Vec<&El> = nodes
2206 .into_iter()
2207 .filter(|node| {
2208 node.selection_source
2209 .as_ref()
2210 .is_some_and(|source| source.visible.contains("item"))
2211 })
2212 .collect();
2213 assert_eq!(list_nodes.len(), 3);
2214
2215 let first = list_nodes[0];
2216 let third = list_nodes[2];
2217 let first_key = first.key.as_deref().unwrap();
2218 let third_key = third.key.as_deref().unwrap();
2219 let third_len = third.selection_source.as_ref().unwrap().visible_len();
2220
2221 let selected = selected_text(
2222 &doc,
2223 &Selection {
2224 range: Some(SelectionRange {
2225 anchor: SelectionPoint::new(first_key, 0),
2226 head: SelectionPoint::new(third_key, third_len),
2227 }),
2228 },
2229 );
2230 assert_eq!(
2231 selected.as_deref(),
2232 Some("- first item\n- second item\n- third item")
2233 );
2234 }
2235
2236 #[test]
2237 fn markdown_list_whole_item_selection_copies_marker_source() {
2238 let input = "Intro paragraph.\n\n- first item\n- second item";
2239 let doc = md(input);
2240 let mut nodes = Vec::new();
2241 collect_source_backed(&doc, &mut nodes);
2242 let first_item = nodes
2243 .into_iter()
2244 .find(|node| {
2245 node.selection_source
2246 .as_ref()
2247 .is_some_and(|source| source.visible == "first item")
2248 })
2249 .expect("first list item");
2250 let source = first_item.selection_source.as_ref().unwrap();
2251 let key = first_item.key.as_deref().unwrap();
2252
2253 assert_eq!(
2254 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2255 Some("- first item")
2256 );
2257 assert_eq!(
2258 selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2259 Some("irst ite")
2260 );
2261 }
2262
2263 #[test]
2264 fn markdown_list_selection_preserves_ordered_and_task_markers() {
2265 let input = "3. third item\n4. fourth item\n\n- [x] done item\n- [ ] todo item";
2266 let doc = md(input);
2267 let mut nodes = Vec::new();
2268 collect_source_backed(&doc, &mut nodes);
2269
2270 let selected_whole_item = |visible: &str| {
2271 let item = nodes
2272 .iter()
2273 .find(|node| {
2274 node.selection_source
2275 .as_ref()
2276 .is_some_and(|source| source.visible == visible)
2277 })
2278 .expect("list item");
2279 let source = item.selection_source.as_ref().unwrap();
2280 let key = item.key.as_deref().unwrap();
2281 selected_text(&doc, &selection(key, 0, source.visible_len()))
2282 };
2283
2284 assert_eq!(
2285 selected_whole_item("third item").as_deref(),
2286 Some("3. third item")
2287 );
2288 assert_eq!(
2289 selected_whole_item("done item").as_deref(),
2290 Some("- [x] done item")
2291 );
2292 assert_eq!(
2293 selected_whole_item("todo item").as_deref(),
2294 Some("- [ ] todo item")
2295 );
2296 }
2297
2298 #[test]
2299 fn math_option_routes_inline_and_display_math_to_math_nodes() {
2300 let bs = blocks_with_options(
2301 "Euler $e^{i\\pi}+1=0$\n\n$$\\frac{a}{b}$$",
2302 MarkdownOptions::default().math(true),
2303 );
2304 assert_eq!(bs.len(), 2);
2305 assert_eq!(bs[0].kind, Kind::Inlines);
2306 assert!(
2307 bs[0]
2308 .children
2309 .iter()
2310 .any(|child| matches!(child.kind, Kind::Math) && child.math.is_some())
2311 );
2312 assert_eq!(bs[1].kind, Kind::Math);
2313 assert_eq!(bs[1].math_display, MathDisplay::Block);
2314 }
2315
2316 #[test]
2317 fn inline_math_selection_copies_original_tex_source_atomically() {
2318 let input = "Inline $x_1^2$ math.";
2319 let doc = md_with_options(input, MarkdownOptions::default().math(true));
2320 let node = first_source_backed(&doc).expect("source-backed paragraph");
2321 let source = node.selection_source.as_ref().unwrap();
2322 let key = node.key.as_deref().unwrap();
2323 let math_start = source.visible.find('\u{fffc}').unwrap();
2324 let math_end = math_start + "\u{fffc}".len();
2325
2326 assert_eq!(
2327 selected_text(&doc, &selection(key, math_start, math_end)).as_deref(),
2328 Some("$x_1^2$")
2329 );
2330 assert_eq!(
2331 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2332 Some(input)
2333 );
2334 }
2335
2336 #[test]
2337 fn display_math_selection_copies_original_tex_source_atomically() {
2338 let input = "$$\\frac{a}{b}$$";
2339 let doc = md_with_options(input, MarkdownOptions::default().math(true));
2340 let node = first_source_backed(&doc).expect("source-backed display math");
2341 let source = node.selection_source.as_ref().unwrap();
2342 let key = node.key.as_deref().unwrap();
2343
2344 assert_eq!(
2345 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2346 Some(input)
2347 );
2348 }
2349
2350 #[test]
2351 fn link_groups_runs_under_the_same_url() {
2352 let bs = blocks("Check [the **bold** site](https://damascene.dev) for info.");
2353 assert_eq!(bs[0].kind, Kind::Inlines);
2354 let linked: Vec<&El> = bs[0]
2355 .children
2356 .iter()
2357 .filter(|r| r.text_link.as_deref() == Some("https://damascene.dev"))
2358 .collect();
2359 assert!(!linked.is_empty(), "expected at least one linked run");
2360 assert!(linked.iter().any(|r| r.font_weight == FontWeight::Bold));
2363 }
2364
2365 #[test]
2366 fn bullet_list_emits_bullet_list_widget() {
2367 let bs = blocks("- one\n- two\n- three");
2368 assert_eq!(bs.len(), 1);
2369 assert_eq!(bs[0].kind, Kind::Group);
2372 assert_eq!(bs[0].axis, Axis::Column);
2373 assert_eq!(bs[0].children.len(), 3);
2374 }
2375
2376 #[test]
2377 fn ordered_list_emits_numbered_list_widget() {
2378 let bs = blocks("1. alpha\n2. beta\n3. gamma");
2379 assert_eq!(bs[0].kind, Kind::Group);
2380 assert_eq!(bs[0].axis, Axis::Column);
2381 assert_eq!(bs[0].children.len(), 3);
2382 let first_marker_slot = &bs[0].children[0].children[0];
2384 let first_marker = &first_marker_slot.children[0];
2385 assert_eq!(first_marker.text.as_deref(), Some("1."));
2386 }
2387
2388 #[test]
2389 fn ordered_list_preserves_non_one_start_number() {
2390 let bs = blocks("42. alpha\n43. beta");
2391 assert_eq!(bs[0].children.len(), 2);
2392 let first_marker_slot = &bs[0].children[0].children[0];
2393 let second_marker_slot = &bs[0].children[1].children[0];
2394 assert_eq!(first_marker_slot.children[0].text.as_deref(), Some("42."));
2395 assert_eq!(second_marker_slot.children[0].text.as_deref(), Some("43."));
2396 }
2397
2398 #[test]
2399 fn task_list_emits_static_task_markers() {
2400 let bs = blocks("- [x] done\n- [ ] todo");
2401 assert_eq!(bs.len(), 1);
2402 assert_eq!(bs[0].children.len(), 2);
2403
2404 let checked = &bs[0].children[0].children[0].children[0];
2405 let unchecked = &bs[0].children[1].children[0].children[0];
2406 assert_eq!(checked.kind, Kind::Custom("task_marker"));
2407 assert_eq!(unchecked.kind, Kind::Custom("task_marker"));
2408 assert_eq!(checked.fill, Some(tokens::PRIMARY));
2409 assert_eq!(unchecked.fill, Some(tokens::CARD));
2410 assert!(!checked.focusable);
2411 assert!(!unchecked.focusable);
2412 }
2413
2414 #[test]
2415 fn nested_list_lives_inside_the_outer_item() {
2416 let input = "- outer one\n - inner a\n - inner b\n- outer two";
2417 let bs = blocks(input);
2418 assert_eq!(bs.len(), 1);
2419 let outer = &bs[0];
2420 assert_eq!(outer.children.len(), 2);
2421 let first_item_body = &outer.children[0].children[1];
2425 let inner_content = &first_item_body.children[0];
2428 assert_eq!(inner_content.kind, Kind::Group);
2431 assert!(inner_content.children.len() >= 2);
2432 }
2433
2434 #[test]
2435 fn blockquote_wraps_inner_paragraphs() {
2436 let bs = blocks("> First line.\n>\n> Second line.");
2437 assert_eq!(bs.len(), 1);
2438 assert_eq!(bs[0].kind, Kind::Group);
2440 assert_eq!(bs[0].axis, Axis::Overlay);
2441 assert_eq!(bs[0].children.len(), 2);
2442 let body = &bs[0].children[1];
2443 assert_eq!(body.children.len(), 2);
2444 }
2445
2446 #[test]
2447 fn fenced_code_block_keeps_verbatim_text() {
2448 let bs = blocks("```\nfn main() {}\n```");
2449 assert_eq!(bs.len(), 1);
2450 let surface = &bs[0];
2454 assert_eq!(surface.surface_role, SurfaceRole::Sunken);
2455 let body = &surface.children[0];
2456 assert_eq!(body.text.as_deref(), Some("fn main() {}"));
2457 assert!(body.font_mono);
2458 assert_eq!(
2459 body.mono_font_family,
2460 damascene_core::tree::FontFamily::JetBrainsMono
2461 );
2462 }
2463
2464 #[test]
2465 fn indented_code_block_keeps_verbatim_text() {
2466 let bs = blocks(" let x = 1;\n let y = 2;");
2467 assert_eq!(bs.len(), 1);
2468 let body = &bs[0].children[0];
2469 assert_eq!(body.text.as_deref(), Some("let x = 1;\nlet y = 2;"));
2470 }
2471
2472 #[test]
2473 fn markdown_code_block_selection_copies_fence_when_whole_body_selected() {
2474 let input = "```rust\nfn main() {}\nlet x = 1;\n```";
2475 let doc = md(input);
2476 let mut nodes = Vec::new();
2477 collect_source_backed(&doc, &mut nodes);
2478 let body = nodes
2479 .into_iter()
2480 .find(|node| {
2481 node.selection_source
2482 .as_ref()
2483 .is_some_and(|source| source.visible == "fn main() {}\nlet x = 1;")
2484 })
2485 .expect("source-backed code body");
2486 let source = body.selection_source.as_ref().unwrap();
2487 let key = body.key.as_deref().unwrap();
2488 let main_start = source.visible.find("main").unwrap();
2489
2490 assert_eq!(
2491 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2492 Some(input)
2493 );
2494 assert_eq!(
2495 selected_text(&doc, &selection(key, main_start, main_start + "main".len())).as_deref(),
2496 Some("main")
2497 );
2498 }
2499
2500 #[test]
2501 fn markdown_indented_code_partial_selection_copies_visible_code() {
2502 let input = " let x = 1;\n let y = 2;";
2503 let doc = md(input);
2504 let node = first_source_backed(&doc).expect("source-backed code body");
2505 let source = node.selection_source.as_ref().unwrap();
2506 let key = node.key.as_deref().unwrap();
2507 let y_start = source.visible.find("let y").unwrap();
2508
2509 assert_eq!(
2510 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2511 Some(input)
2512 );
2513 assert_eq!(
2514 selected_text(&doc, &selection(key, y_start, source.visible_len())).as_deref(),
2515 Some("let y = 2;")
2516 );
2517 }
2518
2519 #[test]
2523 fn fenced_code_block_unknown_language_falls_back_to_plain_mono() {
2524 let bs = blocks("```nothinglikethis\nfn x() {}\n```");
2525 assert_eq!(bs.len(), 1);
2526 let body = &bs[0].children[0];
2527 assert_eq!(body.kind, Kind::Text);
2528 assert_eq!(body.text.as_deref(), Some("fn x() {}"));
2529 assert!(body.font_mono);
2530 }
2531
2532 #[cfg(feature = "highlighting")]
2539 #[test]
2540 fn fenced_rust_code_block_emits_highlighted_runs() {
2541 let bs = blocks("```rust\n// hi\nfn main() {}\n```");
2542 assert_eq!(bs.len(), 1);
2543 let surface = &bs[0];
2544 assert_eq!(surface.surface_role, SurfaceRole::Sunken);
2545 let body = &surface.children[0];
2546 assert_eq!(body.kind, Kind::Inlines);
2547 assert!(body.font_mono);
2548 let text_runs: Vec<&El> = body
2549 .children
2550 .iter()
2551 .filter(|c| c.kind == Kind::Text)
2552 .collect();
2553 assert!(
2554 text_runs.len() > 2,
2555 "expected multiple highlighted runs, got {}",
2556 text_runs.len()
2557 );
2558 assert!(
2559 text_runs.iter().all(|r| r.font_mono),
2560 "every highlighted run should ride the mono path"
2561 );
2562 assert!(
2563 text_runs.iter().any(|r| r.text_color.is_some()),
2564 "expected at least one run to carry a syntax color"
2565 );
2566 }
2567
2568 #[test]
2569 fn horizontal_rule_emits_a_divider() {
2570 let bs = blocks("Above.\n\n---\n\nBelow.");
2571 let kinds: Vec<&Kind> = bs.iter().map(|b| &b.kind).collect();
2572 assert!(kinds.iter().any(|k| matches!(k, Kind::Divider)));
2573 }
2574
2575 #[test]
2576 fn hard_break_inside_paragraph_emits_hard_break_node() {
2577 let bs = blocks("line one \nline two");
2579 assert_eq!(bs[0].kind, Kind::Inlines);
2580 assert!(
2581 bs[0]
2582 .children
2583 .iter()
2584 .any(|c| matches!(c.kind, Kind::HardBreak))
2585 );
2586 }
2587
2588 #[test]
2589 fn soft_break_renders_as_a_space() {
2590 let bs = blocks("line one\nline two");
2591 assert_eq!(bs[0].kind, Kind::Text);
2592 let s = bs[0].text.as_deref().unwrap();
2594 assert!(s.contains("line one line two"), "got {s:?}");
2595 }
2596
2597 #[test]
2598 fn image_renders_as_alt_placeholder() {
2599 let bs = blocks("");
2600 assert_eq!(bs.len(), 1);
2601 assert_eq!(bs[0].kind, Kind::Inlines);
2602 let run = &bs[0].children[0];
2603 let s = run.text.as_deref().unwrap_or("");
2604 assert!(s.contains("diagram of pipeline"), "got {s:?}");
2605 assert!(s.contains("pipeline.png"), "got {s:?}");
2606 assert!(s.contains("Pipeline"), "got {s:?}");
2607 assert_eq!(run.text_link.as_deref(), Some("pipeline.png"));
2608 }
2609
2610 #[test]
2611 fn inline_image_placeholder_preserves_order() {
2612 let bs = blocks("Before  after");
2613 assert_eq!(bs[0].kind, Kind::Inlines);
2614 let text: String = bs[0]
2615 .children
2616 .iter()
2617 .filter_map(|run| run.text.as_deref())
2618 .collect();
2619 assert!(
2620 text.contains("Before [image: alt] img.png after"),
2621 "got {text:?}"
2622 );
2623 }
2624
2625 #[test]
2626 fn markdown_image_selection_copies_image_source_atomically() {
2627 let input = "Before  after";
2628 let doc = md(input);
2629 let node = first_source_backed(&doc).expect("source-backed paragraph");
2630 let source = node.selection_source.as_ref().unwrap();
2631 let key = node.key.as_deref().unwrap();
2632 let label = "[image: alt text] img.png \"Title\"";
2633 let image_start = source.visible.find(label).unwrap();
2634 let image_end = image_start + label.len();
2635
2636 assert_eq!(
2637 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2638 Some(input)
2639 );
2640 assert_eq!(
2641 selected_text(&doc, &selection(key, image_start, image_end)).as_deref(),
2642 Some("")
2643 );
2644 }
2645
2646 #[test]
2647 fn document_outer_column_carries_block_gap() {
2648 let el = md("# A\n\nb");
2649 assert_eq!(el.kind, Kind::Group);
2650 assert_eq!(el.gap, tokens::SPACE_4);
2651 }
2652
2653 #[test]
2654 fn table_emits_header_plus_body_widget() {
2655 let bs = blocks(
2656 "\
2657| Name | Role |\n\
2658|-------|------|\n\
2659| Ada | dev |\n\
2660| Grace | ops |\n",
2661 );
2662 assert_eq!(bs.len(), 1);
2663 let t = &bs[0];
2664 assert_eq!(t.kind, Kind::Custom("table"));
2667 assert_eq!(t.children.len(), 2);
2668 let header = &t.children[0];
2669 let body = &t.children[1];
2670 assert_eq!(header.kind, Kind::Custom("table_header"));
2671 assert_eq!(body.kind, Kind::Custom("table_body"));
2672 assert_eq!(header.children.len(), 1);
2674 assert_eq!(header.children[0].children.len(), 2);
2675 assert_eq!(header.children[0].children[0].text.as_deref(), Some("Name"));
2676 assert_eq!(header.children[0].children[1].text.as_deref(), Some("Role"));
2677 assert_eq!(body.children.len(), 2);
2679 assert_eq!(body.children[0].children.len(), 2);
2680 assert_eq!(body.children[0].children[0].text.as_deref(), Some("Ada"));
2681 assert_eq!(body.children[0].children[1].text.as_deref(), Some("dev"));
2682 }
2683
2684 #[test]
2685 fn table_header_cells_carry_caption_styling() {
2686 let bs = blocks(
2687 "\
2688| Header |\n\
2689|--------|\n\
2690| body |\n",
2691 );
2692 let t = &bs[0];
2693 let header_cell = &t.children[0].children[0].children[0];
2694 assert_eq!(header_cell.text.as_deref(), Some("Header"));
2695 assert_eq!(header_cell.text_role, TextRole::Caption);
2697 }
2698
2699 #[test]
2700 fn table_body_cells_with_inline_styling_use_text_runs() {
2701 let bs = blocks(
2702 "\
2703| Col |\n\
2704|-----|\n\
2705| **bold** word |\n",
2706 );
2707 let t = &bs[0];
2708 let body_cell = &t.children[1].children[0].children[0];
2709 assert_eq!(body_cell.kind, Kind::Inlines);
2711 assert!(
2712 body_cell
2713 .children
2714 .iter()
2715 .any(|r| r.font_weight == FontWeight::Bold && r.text.as_deref() == Some("bold"))
2716 );
2717 }
2718
2719 #[test]
2720 fn table_alignment_applies_to_header_and_body_cells() {
2721 let bs = blocks(
2722 "\
2723| Left | Center | Right |\n\
2724|:-----|:------:|------:|\n\
2725| a | b | c |\n",
2726 );
2727 let t = &bs[0];
2728 let header_row = &t.children[0].children[0];
2729 let body_row = &t.children[1].children[0];
2730
2731 assert_eq!(header_row.children[0].text_align, TextAlign::Start);
2732 assert_eq!(header_row.children[1].text_align, TextAlign::Center);
2733 assert_eq!(header_row.children[2].text_align, TextAlign::End);
2734 assert_eq!(body_row.children[0].text_align, TextAlign::Start);
2735 assert_eq!(body_row.children[1].text_align, TextAlign::Center);
2736 assert_eq!(body_row.children[2].text_align, TextAlign::End);
2737 }
2738
2739 #[test]
2740 fn table_header_cells_preserve_inline_styling() {
2741 let bs = blocks(
2742 "\
2743| **Header** |\n\
2744|------------|\n\
2745| body |\n",
2746 );
2747 let header_cell = &bs[0].children[0].children[0].children[0];
2748 assert_eq!(header_cell.kind, Kind::Inlines);
2749 assert!(
2750 header_cell
2751 .children
2752 .iter()
2753 .any(|r| r.font_weight == FontWeight::Bold
2754 && r.text_role == TextRole::Caption
2755 && r.text.as_deref() == Some("Header"))
2756 );
2757 }
2758
2759 #[test]
2760 fn markdown_table_cell_selection_copies_row_source_when_whole_cell_selected() {
2761 let input = "\
2762| Name | Role |\n\
2763|------|------|\n\
2764| **Ada** | dev |\n";
2765 let doc = md(input);
2766 let mut nodes = Vec::new();
2767 collect_source_backed(&doc, &mut nodes);
2768 let ada_cell = nodes
2769 .into_iter()
2770 .find(|node| {
2771 node.selection_source
2772 .as_ref()
2773 .is_some_and(|source| source.visible == "Ada")
2774 })
2775 .expect("source-backed Ada table cell");
2776 let source = ada_cell.selection_source.as_ref().unwrap();
2777 let key = ada_cell.key.as_deref().unwrap();
2778
2779 assert_eq!(
2780 selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2781 Some("| **Ada** | dev |")
2782 );
2783 assert_eq!(
2784 selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2785 Some("d")
2786 );
2787 }
2788
2789 #[test]
2790 fn markdown_table_row_selection_copies_pipe_row_source_once() {
2791 let input = "\
2792| Name | Role |\n\
2793|------|------|\n\
2794| **Ada** | dev |\n";
2795 let doc = md(input);
2796 let mut nodes = Vec::new();
2797 collect_source_backed(&doc, &mut nodes);
2798 let ada_cell = nodes
2799 .iter()
2800 .find(|node| {
2801 node.selection_source
2802 .as_ref()
2803 .is_some_and(|source| source.visible == "Ada")
2804 })
2805 .expect("source-backed Ada table cell");
2806 let role_cell = nodes
2807 .iter()
2808 .find(|node| {
2809 node.selection_source
2810 .as_ref()
2811 .is_some_and(|source| source.visible == "dev")
2812 })
2813 .expect("source-backed role table cell");
2814 let ada_key = ada_cell.key.as_deref().unwrap();
2815 let role_key = role_cell.key.as_deref().unwrap();
2816 let role_len = role_cell.selection_source.as_ref().unwrap().visible_len();
2817
2818 let selected = selected_text(
2819 &doc,
2820 &Selection {
2821 range: Some(SelectionRange {
2822 anchor: SelectionPoint::new(ada_key, 0),
2823 head: SelectionPoint::new(role_key, role_len),
2824 }),
2825 },
2826 );
2827 assert_eq!(selected.as_deref(), Some("| **Ada** | dev |"));
2828 }
2829
2830 #[test]
2831 fn markdown_table_header_draws_and_copy_preserves_separator() {
2832 let input = "\
2833| Construct | Maps to |\n\
2834|------------|--------------------|\n\
2835| Heading | `h1` / `h2` / `h3` |\n\
2836| List | `bullet_list` / `numbered_list` |\n\
2837| Blockquote | `blockquote` |\n\
2838| Code block | `code_block` |\n\
2839| Table | `table` |\n";
2840 let mut doc = scroll([column([md(input)])
2841 .gap(tokens::SPACE_4)
2842 .align(Align::Start)
2843 .width(Size::Fill(1.0))])
2844 .height(Size::Fill(1.0));
2845 let mut state = UiState::new();
2846 layout(&mut doc, &mut state, Rect::new(0.0, 0.0, 640.0, 240.0));
2847 let ops = draw_ops(&doc, &state);
2848
2849 let text_y = |needle: &str| {
2850 ops.iter().find_map(|op| match op {
2851 DrawOp::GlyphRun { text, rect, .. } if text == needle => Some(rect.y),
2852 _ => None,
2853 })
2854 };
2855 let construct_y = text_y("Construct").expect("Construct header should draw");
2856 let heading_y = text_y("Heading").expect("Heading body cell should draw");
2857 assert!(
2858 construct_y < heading_y,
2859 "expected header above body, got Construct y={construct_y}, Heading y={heading_y}"
2860 );
2861
2862 let mut nodes = Vec::new();
2863 collect_source_backed(&doc, &mut nodes);
2864 let construct_cell = nodes
2865 .iter()
2866 .find(|node| {
2867 node.selection_source
2868 .as_ref()
2869 .is_some_and(|source| source.visible == "Construct")
2870 })
2871 .expect("source-backed Construct table cell");
2872 let heading_cell = nodes
2873 .iter()
2874 .find(|node| {
2875 node.selection_source
2876 .as_ref()
2877 .is_some_and(|source| source.visible == "Heading")
2878 })
2879 .expect("source-backed Heading table cell");
2880 let construct_key = construct_cell.key.as_deref().unwrap();
2881 let heading_key = heading_cell.key.as_deref().unwrap();
2882 let heading_len = heading_cell
2883 .selection_source
2884 .as_ref()
2885 .unwrap()
2886 .visible_len();
2887 let selected = selected_text(
2888 &doc,
2889 &Selection {
2890 range: Some(SelectionRange {
2891 anchor: SelectionPoint::new(construct_key, 0),
2892 head: SelectionPoint::new(heading_key, heading_len),
2893 }),
2894 },
2895 );
2896 assert_eq!(
2897 selected.as_deref(),
2898 Some(
2899 "| Construct | Maps to |\n|------------|--------------------|\n| Heading | `h1` / `h2` / `h3` |"
2900 )
2901 );
2902 }
2903
2904 #[test]
2905 fn strikethrough_inline_run_marks_text_strikethrough() {
2906 let bs = blocks("Some ~~obsolete~~ text.");
2910 assert_eq!(bs[0].kind, Kind::Inlines);
2911 let strike: Vec<&El> = bs[0]
2912 .children
2913 .iter()
2914 .filter(|r| r.text_strikethrough)
2915 .collect();
2916 assert!(!strike.is_empty(), "expected a strikethrough run");
2917 assert_eq!(strike[0].text.as_deref(), Some("obsolete"));
2918 }
2919
2920 #[test]
2921 fn smart_punctuation_is_opt_in() {
2922 let plain = blocks("Wait...");
2923 assert_eq!(plain[0].text.as_deref(), Some("Wait..."));
2924
2925 let smart = match md_with_options(
2926 "Wait...",
2927 MarkdownOptions::default().smart_punctuation(true),
2928 ) {
2929 el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
2930 other => panic!("expected outer column, got {:?}", other.kind),
2931 };
2932 assert_eq!(smart[0].text.as_deref(), Some("Wait\u{2026}"));
2933 }
2934
2935 #[test]
2936 fn gfm_alerts_are_opt_in() {
2937 let plain = blocks("> [!WARNING]\n> Careful.");
2938 assert_eq!(plain[0].axis, Axis::Overlay);
2939
2940 let alert_blocks = match md_with_options(
2941 "> [!WARNING]\n> Careful.",
2942 MarkdownOptions::default().gfm_alerts(true),
2943 ) {
2944 el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
2945 other => panic!("expected outer column, got {:?}", other.kind),
2946 };
2947 assert_eq!(alert_blocks[0].kind, Kind::Custom("alert"));
2948 assert_eq!(alert_blocks[0].children[0].text.as_deref(), Some("Warning"));
2949 assert_eq!(
2950 alert_blocks[0].fill,
2951 Some(tokens::WARNING.with_alpha_u8(38))
2952 );
2953 }
2954
2955 #[cfg(feature = "html")]
2956 #[test]
2957 fn html_block_event_lands_as_block_when_feature_enabled() {
2958 let bs = blocks("First paragraph.\n\n<div><h2>From HTML</h2></div>\n\nLast paragraph.");
2961 assert_eq!(bs.len(), 3);
2962 assert_eq!(bs[0].text.as_deref(), Some("First paragraph."));
2963 assert_eq!(bs[1].kind, Kind::Heading);
2965 assert_eq!(bs[1].text.as_deref(), Some("From HTML"));
2966 assert_eq!(bs[2].text.as_deref(), Some("Last paragraph."));
2967 }
2968
2969 #[cfg(feature = "html")]
2970 #[test]
2971 fn html_block_script_is_dropped() {
2972 let bs = blocks("Before.\n\n<script>alert('xss')</script>\n\nAfter.");
2974 let combined: String = bs
2975 .iter()
2976 .filter_map(|b| b.text.as_deref())
2977 .collect::<Vec<_>>()
2978 .join("\n");
2979 assert!(combined.contains("Before."));
2980 assert!(combined.contains("After."));
2981 assert!(!combined.contains("alert"));
2982 }
2983
2984 #[cfg(feature = "html")]
2985 fn paragraph_runs(block: &El) -> Vec<&El> {
2986 block.children.iter().collect()
2987 }
2988
2989 #[cfg(feature = "html")]
2990 #[test]
2991 fn fragmented_inline_tag_pair_styles_the_text_between() {
2992 let bs = blocks("before <b>bold bit</b> after");
2995 let p = &bs[0];
2996 let runs = paragraph_runs(p);
2997 let bold = runs
2998 .iter()
2999 .find(|r| r.text.as_deref() == Some("bold bit"))
3000 .expect("bold run");
3001 assert_eq!(bold.font_weight, FontWeight::Bold);
3002 let after = runs
3003 .iter()
3004 .find(|r| r.text.as_deref().is_some_and(|t| t.contains("after")))
3005 .expect("after run");
3006 assert_eq!(after.font_weight, FontWeight::Regular);
3007 }
3008
3009 #[cfg(feature = "html")]
3010 #[test]
3011 fn fragmented_span_style_carries_color_onto_text() {
3012 let bs = blocks("a <span style=\"color: #ff0000\">red</span> b");
3013 let p = &bs[0];
3014 let red = p
3015 .children
3016 .iter()
3017 .find(|r| r.text.as_deref() == Some("red"))
3018 .expect("red run");
3019 assert_eq!(red.text_color, Some(Color::srgb_u8(255, 0, 0)));
3020 }
3021
3022 #[cfg(feature = "html")]
3023 #[test]
3024 fn nested_fragmented_tags_compose_and_markdown_emphasis_survives() {
3025 let bs = blocks("<u><b>x *and md*</b></u> tail");
3026 let p = &bs[0];
3027 let x = p
3028 .children
3029 .iter()
3030 .find(|r| r.text.as_deref().is_some_and(|t| t.contains('x')))
3031 .expect("x run");
3032 assert_eq!(x.font_weight, FontWeight::Bold);
3033 assert!(x.text_underline);
3034 let md_run = p
3037 .children
3038 .iter()
3039 .find(|r| r.text.as_deref() == Some("and md"))
3040 .expect("emphasis run");
3041 assert!(md_run.text_italic);
3042 assert_eq!(md_run.font_weight, FontWeight::Bold);
3043 assert!(md_run.text_underline);
3044 let tail = p
3046 .children
3047 .iter()
3048 .find(|r| r.text.as_deref().is_some_and(|t| t.contains("tail")))
3049 .expect("tail run");
3050 assert!(!tail.text_underline);
3051 assert_eq!(tail.font_weight, FontWeight::Regular);
3052 }
3053
3054 #[cfg(feature = "html")]
3055 #[test]
3056 fn unclosed_inline_tag_does_not_bleed_into_the_next_paragraph() {
3057 let bs = blocks("<b>dangling\n\nnext paragraph");
3058 assert_eq!(bs.len(), 2);
3059 let dangling = bs[0]
3060 .children
3061 .iter()
3062 .find(|r| r.text.as_deref().is_some_and(|t| t.contains("dangling")))
3063 .or_else(|| bs[0].text.is_some().then_some(&bs[0]))
3064 .expect("dangling run");
3065 assert_eq!(dangling.font_weight, FontWeight::Bold);
3066 assert_eq!(bs[1].font_weight, FontWeight::Regular);
3068 }
3069
3070 #[cfg(feature = "html")]
3071 #[test]
3072 fn md_with_lints_surfaces_embedded_html_findings() {
3073 let (_, findings) = md_with_lints(
3074 "text\n\n<div style=\"color: oklch(0.7 0.1 200)\">styled</div>",
3075 MarkdownOptions::default(),
3076 );
3077 assert!(
3078 findings.iter().any(|f| matches!(
3079 f.kind,
3080 damascene_html::FindingKind::DroppedDeclaration
3081 ) && f.detail.contains("oklch")),
3082 "expected the embedded HTML's finding to surface, got {findings:?}"
3083 );
3084 let (_, clean) = md_with_lints("just *markdown*", MarkdownOptions::default());
3086 assert!(clean.is_empty());
3087 }
3088
3089 #[cfg(feature = "html")]
3090 #[test]
3091 fn sanitize_styles_knob_plumbs_through_to_embedded_html() {
3092 let input = "x <span style=\"color: #ff0000\">styled</span> y";
3093 let options = MarkdownOptions::default()
3094 .html_options(damascene_html::HtmlOptions::default().sanitize_styles(true));
3095 let (el, findings) = md_with_lints(input, options);
3096 assert!(
3097 findings
3098 .iter()
3099 .any(|f| matches!(f.kind, damascene_html::FindingKind::SanitizedStyle)),
3100 "expected a SanitizedStyle finding, got {findings:?}"
3101 );
3102 let p = &el.children[0];
3105 let styled = p
3106 .children
3107 .iter()
3108 .find(|r| r.text.as_deref() == Some("styled"))
3109 .or_else(|| p.text.is_some().then_some(p))
3110 .expect("styled run");
3111 assert_ne!(styled.text_color, Some(Color::srgb_u8(255, 0, 0)));
3112 }
3113
3114 #[cfg(feature = "html")]
3115 #[test]
3116 fn fragmented_anchor_pair_links_the_text_between() {
3117 let bs = blocks("see <a href=\"https://damascene.dev\">the site</a> now");
3118 let p = &bs[0];
3119 let link = p
3120 .children
3121 .iter()
3122 .find(|r| r.text.as_deref() == Some("the site"))
3123 .expect("link run");
3124 assert_eq!(link.text_link.as_deref(), Some("https://damascene.dev"));
3125 }
3126
3127 #[cfg(not(feature = "html"))]
3128 #[test]
3129 fn html_block_event_is_dropped_when_feature_disabled() {
3130 let bs = blocks("First.\n\n<div><h2>Hidden</h2></div>\n\nLast.");
3132 let combined: String = bs
3133 .iter()
3134 .filter_map(|b| b.text.as_deref())
3135 .collect::<Vec<_>>()
3136 .join(" ");
3137 assert!(!combined.contains("Hidden"));
3138 }
3139}