Skip to main content

asciidoc_parser/blocks/
simple.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::Attrlist,
4    blocks::{
5        CompoundDelimitedBlock, ContentModel, IsBlock, ListItemMarker, RawDelimitedBlock,
6        caption::assign_block_caption, metadata::BlockMetadata,
7    },
8    content::{Content, SubstitutionGroup},
9    span::MatchedItem,
10    strings::CowStr,
11};
12
13/// The style of a simple block.
14#[derive(Clone, Copy, Eq, PartialEq)]
15pub enum SimpleBlockStyle {
16    /// A paragraph block with normal substitutions.
17    Paragraph,
18
19    /// A literal block with no substitutions.
20    Literal,
21
22    /// Blocks and paragraphs assigned the listing style display their rendered
23    /// content exactly as you see it in the source. Listing content is
24    /// converted to preformatted text (i.e., `<pre>`). The content is presented
25    /// in a fixed-width font and endlines are preserved. Only [special
26    /// characters] and callouts are replaced when the document is converted.
27    ///
28    /// [special characters]: https://docs.asciidoctor.org/asciidoc/latest/subs/special-characters/
29    Listing,
30
31    /// A source block is a specialization of a listing block. Developers are
32    /// accustomed to seeing source code colorized to emphasize the code’s
33    /// structure (i.e., keywords, types, delimiters, etc.).
34    Source,
35}
36
37impl std::fmt::Debug for SimpleBlockStyle {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            SimpleBlockStyle::Paragraph => write!(f, "SimpleBlockStyle::Paragraph"),
41            SimpleBlockStyle::Literal => write!(f, "SimpleBlockStyle::Literal"),
42            SimpleBlockStyle::Listing => write!(f, "SimpleBlockStyle::Listing"),
43            SimpleBlockStyle::Source => write!(f, "SimpleBlockStyle::Source"),
44        }
45    }
46}
47
48/// A block that's treated as contiguous lines of paragraph text (and subject to
49/// normal substitutions) (e.g., a paragraph block).
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct SimpleBlock<'src> {
52    content: Content<'src>,
53    source: Span<'src>,
54    style: SimpleBlockStyle,
55    title_source: Option<Span<'src>>,
56    title: Option<String>,
57    caption: Option<String>,
58    number: Option<usize>,
59    anchor: Option<Span<'src>>,
60    anchor_reftext: Option<Span<'src>>,
61    attrlist: Option<Attrlist<'src>>,
62}
63
64impl<'src> SimpleBlock<'src> {
65    pub(crate) fn parse(
66        metadata: &BlockMetadata<'src>,
67        parser: &mut Parser,
68    ) -> Option<MatchedItem<'src, Self>> {
69        let MatchedItem {
70            item: (content, style),
71            after,
72        } = parse_lines(
73            metadata.block_start,
74            &metadata.attrlist,
75            false,
76            false,
77            false,
78            parser,
79            &[],
80        )?;
81
82        // A paragraph carrying a captionable block style (e.g. `[example]`) is
83        // captioned and numbered just like its delimited counterpart. The raw
84        // context is `paragraph`; `assign_block_caption` resolves the block
85        // style to the captioning context.
86        let caption = assign_block_caption(
87            parser,
88            "paragraph",
89            metadata.attrlist.as_ref(),
90            metadata.title.is_some(),
91        );
92        let number = caption.as_ref().and_then(|caption| caption.number);
93        let caption = caption.map(|caption| caption.prefix);
94
95        Some(MatchedItem {
96            item: Self {
97                content,
98                source: metadata
99                    .source
100                    .trim_remainder(after)
101                    .trim_trailing_whitespace(),
102                style,
103                title_source: metadata.title_source,
104                title: metadata.title.clone(),
105                caption,
106                number,
107                anchor: metadata.anchor,
108                anchor_reftext: metadata.anchor_reftext,
109                attrlist: metadata.attrlist.clone(),
110            },
111            after: after.discard_empty_lines(),
112        })
113    }
114
115    pub(crate) fn parse_for_list_item(
116        metadata: &BlockMetadata<'src>,
117        parser: &mut Parser,
118        is_continuation: bool,
119        parent_list_markers: &[ListItemMarker<'src>],
120    ) -> Option<MatchedItem<'src, Self>> {
121        let MatchedItem {
122            item: (content, style),
123            after,
124        } = parse_lines(
125            metadata.block_start,
126            &metadata.attrlist,
127            true,
128            false,
129            is_continuation,
130            parser,
131            parent_list_markers,
132        )?;
133
134        // A paragraph carrying a captionable block style (e.g. `[example]`) is
135        // captioned and numbered just like its delimited counterpart. The raw
136        // context is `paragraph`; `assign_block_caption` resolves the block
137        // style to the captioning context.
138        let caption = assign_block_caption(
139            parser,
140            "paragraph",
141            metadata.attrlist.as_ref(),
142            metadata.title.is_some(),
143        );
144        let number = caption.as_ref().and_then(|caption| caption.number);
145        let caption = caption.map(|caption| caption.prefix);
146
147        Some(MatchedItem {
148            item: Self {
149                content,
150                source: metadata
151                    .source
152                    .trim_remainder(after)
153                    .trim_trailing_whitespace(),
154                style,
155                title_source: metadata.title_source,
156                title: metadata.title.clone(),
157                caption,
158                number,
159                anchor: metadata.anchor,
160                anchor_reftext: metadata.anchor_reftext,
161                attrlist: metadata.attrlist.clone(),
162            },
163            after,
164        })
165    }
166
167    /// Parse a simple block for use in a definition list item.
168    ///
169    /// In definition lists, indented content is treated as a paragraph
170    /// with the indentation stripped, not as a literal block.
171    pub(crate) fn parse_for_definition_list(
172        metadata: &BlockMetadata<'src>,
173        parser: &mut Parser,
174    ) -> Option<MatchedItem<'src, Self>> {
175        let MatchedItem {
176            item: (content, style),
177            after,
178        } = parse_lines(
179            metadata.block_start,
180            &metadata.attrlist,
181            true,
182            true,
183            false,
184            parser,
185            &[],
186        )?;
187
188        // A paragraph carrying a captionable block style (e.g. `[example]`) is
189        // captioned and numbered just like its delimited counterpart. The raw
190        // context is `paragraph`; `assign_block_caption` resolves the block
191        // style to the captioning context.
192        let caption = assign_block_caption(
193            parser,
194            "paragraph",
195            metadata.attrlist.as_ref(),
196            metadata.title.is_some(),
197        );
198        let number = caption.as_ref().and_then(|caption| caption.number);
199        let caption = caption.map(|caption| caption.prefix);
200
201        Some(MatchedItem {
202            item: Self {
203                content,
204                source: metadata
205                    .source
206                    .trim_remainder(after)
207                    .trim_trailing_whitespace(),
208                style,
209                title_source: metadata.title_source,
210                title: metadata.title.clone(),
211                caption,
212                number,
213                anchor: metadata.anchor,
214                anchor_reftext: metadata.anchor_reftext,
215                attrlist: metadata.attrlist.clone(),
216            },
217            after,
218        })
219    }
220
221    pub(crate) fn parse_fast(
222        source: Span<'src>,
223        parser: &Parser,
224    ) -> Option<MatchedItem<'src, Self>> {
225        let MatchedItem {
226            item: (content, style),
227            after,
228        } = parse_lines(source, &None, false, false, false, parser, &[])?;
229
230        let source = content.original();
231
232        Some(MatchedItem {
233            item: Self {
234                content,
235                source,
236                style,
237                title_source: None,
238                title: None,
239                caption: None,
240                number: None,
241                anchor: None,
242                anchor_reftext: None,
243                attrlist: None,
244            },
245            after: after.discard_empty_lines(),
246        })
247    }
248
249    /// Return the interpreted content of this block.
250    pub fn content(&self) -> &Content<'src> {
251        &self.content
252    }
253
254    /// Return the style of this block.
255    pub fn style(&self) -> SimpleBlockStyle {
256        self.style
257    }
258}
259
260/// Parse the content-bearing lines for this block.
261///
262/// If `force_paragraph_style` is true, indented content is treated as a
263/// paragraph (with indentation stripped) rather than as a literal block. This
264/// is used for definition list items where indentation is purely visual
265/// formatting.
266///
267/// If `preserve_literal_indent` is true and the content is a literal block,
268/// indentation is preserved as-is (used for `+` continuation content).
269fn parse_lines<'src>(
270    source: Span<'src>,
271    attrlist: &Option<Attrlist<'src>>,
272    mut stop_for_list_items: bool,
273    force_paragraph_style: bool,
274    preserve_literal_indent: bool,
275    parser: &Parser,
276    parent_list_markers: &[ListItemMarker<'src>],
277) -> Option<MatchedItem<'src, (Content<'src>, SimpleBlockStyle)>> {
278    let source_after_whitespace = source.discard_whitespace();
279    let first_line_indent = source_after_whitespace.col() - 1;
280
281    // Track if we're in "indented literal" mode (literal style from indentation).
282    // In this mode, we should still stop for list markers that are NOT indented.
283    let mut indented_literal_mode = false;
284
285    let mut style = if source_after_whitespace.col() == source.col() || force_paragraph_style {
286        // When force_paragraph_style is true, we still need to track that the content
287        // is indented so we can properly stop at unindented list markers.
288        if source_after_whitespace.col() != source.col() {
289            indented_literal_mode = true;
290        }
291        SimpleBlockStyle::Paragraph
292    } else {
293        // Indented content treated as literal: don't stop for list markers
294        // (they become part of the literal content).
295        stop_for_list_items = false;
296        SimpleBlockStyle::Literal
297    };
298
299    // Block style can override the interpretation of literal from reading
300    // indentation.
301    if let Some(attrlist) = attrlist {
302        match attrlist.block_style() {
303            Some("normal") => {
304                style = SimpleBlockStyle::Paragraph;
305            }
306
307            Some("literal") => {
308                stop_for_list_items = false;
309                indented_literal_mode = false;
310                style = SimpleBlockStyle::Literal;
311            }
312
313            Some("listing") => {
314                stop_for_list_items = false;
315                indented_literal_mode = false;
316                style = SimpleBlockStyle::Listing;
317            }
318
319            Some("source") => {
320                stop_for_list_items = false;
321                indented_literal_mode = false;
322                style = SimpleBlockStyle::Source;
323            }
324
325            _ => {}
326        }
327    }
328
329    let mut next = source;
330    let mut filtered_lines: Vec<&'src str> = vec![];
331    let mut skipped_comment_line = false;
332
333    // Determine how much indentation to strip from literal paragraphs.
334    // In definition list continuations, use minimum indentation across all
335    // lines to preserve relative indent. In outline list continuations and
336    // non-continuation contexts, strip based on the first line's indent.
337    let in_definition_list = parent_list_markers
338        .iter()
339        .any(|m| matches!(m, ListItemMarker::DefinedTerm { .. }));
340
341    let strip_indent =
342        if preserve_literal_indent && style == SimpleBlockStyle::Literal && in_definition_list {
343            // Two-pass approach: find minimum indentation across all lines.
344            let mut scan = source;
345            let mut min_indent = first_line_indent;
346            let mut line_count = 0;
347
348            while let Some(line_mi) = scan.take_non_empty_line() {
349                let line = line_mi.item;
350
351                // Apply same stop conditions as the main loop.
352                if line_count > 0 && line.data() == "+" {
353                    break;
354                }
355
356                if let Some(n) = line.position(|c| c != ' ' && c != '\t') {
357                    min_indent = min_indent.min(n);
358                }
359
360                line_count += 1;
361                scan = line_mi.after;
362            }
363            min_indent
364        } else {
365            first_line_indent
366        };
367
368    while let Some(line_mi) = next.take_non_empty_line() {
369        let mut line = line_mi.item;
370
371        // If we've skipped a comment line and this is a section header, stop here
372        // so the section can be parsed as a separate block. Only do this at the
373        // top level (not inside lists), indicated by stop_for_list_items being false.
374        if !stop_for_list_items
375            && skipped_comment_line
376            && style == SimpleBlockStyle::Paragraph
377            && is_section_header(line.data())
378        {
379            break;
380        }
381
382        // There are several stop conditions for simple paragraph blocks. These
383        // "shouldn't" be encountered on the first line (we shouldn't be calling
384        // `SimpleBlock::parse` in these conditions), but in case it is, we simply
385        // ignore them on the first line.
386        if !filtered_lines.is_empty() {
387            // In indented literal mode, only stop for list markers that are NOT indented
388            // (at column 1). This allows definition list items to be properly separated.
389            let should_check_for_list_marker =
390                stop_for_list_items && (!indented_literal_mode || line.col() == 1);
391
392            // If we've already started accumulating content for this list item paragraph,
393            // we don't stop for list markers at any level other than our own or a parent
394            // level.
395            if should_check_for_list_marker
396                && let Some(marker_mi) = ListItemMarker::parse(line, parser)
397            {
398                // In description list continuation context, don't stop for
399                // deeper-nested description list markers (e.g., ::: when the
400                // current context is ::). They are treated as paragraph text.
401                let is_ancestor_list = parent_list_markers
402                    .iter()
403                    .any(|p| p.is_match_for(&marker_mi.item));
404
405                if is_ancestor_list || !preserve_literal_indent {
406                    break;
407                }
408            }
409
410            if line.data() == "+" {
411                break;
412            }
413
414            if line.starts_with('[') && line.ends_with(']') {
415                break;
416            }
417
418            if (line.starts_with('/')
419                || line.starts_with('-')
420                || line.starts_with('.')
421                || line.starts_with('+')
422                || line.starts_with('=')
423                || line.starts_with('*')
424                || line.starts_with('_'))
425                && (RawDelimitedBlock::is_valid_delimiter(&line)
426                    || CompoundDelimitedBlock::is_valid_delimiter(&line))
427            {
428                break;
429            }
430        }
431
432        next = line_mi.after;
433
434        // Only strip comment lines in paragraph style. In literal/listing/source
435        // blocks, "//" lines are preserved as content.
436        if style == SimpleBlockStyle::Paragraph
437            && line.starts_with("//")
438            && !line.starts_with("///")
439        {
440            skipped_comment_line = true;
441            continue;
442        }
443
444        // Strip at most the calculated indentation amount.
445        let should_strip_indent = strip_indent > 0;
446
447        if should_strip_indent && let Some(n) = line.position(|c| c != ' ' && c != '\t') {
448            line = line.into_parse_result(n.min(strip_indent)).after;
449        };
450
451        filtered_lines.push(line.trim_trailing_whitespace().data());
452    }
453
454    let source = source.trim_remainder(next).trim_trailing_whitespace();
455    if source.is_empty() {
456        return None;
457    }
458
459    let filtered_lines = filtered_lines.join("\n");
460    let mut content: Content<'src> = Content::from_filtered(source, filtered_lines);
461
462    let sub_group = match style {
463        // Only apply Verbatim substitutions to literal blocks detected by indentation.
464        // Listing and Source styles declared via attribute list still use Normal subs.
465        SimpleBlockStyle::Literal => SubstitutionGroup::Verbatim,
466        SimpleBlockStyle::Listing | SimpleBlockStyle::Source | SimpleBlockStyle::Paragraph => {
467            SubstitutionGroup::Normal
468        }
469    };
470
471    sub_group.override_via_attrlist(attrlist.as_ref()).apply(
472        &mut content,
473        parser,
474        attrlist.as_ref(),
475    );
476
477    Some(MatchedItem {
478        item: (content, style),
479        after: next,
480    })
481}
482
483impl<'src> IsBlock<'src> for SimpleBlock<'src> {
484    fn content_model(&self) -> ContentModel {
485        ContentModel::Simple
486    }
487
488    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
489        Some(&mut self.content)
490    }
491
492    fn rendered_content(&self) -> Option<&str> {
493        Some(self.content.rendered())
494    }
495
496    fn raw_context(&self) -> CowStr<'src> {
497        "paragraph".into()
498    }
499
500    fn title_source(&'src self) -> Option<Span<'src>> {
501        self.title_source
502    }
503
504    fn title(&self) -> Option<&str> {
505        self.title.as_deref()
506    }
507
508    fn caption(&self) -> Option<&str> {
509        self.caption.as_deref()
510    }
511
512    fn number(&self) -> Option<usize> {
513        self.number
514    }
515
516    fn anchor(&'src self) -> Option<Span<'src>> {
517        self.anchor
518    }
519
520    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
521        self.anchor_reftext
522    }
523
524    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
525        self.attrlist.as_ref()
526    }
527}
528
529impl<'src> HasSpan<'src> for SimpleBlock<'src> {
530    fn span(&self) -> Span<'src> {
531        self.source
532    }
533}
534
535/// Returns true if the line looks like a section header.
536/// Matches `== `, `=== `, etc. (AsciiDoc) or `## `, `### `, etc. (Markdown).
537fn is_section_header(line: &str) -> bool {
538    // AsciiDoc style: `== `, `=== `, etc. (at least 2 `=` followed by space)
539    if line.starts_with("==") {
540        let rest = line.trim_start_matches('=');
541        if rest.starts_with(' ') {
542            return true;
543        }
544    }
545
546    // Markdown style: `## `, `### `, etc. (at least 2 `#` followed by space)
547    if line.starts_with("##") {
548        let rest = line.trim_start_matches('#');
549        if rest.starts_with(' ') {
550            return true;
551        }
552    }
553
554    false
555}
556
557#[cfg(test)]
558mod tests {
559    #![allow(clippy::unwrap_used)]
560
561    use std::ops::Deref;
562
563    use crate::{
564        blocks::{ContentModel, SimpleBlockStyle, metadata::BlockMetadata},
565        tests::prelude::*,
566    };
567
568    #[test]
569    fn impl_clone() {
570        // Silly test to mark the #[derive(...)] line as covered.
571        let mut parser = Parser::default();
572
573        let b1 =
574            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc"), &mut parser).unwrap();
575
576        let b2 = b1.item.clone();
577        assert_eq!(b1.item, b2);
578    }
579
580    #[test]
581    fn style_enum_impl_debug() {
582        assert_eq!(
583            format!("{:?}", SimpleBlockStyle::Paragraph),
584            "SimpleBlockStyle::Paragraph"
585        );
586
587        assert_eq!(
588            format!("{:?}", SimpleBlockStyle::Literal),
589            "SimpleBlockStyle::Literal"
590        );
591
592        assert_eq!(
593            format!("{:?}", SimpleBlockStyle::Listing),
594            "SimpleBlockStyle::Listing"
595        );
596
597        assert_eq!(
598            format!("{:?}", SimpleBlockStyle::Source),
599            "SimpleBlockStyle::Source"
600        );
601    }
602
603    #[test]
604    fn empty_source() {
605        let mut parser = Parser::default();
606        assert!(crate::blocks::SimpleBlock::parse(&BlockMetadata::new(""), &mut parser).is_none());
607    }
608
609    #[test]
610    fn only_spaces() {
611        let mut parser = Parser::default();
612        assert!(
613            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("    "), &mut parser).is_none()
614        );
615    }
616
617    #[test]
618    fn single_line() {
619        let mut parser = Parser::default();
620        let mi =
621            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc"), &mut parser).unwrap();
622
623        assert_eq!(
624            mi.item,
625            SimpleBlock {
626                content: Content {
627                    original: Span {
628                        data: "abc",
629                        line: 1,
630                        col: 1,
631                        offset: 0,
632                    },
633                    rendered: "abc",
634                },
635                source: Span {
636                    data: "abc",
637                    line: 1,
638                    col: 1,
639                    offset: 0,
640                },
641                style: SimpleBlockStyle::Paragraph,
642                title_source: None,
643                title: None,
644                caption: None,
645                number: None,
646                anchor: None,
647                anchor_reftext: None,
648                attrlist: None,
649            },
650        );
651
652        assert_eq!(mi.item.content_model(), ContentModel::Simple);
653        assert_eq!(mi.item.rendered_content().unwrap(), "abc");
654        assert_eq!(mi.item.raw_context().deref(), "paragraph");
655        assert_eq!(mi.item.resolved_context().deref(), "paragraph");
656        assert!(mi.item.declared_style().is_none());
657        assert!(mi.item.id().is_none());
658        assert!(mi.item.roles().is_empty());
659        assert!(mi.item.options().is_empty());
660        assert!(mi.item.title_source().is_none());
661        assert!(mi.item.title().is_none());
662        assert!(mi.item.anchor().is_none());
663        assert!(mi.item.anchor_reftext().is_none());
664        assert!(mi.item.attrlist().is_none());
665        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
666
667        assert_eq!(
668            mi.after,
669            Span {
670                data: "",
671                line: 1,
672                col: 4,
673                offset: 3
674            }
675        );
676    }
677
678    #[test]
679    fn multiple_lines() {
680        let mut parser = Parser::default();
681        let mi = crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc\ndef"), &mut parser)
682            .unwrap();
683
684        assert_eq!(
685            mi.item,
686            SimpleBlock {
687                content: Content {
688                    original: Span {
689                        data: "abc\ndef",
690                        line: 1,
691                        col: 1,
692                        offset: 0,
693                    },
694                    rendered: "abc\ndef",
695                },
696                source: Span {
697                    data: "abc\ndef",
698                    line: 1,
699                    col: 1,
700                    offset: 0,
701                },
702                style: SimpleBlockStyle::Paragraph,
703                title_source: None,
704                title: None,
705                caption: None,
706                number: None,
707                anchor: None,
708                anchor_reftext: None,
709                attrlist: None,
710            }
711        );
712
713        assert_eq!(
714            mi.after,
715            Span {
716                data: "",
717                line: 2,
718                col: 4,
719                offset: 7
720            }
721        );
722
723        assert_eq!(mi.item.rendered_content().unwrap(), "abc\ndef");
724    }
725
726    #[test]
727    fn consumes_blank_lines_after() {
728        let mut parser = Parser::default();
729        let mi = crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc\n\ndef"), &mut parser)
730            .unwrap();
731
732        assert_eq!(
733            mi.item,
734            SimpleBlock {
735                content: Content {
736                    original: Span {
737                        data: "abc",
738                        line: 1,
739                        col: 1,
740                        offset: 0,
741                    },
742                    rendered: "abc",
743                },
744                source: Span {
745                    data: "abc",
746                    line: 1,
747                    col: 1,
748                    offset: 0,
749                },
750                style: SimpleBlockStyle::Paragraph,
751                title_source: None,
752                title: None,
753                caption: None,
754                number: None,
755                anchor: None,
756                anchor_reftext: None,
757                attrlist: None,
758            }
759        );
760
761        assert_eq!(
762            mi.after,
763            Span {
764                data: "def",
765                line: 3,
766                col: 1,
767                offset: 5
768            }
769        );
770    }
771
772    #[test]
773    fn overrides_sub_group_via_subs_attribute() {
774        let mut parser = Parser::default();
775        let mi = crate::blocks::SimpleBlock::parse(
776            &BlockMetadata::new("[subs=quotes]\na<b>c *bold*\n\ndef"),
777            &mut parser,
778        )
779        .unwrap();
780
781        assert_eq!(
782            mi.item,
783            SimpleBlock {
784                content: Content {
785                    original: Span {
786                        data: "a<b>c *bold*",
787                        line: 2,
788                        col: 1,
789                        offset: 14,
790                    },
791                    rendered: "a<b>c <strong>bold</strong>",
792                },
793                source: Span {
794                    data: "[subs=quotes]\na<b>c *bold*",
795                    line: 1,
796                    col: 1,
797                    offset: 0,
798                },
799                style: SimpleBlockStyle::Paragraph,
800                title_source: None,
801                title: None,
802                caption: None,
803                number: None,
804                anchor: None,
805                anchor_reftext: None,
806                attrlist: Some(Attrlist {
807                    attributes: &[ElementAttribute {
808                        name: Some("subs"),
809                        value: "quotes",
810                        shorthand_items: &[],
811                    },],
812                    anchor: None,
813                    source: Span {
814                        data: "subs=quotes",
815                        line: 1,
816                        col: 2,
817                        offset: 1,
818                    },
819                },),
820            }
821        );
822
823        assert_eq!(
824            mi.after,
825            Span {
826                data: "def",
827                line: 4,
828                col: 1,
829                offset: 28
830            }
831        );
832
833        assert_eq!(
834            mi.item.rendered_content().unwrap(),
835            "a<b>c <strong>bold</strong>"
836        );
837    }
838}