use crate::common::nested_to_flat::tree_to_events;
use crate::common::splice::SpliceState;
use crate::error::FormatError;
use crate::ir::events::Event;
use crate::ir::nodes::{Annotation as IrAnnotation, DocNode, InlineContent, TableCellAlignment};
use crate::render_dispatch::{dispatch_render, RenderedNode};
use comrak::nodes::{
Ast, AstNode, ListDelimType, ListType, NodeDescriptionItem, NodeTable, NodeValue,
TableAlignment,
};
use comrak::{format_commonmark, Arena, ComrakOptions};
use lex_core::lex::ast::Document;
use lex_extension_host::Registry;
use std::cell::RefCell;
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownExportOutcome {
pub markdown: String,
pub diagnostics: Vec<String>,
}
pub fn serialize_to_markdown(doc: &Document) -> Result<String, FormatError> {
let outcome = serialize_to_markdown_with_registry(doc, crate::default_registry())?;
Ok(outcome.markdown)
}
pub fn serialize_to_markdown_with_registry(
doc: &Document,
registry: &Registry,
) -> Result<MarkdownExportOutcome, FormatError> {
let ir_doc = crate::to_ir_with_registry(doc, registry);
let plan = dispatch_render(&ir_doc, registry, "markdown");
let document_title = ir_doc.title.as_ref().map(|title_inlines| {
let title_text = inlines_to_text(title_inlines);
let subtitle_text = ir_doc.subtitle.as_ref().map(|sub| inlines_to_text(sub));
(title_text, subtitle_text)
});
let doc_scope_plan = &plan.nodes[..plan.doc_scope_count];
let body_plan = &plan.nodes[plan.doc_scope_count..];
let frontmatter_yaml =
render_document_annotations_as_yaml(&ir_doc.document_annotations, doc_scope_plan);
let events = tree_to_events(&DocNode::Document(ir_doc));
let arena = Arena::new();
let root = build_comrak_ast(&arena, &events, body_plan)?;
let mut output = Vec::new();
let options = default_comrak_options();
format_commonmark(root, &options, &mut output).map_err(|e| {
FormatError::SerializationError(format!("Comrak serialization failed: {e}"))
})?;
let markdown = String::from_utf8(output)
.map_err(|e| FormatError::SerializationError(format!("UTF-8 conversion failed: {e}")))?;
let cleaned = markdown.replace("<!-- end list -->\n\n", "");
let cleaned = strip_heading_digit_dot_escape(&cleaned);
let with_title = prepend_title_as_h1(&cleaned, document_title);
let with_frontmatter = match frontmatter_yaml {
Some(yaml) => format!("{yaml}{with_title}"),
None => with_title,
};
let mut diagnostics: Vec<String> = plan
.nodes
.iter()
.filter_map(|n| n.diagnostic.clone())
.collect();
diagnostics.extend(plan.root_diagnostics);
Ok(MarkdownExportOutcome {
markdown: with_frontmatter,
diagnostics,
})
}
fn render_document_annotations_as_yaml(
annotations: &[IrAnnotation],
doc_scope_plan: &[RenderedNode],
) -> Option<String> {
if annotations.is_empty() {
return None;
}
let mut lines = String::new();
let mut plan_iter = doc_scope_plan.iter().peekable();
for ann in annotations {
let plan_entry = match plan_iter.peek() {
Some(entry) if entry.label == ann.label => plan_iter.next(),
_ => None,
};
if let Some(entry) = plan_entry {
if let Some(out) = &entry.output {
lines.push_str(out);
continue;
}
}
synthesize_yaml_line_from_annotation(&mut lines, ann);
}
if lines.is_empty() {
return None;
}
Some(format!("---\n{lines}---\n\n"))
}
fn synthesize_yaml_line_from_annotation(lines: &mut String, ann: &IrAnnotation) {
let key = ann
.label
.strip_prefix("lex.metadata.")
.unwrap_or(ann.label.as_str())
.to_string();
let body_text = flatten_annotation_body_text(&ann.content)
.replace('\n', " ")
.trim()
.to_string();
if !body_text.is_empty() {
lines.push_str(&format!("{key}: {body_text}\n"));
} else if !ann.parameters.is_empty() {
for (k, v) in &ann.parameters {
lines.push_str(&format!("{key}.{k}: {v}\n"));
}
}
}
fn flatten_annotation_body_text(content: &[DocNode]) -> String {
let mut text = String::new();
for child in content {
if let DocNode::Paragraph(p) = child {
for inline in &p.content {
match inline {
InlineContent::Text(t) | InlineContent::Code(t) | InlineContent::Math(t) => {
text.push_str(t)
}
InlineContent::Reference { raw, .. } => text.push_str(raw),
InlineContent::Link { text: t, .. } => text.push_str(t),
_ => {}
}
}
}
}
text
}
fn prepend_title_as_h1(markdown: &str, title: Option<(String, Option<String>)>) -> String {
match title {
Some((t, Some(sub))) => format!("# {t}\n\n## {sub}\n\n{markdown}"),
Some((t, None)) => format!("# {t}\n\n{markdown}"),
None => markdown.to_string(),
}
}
fn inlines_to_text(content: &[InlineContent]) -> String {
content
.iter()
.map(|inline| match inline {
InlineContent::Text(t) => t.clone(),
InlineContent::Bold(c) => inlines_to_text(c),
InlineContent::Italic(c) => inlines_to_text(c),
InlineContent::Code(c) => c.clone(),
InlineContent::Math(m) => m.clone(),
InlineContent::Reference { raw, .. } => raw.clone(),
InlineContent::Link { text, .. } => text.clone(),
InlineContent::Image(img) => img.alt.clone(),
})
.collect()
}
fn strip_heading_digit_dot_escape(markdown: &str) -> String {
static HEADING_DIGIT_DOT: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"(?m)^(#+ \d+(?:\.\d+)*)\\\.").expect("compile heading digit-dot")
});
HEADING_DIGIT_DOT.replace_all(markdown, "$1.").into_owned()
}
fn default_comrak_options() -> ComrakOptions<'static> {
let mut options = ComrakOptions::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.superscript = true;
options.extension.front_matter_delimiter = Some("---".to_string());
options.extension.description_lists = true;
options.render.unsafe_ = true;
options
}
fn build_comrak_ast<'a>(
arena: &'a Arena<AstNode<'a>>,
events: &[Event],
body_plan: &[RenderedNode],
) -> Result<&'a AstNode<'a>, FormatError> {
let root = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Document,
(0, 0).into(),
))));
let mut current_parent: &'a AstNode<'a> = root;
let mut parent_stack: Vec<&'a AstNode<'a>> = vec![];
let mut in_verbatim = false;
let mut verbatim_content = String::new();
let mut verbatim_language = None;
let mut current_heading: Option<&'a AstNode<'a>> = None;
let mut in_list_item = false;
let mut list_item_paragraph: Option<&'a AstNode<'a>> = None;
let mut in_table_cell = false;
let splice_plan = if body_plan.is_empty() {
None
} else {
Some(body_plan)
};
let mut splice = SpliceState::new(splice_plan);
for event in events {
if splice.should_skip()
&& !matches!(
event,
Event::StartAnnotation { .. } | Event::EndAnnotation { .. }
)
{
continue;
}
match event {
Event::StartDocument => {
}
Event::EndDocument => {
}
Event::StartHeading(level) => {
let heading_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Heading(comrak::nodes::NodeHeading {
level: (*level as u8).min(6),
setext: false,
}),
(0, 0).into(),
))));
current_parent.append(heading_node);
current_heading = Some(heading_node);
}
Event::EndHeading(_) => {
current_heading = None;
}
Event::StartContent => {
}
Event::EndContent => {
}
Event::StartParagraph => {
current_heading = None;
if in_table_cell {
} else {
let para_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Paragraph,
(0, 0).into(),
))));
current_parent.append(para_node);
parent_stack.push(current_parent);
current_parent = para_node;
if in_list_item {
list_item_paragraph = None;
}
}
}
Event::EndParagraph => {
if !in_table_cell {
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced paragraph end".to_string())
})?;
}
}
Event::StartList { ordered, .. } => {
current_heading = None;
let list_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::List(comrak::nodes::NodeList {
list_type: if *ordered {
ListType::Ordered
} else {
ListType::Bullet
},
marker_offset: 0,
padding: 0,
start: 1,
delimiter: ListDelimType::Period,
bullet_char: b'-',
tight: true, }),
(0, 0).into(),
))));
current_parent.append(list_node);
parent_stack.push(current_parent);
current_parent = list_node;
}
Event::EndList => {
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced list end".to_string())
})?;
}
Event::StartListItem => {
current_heading = None;
let item_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Item(comrak::nodes::NodeList {
list_type: ListType::Bullet,
marker_offset: 0,
padding: 0,
start: 1,
delimiter: ListDelimType::Period,
bullet_char: b'-',
tight: true, }),
(0, 0).into(),
))));
current_parent.append(item_node);
parent_stack.push(current_parent);
current_parent = item_node;
in_list_item = true;
list_item_paragraph = None;
}
Event::EndListItem => {
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced list item end".to_string())
})?;
in_list_item = false;
list_item_paragraph = None;
}
Event::StartVerbatim {
language,
subject,
subject_href,
parameters: _,
} => {
current_heading = None;
if let Some(subj) = subject {
let para = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Paragraph,
(0, 0).into(),
))));
current_parent.append(para);
let strong = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Strong,
(0, 0).into(),
))));
para.append(strong);
let text = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(subj.clone()),
(0, 0).into(),
))));
if let Some(href) = subject_href {
let link = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Link(comrak::nodes::NodeLink {
url: href.clone(),
title: String::new(),
}),
(0, 0).into(),
))));
strong.append(link);
link.append(text);
} else {
strong.append(text);
}
}
if let Some(lang) = &language {
if let Some(label) = lang.strip_prefix("lex-metadata:") {
let node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock {
block_type: 0,
literal: String::new(), }),
(0, 0).into(),
))));
current_parent.append(node);
parent_stack.push(current_parent); current_parent = node;
let start_tag = format!("<!-- lex:{label}");
let mut data = node.data.borrow_mut();
if let NodeValue::HtmlBlock(ref mut html) = data.value {
html.literal.push_str(&start_tag);
}
in_verbatim = true;
verbatim_language = language.clone(); verbatim_content.clear();
continue; }
}
in_verbatim = true;
verbatim_language = language.clone();
verbatim_content.clear();
}
Event::EndVerbatim => {
let is_html_block =
matches!(current_parent.data.borrow().value, NodeValue::HtmlBlock(_));
if is_html_block {
let mut data = current_parent.data.borrow_mut();
if let NodeValue::HtmlBlock(ref mut html) = data.value {
html.literal.push_str("\n-->");
}
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced HTML block end".to_string())
})?;
} else {
let code_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::CodeBlock(comrak::nodes::NodeCodeBlock {
fenced: true,
fence_char: b'`',
fence_length: 3,
fence_offset: 0,
info: verbatim_language.take().unwrap_or_default(),
literal: verbatim_content.clone(),
}),
(0, 0).into(),
))));
current_parent.append(code_node);
}
in_verbatim = false;
verbatim_content.clear();
}
Event::Inline(inline_content) => {
let inline_to_emit = inline_content.clone();
if in_verbatim {
if let InlineContent::Text(text) = &inline_to_emit {
if matches!(current_parent.data.borrow().value, NodeValue::HtmlBlock(_)) {
let mut data = current_parent.data.borrow_mut();
if let NodeValue::HtmlBlock(ref mut html) = data.value {
html.literal.push_str(text);
}
} else {
verbatim_content.push_str(text);
}
}
} else if matches!(current_parent.data.borrow().value, NodeValue::HtmlBlock(_)) {
let mut data = current_parent.data.borrow_mut();
if let NodeValue::HtmlBlock(ref mut html) = data.value {
if let InlineContent::Text(text) = inline_to_emit {
html.literal.push_str(&text);
}
}
} else if let Some(heading) = current_heading {
add_inline_to_node(arena, heading, &inline_to_emit)?;
} else if in_list_item {
if matches!(current_parent.data.borrow().value, NodeValue::Paragraph) {
add_inline_to_node(arena, current_parent, &inline_to_emit)?;
} else {
if list_item_paragraph.is_none() {
let para = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Paragraph,
(0, 0).into(),
))));
current_parent.append(para);
list_item_paragraph = Some(para);
}
add_inline_to_node(arena, list_item_paragraph.unwrap(), &inline_to_emit)?;
}
} else {
add_inline_to_node(arena, current_parent, &inline_to_emit)?;
}
}
Event::StartAnnotation {
label, parameters, ..
} if label == "frontmatter" => {
let mut yaml = String::from("---\n");
for (key, value) in parameters {
yaml.push_str(&format!("{key}: {value}\n"));
}
yaml.push_str("---\n\n");
let frontmatter_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::FrontMatter(yaml),
(0, 0).into(),
))));
current_parent.append(frontmatter_node);
}
Event::StartAnnotation {
label, parameters, ..
} => {
current_heading = None;
if let Some(rendered_markdown) = splice.advance_at_start(label) {
let html_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock {
block_type: 0,
literal: rendered_markdown.to_string(),
}),
(0, 0).into(),
))));
current_parent.append(html_node);
} else if !splice.should_skip() {
let mut comment = format!("<!-- lex:{label}");
for (key, value) in parameters {
comment.push_str(&format!(" {key}={value}"));
}
comment.push_str(" -->");
let html_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock {
block_type: 0,
literal: comment,
}),
(0, 0).into(),
))));
current_parent.append(html_node);
}
}
Event::EndAnnotation { label } if label == "frontmatter" => {
}
Event::EndAnnotation { label } => {
if splice.should_skip() {
splice.advance_at_end();
continue;
}
let closing_tag = format!("<!-- /lex:{label} -->");
let html_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock {
block_type: 0,
literal: closing_tag,
}),
(0, 0).into(),
))));
current_parent.append(html_node);
}
Event::StartDefinition => {
current_heading = None;
let dl_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::DescriptionList,
(0, 0).into(),
))));
current_parent.append(dl_node);
parent_stack.push(current_parent);
current_parent = dl_node;
let item_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::DescriptionItem(NodeDescriptionItem {
marker_offset: 0,
padding: 2,
}),
(0, 0).into(),
))));
current_parent.append(item_node);
parent_stack.push(current_parent);
current_parent = item_node;
}
Event::EndDefinition => {
let _ = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced definition item end".to_string())
})?;
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced definition list end".to_string())
})?;
}
Event::StartDefinitionTerm => {
current_heading = None;
let term_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::DescriptionTerm,
(0, 0).into(),
))));
current_parent.append(term_node);
parent_stack.push(current_parent);
current_parent = term_node;
let para_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Paragraph,
(0, 0).into(),
))));
current_parent.append(para_node);
parent_stack.push(current_parent);
current_parent = para_node;
}
Event::EndDefinitionTerm => {
let _ = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError(
"Unbalanced definition term paragraph end".to_string(),
)
})?;
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced definition term end".to_string())
})?;
}
Event::StartDefinitionDescription => {
let details_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::DescriptionDetails,
(0, 0).into(),
))));
current_parent.append(details_node);
parent_stack.push(current_parent);
current_parent = details_node;
}
Event::EndDefinitionDescription => {
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError(
"Unbalanced definition description end".to_string(),
)
})?;
}
Event::StartTable { caption, .. } => {
current_heading = None;
if let Some(caption_inlines) = caption {
let para = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Paragraph,
(0, 0).into(),
))));
current_parent.append(para);
let strong = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Strong,
(0, 0).into(),
))));
para.append(strong);
for inline in caption_inlines {
add_inline_to_node(arena, strong, inline)?;
}
}
let table_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Table(NodeTable {
alignments: vec![],
num_columns: 0,
num_rows: 0,
num_nonempty_cells: 0,
}),
(0, 0).into(),
))));
current_parent.append(table_node);
parent_stack.push(current_parent);
current_parent = table_node;
}
Event::EndTable => {
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced table end".to_string())
})?;
}
Event::StartTableRow { header } => {
let row_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::TableRow(*header),
(0, 0).into(),
))));
current_parent.append(row_node);
parent_stack.push(current_parent);
current_parent = row_node;
}
Event::EndTableRow => {
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced table row end".to_string())
})?;
}
Event::StartTableFootnotes => {
}
Event::EndTableFootnotes => {
}
Event::StartTableCell {
header: _, align, ..
} => {
let cell_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::TableCell,
(0, 0).into(),
))));
current_parent.append(cell_node);
parent_stack.push(current_parent);
if parent_stack.len() >= 2 {
let table_node = parent_stack[parent_stack.len() - 2];
let row_node = parent_stack[parent_stack.len() - 1];
let mut table_data = table_node.data.borrow_mut();
if let NodeValue::Table(ref mut table) = table_data.value {
let is_first_row = table_node
.first_child()
.is_some_and(|first| std::ptr::eq(first, row_node));
if is_first_row {
let align_enum = match align {
TableCellAlignment::Left => TableAlignment::Left,
TableCellAlignment::Right => TableAlignment::Right,
TableCellAlignment::Center => TableAlignment::Center,
TableCellAlignment::None => TableAlignment::None,
};
table.alignments.push(align_enum);
}
}
}
current_parent = cell_node;
in_table_cell = true;
}
Event::EndTableCell => {
in_table_cell = false;
current_parent = parent_stack.pop().ok_or_else(|| {
FormatError::SerializationError("Unbalanced table cell end".to_string())
})?;
}
Event::Image(image) => {
let para_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Paragraph,
(0, 0).into(),
))));
current_parent.append(para_node);
let image_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Image(comrak::nodes::NodeLink {
url: image.src.clone(),
title: image.title.clone().unwrap_or_default(),
}),
(0, 0).into(),
))));
para_node.append(image_node);
let text_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(image.alt.clone()),
(0, 0).into(),
))));
image_node.append(text_node);
}
Event::Video(video) => {
let mut html = format!(r#"<video src="{}""#, video.src);
if let Some(poster) = &video.poster {
html.push_str(&format!(r#" poster="{poster}""#));
}
if let Some(title) = &video.title {
html.push_str(&format!(r#" title="{title}""#));
}
html.push_str(" controls></video>");
let html_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock {
block_type: 0,
literal: html,
}),
(0, 0).into(),
))));
current_parent.append(html_node);
}
Event::Audio(audio) => {
let mut html = format!(r#"<audio src="{}""#, audio.src);
if let Some(title) = &audio.title {
html.push_str(&format!(r#" title="{title}""#));
}
html.push_str(" controls></audio>");
let html_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock {
block_type: 0,
literal: html,
}),
(0, 0).into(),
))));
current_parent.append(html_node);
}
}
}
Ok(root)
}
fn add_inline_to_node<'a>(
arena: &'a Arena<AstNode<'a>>,
parent: &'a AstNode<'a>,
inline: &crate::ir::nodes::InlineContent,
) -> Result<(), FormatError> {
use crate::ir::nodes::InlineContent;
match inline {
InlineContent::Text(text) => {
let sanitized = text.replace('\n', " ");
let text_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(sanitized),
(0, 0).into(),
))));
parent.append(text_node);
}
InlineContent::Bold(children) => {
let strong_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Strong,
(0, 0).into(),
))));
parent.append(strong_node);
for child in children {
add_inline_to_node(arena, strong_node, child)?;
}
}
InlineContent::Italic(children) => {
let emph_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Emph,
(0, 0).into(),
))));
parent.append(emph_node);
for child in children {
add_inline_to_node(arena, emph_node, child)?;
}
}
InlineContent::Code(code_text) => {
let code_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Code(comrak::nodes::NodeCode {
num_backticks: 1,
literal: code_text.clone(),
}),
(0, 0).into(),
))));
parent.append(code_node);
}
InlineContent::Reference {
raw: ref_text,
kind,
} => {
use crate::ir::nodes::ReferenceType;
let url = match kind {
ReferenceType::Citation(data) => {
let key = data.keys.first().cloned().unwrap_or_else(|| {
ref_text.strip_prefix('@').unwrap_or(ref_text).to_string()
});
Some(format!("#ref-{key}"))
}
ReferenceType::NotSure => ref_text
.strip_prefix('@')
.map(|citation| format!("#ref-{citation}")),
_ => None,
};
if let Some(url) = url {
let link_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Link(comrak::nodes::NodeLink {
url,
title: String::new(),
}),
(0, 0).into(),
))));
parent.append(link_node);
let text_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(ref_text.clone()),
(0, 0).into(),
))));
link_node.append(text_node);
} else {
let text_with_brackets = format!("[{ref_text}]");
let text_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(text_with_brackets),
(0, 0).into(),
))));
parent.append(text_node);
}
}
InlineContent::Link { text, href } => {
let link_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Link(comrak::nodes::NodeLink {
url: href.clone(),
title: String::new(),
}),
(0, 0).into(),
))));
parent.append(link_node);
let text_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(text.clone()),
(0, 0).into(),
))));
link_node.append(text_node);
}
InlineContent::Math(math_text) => {
let math_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::HtmlInline(format!("${math_text}$")),
(0, 0).into(),
))));
parent.append(math_node);
}
InlineContent::Image(image) => {
let image_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Image(comrak::nodes::NodeLink {
url: image.src.clone(),
title: image.title.clone().unwrap_or_default(),
}),
(0, 0).into(),
))));
parent.append(image_node);
let text_node = arena.alloc(AstNode::new(RefCell::new(Ast::new(
NodeValue::Text(image.alt.clone()),
(0, 0).into(),
))));
image_node.append(text_node);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use comrak::{parse_document, ComrakOptions};
use lex_core::lex::transforms::standard::STRING_TO_AST;
#[test]
fn test_simple_paragraph_ast() {
let lex_src = "This is a simple paragraph.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = serialize_to_markdown(&lex_doc).unwrap();
let arena = Arena::new();
let options = ComrakOptions::default();
let root = parse_document(&arena, &md, &options);
let mut found_paragraph = false;
for child in root.children() {
if matches!(child.data.borrow().value, NodeValue::Paragraph) {
found_paragraph = true;
for _inline in child.children() {
if let NodeValue::Text(ref text) = child.data.borrow().value {
assert!(text.contains("simple paragraph"));
}
}
}
}
assert!(found_paragraph, "Should have a paragraph node");
}
#[test]
fn test_heading_ast() {
let lex_src = "1. Introduction\n\n Content here.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = serialize_to_markdown(&lex_doc).unwrap();
let arena = Arena::new();
let options = ComrakOptions::default();
let root = parse_document(&arena, &md, &options);
let mut found_heading = false;
for child in root.children() {
if let NodeValue::Heading(ref heading) = child.data.borrow().value {
assert_eq!(heading.level, 2);
found_heading = true;
}
}
assert!(found_heading, "Should have a heading node");
}
#[test]
fn lex_metadata_annotations_emit_yaml_frontmatter() {
let lex_src = ":: title :: My Doc\n\n:: author :: Alice\n\nBody.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = serialize_to_markdown(&lex_doc).unwrap();
assert!(
md.starts_with("---\n"),
"expected YAML frontmatter at start of markdown, got:\n{md}"
);
assert!(md.contains("title: My Doc"), "missing title key in:\n{md}");
assert!(md.contains("author: Alice"), "missing author key in:\n{md}");
}
#[test]
fn yaml_synthesis_includes_link_and_reference_inlines() {
use crate::ir::nodes::{Annotation as IrAnn, DocNode, InlineContent, LabelForm, Paragraph};
let yaml = render_document_annotations_as_yaml(
&[IrAnn {
label: "lex.metadata.author".to_string(),
parameters: vec![],
content: vec![DocNode::Paragraph(Paragraph {
content: vec![
InlineContent::Text("Alice ".to_string()),
InlineContent::Link {
text: "https://alice.example".to_string(),
href: "https://alice.example".to_string(),
},
],
})],
form: LabelForm::Canonical,
}],
&[],
)
.expect("yaml block synthesized");
assert!(
yaml.contains("author: Alice https://alice.example"),
"{yaml}"
);
let yaml = render_document_annotations_as_yaml(
&[IrAnn {
label: "lex.metadata.tags".to_string(),
parameters: vec![],
content: vec![DocNode::Paragraph(Paragraph {
content: vec![
InlineContent::Text("rust ".to_string()),
InlineContent::Reference {
raw: "@manning".to_string(),
kind: crate::ir::nodes::ReferenceType::NotSure,
},
],
})],
form: LabelForm::Canonical,
}],
&[],
)
.expect("yaml block synthesized");
assert!(yaml.contains("tags: rust @manning"), "{yaml}");
}
#[test]
fn yaml_synthesis_collapses_internal_newlines_to_spaces() {
use crate::ir::nodes::{Annotation as IrAnn, DocNode, InlineContent, LabelForm, Paragraph};
let yaml = render_document_annotations_as_yaml(
&[IrAnn {
label: "lex.metadata.note".to_string(),
parameters: vec![],
content: vec![DocNode::Paragraph(Paragraph {
content: vec![
InlineContent::Text("Line one.".to_string()),
InlineContent::Text("\n".to_string()),
InlineContent::Text("Line two.".to_string()),
],
})],
form: LabelForm::Canonical,
}],
&[],
)
.expect("yaml block synthesized");
assert!(
yaml.contains("note: Line one. Line two.\n"),
"internal newlines must collapse to spaces; got:\n{yaml}"
);
let value_line = yaml.lines().find(|l| l.starts_with("note:")).unwrap();
assert!(
!value_line.contains('\n'),
"value line must be single-line in YAML"
);
}
#[test]
fn yaml_synthesis_includes_code_and_math_inlines() {
use crate::ir::nodes::{Annotation as IrAnn, DocNode, InlineContent, LabelForm, Paragraph};
let yaml = render_document_annotations_as_yaml(
&[IrAnn {
label: "lex.metadata.title".to_string(),
parameters: vec![],
content: vec![DocNode::Paragraph(Paragraph {
content: vec![
InlineContent::Text("Doc with ".to_string()),
InlineContent::Code("snippet".to_string()),
InlineContent::Text(" and ".to_string()),
InlineContent::Math("E=mc^2".to_string()),
],
})],
form: LabelForm::Canonical,
}],
&[],
)
.expect("yaml block synthesized");
assert!(
yaml.contains("title: Doc with snippet and E=mc^2"),
"{yaml}"
);
}
}