Skip to main content

domtree/serialize/
markdown.rs

1//! HTML → Markdown (CommonMark, plus GFM tables and strikethrough), behind the
2//! `markdown` feature.
3//!
4//! One iterative pass over the tree (`walk`). Inline content collects in a line
5//! buffer — whitespace collapsed, Markdown punctuation escaped, emphasis and
6//! link markers opened lazily so empty spans vanish — and is written out at each
7//! block boundary behind the prefixes of the open containers (`> ` for quotes,
8//! a list marker or its continuation indent). Containers nested deeper than
9//! [`MAX_NEST`] are flattened rather than indented further, which bounds the
10//! per-line work and the output size; their content is still emitted.
11//!
12//! Closing markers and code spans are held back until the next write, so
13//! adjacent identical spans merge (`<b>a</b><b>b</b>` → `**ab**`, not the
14//! misparsed `**a****b**`). Adjacent lists of the same kind alternate markers
15//! (`-`/`*`, `.`/`)`) so they stay separate lists.
16//!
17//! Known limitation: CommonMark's flanking rules mean emphasis does not render
18//! when a marker sits between punctuation inside and a letter outside, e.g.
19//! `<b>Note:</b>Text` → `**Note:**Text`. The text is still exact; only the
20//! styling is lost. There is no pure-Markdown spelling of it that keeps the
21//! text unchanged.
22
23use alloc::string::String;
24use alloc::vec::Vec;
25use core::fmt::Write as _;
26use core::mem;
27
28use super::{is_space, push_verbatim, skip_newline, walk};
29use crate::node::NodeKind;
30use crate::tags::{self, Display};
31use crate::tree::{Dom, NodeRef};
32
33/// Quotes and list items nested deeper than this are not indented further.
34const MAX_NEST: usize = 32;
35
36/// Tables wider than this are not padded to a rectangle (GFM fills in the
37/// missing cells), so a ragged table cannot blow up the output quadratically.
38const MAX_PAD: usize = 64;
39
40/// CommonMark list numbers have at most nine digits and are never negative
41/// (`-3.` would read as a bullet), so numbers are clamped to `0..=MAX_NUMBER`.
42const MAX_NUMBER: i64 = 999_999_999;
43
44impl Dom {
45    /// Convert the document to Markdown (CommonMark + GFM tables and
46    /// strikethrough) — handy for LLM prompts, RAG indexes and notes.
47    ///
48    /// Headings, paragraphs, emphasis, links, images, code spans, fenced code
49    /// blocks (language from `class="language-…"`), nested lists, block quotes
50    /// and tables map to their Markdown forms, and text is escaped so it renders
51    /// as itself. Hidden elements (`<head>`, `<script>`, …) are skipped as in
52    /// [`Dom::to_text`]. Blocks are separated by one blank line, `<br>` becomes
53    /// a backslash hard break, and the output has no leading or trailing
54    /// newline.
55    ///
56    /// ```
57    /// let dom = domtree::parse(
58    ///     "<h1>Title</h1><p>Some <b>bold</b> and a <a href='/x'>link</a>.</p>\
59    ///      <ul><li>one</li><li>two</li></ul>",
60    /// );
61    /// assert_eq!(
62    ///     dom.to_markdown(),
63    ///     "# Title\n\nSome **bold** and a [link](/x).\n\n- one\n- two",
64    /// );
65    /// ```
66    pub fn to_markdown(&self) -> String {
67        let mut md = Markdown::default();
68        for root in self.roots() {
69            walk(root, |node, enter| md.visit(node, enter));
70        }
71        md.finish()
72    }
73}
74
75impl NodeRef<'_> {
76    /// This node's subtree as Markdown; see [`Dom::to_markdown`].
77    ///
78    /// ```
79    /// let dom = domtree::parse(
80    ///     "<div><pre><code class='language-rust'>fn main() {}\n</code></pre></div>",
81    /// );
82    /// let pre = dom.find_by_tag("pre").next().unwrap();
83    /// assert_eq!(pre.to_markdown(), "```rust\nfn main() {}\n```");
84    /// ```
85    pub fn to_markdown(&self) -> String {
86        let mut md = Markdown::default();
87        walk(*self, |node, enter| md.visit(node, enter));
88        md.finish()
89    }
90}
91
92/// An inline element whose content is wrapped in markers.
93#[derive(Clone, Copy)]
94enum Mark<'a> {
95    Strong,
96    Emphasis,
97    Strike,
98    Link(NodeRef<'a>),
99}
100
101/// An open [`Mark`]: `open` while its opening marker is in the current line,
102/// `used` once it has wrapped any content.
103#[derive(Clone, Copy)]
104struct Wrap<'a> {
105    mark: Mark<'a>,
106    open: bool,
107    used: bool,
108    /// Whitespace was pending when the element opened.
109    spaced: bool,
110}
111
112impl Wrap<'_> {
113    /// Whether the opening marker still has to be written before content. A
114    /// link already written in an earlier block is not repeated.
115    fn pending(&self) -> bool {
116        match self.mark {
117            Mark::Link(_) => !self.used,
118            _ => !self.open,
119        }
120    }
121}
122
123/// A container that prefixes each line written inside it.
124enum Prefix {
125    /// `> `; `true` once a line has been written through it.
126    Quote(bool),
127    /// A list item: its list (index into `lists`), and the width of its marker
128    /// — 0 until the marker is written on the item's first line.
129    Item { list: Option<usize>, width: usize },
130}
131
132struct List {
133    ordered: bool,
134    /// `<ol reversed>`: numbers count down.
135    reversed: bool,
136    /// Uses the alternate marker (`*` or `)`), to stay apart from the list
137    /// right before it.
138    alt: bool,
139    next: u32,
140    /// Whether an item has been written (later items then follow tightly).
141    written: bool,
142}
143
144/// Text captured verbatim for a code span or a fenced code block.
145struct Code<'a> {
146    buf: String,
147    block: bool,
148    /// Nothing captured yet: a leading newline is dropped (as after `<pre>`).
149    fresh: bool,
150    lang: &'a str,
151}
152
153/// A GFM table being collected; it is written when the `<table>` closes.
154#[derive(Default)]
155struct Table {
156    rows: Vec<Vec<String>>,
157    cells: Vec<String>,
158}
159
160/// What an open element changed, undone when it closes.
161#[derive(Clone, Copy, Default)]
162struct Frame {
163    block: bool,
164    space: bool,
165    mark: bool,
166    prefix: bool,
167    list: bool,
168    inline: bool,
169    heading: bool,
170    code: bool,
171    role: Role,
172}
173
174#[derive(Clone, Copy, Default)]
175enum Role {
176    #[default]
177    None,
178    Table,
179    Row,
180    Cell,
181}
182
183#[derive(Default)]
184struct Markdown<'a> {
185    out: String,
186    /// Separation owed before the next block: 1 = new line, 2 = blank line.
187    sep: u8,
188    prefixes: Vec<Prefix>,
189    lists: Vec<List>,
190    /// The last list to close: (container depth, output length then, ordered,
191    /// alternate marker). A list of the same kind opening right there must
192    /// switch markers, or Markdown would merge the two.
193    last_list: Option<(usize, usize, bool, bool)>,
194    frames: Vec<Frame>,
195    /// The current paragraph's inline Markdown (hard breaks are `\` + `\n`).
196    line: String,
197    space: bool,
198    breaks: u8,
199    wraps: Vec<Wrap<'a>>,
200    /// Closing markers of spans that just ended, held back so an adjacent span
201    /// of the same kind can continue instead.
202    closing: Vec<Mark<'a>>,
203    /// An inline code span held back so adjacent `<code>`s join into one.
204    code_run: String,
205    heading: u8,
206    /// Depth of contexts that only take inline content (headings, table cells).
207    inline_only: u32,
208    code: Option<Code<'a>>,
209    table: Option<Table>,
210}
211
212impl<'a> Markdown<'a> {
213    fn visit(&mut self, node: NodeRef<'a>, enter: bool) -> bool {
214        match node.kind() {
215            NodeKind::Text(t) => self.text(t),
216            NodeKind::Element { tag } if enter => return self.open(node, tag),
217            NodeKind::Element { .. } => self.close(),
218            NodeKind::Comment(_) | NodeKind::Doctype(_) => {}
219        }
220        false
221    }
222
223    fn open(&mut self, node: NodeRef<'a>, tag: &'a str) -> bool {
224        let display = tags::display(tag);
225        if display == Display::Hidden {
226            return false;
227        }
228        let mut f = Frame::default();
229        if let Some(code) = &mut self.code {
230            // Inside code only the line structure matters.
231            if tag == "br" {
232                code.br();
233            } else if display != Display::Inline {
234                code.newline();
235                f.block = true;
236            }
237            self.frames.push(f);
238            return true;
239        }
240        let top = self.inline_only == 0;
241        match tag {
242            "br" if !top => self.space = true,
243            "br" if !self.line.is_empty() || !self.code_run.is_empty() => {
244                self.breaks = (self.breaks + 1).min(2);
245            }
246            "hr" if top => {
247                self.block(&mut f);
248                self.begin_block();
249                self.out.push_str("---");
250            }
251            "img" => self.image(node),
252            "code" | "kbd" | "samp" | "tt" => self.start_code(&mut f, false, ""),
253            "pre" if top => {
254                self.block(&mut f);
255                self.start_code(&mut f, true, code_lang(node));
256            }
257            "pre" => {
258                self.block(&mut f); // only inline content allowed: a space
259                self.start_code(&mut f, false, "");
260            }
261            "strong" | "b" => f.mark = self.push_mark(Mark::Strong),
262            "em" | "i" => f.mark = self.push_mark(Mark::Emphasis),
263            "del" | "s" | "strike" => f.mark = self.push_mark(Mark::Strike),
264            "a" if node.attr("href").is_some_and(|h| !h.trim().is_empty()) => {
265                f.mark = self.push_mark(Mark::Link(node));
266            }
267            "dt" => {
268                self.block(&mut f);
269                f.mark = self.push_mark(Mark::Strong);
270            }
271            "h1" | "h2" | "h3" | "h4" | "h5" | "h6" if top => {
272                self.block(&mut f);
273                self.heading = tag.as_bytes().get(1).map_or(1, |b| b.saturating_sub(b'0'));
274                self.inline_only += 1;
275                f.heading = true;
276                f.inline = true;
277            }
278            "ul" | "ol" | "menu" if top => {
279                self.flush();
280                // A list nested in an item follows the item's text tightly.
281                let nested = matches!(self.prefixes.last(), Some(Prefix::Item { .. }));
282                self.sep(if nested { 1 } else { 2 });
283                f.block = true;
284                if self.prefixes.len() < MAX_NEST {
285                    let ordered = tag == "ol";
286                    let reversed = ordered && node.has_attr("reversed");
287                    let start = number(node.attr("start")).unwrap_or_else(|| {
288                        if reversed {
289                            let items =
290                                node.child_elements().filter(|c| c.tag_name() == Some("li"));
291                            items.count() as i64
292                        } else {
293                            1
294                        }
295                    });
296                    // Right after a list of the same kind (nothing between)?
297                    let depth = self.prefixes.len();
298                    let alt = matches!(self.last_list, Some((d, at, o, false))
299                        if d == depth && at == self.out.len() && o == ordered);
300                    self.lists.push(List {
301                        ordered,
302                        reversed,
303                        alt,
304                        next: start.clamp(0, MAX_NUMBER) as u32,
305                        written: false,
306                    });
307                    f.list = true;
308                }
309            }
310            "li" if top => {
311                self.flush();
312                let list = self.frames.last().filter(|p| p.list);
313                let list = list.and_then(|_| self.lists.len().checked_sub(1));
314                // `<li value>` renumbers from this item on.
315                if let (Some(l), Some(v)) = (
316                    list.and_then(|i| self.lists.get_mut(i)),
317                    number(node.attr("value")),
318                ) {
319                    l.next = v.clamp(0, MAX_NUMBER) as u32;
320                }
321                // Items of one list are tight: a single newline between them.
322                if list
323                    .and_then(|i| self.lists.get(i))
324                    .is_some_and(|l| l.written)
325                {
326                    self.force_sep(1);
327                } else {
328                    self.sep(1);
329                }
330                f.block = true;
331                if self.prefixes.len() < MAX_NEST {
332                    self.prefixes.push(Prefix::Item { list, width: 0 });
333                    self.last_list = None; // a list in a new item is apart anyway
334                    f.prefix = true;
335                } else {
336                    self.sep(2);
337                }
338            }
339            "blockquote" if top => {
340                self.block(&mut f);
341                if self.prefixes.len() < MAX_NEST {
342                    self.prefixes.push(Prefix::Quote(false));
343                    f.prefix = true;
344                }
345            }
346            "table" if top && self.table.is_none() => {
347                self.block(&mut f);
348                self.table = Some(Table::default());
349                f.role = Role::Table;
350            }
351            "tr" | "td" | "th" if top && self.table.is_some() => {
352                self.flush(); // stray text inside a table goes before it
353                if tag == "tr" {
354                    if let Some(t) = &mut self.table {
355                        t.end_row();
356                    }
357                    f.role = Role::Row;
358                } else {
359                    self.inline_only += 1;
360                    f.role = Role::Cell;
361                    f.inline = true;
362                }
363            }
364            "td" | "th" => {
365                self.space = true;
366                f.space = true;
367            }
368            _ if display != Display::Inline => self.block(&mut f),
369            _ => {}
370        }
371        self.frames.push(f);
372        true
373    }
374
375    fn close(&mut self) {
376        let Some(f) = self.frames.pop() else {
377            return;
378        };
379        if f.code {
380            self.end_code();
381        } else if let Some(code) = &mut self.code {
382            if f.block {
383                code.newline();
384            }
385            return;
386        }
387        if f.mark {
388            self.pop_mark();
389        }
390        match f.role {
391            Role::Cell => {
392                let cell = self.take_cell();
393                if let Some(t) = &mut self.table {
394                    t.cells.push(cell);
395                }
396            }
397            Role::Row => {
398                if let Some(t) = &mut self.table {
399                    t.end_row();
400                }
401            }
402            Role::Table => {
403                self.flush();
404                if let Some(t) = self.table.take() {
405                    self.emit_table(t);
406                }
407            }
408            Role::None => {}
409        }
410        if f.block {
411            self.flush();
412        }
413        if f.heading {
414            self.heading = 0;
415        }
416        if f.inline {
417            self.inline_only = self.inline_only.saturating_sub(1);
418        }
419        if f.prefix {
420            self.prefixes.pop();
421        }
422        if f.list {
423            if let Some(l) = self.lists.pop() {
424                self.last_list = Some((self.prefixes.len(), self.out.len(), l.ordered, l.alt));
425            }
426        }
427        if f.block {
428            self.sep(2);
429        }
430        if f.space {
431            self.space = true;
432        }
433    }
434
435    /// A block-level element: its content becomes its own paragraph, or is just
436    /// set off by a space where only inline content is allowed.
437    fn block(&mut self, f: &mut Frame) {
438        if self.inline_only > 0 {
439            self.space = true;
440            f.space = true;
441        } else {
442            self.flush();
443            self.sep(2);
444            f.block = true;
445        }
446    }
447
448    // --- inline content ---------------------------------------------------
449
450    fn text(&mut self, text: &str) {
451        if let Some(code) = &mut self.code {
452            code.push(text);
453            return;
454        }
455        for (i, word) in text.split(is_space).enumerate() {
456            if i > 0 {
457                self.space = true;
458            }
459            if !word.is_empty() {
460                self.inline();
461                let line_start =
462                    self.inline_only == 0 && (self.line.is_empty() || self.line.ends_with('\n'));
463                escape(&mut self.line, word, line_start);
464            }
465        }
466    }
467
468    /// Settle what is held back — a code span and closing markers — then
469    /// pending whitespace, hard breaks and opening markers, before inline
470    /// content.
471    fn inline(&mut self) {
472        self.settle();
473        if !self.line.is_empty() {
474            if self.breaks > 0 {
475                for _ in 0..self.breaks {
476                    self.line.push_str("\\\n");
477                }
478            } else if self.space {
479                self.line.push(' ');
480            }
481        }
482        self.breaks = 0;
483        self.space = false;
484        for w in self.wraps.iter_mut().filter(|w| w.pending()) {
485            w.open = true;
486            w.used = true;
487            self.line.push_str(match w.mark {
488                Mark::Strong => "**",
489                Mark::Emphasis => "*",
490                Mark::Strike => "~~",
491                Mark::Link(_) => "[",
492            });
493        }
494    }
495
496    /// Write the held-back code span and closing markers.
497    fn settle(&mut self) {
498        if !self.code_run.is_empty() {
499            push_code_span(&mut self.line, &self.code_run);
500            self.code_run.clear();
501        }
502        for mark in self.closing.drain(..) {
503            push_close(&mut self.line, mark);
504        }
505    }
506
507    fn push_mark(&mut self, mark: Mark<'a>) -> bool {
508        // A nested duplicate (`<b><b>`, a link in a link) would only add noise.
509        let kind = mem::discriminant(&mark);
510        if self
511            .wraps
512            .iter()
513            .any(|w| mem::discriminant(&w.mark) == kind)
514        {
515            return false;
516        }
517        // The same span just ended (`<b>a</b><b>b</b>`): carry on with it.
518        let resume = !matches!(mark, Mark::Link(_))
519            && !self.space
520            && self.breaks == 0
521            && self
522                .closing
523                .last()
524                .is_some_and(|m| mem::discriminant(m) == kind)
525            && !self.wraps.iter().any(Wrap::pending);
526        if resume {
527            self.closing.pop();
528        }
529        self.wraps.push(Wrap {
530            mark,
531            open: resume,
532            used: resume,
533            spaced: self.space,
534        });
535        true
536    }
537
538    fn pop_mark(&mut self) {
539        if let Some(Wrap {
540            mark: Mark::Link(a),
541            used: false,
542            spaced,
543            ..
544        }) = self.wraps.last().copied()
545        {
546            // A link with no visible text: use its URL as the text. Whitespace
547            // that was inside it goes after it.
548            let after = !spaced && mem::take(&mut self.space);
549            self.text(a.attr("href").unwrap_or("").trim());
550            self.space |= after;
551        }
552        if let Some(w) = self.wraps.pop() {
553            if w.open {
554                self.closing.push(w.mark);
555            }
556        }
557    }
558
559    fn image(&mut self, img: NodeRef<'_>) {
560        let alt = img.attr("alt").unwrap_or("");
561        let src = img.attr("src").unwrap_or("").trim();
562        if src.is_empty() {
563            self.text(alt);
564            return;
565        }
566        self.inline();
567        self.line.push_str("![");
568        for (i, word) in alt.split(is_space).filter(|w| !w.is_empty()).enumerate() {
569            if i > 0 {
570                self.line.push(' ');
571            }
572            escape(&mut self.line, word, false);
573        }
574        self.line.push_str("](");
575        push_destination(&mut self.line, src);
576        push_title(&mut self.line, img.attr("title"));
577        self.line.push(')');
578    }
579
580    fn start_code(&mut self, f: &mut Frame, block: bool, lang: &'a str) {
581        self.code = Some(Code {
582            buf: String::new(),
583            block,
584            fresh: block,
585            lang,
586        });
587        f.code = true;
588    }
589
590    fn end_code(&mut self) {
591        let Some(code) = self.code.take() else {
592            return;
593        };
594        if code.block {
595            self.code_block(&code.buf, code.lang);
596            return;
597        }
598        let mut text = String::new();
599        for word in code.buf.split(is_space).filter(|w| !w.is_empty()) {
600            if !text.is_empty() {
601                text.push(' ');
602            }
603            text.push_str(word);
604        }
605        if text.is_empty() {
606            return;
607        }
608        // Right after another span (`<code>a</code><code>b</code>`): join them,
609        // since two spans side by side would parse as one with a literal fence.
610        let adjacent = !self.code_run.is_empty()
611            && self.closing.is_empty()
612            && !self.space
613            && self.breaks == 0
614            && !self.wraps.iter().any(Wrap::pending);
615        if adjacent {
616            self.code_run.push_str(&text);
617        } else {
618            self.inline();
619            self.code_run = text;
620        }
621    }
622
623    // --- blocks -----------------------------------------------------------
624
625    /// End the current paragraph: close its open markers and write it out.
626    fn flush(&mut self) {
627        self.finish_line();
628        if self.line.is_empty() {
629            return;
630        }
631        let line = mem::take(&mut self.line);
632        self.begin_block();
633        if self.heading > 0 {
634            push_n(&mut self.out, '#', usize::from(self.heading));
635            self.out.push(' ');
636        }
637        for (i, part) in line.split('\n').enumerate() {
638            if i > 0 {
639                self.next_line(false);
640            }
641            self.out.push_str(part);
642        }
643        self.line = line;
644        self.line.clear();
645    }
646
647    /// Close the markers open in the current line (they reopen lazily if the
648    /// element continues into the next block) and drop pending whitespace.
649    fn finish_line(&mut self) {
650        self.settle();
651        for w in self.wraps.iter_mut().rev().filter(|w| w.open) {
652            w.open = false;
653            push_close(&mut self.line, w.mark);
654        }
655        self.space = false;
656        self.breaks = 0;
657    }
658
659    /// Start a block's first line: the owed separation, then the prefixes.
660    fn begin_block(&mut self) {
661        if !self.out.is_empty() {
662            if self.sep >= 2 {
663                self.next_line(true);
664            }
665            self.out.push('\n');
666        }
667        self.sep = 0;
668        self.write_prefix(false);
669    }
670
671    fn next_line(&mut self, blank: bool) {
672        self.out.push('\n');
673        self.write_prefix(blank);
674    }
675
676    /// Write the container prefixes for a new line. A blank line stops at the
677    /// first container that has not written a line yet (the blank line comes
678    /// before it), and carries no trailing spaces.
679    fn write_prefix(&mut self, blank: bool) {
680        let Markdown {
681            out,
682            prefixes,
683            lists,
684            ..
685        } = self;
686        for prefix in prefixes.iter_mut() {
687            match prefix {
688                Prefix::Quote(started) => {
689                    if !*started {
690                        if blank {
691                            break;
692                        }
693                        *started = true;
694                    }
695                    out.push_str("> ");
696                }
697                Prefix::Item { width, .. } if *width > 0 => push_n(out, ' ', *width),
698                Prefix::Item { .. } if blank => break,
699                Prefix::Item { list, width } => {
700                    let at = out.len();
701                    match list.and_then(|i| lists.get_mut(i)) {
702                        Some(l) if l.ordered => {
703                            let _ = write!(out, "{}{} ", l.next, if l.alt { ')' } else { '.' });
704                            l.next = if l.reversed {
705                                l.next.saturating_sub(1)
706                            } else {
707                                l.next.saturating_add(1).min(MAX_NUMBER as u32)
708                            };
709                            l.written = true;
710                        }
711                        Some(l) => {
712                            out.push_str(if l.alt { "* " } else { "- " });
713                            l.written = true;
714                        }
715                        None => out.push_str("- "),
716                    }
717                    *width = out.len() - at;
718                }
719            }
720        }
721        if blank {
722            let end = out.trim_end_matches(' ').len();
723            out.truncate(end);
724        }
725    }
726
727    /// Whether the innermost container has written a line. Until it has, the
728    /// separation requested when it opened stands; requests from inside it are
729    /// ignored (they would land before its marker).
730    fn started(&self) -> bool {
731        match self.prefixes.last() {
732            Some(Prefix::Quote(started)) => *started,
733            Some(Prefix::Item { width, .. }) => *width > 0,
734            None => true,
735        }
736    }
737
738    fn sep(&mut self, n: u8) {
739        if self.started() {
740            self.sep = self.sep.max(n);
741        }
742    }
743
744    fn force_sep(&mut self, n: u8) {
745        if self.started() {
746            self.sep = n;
747        }
748    }
749
750    fn code_block(&mut self, raw: &str, lang: &str) {
751        let body = raw.trim_end();
752        if body.trim_start().is_empty() {
753            return;
754        }
755        let fence = longest_run(body, '`').max(2) + 1;
756        self.begin_block();
757        push_n(&mut self.out, '`', fence);
758        self.out.push_str(lang);
759        for line in body.split('\n') {
760            self.next_line(line.is_empty());
761            self.out.push_str(line);
762        }
763        self.next_line(false);
764        push_n(&mut self.out, '`', fence);
765    }
766
767    fn take_cell(&mut self) -> String {
768        self.finish_line();
769        let cell = self.line.trim().replace('|', "\\|");
770        self.line.clear();
771        cell
772    }
773
774    fn emit_table(&mut self, mut table: Table) {
775        table.end_row();
776        let cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
777        let Some((head, body)) = table.rows.split_first().filter(|_| cols > 0) else {
778            return;
779        };
780        // GFM needs a header row: the first row is it, whatever its cells are,
781        // so rows are never reordered.
782        let pad = if cols <= MAX_PAD { cols } else { 0 };
783        self.sep(2);
784        self.begin_block();
785        push_row(&mut self.out, head, cols);
786        self.next_line(false);
787        self.out.push('|');
788        for _ in 0..cols {
789            self.out.push_str(" --- |");
790        }
791        for cells in body {
792            self.next_line(false);
793            push_row(&mut self.out, cells, pad);
794        }
795    }
796
797    fn finish(mut self) -> String {
798        self.flush();
799        let end = self.out.trim_end().len();
800        self.out.truncate(end);
801        self.out
802    }
803}
804
805impl Code<'_> {
806    fn push(&mut self, text: &str) {
807        let text = if mem::take(&mut self.fresh) {
808            skip_newline(text)
809        } else {
810            text
811        };
812        push_verbatim(&mut self.buf, text);
813    }
814
815    fn br(&mut self) {
816        self.fresh = false;
817        self.buf.push(if self.block { '\n' } else { ' ' });
818    }
819
820    /// A block boundary inside code: make sure the next text starts a line.
821    fn newline(&mut self) {
822        if !self.block {
823            self.buf.push(' ');
824        } else if !self.buf.is_empty() && !self.buf.ends_with('\n') {
825            self.buf.push('\n');
826        }
827    }
828}
829
830impl Table {
831    fn end_row(&mut self) {
832        if !self.cells.is_empty() {
833            self.rows.push(mem::take(&mut self.cells));
834        }
835    }
836}
837
838/// An integer attribute value (`start`, `value`).
839fn number(value: Option<&str>) -> Option<i64> {
840    value.and_then(|v| v.trim().parse().ok())
841}
842
843/// An inline code span; its fence is longer than any backtick run inside.
844fn push_code_span(out: &mut String, text: &str) {
845    let fence = longest_run(text, '`') + 1;
846    let pad = text.starts_with('`') || text.ends_with('`');
847    push_n(out, '`', fence);
848    if pad {
849        out.push(' ');
850    }
851    out.push_str(text);
852    if pad {
853        out.push(' ');
854    }
855    push_n(out, '`', fence);
856}
857
858/// The language of a code block, from `class="language-x"` (or `lang-x`) on
859/// the `<pre>` or on its `<code>` child.
860fn code_lang<'a>(pre: NodeRef<'a>) -> &'a str {
861    let lang = |n: NodeRef<'a>| {
862        n.classes().find_map(|c| {
863            c.strip_prefix("language-")
864                .or_else(|| c.strip_prefix("lang-"))
865        })
866    };
867    lang(pre)
868        .or_else(|| {
869            pre.child_elements()
870                .find(|c| c.tag_name() == Some("code"))
871                .and_then(lang)
872        })
873        .filter(|l| !l.contains('`'))
874        .unwrap_or("")
875}
876
877/// Append one word (no whitespace) of text, escaping what Markdown would
878/// otherwise interpret. `line_start` adds the checks for block syntax (`#`,
879/// `>`, list markers, …) that only matters at the start of a line.
880fn escape(out: &mut String, word: &str, line_start: bool) {
881    let bytes = word.as_bytes();
882    let first = bytes.first().copied().unwrap_or(0);
883    let all = |b: u8| bytes.iter().all(|&x| x == b);
884    // Byte offset of a character that needs escaping because of where it is.
885    let at = if all(b'#') {
886        Some(0) // an ATX heading marker or closing sequence
887    } else if !line_start {
888        None
889    } else if first == b'>' || (matches!(first, b'-' | b'+' | b'=') && all(first)) {
890        Some(0) // block quote, bullet, thematic break or setext underline
891    } else {
892        // An ordered-list marker: up to nine digits, then `.` or `)`.
893        let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
894        let marker = (1..=9).contains(&digits)
895            && bytes.len() == digits + 1
896            && matches!(bytes.get(digits), Some(b'.' | b')'));
897        marker.then_some(digits)
898    };
899    let mut prev = None;
900    let mut chars = word.char_indices().peekable();
901    while let Some((i, c)) = chars.next() {
902        let next = chars.peek().map(|&(_, n)| n);
903        let escaped = match c {
904            '\\' | '*' | '`' | '[' | ']' | '~' => true,
905            // `_` inside a word cannot open or close emphasis.
906            '_' => {
907                !(prev.is_some_and(char::is_alphanumeric)
908                    && next.is_some_and(char::is_alphanumeric))
909            }
910            // Could open an HTML tag, comment or autolink.
911            '<' => next.is_some_and(|n| n.is_ascii_alphabetic() || matches!(n, '/' | '!' | '?')),
912            // Could open a character reference.
913            '&' => next.is_some_and(|n| n.is_ascii_alphanumeric() || n == '#'),
914            _ => Some(i) == at,
915        };
916        if escaped {
917            out.push('\\');
918        }
919        out.push(c);
920        prev = Some(c);
921    }
922}
923
924fn push_close(out: &mut String, mark: Mark<'_>) {
925    match mark {
926        Mark::Strong => out.push_str("**"),
927        Mark::Emphasis => out.push('*'),
928        Mark::Strike => out.push_str("~~"),
929        Mark::Link(a) => {
930            out.push_str("](");
931            push_destination(out, a.attr("href").unwrap_or("").trim());
932            push_title(out, a.attr("title"));
933            out.push(')');
934        }
935    }
936}
937
938/// A link destination, wrapped in `<…>` when it holds spaces, parentheses or
939/// angle brackets. Line breaks and tabs (which browsers strip from URLs) are
940/// dropped.
941fn push_destination(out: &mut String, url: &str) {
942    let pointy = url.chars().any(|c| {
943        matches!(c, ' ' | '(' | ')' | '<' | '>')
944            || (c.is_ascii_control() && !matches!(c, '\n' | '\r' | '\t'))
945    });
946    if pointy {
947        out.push('<');
948    }
949    for c in url.chars() {
950        match c {
951            '\n' | '\r' | '\t' => {}
952            '\\' => out.push_str("\\\\"),
953            '<' | '>' => {
954                out.push('\\');
955                out.push(c);
956            }
957            c => out.push(c),
958        }
959    }
960    if pointy {
961        out.push('>');
962    }
963}
964
965fn push_title(out: &mut String, title: Option<&str>) {
966    let Some(title) = title.map(str::trim).filter(|t| !t.is_empty()) else {
967        return;
968    };
969    out.push_str(" \"");
970    for c in title.chars() {
971        match c {
972            '"' | '\\' => {
973                out.push('\\');
974                out.push(c);
975            }
976            c if is_space(c) => out.push(' '),
977            c => out.push(c),
978        }
979    }
980    out.push('"');
981}
982
983/// A GFM table row with at least `width` cells (missing ones left empty).
984fn push_row(out: &mut String, cells: &[String], width: usize) {
985    out.push('|');
986    for i in 0..width.max(cells.len()) {
987        match cells.get(i).filter(|c| !c.is_empty()) {
988            Some(cell) => {
989                out.push(' ');
990                out.push_str(cell);
991                out.push_str(" |");
992            }
993            None => out.push_str(" |"),
994        }
995    }
996}
997
998fn longest_run(s: &str, ch: char) -> usize {
999    let (mut best, mut run) = (0, 0);
1000    for c in s.chars() {
1001        run = if c == ch { run + 1 } else { 0 };
1002        best = best.max(run);
1003    }
1004    best
1005}
1006
1007fn push_n(out: &mut String, ch: char, n: usize) {
1008    for _ in 0..n {
1009        out.push(ch);
1010    }
1011}