use mant_ir::{Inline, LinkTarget};
use super::MarkdownOptions;
pub(super) fn render_inline(children: &[Inline], options: MarkdownOptions) -> String {
render_inline_raw(children, options)
.split('\n')
.map(|line| line.trim_matches([' ', '\t']))
.filter(|line| !line.is_empty())
.map(protect_block_prefix)
.collect::<Vec<_>>()
.join(" \n")
}
pub(super) fn flatten_inline(children: &[Inline]) -> String {
let mut output = String::new();
for child in children {
match child {
Inline::Text { value } | Inline::Code { value } => output.push_str(value),
Inline::Strong { children }
| Inline::Emphasis { children }
| Inline::Link { children, .. } => {
output.push_str(&flatten_inline(children));
}
Inline::Anchor { .. } => {}
Inline::LineBreak => output.push('\n'),
}
}
output
}
pub(super) fn escape_text(value: &str) -> String {
let mut output = String::new();
let mut remainder = value;
while let Some((start, opening_width)) = find_angle_url(remainder) {
output.push_str(&escape_plain_text(&remainder[..start]));
let after_open = &remainder[start + opening_width..];
let closing = if opening_width == 2 { ">>" } else { ">" };
let Some(end) = after_open.find(closing) else {
output.push_str(&escape_plain_text(&remainder[start..]));
return output;
};
let url = &after_open[..end];
if url.chars().any(char::is_whitespace) || url.contains(['<', '>']) {
output.push_str(&escape_plain_text(&remainder[start..start + opening_width]));
remainder = after_open;
continue;
}
output.push('<');
output.push_str(url);
output.push('>');
remainder = &after_open[end + closing.len()..];
}
output.push_str(&escape_plain_text(remainder));
output
}
pub(super) fn fenced_code(value: &str, language: Option<&str>) -> String {
let width = longest_backtick_run(value).saturating_add(1).max(3);
let fence = "`".repeat(width);
let language = language
.map(|language| {
language
.chars()
.filter(|character| {
character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '_')
})
.collect::<String>()
})
.filter(|language| !language.is_empty())
.unwrap_or_default();
let boundary = if value.ends_with('\n') { "" } else { "\n" };
format!("{fence}{language}\n{value}{boundary}{fence}")
}
pub(super) fn code_span(value: &str) -> String {
let width = longest_backtick_run(value).saturating_add(1).max(1);
let delimiter = "`".repeat(width);
let padding = (value.starts_with(['`', ' ']) || value.ends_with(['`', ' ']))
&& !value.chars().all(|character| character == ' ');
if padding {
format!("{delimiter} {value} {delimiter}")
} else {
format!("{delimiter}{value}{delimiter}")
}
}
fn render_inline_raw(nodes: &[Inline], options: MarkdownOptions) -> String {
let mut output = String::new();
let mut index = 0;
while let Some(child) = nodes.get(index) {
match child {
Inline::Text { value } => output.push_str(&escape_text(value)),
Inline::Strong {
children: styled_children,
} => {
let mut rendered = render_inline_raw(styled_children, options);
index += 1;
while let Some(Inline::Strong { children }) = nodes.get(index) {
rendered.push_str(&render_inline_raw(children, options));
index += 1;
}
output.push_str(&render_styled(&rendered, "**", "__", &output));
continue;
}
Inline::Emphasis {
children: styled_children,
} => {
let mut rendered = render_inline_raw(styled_children, options);
index += 1;
while let Some(Inline::Emphasis { children }) = nodes.get(index) {
rendered.push_str(&render_inline_raw(children, options));
index += 1;
}
output.push_str(&render_styled(&rendered, "*", "_", &output));
continue;
}
Inline::Code { value } => output.push_str(&code_span(value)),
Inline::Link {
target,
title,
children,
} => match target {
LinkTarget::External { uri } => {
output.push_str(&render_link(uri, title.as_deref(), children, options));
}
LinkTarget::Email { address } => output.push_str(&render_link(
&format!("mailto:{address}"),
title.as_deref(),
children,
options,
)),
LinkTarget::Document { name, fragment } => {
let mut destination = format!("{name}.md");
if let Some(fragment) = fragment {
destination.push('#');
destination.push_str(fragment);
}
output.push_str(&render_link(
&destination,
title.as_deref(),
children,
options,
));
}
LinkTarget::Section { id } if options.preserve_anchors => output.push_str(
&render_link(&format!("#{id}"), title.as_deref(), children, options),
),
LinkTarget::Manual { .. } | LinkTarget::Section { .. } => {
output.push_str(&render_inline_raw(children, options));
}
},
Inline::Anchor { id } if options.preserve_anchors => {
output.push_str(&html_anchor(id));
}
Inline::Anchor { .. } => {}
Inline::LineBreak => output.push('\n'),
}
index += 1;
}
output
}
fn render_styled(
rendered: &str,
primary_marker: &str,
alternate_marker: &str,
preceding: &str,
) -> String {
let core = rendered.trim_matches([' ', '\t']);
if core.is_empty() {
return rendered.to_owned();
}
let leading_width = rendered.len() - rendered.trim_start_matches([' ', '\t']).len();
let trailing_width = rendered.len() - rendered.trim_end_matches([' ', '\t']).len();
let leading = &rendered[..leading_width];
let trailing = &rendered[rendered.len() - trailing_width..];
let marker = if preceding.ends_with('*') || core.contains(primary_marker) {
alternate_marker
} else {
primary_marker
};
format!("{leading}{marker}{core}{marker}{trailing}")
}
fn render_link(
target: &str,
title: Option<&str>,
children: &[Inline],
options: MarkdownOptions,
) -> String {
let label = render_inline_raw(children, options);
if (target.starts_with("http://") || target.starts_with("https://"))
&& flatten_inline(children) == target
&& !target.chars().any(char::is_whitespace)
&& !target.contains(['<', '>'])
{
return format!("<{target}>");
}
let target = target
.replace('\\', "\\\\")
.replace('(', "\\(")
.replace(')', "\\)")
.replace(' ', "%20");
title.map_or_else(
|| format!("[{label}]({target})"),
|title| format!("[{label}]({target} \"{}\")", title.replace('"', "\\\"")),
)
}
pub(crate) fn html_anchor(id: &str) -> String {
format!("<a id=\"{}\"></a>", escape_html_attribute(id))
}
fn escape_html_attribute(value: &str) -> String {
value
.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn escape_plain_text(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut characters = value.chars().peekable();
let mut previous = None;
while let Some(character) = characters.next() {
let intraword_underscore = character == '_'
&& previous.is_some_and(char::is_alphanumeric)
&& characters
.peek()
.is_some_and(|character| character.is_alphanumeric());
if character == '&' {
output.push_str("&");
previous = Some(character);
continue;
}
if matches!(character, '\\' | '`' | '*' | '[' | ']' | '<' | '>' | '$')
|| (character == '_' && !intraword_underscore)
{
output.push('\\');
}
output.push(character);
previous = Some(character);
}
output
}
pub(super) fn protect_block_prefix(line: &str) -> String {
let bytes = line.as_bytes();
let hashes = bytes.iter().take_while(|byte| **byte == b'#').count();
let insertion = if (hashes > 0 && bytes.get(hashes).is_none_or(u8::is_ascii_whitespace))
|| bytes.starts_with(b">")
|| bytes.starts_with(b"- ")
|| bytes.starts_with(b"+ ")
|| bytes.starts_with(b"* ")
|| (!bytes.is_empty() && bytes.iter().all(|byte| *byte == b'-'))
|| (!bytes.is_empty() && bytes.iter().all(|byte| *byte == b'='))
{
Some(0)
} else {
let digits = bytes
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
(digits > 0
&& bytes
.get(digits..digits.saturating_add(2))
.is_some_and(|suffix| matches!(suffix, b". " | b") ")))
.then_some(digits)
};
insertion.map_or_else(
|| line.to_owned(),
|width| format!("{}\\{}", &line[..width], &line[width..]),
)
}
fn find_angle_url(value: &str) -> Option<(usize, usize)> {
[
("<<http://", 2),
("<<https://", 2),
("<http://", 1),
("<https://", 1),
]
.into_iter()
.filter_map(|(needle, width)| value.find(needle).map(|index| (index, width)))
.min_by_key(|(index, width)| (*index, usize::MAX - *width))
}
fn longest_backtick_run(value: &str) -> usize {
let mut longest = 0;
let mut current = 0;
for character in value.chars() {
if character == '`' {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
longest
}
#[cfg(test)]
mod tests {
use super::escape_plain_text;
#[test]
fn plain_text_escapes_only_delimiter_capable_underscores() {
for (source, expected) in [
("PATH_SCRIPT", "PATH_SCRIPT"),
("a_b", "a_b"),
("路径_脚本", "路径_脚本"),
("_leading", "\\_leading"),
("trailing_", "trailing\\_"),
("a__b", "a\\_\\_b"),
] {
assert_eq!(escape_plain_text(source), expected, "{source}");
}
}
#[test]
fn plain_text_escapes_literal_backticks() {
assert_eq!(
escape_plain_text("`bold' and ```"),
"\\`bold' and \\`\\`\\`"
);
}
}