use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment}; use comrak::{parse_document, Arena, Options}; use serde_json::{json, Map, Value}; #[derive(Debug, Clone)] pub struct ParsedLocalMarkdownPage { pub title: String, pub mnote_id: Option, pub body: String, } #[derive(Debug, Clone)] struct MarkdownAstDocument { blocks: Vec, } #[derive(Debug, Clone)] enum MarkdownBlock { Paragraph(Vec), Heading { level: u8, content: Vec, }, Quote(Vec), CodeBlock { language: String, text: String, }, Divider, BulletListItem(Vec), NumberedListItem(Vec), Todo { checked: bool, content: Vec, }, Table { alignments: Vec, rows: Vec, }, Media { name: String, source_path: String, }, } #[derive(Debug, Clone)] struct MarkdownTableRow { is_header: bool, cells: Vec, } #[derive(Debug, Clone)] struct MarkdownTableCell { content: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct MarkdownInline { text: String, styles: MarkdownInlineStyles, } #[derive(Debug, Clone, Default, PartialEq, Eq)] struct MarkdownInlineStyles { bold: bool, italic: bool, strike: bool, underline: bool, code: bool, link: Option, } pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage { let (frontmatter, body) = split_frontmatter(markdown); let title = frontmatter .as_deref() .and_then(|content| read_frontmatter_field(content, "title")) .or_else(|| extract_first_h1_title(body)) .unwrap_or_else(|| file_stem_title(file_name)); let mnote_id = frontmatter .as_deref() .and_then(|content| read_frontmatter_field(content, "mnote_id")); ParsedLocalMarkdownPage { title, mnote_id, body: body.to_string(), } } pub fn markdown_to_blocks(markdown: &str) -> Value { markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown)) } pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { let value = trimmed .strip_prefix('!') .unwrap_or(trimmed) .strip_prefix('[')?; let (label, rest) = value.split_once("](")?; let target = rest.strip_suffix(')')?.trim(); if target.is_empty() || target.starts_with("http://") || target.starts_with("https://") || target.starts_with('#') || target.starts_with("mailto:") { return None; } let target_path = std::path::Path::new(target); let extension = target_path.extension().and_then(|value| value.to_str())?; if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") { return None; } let fallback_name = target_path .file_name() .and_then(|value| value.to_str()) .unwrap_or(target) .trim(); let name = if label.trim().is_empty() { fallback_name } else { label.trim() }; Some((name.to_string(), target.to_string())) } fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument { let arena = Arena::new(); let mut options = Options::default(); options.extension.table = true; options.extension.tasklist = true; options.extension.strikethrough = true; options.extension.autolink = true; options.extension.front_matter_delimiter = Some("---".to_string()); options.parse.tasklist_in_table = true; let root = parse_document(&arena, markdown, &options); let mut blocks = Vec::new(); for node in root.children() { append_ast_block(node, &mut blocks); } MarkdownAstDocument { blocks } } fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { match node.data.borrow().value.clone() { NodeValue::Paragraph => append_ast_paragraph(node, blocks), NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading { level: heading.level, content: collect_inline_children(node), }), NodeValue::BlockQuote => blocks.push(MarkdownBlock::Quote(collect_inline_children(node))), NodeValue::ThematicBreak => blocks.push(MarkdownBlock::Divider), NodeValue::CodeBlock(code_block) => blocks.push(MarkdownBlock::CodeBlock { language: code_block.info.clone(), text: code_block.literal.clone(), }), NodeValue::List(list) => { for item in node.children() { append_ast_list_item(item, list.list_type == ListType::Ordered, blocks); } } NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)), NodeValue::HtmlBlock(html) => blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline { text: html.literal.clone(), styles: MarkdownInlineStyles::default(), }])), NodeValue::FrontMatter(_) => {} _ => { let text = collect_plain_text(node); if !text.is_empty() { blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline { text, styles: MarkdownInlineStyles::default(), }])); } } } } fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { if let Some((name, source_path)) = paragraph_attachment_media(node) { blocks.push(MarkdownBlock::Media { name, source_path }); return; } if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) { blocks.push(MarkdownBlock::Media { name, source_path }); let content = merge_adjacent_inline_nodes(remaining); if !content.is_empty() { blocks.push(MarkdownBlock::Paragraph(content)); } return; } blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node))); } fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec) { let (is_task, checked) = match node.data.borrow().value.clone() { NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()), NodeValue::Item(_) => (false, false), _ => return append_ast_block(node, blocks), }; let mut content = Vec::new(); for child in node.children() { match child.data.borrow().value.clone() { NodeValue::Paragraph => content.extend(collect_inline_children(child)), _ => append_ast_block(child, blocks), } } if is_task { blocks.push(MarkdownBlock::Todo { checked, content }); } else if ordered { blocks.push(MarkdownBlock::NumberedListItem(content)); } else { blocks.push(MarkdownBlock::BulletListItem(content)); } } fn ast_table_to_ir<'a>(node: &'a AstNode<'a>, alignments: Vec) -> MarkdownBlock { let rows = node .children() .map(|row| { let is_header = matches!(row.data.borrow().value, NodeValue::TableRow(true)); let cells = row .children() .map(|cell| MarkdownTableCell { content: collect_inline_children(cell), }) .collect::>(); MarkdownTableRow { is_header, cells } }) .collect::>(); MarkdownBlock::Table { alignments, rows } } fn collect_inline_children<'a>(node: &'a AstNode<'a>) -> Vec { let mut nodes = Vec::new(); for child in node.children() { collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut nodes); } merge_adjacent_inline_nodes(nodes) } fn collect_inline_nodes<'a>( node: &'a AstNode<'a>, active_styles: &MarkdownInlineStyles, nodes: &mut Vec, ) { match node.data.borrow().value.clone() { NodeValue::Text(text) => push_inline_text_node(nodes, text.as_ref(), active_styles), NodeValue::TaskItem(task_item) => { let marker = if task_item.symbol.is_some() { "[x] " } else { "[ ] " }; push_inline_text_node(nodes, marker, active_styles); for child in node.children() { collect_inline_nodes(child, active_styles, nodes); } } NodeValue::Code(code) => { let mut styles = active_styles.clone(); styles.code = true; push_inline_text_node(nodes, &code.literal, &styles); } NodeValue::Strong => { let mut styles = active_styles.clone(); styles.bold = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Emph => { let mut styles = active_styles.clone(); styles.italic = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Strikethrough => { let mut styles = active_styles.clone(); styles.strike = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Underline => { let mut styles = active_styles.clone(); styles.underline = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Link(link) => { let mut styles = active_styles.clone(); styles.link = Some(link.url.clone()); collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::SoftBreak | NodeValue::LineBreak => { push_inline_text_node(nodes, " ", active_styles); } NodeValue::HtmlInline(text) => push_inline_text_node(nodes, text.as_ref(), active_styles), NodeValue::Image(link) => { let mut styles = active_styles.clone(); styles.link = Some(link.url.clone()); push_inline_text_node(nodes, link.url.as_str(), &styles); } _ => collect_inline_children_with_styles(node, active_styles, nodes), } } fn collect_inline_children_with_styles<'a>( node: &'a AstNode<'a>, active_styles: &MarkdownInlineStyles, nodes: &mut Vec, ) { for child in node.children() { collect_inline_nodes(child, active_styles, nodes); } } fn push_inline_text_node( nodes: &mut Vec, text: &str, styles: &MarkdownInlineStyles, ) { if text.is_empty() { return; } nodes.push(MarkdownInline { text: text.to_string(), styles: styles.clone(), }); } fn merge_adjacent_inline_nodes(nodes: Vec) -> Vec { let mut merged = Vec::::new(); for node in nodes { if let Some(last) = merged.last_mut() { if last.styles == node.styles { last.text.push_str(&node.text); continue; } } merged.push(node); } merged } fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { let mut children = node.children(); let first = children.next()?; if children.next().is_some() { return None; } link_attachment_media(first) } fn paragraph_leading_attachment_media<'a>( node: &'a AstNode<'a>, ) -> Option<(String, String, Vec)> { let mut children = node.children(); let first = children.next()?; let (name, source_path) = link_attachment_media(first)?; let second = children.next()?; if !matches!( second.data.borrow().value, NodeValue::SoftBreak | NodeValue::LineBreak ) { return None; } let mut remaining = Vec::new(); for child in children { collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut remaining); } Some((name, source_path, remaining)) } fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { let NodeValue::Link(link) = &node.data.borrow().value else { return None; }; parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url)) } fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value { Value::Array( document .blocks .iter() .enumerate() .map(|(index, block)| markdown_block_to_json(block, index + 1)) .collect(), ) } fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value { match block { MarkdownBlock::Paragraph(content) => json_block( "paragraph", legacy_inline_nodes_to_json(content), block_number, ), MarkdownBlock::Heading { level, content } => json!({ "id": format!("local-block-{block_number}"), "type": "heading", "props": { "level": level }, "content": legacy_inline_nodes_to_json(content), "children": [], }), MarkdownBlock::Quote(content) => { json_block("quote", legacy_inline_nodes_to_json(content), block_number) } MarkdownBlock::CodeBlock { language, text } => json!({ "id": format!("local-block-{block_number}"), "type": "codeBlock", "props": { "language": language }, "content": [{ "type": "text", "text": text, "styles": {} }], "children": [], }), MarkdownBlock::Divider => json!({ "id": format!("local-block-{block_number}"), "type": "divider", "content": [], "children": [], }), MarkdownBlock::BulletListItem(content) => json_block( "bulletListItem", legacy_inline_nodes_to_json(content), block_number, ), MarkdownBlock::NumberedListItem(content) => json_block( "numberedListItem", legacy_inline_nodes_to_json(content), block_number, ), MarkdownBlock::Todo { checked, content } => json!({ "id": format!("local-block-{block_number}"), "type": "todo", "props": { "checked": checked }, "content": legacy_inline_nodes_to_json(content), "children": [], }), MarkdownBlock::Table { alignments, rows } => { table_block_to_json(alignments, rows, block_number) } MarkdownBlock::Media { name, source_path } => json!({ "id": format!("local-block-{block_number}"), "type": "media", "props": { "name": name, "sourcePath": source_path, }, "content": [], "children": [], }), } } fn table_block_to_json( alignments: &[TableAlignment], rows: &[MarkdownTableRow], block_number: usize, ) -> Value { let content_rows = rows .iter() .map(|row| { let cells = row .cells .iter() .enumerate() .map(|(column_index, cell)| { let cell_type = if row.is_header { "tableHeader" } else { "tableCell" }; let text_align = match alignments .get(column_index) .copied() .unwrap_or(TableAlignment::None) { TableAlignment::Left => "left", TableAlignment::Center => "center", TableAlignment::Right => "right", TableAlignment::None => "", }; json!({ "type": cell_type, "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null, "textAlign": text_align, }, "content": [{ "type": "paragraph", "content": tiptap_inline_nodes_to_json(&cell.content), }] }) }) .collect::>(); json!({ "type": "tableRow", "content": cells, }) }) .collect::>(); json!({ "id": format!("local-block-{block_number}"), "type": "table", "props": { "tiptapTable": { "type": "table", "attrs": { "blockId": format!("local-block-{block_number}") }, "content": content_rows, } }, "content": [], "children": [], }) } fn json_block(block_type: &str, content: Vec, block_number: usize) -> Value { json!({ "id": format!("local-block-{block_number}"), "type": block_type, "content": content, "children": [], }) } fn legacy_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec { nodes .iter() .map(|node| { json!({ "type": "text", "text": node.text, "styles": legacy_styles_to_json(&node.styles), }) }) .collect() } fn legacy_styles_to_json(styles: &MarkdownInlineStyles) -> Value { let mut object = Map::new(); if styles.bold { object.insert("bold".to_string(), Value::Bool(true)); } if styles.italic { object.insert("italic".to_string(), Value::Bool(true)); } if styles.strike { object.insert("strike".to_string(), Value::Bool(true)); } if styles.underline { object.insert("underline".to_string(), Value::Bool(true)); } if styles.code { object.insert("code".to_string(), Value::Bool(true)); } if let Some(href) = styles .link .as_deref() .map(str::trim) .filter(|href| !href.is_empty()) { object.insert("link".to_string(), Value::String(href.to_string())); } Value::Object(object) } fn tiptap_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec { nodes .iter() .filter(|node| !node.text.is_empty()) .map(|node| { let mut object = Map::new(); object.insert("type".to_string(), Value::String("text".to_string())); object.insert("text".to_string(), Value::String(node.text.clone())); let marks = tiptap_marks_from_styles(&node.styles); if !marks.is_empty() { object.insert("marks".to_string(), Value::Array(marks)); } Value::Object(object) }) .collect() } fn tiptap_marks_from_styles(styles: &MarkdownInlineStyles) -> Vec { let mut marks = Vec::new(); if styles.bold { marks.push(json!({ "type": "bold" })); } if styles.italic { marks.push(json!({ "type": "italic" })); } if styles.underline { marks.push(json!({ "type": "underline" })); } if styles.strike { marks.push(json!({ "type": "strike" })); } if styles.code { marks.push(json!({ "type": "code" })); } if let Some(href) = styles .link .as_deref() .map(str::trim) .filter(|href| !href.is_empty()) { marks.push(json!({ "type": "link", "attrs": { "href": href } })); } marks } fn collect_plain_text<'a>(node: &'a AstNode<'a>) -> String { let mut parts = Vec::new(); for descendant in node.descendants() { if let NodeValue::Text(text) = &descendant.data.borrow().value { parts.push(text.as_ref().to_string()); } } parts.join("") } pub(crate) fn split_frontmatter(markdown: &str) -> (Option, &str) { let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); if !normalized.starts_with("---\n") { return (None, normalized); } let rest = &normalized[4..]; if let Some(end) = rest.find("\n---\n") { let frontmatter = rest[..end].to_string(); let body = &rest[end + 5..]; return (Some(frontmatter), body); } (None, normalized) } fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option { frontmatter.lines().find_map(|line| { let (candidate_key, value) = line.split_once(':')?; if candidate_key.trim() != key { return None; } let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) } fn extract_first_h1_title(body: &str) -> Option { body.lines().find_map(|line| { let trimmed = line.trim(); trimmed .strip_prefix("# ") .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) } pub(crate) fn file_stem_title(file_name: &str) -> String { std::path::Path::new(file_name) .file_stem() .and_then(|stem| stem.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(file_name) .to_string() }