use crate::model::config::DjotOptions;
use crate::model::document::{LinkType, MathType};
use crate::model::inline::{
append_refs_extension, detect_and_strip_checkbox, Attributes, Inline, Inlines,
};
use crate::model::is_ref_url;
use crate::model::node::ColumnAlignment;
use crate::model::writer::{frontmatter_to_yaml, Block, Blocks};
pub struct DjotWriter {}
impl Default for DjotWriter {
fn default() -> Self {
Self::new()
}
}
impl DjotWriter {
pub fn new() -> DjotWriter {
DjotWriter {}
}
}
impl DjotWriter {
pub fn write(&self, blocks: &Blocks, options: &DjotOptions) -> String {
blocks_to_djot(blocks, options, false)
}
pub fn write_skip_frontmatter(&self, blocks: &Blocks, options: &DjotOptions) -> String {
blocks_to_djot(blocks, options, true)
}
}
fn blocks_to_djot(blocks: &Blocks, options: &DjotOptions, skip_frontmatter: bool) -> String {
let parts: Vec<String> = blocks
.iter()
.filter(|block| !(skip_frontmatter && matches!(block, Block::Frontmatter(_))))
.map(|block| block_to_djot(block, options))
.collect();
ensure_trailing_newline(parts.join("\n"))
}
fn block_to_djot(block: &Block, options: &DjotOptions) -> String {
match block {
Block::Frontmatter(mapping) => {
format!("---\n{}---\n", frontmatter_to_yaml(mapping))
}
Block::Header(level, inlines) => {
format!(
"{} {}\n",
"#".repeat(*level as usize),
inlines_to_djot(inlines, options)
)
}
Block::Para(inlines) | Block::Plain(inlines) => {
format!("{}\n", inlines_to_djot(inlines, options))
}
Block::LineBlock(lines) => {
let body = lines
.iter()
.map(|line| inlines_to_djot(line, options))
.collect::<Vec<String>>()
.join("\\\n");
format!("{}\n", body)
}
Block::HorizontalRule => "----\n".to_string(),
Block::CodeBlock(lang, text) => {
let body = text.trim_matches('\n');
match lang.clone().filter(|lang| !lang.trim().is_empty()) {
Some(lang) => format!("``` {}\n{}\n```\n", lang, body),
None => format!("```\n{}\n```\n", body),
}
}
Block::RawBlock(_, text) => ensure_trailing_newline(text.clone()),
Block::BlockQuote(blocks) => {
let inner = blocks_to_djot(blocks, options, false);
let quoted = inner
.lines()
.map(|line| {
if line.is_empty() {
">".to_string()
} else {
format!("> {}", line)
}
})
.collect::<Vec<String>>()
.join("\n");
format!("{}\n", quoted)
}
Block::BulletList(items) => list_to_djot(items, options, false),
Block::OrderedList(items) => list_to_djot(items, options, true),
Block::Table(header, alignment, rows) => table_to_djot(header, alignment, rows, options),
}
}
fn list_to_djot(items: &[Blocks], options: &DjotOptions, ordered: bool) -> String {
let mut out = String::new();
for (index, item) in items.iter().enumerate() {
let marker = if ordered {
format!("{}.", index + 1)
} else {
"-".to_string()
};
let (checkbox, item) = strip_item_checkbox(item);
let pad = marker.chars().count() + 1;
let item_text: String = item
.iter()
.map(|block| block_to_djot(block, options))
.collect::<Vec<String>>()
.join("\n");
for (n, line) in item_text.lines().enumerate() {
if n == 0 {
out.push_str(&format!("{} {}{}\n", marker, checkbox, line));
} else if line.is_empty() {
out.push('\n');
} else {
out.push_str(&format!("{}{}\n", " ".repeat(pad), line));
}
}
}
out
}
fn strip_item_checkbox(item: &Blocks) -> (&'static str, Blocks) {
let inlines = match item.first() {
Some(Block::Para(inlines)) | Some(Block::Plain(inlines)) => inlines,
_ => return ("", item.clone()),
};
let (checked, stripped) = detect_and_strip_checkbox(inlines);
let prefix = match checked {
Some(true) => "[x] ",
Some(false) => "[ ] ",
None => return ("", item.clone()),
};
let mut item = item.clone();
item[0] = match &item[0] {
Block::Para(_) => Block::Para(stripped),
Block::Plain(_) => Block::Plain(stripped),
other => other.clone(),
};
(prefix, item)
}
fn table_to_djot(
header: &[Inlines],
alignment: &[ColumnAlignment],
rows: &[Vec<Inlines>],
options: &DjotOptions,
) -> String {
let mut out = String::new();
let render_row = |cells: &[Inlines]| -> String {
let rendered = cells
.iter()
.map(|cell| inlines_to_djot(cell, options).replace('|', "\\|"))
.collect::<Vec<String>>()
.join(" | ");
format!("| {} |\n", rendered)
};
if !header.is_empty() {
out.push_str(&render_row(header));
let separator = header
.iter()
.enumerate()
.map(
|(i, _)| match alignment.get(i).copied().unwrap_or(ColumnAlignment::None) {
ColumnAlignment::Left => ":---".to_string(),
ColumnAlignment::Right => "---:".to_string(),
ColumnAlignment::Center => ":---:".to_string(),
ColumnAlignment::None => "---".to_string(),
},
)
.collect::<Vec<String>>()
.join(" | ");
out.push_str(&format!("| {} |\n", separator));
}
for row in rows {
out.push_str(&render_row(row));
}
out
}
fn inlines_to_djot(inlines: &Inlines, options: &DjotOptions) -> String {
let mut out = String::new();
for inline in inlines {
render_inline_djot(inline, options, &mut out);
}
out
}
fn render_inline_djot(inline: &Inline, options: &DjotOptions, out: &mut String) {
match inline {
Inline::Str(text) => out.push_str(&escape_djot(text)),
Inline::Space => out.push(' '),
Inline::SoftBreak => out.push('\n'),
Inline::LineBreak => out.push_str("\\\n"),
Inline::Emph(inner) => {
out.push('_');
out.push_str(&inlines_to_djot(inner, options));
out.push('_');
}
Inline::Strong(inner) => {
out.push('*');
out.push_str(&inlines_to_djot(inner, options));
out.push('*');
}
Inline::Strikeout(inner) => {
out.push_str("{-");
out.push_str(&inlines_to_djot(inner, options));
out.push_str("-}");
}
Inline::Underline(inner) => {
out.push_str("{+");
out.push_str(&inlines_to_djot(inner, options));
out.push_str("+}");
}
Inline::Insert(inner) => {
out.push_str("{+");
out.push_str(&inlines_to_djot(inner, options));
out.push_str("+}");
}
Inline::Delete(inner) => {
out.push_str("{-");
out.push_str(&inlines_to_djot(inner, options));
out.push_str("-}");
}
Inline::Mark(inner) => {
out.push_str("{=");
out.push_str(&inlines_to_djot(inner, options));
out.push_str("=}");
}
Inline::Symbol(text) => {
out.push(':');
out.push_str(text);
out.push(':');
}
Inline::Span(attr, inner) => {
out.push('[');
out.push_str(&inlines_to_djot(inner, options));
out.push(']');
out.push_str(&render_attributes(attr));
}
Inline::Superscript(inner) => {
out.push('^');
out.push_str(&inlines_to_djot(inner, options));
out.push('^');
}
Inline::Subscript(inner) => {
out.push('~');
out.push_str(&inlines_to_djot(inner, options));
out.push('~');
}
Inline::SmallCaps(inner) => out.push_str(&inlines_to_djot(inner, options)),
Inline::Code(_, body) => render_verbatim(body, out),
Inline::Math(math_type, body) => {
out.push_str(if *math_type == MathType::DisplayMath {
"$$"
} else {
"$"
});
render_verbatim(body, out);
}
Inline::RawInline(_, content) => out.push_str(content),
Inline::Link(url, _, link_type, inlines) => {
let inner = inlines_to_djot(inlines, options);
if *link_type == LinkType::Markdown
&& !is_ref_url(url)
&& inner.eq_ignore_ascii_case(url)
{
out.push('<');
out.push_str(url);
out.push('>');
return;
}
let final_url = if is_ref_url(url) {
append_refs_extension(url, &options.refs_extension)
} else {
url.to_string()
};
out.push('[');
out.push_str(&inner);
out.push_str("](");
out.push_str(&final_url);
out.push(')');
}
Inline::Reference(reference) => {
let url =
append_refs_extension(&reference.key.to_library_url(), &options.refs_extension);
out.push('[');
out.push_str(&escape_djot(&reference.text));
out.push_str("](");
out.push_str(&url);
out.push(')');
}
Inline::Image(url, _, alt) => {
out.push_str(";
out.push_str(url);
out.push(')');
}
}
}
fn render_verbatim(body: &str, out: &mut String) {
let mut max_run = 0;
let mut run = 0;
for ch in body.chars() {
if ch == '`' {
run += 1;
max_run = max_run.max(run);
} else {
run = 0;
}
}
let fence = "`".repeat(max_run + 1);
let padded = body.starts_with('`') || body.ends_with('`');
out.push_str(&fence);
if padded {
out.push(' ');
}
out.push_str(body);
if padded {
out.push(' ');
}
out.push_str(&fence);
}
fn escape_djot(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if matches!(
ch,
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '~' | '^' | '$'
) {
out.push('\\');
}
out.push(ch);
}
out
}
fn render_attributes(attr: &Attributes) -> String {
if attr.is_empty() {
return String::new();
}
let mut parts = Vec::new();
if !attr.id.is_empty() {
parts.push(format!("#{}", attr.id));
}
for class in &attr.classes {
parts.push(format!(".{}", class));
}
for (key, value) in &attr.pairs {
if value
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '}')
{
parts.push(format!("{}=\"{}\"", key, value.replace('"', "\\\"")));
} else {
parts.push(format!("{}={}", key, value));
}
}
format!("{{{}}}", parts.join(" "))
}
fn ensure_trailing_newline(s: String) -> String {
if s.is_empty() || s.ends_with('\n') {
s
} else {
s + "\n"
}
}