Skip to main content

magi/
md.rs

1//! GFM markdown, parsed into a serializable node tree instead of HTML.
2//!
3//! The web UI's one hard rule is that DOM is built with `createElement` and
4//! `textContent` and never with `innerHTML` — an AI-authored chat reply, a
5//! question's reasoning, and an operator's task instruction are all arbitrary
6//! text, and any of them could contain a `<script>` tag. That rule used to
7//! force the front end into `assets/ui/app.js`'s own tiny hand-rolled
8//! markdown reader, which understood four constructs and left everything
9//! else — tables, task lists, links — as literal asterisks and brackets.
10//!
11//! This module moves the actual parsing to [`comrak`], a full GFM
12//! implementation, and hands the client a tree of [`Node`] instead of a
13//! string of HTML. The client walks the tree and builds elements directly;
14//! there is never a markup string to insert, so the no-`innerHTML` rule holds
15//! even though the markdown support is now complete.
16//!
17//! Two things are deliberately stricter than the source markdown, both
18//! because this tree can carry attacker-authored text (an AI writes chat
19//! turns and question detail) into a browser with no server-side sanitizer
20//! standing between them:
21//!
22//! - [`normalize_link`] keeps only `http:`/`https:` link destinations.
23//! - [`normalize_image`] keeps only `data:` image URIs and, when the
24//!   markdown came from a question, relative filenames resolved against that
25//!   question's existing sandboxed panel asset route (see
26//!   [`ImageBase::QuestionPanel`]).
27//!
28//! Raw HTML in the source (a `<script>` block, an `<img onerror=…>`) is never
29//! interpreted: [`to_nodes`] turns it into a [`Node::Text`] carrying the
30//! literal characters, so a client that renders it with `textContent` shows
31//! the tag rather than running it.
32
33use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
34use comrak::{Arena, Options, parse_document};
35use serde::Serialize;
36
37use crate::ask::valid_asset_name;
38
39/// How a relative image path in the source markdown is allowed to resolve.
40///
41/// A relative path (`![shot](shot.png)`) names no host and no scheme, so on
42/// its own it is not renderable at all — magi's web UI never serves files
43/// from an arbitrary directory. The one place a relative path is meaningful
44/// is a question's `detail`, where it names one of that question's own panel
45/// assets, already reachable at `GET /api/questions/{id}/panel/{name}`. Every
46/// other caller passes [`ImageBase::None`], under which a relative path is
47/// rejected exactly like a `file:` URL.
48#[derive(Debug, Clone)]
49pub enum ImageBase {
50    /// No question backs this text, so a relative image path cannot be
51    /// resolved.
52    None,
53    /// This text is a question's `detail`; a relative image path resolves
54    /// against that question's panel asset route.
55    QuestionPanel {
56        /// The question id the panel route is scoped to.
57        id: String,
58    },
59}
60
61/// Column alignment of a table cell, per the GFM table extension.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Align {
65    /// No alignment was requested for this column.
66    None,
67    /// `:---`
68    Left,
69    /// `:---:`
70    Center,
71    /// `---:`
72    Right,
73}
74
75fn align_of(a: TableAlignment) -> Align {
76    match a {
77        TableAlignment::None => Align::None,
78        TableAlignment::Left => Align::Left,
79        TableAlignment::Center => Align::Center,
80        TableAlignment::Right => Align::Right,
81    }
82}
83
84/// One cell of a [`Node::Table`] row.
85#[derive(Debug, Clone, PartialEq, Serialize)]
86pub struct TableCell {
87    /// Is this cell part of the header row?
88    pub header: bool,
89    /// This cell's column alignment.
90    pub align: Align,
91    /// The cell's inline content.
92    pub children: Vec<Node>,
93}
94
95/// One node of parsed markdown.
96///
97/// Shaped for a client to walk and turn into DOM nodes directly — every
98/// variant is either a container (`children`, or for a list or table, a
99/// nested collection) or a leaf that carries exactly the data needed to
100/// build one element. There is no HTML anywhere in this type; see the module
101/// documentation for the two places that turn attacker-controlled markup
102/// into plain [`Node::Text`] instead of a live link or image.
103#[derive(Debug, Clone, PartialEq, Serialize)]
104#[serde(tag = "type", rename_all = "snake_case")]
105pub enum Node {
106    /// A paragraph.
107    Paragraph {
108        /// Inline content.
109        children: Vec<Node>,
110    },
111    /// A heading.
112    Heading {
113        /// Heading level, 1 through 6.
114        level: u8,
115        /// Inline content.
116        children: Vec<Node>,
117    },
118    /// An unordered (bullet) list.
119    BulletList {
120        /// The list's items. Always [`Node::ListItem`].
121        items: Vec<Node>,
122    },
123    /// An ordered (numbered) list.
124    OrderedList {
125        /// The number the list starts counting from.
126        start: u32,
127        /// The list's items. Always [`Node::ListItem`].
128        items: Vec<Node>,
129    },
130    /// One item of a list, or of a GFM task list.
131    ListItem {
132        /// `Some(true)` for a checked task item, `Some(false)` for an
133        /// unchecked one, `None` for a plain list item.
134        checked: Option<bool>,
135        /// The item's block content, which may include a nested list.
136        children: Vec<Node>,
137    },
138    /// A GFM table.
139    Table {
140        /// One alignment per column, in column order.
141        align: Vec<Align>,
142        /// Rows in reading order; the first is the header row.
143        rows: Vec<Vec<TableCell>>,
144    },
145    /// A block quote.
146    BlockQuote {
147        /// The quote's block content.
148        children: Vec<Node>,
149    },
150    /// A horizontal rule (`---`).
151    ThematicBreak,
152    /// A fenced or indented code block.
153    ///
154    /// Never syntax-highlighted: magi does not add a highlighting
155    /// dependency, so `lang` is carried only as a label for the client to
156    /// show, not as a hint the server has already acted on.
157    CodeBlock {
158        /// The fence's info string (e.g. `rust`), if the source gave one.
159        lang: Option<String>,
160        /// The block's literal text.
161        code: String,
162    },
163    /// An inline code span.
164    Code {
165        /// The span's literal text.
166        code: String,
167    },
168    /// Emphasized (`*italic*`) inline content.
169    Emphasis {
170        /// Inline content.
171        children: Vec<Node>,
172    },
173    /// Strongly emphasized (`**bold**`) inline content.
174    Strong {
175        /// Inline content.
176        children: Vec<Node>,
177    },
178    /// Struck-through (`~~text~~`) inline content.
179    Strikethrough {
180        /// Inline content.
181        children: Vec<Node>,
182    },
183    /// A link. Only ever produced for an `http:`/`https:` destination; any
184    /// other scheme becomes [`Node::Text`] instead, see [`normalize_link`].
185    Link {
186        /// The link's destination.
187        href: String,
188        /// The link's inline text.
189        children: Vec<Node>,
190    },
191    /// An application route verified against the server's stores, never a
192    /// destination supplied by Markdown.
193    InternalLink {
194        /// The verified hash route.
195        href: String,
196        /// Inline content, optionally retaining code styling.
197        children: Vec<Node>,
198    },
199    /// An image. Only ever produced for a `data:` image URI or a resolved
200    /// question panel asset; anything else becomes [`Node::Text`] instead,
201    /// see [`normalize_image`].
202    Image {
203        /// The image's resolved source.
204        src: String,
205        /// The image's alt text.
206        alt: String,
207    },
208    /// A soft line break: a single newline in the source, conventionally
209    /// rendered as a space or an ordinary wrap.
210    SoftBreak,
211    /// A hard line break: two trailing spaces, or a trailing backslash, in
212    /// the source.
213    LineBreak,
214    /// Plain text.
215    ///
216    /// Also stands in for anything [`to_nodes`] refuses to render as markup:
217    /// raw HTML from the source, a link with a disallowed scheme, an image
218    /// with a disallowed source.
219    Text {
220        /// The text itself.
221        value: String,
222    },
223}
224
225/// Parse `text` as GFM markdown into a node tree.
226///
227/// Enables the GFM extensions an operator or an agent's prose actually uses —
228/// tables, task lists, strikethrough, autolinked bare URLs — and nothing
229/// that changes how plain prose reads (no smart quotes, no superscript).
230/// `image_base` controls whether a relative image path in `text` can resolve
231/// to anything; see [`ImageBase`].
232pub fn to_nodes(text: &str, image_base: &ImageBase) -> Vec<Node> {
233    let arena = Arena::new();
234    let mut options = Options::default();
235    options.extension.table = true;
236    options.extension.strikethrough = true;
237    options.extension.tasklist = true;
238    options.extension.autolink = true;
239    let root = parse_document(&arena, text, &options);
240    children_of(root, image_base)
241}
242
243fn children_of<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Vec<Node> {
244    node.children()
245        .filter_map(|child| convert(child, image_base))
246        .collect()
247}
248
249fn convert<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Option<Node> {
250    let value = node.data.borrow().value.clone();
251    Some(match value {
252        NodeValue::Paragraph => Node::Paragraph {
253            children: children_of(node, image_base),
254        },
255        NodeValue::Heading(h) => Node::Heading {
256            level: h.level,
257            children: children_of(node, image_base),
258        },
259        NodeValue::List(l) => {
260            let items = children_of(node, image_base);
261            if l.list_type == ListType::Ordered {
262                Node::OrderedList {
263                    start: l.start as u32,
264                    items,
265                }
266            } else {
267                Node::BulletList { items }
268            }
269        }
270        NodeValue::Item(_) => Node::ListItem {
271            checked: None,
272            children: children_of(node, image_base),
273        },
274        NodeValue::TaskItem(t) => Node::ListItem {
275            checked: Some(t.symbol.is_some()),
276            children: children_of(node, image_base),
277        },
278        NodeValue::BlockQuote => Node::BlockQuote {
279            children: children_of(node, image_base),
280        },
281        NodeValue::ThematicBreak => Node::ThematicBreak,
282        NodeValue::CodeBlock(cb) => Node::CodeBlock {
283            lang: (!cb.info.is_empty()).then_some(cb.info),
284            code: cb.literal,
285        },
286        NodeValue::Code(c) => Node::Code { code: c.literal },
287        // Raw HTML is never interpreted: the literal source text becomes a
288        // text node, so a client rendering it with `textContent` shows the
289        // tag's characters instead of running or styling anything.
290        NodeValue::HtmlBlock(h) => Node::Text { value: h.literal },
291        NodeValue::HtmlInline(s) => Node::Text { value: s },
292        NodeValue::Text(s) => Node::Text {
293            value: s.into_owned(),
294        },
295        NodeValue::SoftBreak => Node::SoftBreak,
296        NodeValue::LineBreak => Node::LineBreak,
297        NodeValue::Emph => Node::Emphasis {
298            children: children_of(node, image_base),
299        },
300        NodeValue::Strong => Node::Strong {
301            children: children_of(node, image_base),
302        },
303        NodeValue::Strikethrough => Node::Strikethrough {
304            children: children_of(node, image_base),
305        },
306        NodeValue::Link(l) => normalize_link(&l.url, children_of(node, image_base)),
307        NodeValue::Image(l) => {
308            let alt = plain_text(&children_of(node, image_base));
309            normalize_image(&l.url, alt, image_base)
310        }
311        NodeValue::Table(t) => {
312            let rows = node
313                .children()
314                .map(|row| table_row(row, &t.alignments, image_base))
315                .collect();
316            Node::Table {
317                align: t.alignments.iter().copied().map(align_of).collect(),
318                rows,
319            }
320        }
321        // Everything else is either unreachable (`TableRow`/`TableCell`,
322        // handled inside `table_row` rather than through this generic walk)
323        // or an extension `to_nodes` never turns on (footnotes, math,
324        // wikilinks, alerts, ...), so it cannot appear in a tree this module
325        // produced.
326        _ => return None,
327    })
328}
329
330fn table_row<'a>(
331    row: &'a AstNode<'a>,
332    aligns: &[TableAlignment],
333    image_base: &ImageBase,
334) -> Vec<TableCell> {
335    let header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
336    row.children()
337        .enumerate()
338        .map(|(i, cell)| TableCell {
339            header,
340            align: aligns.get(i).copied().map(align_of).unwrap_or(Align::None),
341            children: children_of(cell, image_base),
342        })
343        .collect()
344}
345
346/// The plain-text reading of a node tree: every [`Node::Text`]/[`Node::Code`]
347/// leaf's characters, recursively, with breaks turned into whitespace.
348///
349/// Used for an image's alt text (comrak keeps it as the image's inline
350/// children rather than a separate string) and for the label of a link whose
351/// scheme [`normalize_link`] refuses to keep live.
352fn plain_text(nodes: &[Node]) -> String {
353    let mut out = String::new();
354    for node in nodes {
355        match node {
356            Node::Text { value } | Node::Code { code: value } => out.push_str(value),
357            Node::Image { alt, .. } => out.push_str(alt),
358            Node::SoftBreak => out.push(' '),
359            Node::LineBreak => out.push('\n'),
360            Node::Paragraph { children }
361            | Node::Heading { children, .. }
362            | Node::Emphasis { children }
363            | Node::Strong { children }
364            | Node::Strikethrough { children }
365            | Node::BlockQuote { children }
366            | Node::ListItem { children, .. }
367            | Node::Link { children, .. }
368            | Node::InternalLink { children, .. } => out.push_str(&plain_text(children)),
369            Node::BulletList { .. }
370            | Node::OrderedList { .. }
371            | Node::Table { .. }
372            | Node::ThematicBreak
373            | Node::CodeBlock { .. } => {}
374        }
375    }
376    out
377}
378
379/// Keep a link live only for `http:`/`https:`; everything else — a
380/// `javascript:`/`data:`/`vbscript:`/`file:` scheme, or no scheme at all —
381/// is not a destination magi's web UI will navigate to, so the link
382/// disappears and its label survives as plain text.
383fn normalize_link(url: &str, children: Vec<Node>) -> Node {
384    let allowed = match url.split_once(':') {
385        Some((scheme, _)) => {
386            scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
387        }
388        None => false,
389    };
390    if allowed {
391        Node::Link {
392            href: url.to_owned(),
393            children,
394        }
395    } else {
396        Node::Text {
397            value: plain_text(&children),
398        }
399    }
400}
401
402/// Keep an image live only for a `data:` image URI, or — when `image_base`
403/// names a question — a bare relative filename resolved against that
404/// question's own panel asset route. An absolute `https://` image, a
405/// protocol-relative `//` image, and a relative path outside a question's
406/// panel all fail both checks and fall back to alt text plus the URL, so the
407/// operator still sees what the agent meant to show without magi's UI
408/// fetching anything from outside itself.
409fn normalize_image(url: &str, alt: String, image_base: &ImageBase) -> Node {
410    if url.to_ascii_lowercase().starts_with("data:image/") {
411        return Node::Image {
412            src: url.to_owned(),
413            alt,
414        };
415    }
416    if let ImageBase::QuestionPanel { id } = image_base {
417        if valid_asset_name(url) {
418            return Node::Image {
419                src: format!("/api/questions/{id}/panel/{url}"),
420                alt,
421            };
422        }
423    }
424    let value = if alt.is_empty() {
425        url.to_owned()
426    } else {
427        format!("{alt} ({url})")
428    };
429    Node::Text { value }
430}
431
432#[cfg(test)]
433mod tests {
434    use pretty_assertions::assert_eq;
435
436    use super::*;
437
438    fn nodes(text: &str) -> Vec<Node> {
439        to_nodes(text, &ImageBase::None)
440    }
441
442    fn text(s: &str) -> Node {
443        Node::Text {
444            value: s.to_owned(),
445        }
446    }
447
448    #[test]
449    fn a_heading_carries_its_level() {
450        assert_eq!(
451            nodes("### Three"),
452            vec![Node::Heading {
453                level: 3,
454                children: vec![text("Three")],
455            }]
456        );
457    }
458
459    #[test]
460    fn emphasis_and_strong_and_strikethrough_each_get_their_own_node() {
461        assert_eq!(
462            nodes("*i* **b** ~~s~~"),
463            vec![Node::Paragraph {
464                children: vec![
465                    Node::Emphasis {
466                        children: vec![text("i")]
467                    },
468                    text(" "),
469                    Node::Strong {
470                        children: vec![text("b")]
471                    },
472                    text(" "),
473                    Node::Strikethrough {
474                        children: vec![text("s")]
475                    },
476                ],
477            }]
478        );
479    }
480
481    #[test]
482    fn a_bullet_list_is_a_bullet_list() {
483        assert_eq!(
484            nodes("- one\n- two"),
485            vec![Node::BulletList {
486                items: vec![
487                    Node::ListItem {
488                        checked: None,
489                        children: vec![Node::Paragraph {
490                            children: vec![text("one")]
491                        }],
492                    },
493                    Node::ListItem {
494                        checked: None,
495                        children: vec![Node::Paragraph {
496                            children: vec![text("two")]
497                        }],
498                    },
499                ],
500            }]
501        );
502    }
503
504    #[test]
505    fn an_ordered_list_keeps_its_start_number() {
506        let Some(Node::OrderedList { start, items }) = nodes("5. five\n6. six").into_iter().next()
507        else {
508            panic!("expected an ordered list");
509        };
510        assert_eq!(start, 5);
511        assert_eq!(items.len(), 2);
512    }
513
514    #[test]
515    fn a_nested_list_is_a_list_item_containing_a_list() {
516        let doc = nodes("- outer\n  - inner");
517        let Some(Node::BulletList { items }) = doc.into_iter().next() else {
518            panic!("expected a bullet list");
519        };
520        let Node::ListItem { children, .. } = &items[0] else {
521            panic!("expected a list item");
522        };
523        assert!(
524            children
525                .iter()
526                .any(|c| matches!(c, Node::BulletList { .. })),
527            "the outer item's children should hold the nested list: {children:?}"
528        );
529    }
530
531    #[test]
532    fn task_list_items_carry_their_checked_state() {
533        let Some(Node::BulletList { items }) = nodes("- [ ] todo\n- [x] done").into_iter().next()
534        else {
535            panic!("expected a bullet list");
536        };
537        assert_eq!(items.len(), 2);
538        assert!(matches!(
539            items[0],
540            Node::ListItem {
541                checked: Some(false),
542                ..
543            }
544        ));
545        assert!(matches!(
546            items[1],
547            Node::ListItem {
548                checked: Some(true),
549                ..
550            }
551        ));
552    }
553
554    #[test]
555    fn a_table_keeps_its_header_and_its_column_alignment() {
556        let md = "| a | b |\n|:--|--:|\n| 1 | 2 |\n";
557        let Some(Node::Table { align, rows }) = nodes(md).into_iter().next() else {
558            panic!("expected a table");
559        };
560        assert_eq!(align, vec![Align::Left, Align::Right]);
561        assert_eq!(rows.len(), 2, "a header row and one body row: {rows:?}");
562        assert!(rows[0][0].header, "the first row is the header: {rows:?}");
563        assert!(!rows[1][0].header, "the body row is not a header: {rows:?}");
564        assert_eq!(rows[0][0].align, Align::Left);
565        assert_eq!(rows[0][1].align, Align::Right);
566    }
567
568    #[test]
569    fn a_block_quote_is_a_block_quote() {
570        assert_eq!(
571            nodes("> quoted"),
572            vec![Node::BlockQuote {
573                children: vec![Node::Paragraph {
574                    children: vec![text("quoted")]
575                }],
576            }]
577        );
578    }
579
580    #[test]
581    fn a_thematic_break_needs_nothing_else() {
582        assert_eq!(nodes("---"), vec![Node::ThematicBreak]);
583    }
584
585    #[test]
586    fn inline_code_is_never_interpreted_as_markdown() {
587        assert_eq!(
588            nodes("`*not italic*`"),
589            vec![Node::Paragraph {
590                children: vec![Node::Code {
591                    code: "*not italic*".to_owned()
592                }],
593            }]
594        );
595    }
596
597    #[test]
598    fn a_fenced_code_block_carries_its_language_but_no_color() {
599        assert_eq!(
600            nodes("```rust\nfn x() {}\n```"),
601            vec![Node::CodeBlock {
602                lang: Some("rust".to_owned()),
603                code: "fn x() {}\n".to_owned(),
604            }]
605        );
606    }
607
608    #[test]
609    fn an_http_link_stays_a_link() {
610        assert_eq!(
611            nodes("[go](https://example.com/x)"),
612            vec![Node::Paragraph {
613                children: vec![Node::Link {
614                    href: "https://example.com/x".to_owned(),
615                    children: vec![text("go")],
616                }],
617            }]
618        );
619    }
620
621    #[test]
622    fn a_javascript_link_is_not_a_link_node_at_all() {
623        let doc = nodes("[x](javascript:alert(1))");
624        // No node anywhere in the tree may be `Node::Link`.
625        fn has_link(nodes: &[Node]) -> bool {
626            nodes.iter().any(|n| match n {
627                Node::Link { .. } => true,
628                Node::Paragraph { children }
629                | Node::Heading { children, .. }
630                | Node::Emphasis { children }
631                | Node::Strong { children }
632                | Node::Strikethrough { children }
633                | Node::BlockQuote { children }
634                | Node::ListItem { children, .. } => has_link(children),
635                _ => false,
636            })
637        }
638        assert!(!has_link(&doc), "must not contain a link node: {doc:?}");
639        assert_eq!(
640            doc,
641            vec![Node::Paragraph {
642                children: vec![text("x")]
643            }]
644        );
645    }
646
647    #[test]
648    fn an_absolute_https_image_does_not_render() {
649        let doc = nodes("![a](https://example.com/x.png)");
650        assert_eq!(
651            doc,
652            vec![Node::Paragraph {
653                children: vec![text("a (https://example.com/x.png)")]
654            }]
655        );
656    }
657
658    #[test]
659    fn a_data_uri_image_renders() {
660        let doc = nodes("![a](data:image/png;base64,AAAA)");
661        assert_eq!(
662            doc,
663            vec![Node::Paragraph {
664                children: vec![Node::Image {
665                    src: "data:image/png;base64,AAAA".to_owned(),
666                    alt: "a".to_owned(),
667                }],
668            }]
669        );
670    }
671
672    #[test]
673    fn a_question_relative_image_resolves_to_its_panel_route() {
674        let base = ImageBase::QuestionPanel {
675            id: "20260903-014455-ab12".to_owned(),
676        };
677        let doc = to_nodes("![shot](shot.png)", &base);
678        assert_eq!(
679            doc,
680            vec![Node::Paragraph {
681                children: vec![Node::Image {
682                    src: "/api/questions/20260903-014455-ab12/panel/shot.png".to_owned(),
683                    alt: "shot".to_owned(),
684                }],
685            }]
686        );
687    }
688
689    #[test]
690    fn a_protocol_relative_image_does_not_render_even_with_a_question_base() {
691        let base = ImageBase::QuestionPanel {
692            id: "20260903-014455-ab12".to_owned(),
693        };
694        let doc = to_nodes("![a](//evil.example/x.png)", &base);
695        assert!(
696            !doc.iter().any(|n| matches!(n, Node::Paragraph { children } if children.iter().any(|c| matches!(c, Node::Image { .. })))),
697            "a protocol-relative source must never become an image: {doc:?}"
698        );
699    }
700
701    #[test]
702    fn raw_html_becomes_text_everywhere_in_the_tree() {
703        let doc = nodes("before <script>alert(1)</script> after");
704        fn contains_html_markup(nodes: &[Node]) -> bool {
705            nodes.iter().any(|n| match n {
706                Node::Text { value } => value.contains("<script"),
707                Node::Paragraph { children }
708                | Node::Heading { children, .. }
709                | Node::Emphasis { children }
710                | Node::Strong { children }
711                | Node::Strikethrough { children }
712                | Node::BlockQuote { children }
713                | Node::ListItem { children, .. } => contains_html_markup(children),
714                _ => false,
715            })
716        }
717        assert!(
718            contains_html_markup(&doc),
719            "the literal tag text must survive as a text node: {doc:?}"
720        );
721        // And, symmetrically: no node in the tree may claim to *be* HTML —
722        // there is no such variant, so this is really asserting the parse
723        // produced ordinary text/paragraph nodes and nothing else.
724        for node in &doc {
725            assert!(
726                matches!(node, Node::Paragraph { .. }),
727                "a document with only text and an HTML span is one paragraph: {doc:?}"
728            );
729        }
730    }
731
732    #[test]
733    fn a_block_level_script_tag_becomes_a_text_node_too() {
734        let doc = nodes("<script>alert(1)</script>");
735        assert_eq!(
736            doc,
737            vec![text("<script>alert(1)</script>")],
738            "an HTML block is one literal text node, not markup: {doc:?}"
739        );
740    }
741}