use std::fmt::{self, Display, Formatter};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use super::parse::Marker;
use crate::ast::{Alignment, Block, Document, ImageSource, Inline, LinkTarget, Macro, MacroBody, Table, Task};
impl Document {
pub fn to_markdown(&self) -> String {
Md(&self.blocks[..]).to_string()
}
}
struct Md<T>(T);
impl Display for Md<&[Block]> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
for (i, block) in self.0.iter().enumerate() {
if i > 0 {
f.write_str("\n\n")?;
}
write!(f, "{}", Md(block))?;
}
Ok(())
}
}
impl Display for Md<&[Inline]> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
for inline in self.0 {
write!(f, "{}", Md(inline))?;
}
Ok(())
}
}
impl Display for Md<&Block> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self.0 {
Block::Heading { level, content } => write!(f, "{} {}", "#".repeat(*level as usize), Md(&content[..])),
Block::Paragraph(content) => write!(f, "{}", Md(&content[..])),
Block::CodeBlock { language, code } => {
let lang = language.as_deref().unwrap_or("");
write!(f, "```{lang}\n{code}\n```")
},
Block::BlockQuote(blocks) => {
let quoted = Md(&blocks[..])
.to_string()
.lines()
.map(|line| if line.is_empty() { ">".to_string() } else { format!("> {line}") })
.collect::<Vec<_>>()
.join("\n");
f.write_str("ed)
},
Block::List { ordered, items } => {
let mut out = Vec::new();
for (i, item) in items.iter().enumerate() {
let marker = if *ordered { format!("{}. ", i + 1) } else { "- ".to_string() };
let indent = " ".repeat(marker.len());
let mut item_str = String::new();
for (j, block) in item.iter().enumerate() {
let rendered = Md(block).to_string();
if j == 0 {
item_str.push_str(&marker);
item_str.push_str(&rendered.indent(&indent, false));
} else {
item_str.push('\n');
item_str.push_str(&rendered.indent(&indent, true));
}
}
out.push(item_str);
}
f.write_str(&out.join("\n"))
},
Block::ThematicBreak => f.write_str("---"),
Block::Table(t) => write!(f, "{}", Md(t)),
Block::TaskList(tasks) => {
for (i, task) in tasks.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
write!(f, "{}", Md(task))?;
}
Ok(())
},
Block::Macro(m) => write!(f, "{}", Md(m)),
Block::RawConfluence(s) => f.write_str(&Marker::raw(s)),
}
}
}
impl Display for Md<&Inline> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self.0 {
Inline::Text(t) => f.write_str(&t.escape_markdown()),
Inline::Strong(c) => write!(f, "**{}**", Md(&c[..])),
Inline::Emphasis(c) => write!(f, "*{}*", Md(&c[..])),
Inline::Strikethrough(c) => write!(f, "~~{}~~", Md(&c[..])),
Inline::Code(c) => write!(f, "`{c}`"),
Inline::Link { target, title, content } => {
let url = Md(target).url();
match title {
Some(t) => write!(f, "[{}]({url} \"{t}\")", Md(&content[..])),
None => write!(f, "[{}]({url})", Md(&content[..])),
}
},
Inline::Image { source, alt } => write!(f, "", Md(source).url()),
Inline::SoftBreak => f.write_str("\n"),
Inline::HardBreak => f.write_str(" \n"),
Inline::Macro(m) => write!(f, "{}", Md(m)),
Inline::RawConfluence(s) => f.write_str(&Marker::raw(s)),
}
}
}
impl Md<&Macro> {
fn marker(&self) -> String {
let mut out = format!("<!--cf:{}", self.0.name);
for (k, v) in &self.0.params {
out.push_str(&format!(" {k}=\"{}\"", v.replace('"', """).replace("-->", "-->")));
}
out.push_str("-->");
out
}
fn body(&self) -> String {
match &self.0.body {
MacroBody::RichText(blocks) => Md(&blocks[..]).to_string(),
MacroBody::PlainText(text) => text.clone(),
MacroBody::Empty => String::new(),
}
}
fn alert_token(&self) -> Option<&'static str> {
match self.0.name.as_str() {
"note" => Some("NOTE"),
"tip" => Some("TIP"),
"warning" => Some("WARNING"),
"info" => Some("IMPORTANT"),
_ => None,
}
}
fn alert(&self, token: &str) -> String {
let mut out = format!("> [!{token}]");
for line in self.body().lines() {
out.push('\n');
if line.is_empty() {
out.push('>');
} else {
out.push_str("> ");
out.push_str(line);
}
}
out
}
fn details(&self) -> String {
let title = self.0.param("title").unwrap_or_default();
format!("<details><summary>{title}</summary>\n\n{}\n\n</details>", self.body())
}
}
impl Display for Md<&Macro> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let m = self.0;
let untitled = m.param("title").is_none();
if let Some(token) = self.alert_token()
&& untitled
{
return f.write_str(&self.alert(token));
}
if m.name == "expand" {
return f.write_str(&self.details());
}
if matches!(m.body, MacroBody::Empty) {
return f.write_str(&self.marker());
}
write!(f, "{}\n\n{}\n\n<!--/cf:{}-->", self.marker(), self.body(), m.name)
}
}
impl Display for Md<&Task> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mark = if self.0.checked { "x" } else { " " };
write!(f, "- [{mark}] {}", Md(&self.0.content[..]))
}
}
impl Md<&Table> {
fn delim(&self) -> String {
let cells: Vec<&str> = (0..self.0.head.len())
.map(|i| match self.0.align.get(i).copied().unwrap_or(Alignment::None) {
Alignment::None => "---",
Alignment::Left => ":--",
Alignment::Center => ":-:",
Alignment::Right => "--:",
})
.collect();
format!("| {} |", cells.join(" | "))
}
}
impl Display for Md<&Table> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}\n{}", Md(&self.0.head[..]), self.delim())?;
for row in &self.0.rows {
write!(f, "\n{}", Md(&row[..]))?;
}
Ok(())
}
}
impl Display for Md<&[Vec<Inline>]> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let cells: Vec<String> = self
.0
.iter()
.map(|c| Md(&c[..]).to_string().replace('|', "\\|"))
.collect();
write!(f, "| {} |", cells.join(" | "))
}
}
impl Md<&LinkTarget> {
fn url(&self) -> String {
match self.0 {
LinkTarget::External(url) => url.clone(),
LinkTarget::Page { space, title, content_id } => {
let space = space
.as_ref()
.map(|s| format!("space={}&", s.encode_uri()))
.unwrap_or_default();
let id = content_id
.as_ref()
.map(|id| format!("&id={}", id.encode_uri()))
.unwrap_or_default();
format!("confluence://page?{space}title={}{id}", title.encode_uri())
},
LinkTarget::Content(id) => format!("confluence://content?id={}", id.encode_uri()),
LinkTarget::Attachment(file) => format!("confluence://attachment?file={}", file.encode_uri()),
LinkTarget::Anchor(name) => format!("confluence://anchor?name={}", name.encode_uri()),
}
}
}
impl Md<&ImageSource> {
fn url(&self) -> String {
match self.0 {
ImageSource::External(url) => url.clone(),
ImageSource::Attachment(file) => format!("confluence://attachment?file={}", file.encode_uri()),
}
}
}
trait StrExt {
fn escape_markdown(&self) -> String;
fn encode_uri(&self) -> String;
fn indent(&self, indent: &str, indent_first: bool) -> String;
}
impl StrExt for str {
fn escape_markdown(&self) -> String {
let mut out = String::with_capacity(self.len());
for ch in self.chars() {
if matches!(ch, '\\' | '`' | '*' | '_' | '[' | ']' | '~' | '<') {
out.push('\\');
}
out.push(ch);
}
out
}
fn encode_uri(&self) -> String {
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
utf8_percent_encode(self, UNRESERVED).to_string()
}
fn indent(&self, indent: &str, indent_first: bool) -> String {
self.lines()
.enumerate()
.map(|(i, line)| {
if line.is_empty() || (i == 0 && !indent_first) {
line.to_string()
} else {
format!("{indent}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
}