use markdown::{mdast::Node, message::Message};
use super::{Asset, Context, MediaType, ProcessesAssets, ProcessingError};
impl From<Message> for ProcessingError {
fn from(error: Message) -> Self {
ProcessingError::Compilation {
message: error.to_string().into(),
}
}
}
pub struct MarkdownProcessor {}
impl ProcessesAssets for MarkdownProcessor {
fn process(&self, _context: &mut Context, asset: &mut Asset) -> Result<(), ProcessingError> {
if *asset.media_type() != MediaType::Markdown {
return Ok(());
}
tracing::trace!("markdown: {}", asset.path());
let text = asset.as_text()?;
let ast = markdown::to_mdast(text, &markdown::ParseOptions::default())?;
let mut compiled_html = String::with_capacity(text.len());
compile_ast_node(None, &ast, &mut compiled_html);
asset.replace_with_text(compiled_html.into(), MediaType::Html);
Ok(())
}
}
fn compile_ast_node(parent_node: Option<&Node>, node: &Node, compiled_html: &mut String) {
match node {
Node::Root(_) => {
compile_ast_node_children(node, compiled_html);
}
Node::Paragraph(_) => {
*compiled_html += "<p>";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</p>";
}
Node::Blockquote(_) => {
*compiled_html += "<Blockquote>";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</Blockquote>";
}
Node::List(list) => {
if list.ordered {
*compiled_html += "<ol>";
} else {
*compiled_html += "<ul>";
}
compile_ast_node_children(node, compiled_html);
if list.ordered {
*compiled_html += "</ol>";
} else {
*compiled_html += "</ul>";
}
}
Node::ListItem(_) => {
*compiled_html += "<li>";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</li>";
}
Node::Heading(heading) => {
*compiled_html += "<h";
*compiled_html += &heading.depth.to_string();
let heading_str = node.to_string();
let mut id = String::with_capacity(heading_str.len());
for char in heading_str.chars() {
if char.is_ascii_alphanumeric() {
id.push(char.to_ascii_lowercase())
} else if id.chars().last().is_some_and(|c| c != '-') {
id.push('-');
}
}
*compiled_html += " id=\"";
*compiled_html += &id;
*compiled_html += "\">";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</h";
*compiled_html += &heading.depth.to_string();
*compiled_html += ">";
}
Node::Emphasis(_) => {
*compiled_html += "<em>";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</em>";
}
Node::Strong(_) => {
*compiled_html += "<strong>";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</strong>";
}
Node::Link(link) => {
let link_url = &link.url;
*compiled_html += "<a href=\"";
*compiled_html += &link_url.replace('\"', "").replace("\\\"", "");
if let Some(title) = link.title.as_ref() {
*compiled_html += "\" title=\"";
*compiled_html += &title.replace('\"', """).replace("\\\"", """);
}
*compiled_html += "\">";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</a>";
}
Node::Image(image) => {
let image_url = &image.url;
*compiled_html += "<img alt=\"";
*compiled_html += &image.alt.replace('\"', """).replace("\\\"", """);
*compiled_html += "\" src=\"";
*compiled_html += image_url;
if let Some(title) = image.title.as_ref() {
*compiled_html += "\" title=\"";
*compiled_html += &title.replace('\"', """).replace("\\\"", """);
}
*compiled_html += "\">";
}
Node::Break(_) => {
*compiled_html += "<br/>";
}
Node::ThematicBreak(_) => {
*compiled_html += "<hr/>";
}
Node::Html(html) => {
*compiled_html += &html.value;
}
Node::Text(text) => {
if matches!(parent_node, Some(Node::Paragraph(..))) {
*compiled_html += &text.value.replace("--", "—");
} else {
*compiled_html += &text.value;
}
}
Node::InlineCode(code) => {
*compiled_html += "<code>";
*compiled_html += &code.value;
*compiled_html += "</code>";
}
Node::Code(code) => {
if let Some(lang) = &code.lang {
*compiled_html += "<pre rel=\"";
*compiled_html += lang;
*compiled_html += "\"><code class=\"language-";
*compiled_html += lang;
*compiled_html += "\">";
} else {
*compiled_html += "<pre><code>";
}
*compiled_html += &code.value;
*compiled_html += "</code></pre>";
}
Node::Delete(_) => {
*compiled_html += "<s>";
compile_ast_node_children(node, compiled_html);
*compiled_html += "</s>";
}
Node::Definition(_) => unimplemented!("definition"),
Node::FootnoteDefinition(_)
| Node::FootnoteReference(_)
| Node::LinkReference(_)
| Node::ImageReference(_) => unimplemented!("reference"),
Node::Table(_) | Node::TableRow(_) | Node::TableCell(_) => unimplemented!("table"),
Node::InlineMath(_)
| Node::Math(_)
| Node::MdxJsxFlowElement(_)
| Node::MdxJsxTextElement(_)
| Node::MdxjsEsm(_)
| Node::MdxTextExpression(_)
| Node::MdxFlowExpression(_)
| Node::Toml(_)
| Node::Yaml(_) => unimplemented!("embedded language"),
}
}
fn compile_ast_node_children(node: &Node, compiled_html: &mut String) {
for child in node.children().unwrap() {
compile_ast_node(Some(node), child, compiled_html);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn processes_markdown() {
let mut markdown_asset = Asset::new(
"test.md".into(),
"# Header 1\nBody\n> Quotation in **bold** and _italics_."
.as_bytes()
.to_vec(),
);
let _ = MarkdownProcessor {}.process(&mut Context::default(), &mut markdown_asset);
assert_eq!(
"<h1 id=\"header-1\">Header 1</h1><p>Body</p><Blockquote><p>Quotation in <strong>bold</strong> and <em>italics</em>.</p></Blockquote>",
markdown_asset.as_text().unwrap()
);
assert_eq!(&MediaType::Html, markdown_asset.media_type());
}
}