use alloc::string::String;
use alloc::vec::Vec;
use core::fmt::Write as _;
use core::mem;
use super::{is_space, push_verbatim, skip_newline, walk};
use crate::node::{NodeId, NodeKind};
use crate::tags::{self, Display};
use crate::tree::{Dom, NodeRef};
const MAX_NEST: usize = 32;
const MAX_PAD: usize = 64;
const MAX_NUMBER: i64 = 999_999_999;
impl Dom {
pub fn to_markdown(&self) -> String {
let mut md = Markdown::default();
for root in self.roots() {
walk(root, |node, enter| md.visit(node, enter));
}
md.finish()
}
}
impl NodeRef<'_> {
pub fn to_markdown(&self) -> String {
let mut md = Markdown {
root: Some(self.id()),
..Markdown::default()
};
walk(*self, |node, enter| md.visit(node, enter));
md.finish()
}
}
#[derive(Clone, Copy)]
enum Mark<'a> {
Strong,
Emphasis,
Strike,
Link(NodeRef<'a>),
}
#[derive(Clone, Copy)]
struct Wrap<'a> {
mark: Mark<'a>,
open: bool,
used: bool,
spaced: bool,
}
impl Wrap<'_> {
fn pending(&self) -> bool {
match self.mark {
Mark::Link(_) => !self.used,
_ => !self.open,
}
}
}
enum Prefix {
Quote(bool),
Item { list: Option<usize>, width: usize },
}
struct List {
ordered: bool,
reversed: bool,
alt: bool,
next: u32,
written: bool,
}
struct Code<'a> {
buf: String,
block: bool,
fresh: bool,
lang: &'a str,
}
#[derive(Default)]
struct Table {
rows: Vec<Vec<String>>,
cells: Vec<String>,
}
#[derive(Clone, Copy, Default)]
struct Frame {
block: bool,
space: bool,
mark: bool,
prefix: bool,
list: bool,
inline: bool,
heading: bool,
code: bool,
role: Role,
}
#[derive(Clone, Copy, Default)]
enum Role {
#[default]
None,
Table,
Row,
Cell,
}
#[derive(Default)]
struct Markdown<'a> {
out: String,
sep: u8,
prefixes: Vec<Prefix>,
lists: Vec<List>,
last_list: Option<(usize, usize, bool, bool)>,
frames: Vec<Frame>,
line: String,
space: bool,
breaks: u8,
wraps: Vec<Wrap<'a>>,
closing: Vec<Mark<'a>>,
code_run: String,
heading: u8,
inline_only: u32,
code: Option<Code<'a>>,
table: Option<Table>,
root: Option<NodeId>,
}
impl<'a> Markdown<'a> {
fn visit(&mut self, node: NodeRef<'a>, enter: bool) -> bool {
match node.kind() {
NodeKind::Text(t) => self.text(t),
NodeKind::Element { tag } if enter => return self.open(node, tag),
NodeKind::Element { .. } => self.close(),
NodeKind::Comment(_) | NodeKind::Doctype(_) => {}
}
false
}
fn open(&mut self, node: NodeRef<'a>, tag: &'a str) -> bool {
if self.root != Some(node.id()) && node.hides_itself() {
return false;
}
let mut display = tags::display(tag);
if display == Display::Hidden {
if self.root != Some(node.id()) {
return false;
}
display = Display::Block;
}
let mut f = Frame::default();
if let Some(code) = &mut self.code {
if tag == "br" {
code.br();
} else if display != Display::Inline {
code.newline();
f.block = true;
}
self.frames.push(f);
return true;
}
let top = self.inline_only == 0;
match tag {
"br" if !top => self.space = true,
"br" if !self.line.is_empty() || !self.code_run.is_empty() => {
self.breaks = (self.breaks + 1).min(2);
}
"hr" if top => {
self.block(&mut f);
self.begin_block();
self.out.push_str("---");
}
"img" => self.image(node),
"code" | "kbd" | "samp" | "tt" => self.start_code(&mut f, false, ""),
"pre" if top => {
self.block(&mut f);
self.start_code(&mut f, true, code_lang(node));
}
"pre" => {
self.block(&mut f); self.start_code(&mut f, false, "");
}
"strong" | "b" => f.mark = self.push_mark(Mark::Strong),
"em" | "i" => f.mark = self.push_mark(Mark::Emphasis),
"del" | "s" | "strike" => f.mark = self.push_mark(Mark::Strike),
"a" if node.attr("href").is_some_and(|h| !h.trim().is_empty()) => {
f.mark = self.push_mark(Mark::Link(node));
}
"dt" => {
self.block(&mut f);
f.mark = self.push_mark(Mark::Strong);
}
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" if top => {
self.block(&mut f);
self.heading = tag.as_bytes().get(1).map_or(1, |b| b.saturating_sub(b'0'));
self.inline_only += 1;
f.heading = true;
f.inline = true;
}
"ul" | "ol" | "menu" if top => {
self.flush();
let nested = matches!(self.prefixes.last(), Some(Prefix::Item { .. }));
self.sep(if nested { 1 } else { 2 });
f.block = true;
if self.prefixes.len() < MAX_NEST {
let ordered = tag == "ol";
let reversed = ordered && node.has_attr("reversed");
let start = number(node.attr("start")).unwrap_or_else(|| {
if reversed {
let items =
node.child_elements().filter(|c| c.tag_name() == Some("li"));
items.count() as i64
} else {
1
}
});
let depth = self.prefixes.len();
let alt = matches!(self.last_list, Some((d, at, o, false))
if d == depth && at == self.out.len() && o == ordered);
self.lists.push(List {
ordered,
reversed,
alt,
next: start.clamp(0, MAX_NUMBER) as u32,
written: false,
});
f.list = true;
}
}
"li" if top => {
self.flush();
let list = self.frames.last().filter(|p| p.list);
let list = list.and_then(|_| self.lists.len().checked_sub(1));
if let (Some(l), Some(v)) = (
list.and_then(|i| self.lists.get_mut(i)),
number(node.attr("value")),
) {
l.next = v.clamp(0, MAX_NUMBER) as u32;
}
if list
.and_then(|i| self.lists.get(i))
.is_some_and(|l| l.written)
{
self.force_sep(1);
} else {
self.sep(1);
}
f.block = true;
if self.prefixes.len() < MAX_NEST {
self.prefixes.push(Prefix::Item { list, width: 0 });
self.last_list = None; f.prefix = true;
} else {
self.sep(2);
}
}
"blockquote" if top => {
self.block(&mut f);
if self.prefixes.len() < MAX_NEST {
self.prefixes.push(Prefix::Quote(false));
f.prefix = true;
}
}
"table" if top && self.table.is_none() => {
self.block(&mut f);
self.table = Some(Table::default());
f.role = Role::Table;
}
"tr" | "td" | "th" if top && self.table.is_some() => {
self.flush(); if tag == "tr" {
if let Some(t) = &mut self.table {
t.end_row();
}
f.role = Role::Row;
} else {
self.inline_only += 1;
f.role = Role::Cell;
f.inline = true;
}
}
"td" | "th" => {
self.space = true;
f.space = true;
}
_ if display != Display::Inline => self.block(&mut f),
_ => {}
}
self.frames.push(f);
true
}
fn close(&mut self) {
let Some(f) = self.frames.pop() else {
return;
};
if f.code {
self.end_code();
} else if let Some(code) = &mut self.code {
if f.block {
code.newline();
}
return;
}
if f.mark {
self.pop_mark();
}
match f.role {
Role::Cell => {
let cell = self.take_cell();
if let Some(t) = &mut self.table {
t.cells.push(cell);
}
}
Role::Row => {
if let Some(t) = &mut self.table {
t.end_row();
}
}
Role::Table => {
self.flush();
if let Some(t) = self.table.take() {
self.emit_table(t);
}
}
Role::None => {}
}
if f.block {
self.flush();
}
if f.heading {
self.heading = 0;
}
if f.inline {
self.inline_only = self.inline_only.saturating_sub(1);
}
if f.prefix {
self.prefixes.pop();
}
if f.list
&& let Some(l) = self.lists.pop()
{
self.last_list = Some((self.prefixes.len(), self.out.len(), l.ordered, l.alt));
}
if f.block {
self.sep(2);
}
if f.space {
self.space = true;
}
}
fn block(&mut self, f: &mut Frame) {
if self.inline_only > 0 {
self.space = true;
f.space = true;
} else {
self.flush();
self.sep(2);
f.block = true;
}
}
fn text(&mut self, text: &str) {
if let Some(code) = &mut self.code {
code.push(text);
return;
}
for (i, word) in text.split(is_space).enumerate() {
if i > 0 {
self.space = true;
}
if !word.is_empty() {
self.inline();
let line_start =
self.inline_only == 0 && (self.line.is_empty() || self.line.ends_with('\n'));
escape(&mut self.line, word, line_start);
}
}
}
fn inline(&mut self) {
self.settle();
if !self.line.is_empty() {
if self.breaks > 0 {
for _ in 0..self.breaks {
self.line.push_str("\\\n");
}
} else if self.space {
self.line.push(' ');
}
}
self.breaks = 0;
self.space = false;
for w in self.wraps.iter_mut().filter(|w| w.pending()) {
w.open = true;
w.used = true;
self.line.push_str(match w.mark {
Mark::Strong => "**",
Mark::Emphasis => "*",
Mark::Strike => "~~",
Mark::Link(_) => "[",
});
}
}
fn settle(&mut self) {
if !self.code_run.is_empty() {
push_code_span(&mut self.line, &self.code_run);
self.code_run.clear();
}
for mark in self.closing.drain(..) {
push_close(&mut self.line, mark);
}
}
fn push_mark(&mut self, mark: Mark<'a>) -> bool {
let kind = mem::discriminant(&mark);
if self
.wraps
.iter()
.any(|w| mem::discriminant(&w.mark) == kind)
{
return false;
}
let resume = !matches!(mark, Mark::Link(_))
&& !self.space
&& self.breaks == 0
&& self
.closing
.last()
.is_some_and(|m| mem::discriminant(m) == kind)
&& !self.wraps.iter().any(Wrap::pending);
if resume {
self.closing.pop();
}
self.wraps.push(Wrap {
mark,
open: resume,
used: resume,
spaced: self.space,
});
true
}
fn pop_mark(&mut self) {
if let Some(Wrap {
mark: Mark::Link(a),
used: false,
spaced,
..
}) = self.wraps.last().copied()
{
let after = !spaced && mem::take(&mut self.space);
self.text(a.attr("href").unwrap_or("").trim());
self.space |= after;
}
if let Some(w) = self.wraps.pop()
&& w.open
{
self.closing.push(w.mark);
}
}
fn image(&mut self, img: NodeRef<'_>) {
let alt = img.attr("alt").unwrap_or("");
let src = img.attr("src").unwrap_or("").trim();
if src.is_empty() {
self.text(alt);
return;
}
self.inline();
self.line.push_str(";
push_destination(&mut self.line, src);
push_title(&mut self.line, img.attr("title"));
self.line.push(')');
}
fn start_code(&mut self, f: &mut Frame, block: bool, lang: &'a str) {
self.code = Some(Code {
buf: String::new(),
block,
fresh: block,
lang,
});
f.code = true;
}
fn end_code(&mut self) {
let Some(code) = self.code.take() else {
return;
};
if code.block {
self.code_block(&code.buf, code.lang);
return;
}
let mut text = String::new();
for word in code.buf.split(is_space).filter(|w| !w.is_empty()) {
if !text.is_empty() {
text.push(' ');
}
text.push_str(word);
}
if text.is_empty() {
return;
}
let adjacent = !self.code_run.is_empty()
&& self.closing.is_empty()
&& !self.space
&& self.breaks == 0
&& !self.wraps.iter().any(Wrap::pending);
if adjacent {
self.code_run.push_str(&text);
} else {
self.inline();
self.code_run = text;
}
}
fn flush(&mut self) {
self.finish_line();
if self.line.is_empty() {
return;
}
let line = mem::take(&mut self.line);
self.begin_block();
if self.heading > 0 {
push_n(&mut self.out, '#', usize::from(self.heading));
self.out.push(' ');
}
for (i, part) in line.split('\n').enumerate() {
if i > 0 {
self.next_line(false);
}
self.out.push_str(part);
}
self.line = line;
self.line.clear();
}
fn finish_line(&mut self) {
self.settle();
for w in self.wraps.iter_mut().rev().filter(|w| w.open) {
w.open = false;
push_close(&mut self.line, w.mark);
}
self.space = false;
self.breaks = 0;
}
fn begin_block(&mut self) {
if !self.out.is_empty() {
if self.sep >= 2 {
self.next_line(true);
}
self.out.push('\n');
}
self.sep = 0;
self.write_prefix(false);
}
fn next_line(&mut self, blank: bool) {
self.out.push('\n');
self.write_prefix(blank);
}
fn write_prefix(&mut self, blank: bool) {
let Markdown {
out,
prefixes,
lists,
..
} = self;
for prefix in prefixes.iter_mut() {
match prefix {
Prefix::Quote(started) => {
if !*started {
if blank {
break;
}
*started = true;
}
out.push_str("> ");
}
Prefix::Item { width, .. } if *width > 0 => push_n(out, ' ', *width),
Prefix::Item { .. } if blank => break,
Prefix::Item { list, width } => {
let at = out.len();
match list.and_then(|i| lists.get_mut(i)) {
Some(l) if l.ordered => {
let _ = write!(out, "{}{} ", l.next, if l.alt { ')' } else { '.' });
l.next = if l.reversed {
l.next.saturating_sub(1)
} else {
l.next.saturating_add(1).min(MAX_NUMBER as u32)
};
l.written = true;
}
Some(l) => {
out.push_str(if l.alt { "* " } else { "- " });
l.written = true;
}
None => out.push_str("- "),
}
*width = out.len() - at;
}
}
}
if blank {
let end = out.trim_end_matches(' ').len();
out.truncate(end);
}
}
fn started(&self) -> bool {
match self.prefixes.last() {
Some(Prefix::Quote(started)) => *started,
Some(Prefix::Item { width, .. }) => *width > 0,
None => true,
}
}
fn sep(&mut self, n: u8) {
if self.started() {
self.sep = self.sep.max(n);
}
}
fn force_sep(&mut self, n: u8) {
if self.started() {
self.sep = n;
}
}
fn code_block(&mut self, raw: &str, lang: &str) {
let body = raw.trim_end();
if body.trim_start().is_empty() {
return;
}
let fence = longest_run(body, '`').max(2) + 1;
self.begin_block();
push_n(&mut self.out, '`', fence);
self.out.push_str(lang);
for line in body.split('\n') {
self.next_line(line.is_empty());
self.out.push_str(line);
}
self.next_line(false);
push_n(&mut self.out, '`', fence);
}
fn take_cell(&mut self) -> String {
self.finish_line();
let cell = self.line.trim().replace('|', "\\|");
self.line.clear();
cell
}
fn emit_table(&mut self, mut table: Table) {
table.end_row();
let cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
let Some((head, body)) = table.rows.split_first().filter(|_| cols > 0) else {
return;
};
let pad = if cols <= MAX_PAD { cols } else { 0 };
self.sep(2);
self.begin_block();
push_row(&mut self.out, head, cols);
self.next_line(false);
self.out.push('|');
for _ in 0..cols {
self.out.push_str(" --- |");
}
for cells in body {
self.next_line(false);
push_row(&mut self.out, cells, pad);
}
}
fn finish(mut self) -> String {
self.flush();
let end = self.out.trim_end().len();
self.out.truncate(end);
self.out
}
}
impl Code<'_> {
fn push(&mut self, text: &str) {
let text = if mem::take(&mut self.fresh) {
skip_newline(text)
} else {
text
};
push_verbatim(&mut self.buf, text);
}
fn br(&mut self) {
self.fresh = false;
self.buf.push(if self.block { '\n' } else { ' ' });
}
fn newline(&mut self) {
if !self.block {
self.buf.push(' ');
} else if !self.buf.is_empty() && !self.buf.ends_with('\n') {
self.buf.push('\n');
}
}
}
impl Table {
fn end_row(&mut self) {
if !self.cells.is_empty() {
self.rows.push(mem::take(&mut self.cells));
}
}
}
fn number(value: Option<&str>) -> Option<i64> {
value.and_then(|v| v.trim().parse().ok())
}
fn push_code_span(out: &mut String, text: &str) {
let fence = longest_run(text, '`') + 1;
let pad = text.starts_with('`') || text.ends_with('`');
push_n(out, '`', fence);
if pad {
out.push(' ');
}
out.push_str(text);
if pad {
out.push(' ');
}
push_n(out, '`', fence);
}
fn code_lang<'a>(pre: NodeRef<'a>) -> &'a str {
let lang = |n: NodeRef<'a>| {
n.classes().find_map(|c| {
c.strip_prefix("language-")
.or_else(|| c.strip_prefix("lang-"))
})
};
lang(pre)
.or_else(|| {
pre.child_elements()
.find(|c| c.tag_name() == Some("code"))
.and_then(lang)
})
.filter(|l| !l.contains('`'))
.unwrap_or("")
}
fn escape(out: &mut String, word: &str, line_start: bool) {
let bytes = word.as_bytes();
let first = bytes.first().copied().unwrap_or(0);
let all = |b: u8| bytes.iter().all(|&x| x == b);
let at = if all(b'#') {
Some(0) } else if !line_start {
None
} else if first == b'>' || (matches!(first, b'-' | b'+' | b'=') && all(first)) {
Some(0) } else {
let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
let marker = (1..=9).contains(&digits)
&& bytes.len() == digits + 1
&& matches!(bytes.get(digits), Some(b'.' | b')'));
marker.then_some(digits)
};
let mut prev = None;
let mut chars = word.char_indices().peekable();
while let Some((i, c)) = chars.next() {
let next = chars.peek().map(|&(_, n)| n);
let escaped = match c {
'\\' | '*' | '`' | '[' | ']' | '~' => true,
'_' => {
!(prev.is_some_and(char::is_alphanumeric)
&& next.is_some_and(char::is_alphanumeric))
}
'<' => next.is_some_and(|n| n.is_ascii_alphabetic() || matches!(n, '/' | '!' | '?')),
'&' => next.is_some_and(|n| n.is_ascii_alphanumeric() || n == '#'),
_ => Some(i) == at,
};
if escaped {
out.push('\\');
}
out.push(c);
prev = Some(c);
}
}
fn push_close(out: &mut String, mark: Mark<'_>) {
match mark {
Mark::Strong => out.push_str("**"),
Mark::Emphasis => out.push('*'),
Mark::Strike => out.push_str("~~"),
Mark::Link(a) => {
out.push_str("](");
push_destination(out, a.attr("href").unwrap_or("").trim());
push_title(out, a.attr("title"));
out.push(')');
}
}
}
fn push_destination(out: &mut String, url: &str) {
let pointy = url.chars().any(|c| {
matches!(c, ' ' | '(' | ')' | '<' | '>')
|| (c.is_ascii_control() && !matches!(c, '\n' | '\r' | '\t'))
});
if pointy {
out.push('<');
}
for c in url.chars() {
match c {
'\n' | '\r' | '\t' => {}
'\\' => out.push_str("\\\\"),
'<' | '>' => {
out.push('\\');
out.push(c);
}
c => out.push(c),
}
}
if pointy {
out.push('>');
}
}
fn push_title(out: &mut String, title: Option<&str>) {
let Some(title) = title.map(str::trim).filter(|t| !t.is_empty()) else {
return;
};
out.push_str(" \"");
for c in title.chars() {
match c {
'"' | '\\' => {
out.push('\\');
out.push(c);
}
c if is_space(c) => out.push(' '),
c => out.push(c),
}
}
out.push('"');
}
fn push_row(out: &mut String, cells: &[String], width: usize) {
out.push('|');
for i in 0..width.max(cells.len()) {
match cells.get(i).filter(|c| !c.is_empty()) {
Some(cell) => {
out.push(' ');
out.push_str(cell);
out.push_str(" |");
}
None => out.push_str(" |"),
}
}
}
fn longest_run(s: &str, ch: char) -> usize {
let (mut best, mut run) = (0, 0);
for c in s.chars() {
run = if c == ch { run + 1 } else { 0 };
best = best.max(run);
}
best
}
fn push_n(out: &mut String, ch: char, n: usize) {
for _ in 0..n {
out.push(ch);
}
}