Files
mnote/rust/crates/mnote-web/src/routes/local_markdown_parser.rs
T

1439 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, Options};
use serde::Serialize;
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
pub title: String,
pub body: String,
}
#[derive(Debug, Clone)]
struct MarkdownAstDocument {
blocks: Vec<MarkdownBlock>,
}
#[derive(Debug, Clone)]
enum MarkdownBlock {
Paragraph(Vec<MarkdownInline>),
Heading {
level: u8,
content: Vec<MarkdownInline>,
},
Quote(Vec<MarkdownInline>),
CodeBlock {
language: String,
text: String,
},
Divider,
BulletListItem(Vec<MarkdownInline>),
NumberedListItem(Vec<MarkdownInline>),
Todo {
checked: bool,
content: Vec<MarkdownInline>,
},
Table {
alignments: Vec<TableAlignment>,
rows: Vec<MarkdownTableRow>,
},
Image {
alt: String,
source_path: String,
},
Mindmap {
name: String,
source_path: String,
},
Media {
name: String,
source_path: String,
},
}
#[derive(Debug, Clone)]
struct MarkdownTableRow {
is_header: bool,
cells: Vec<MarkdownTableCell>,
}
#[derive(Debug, Clone)]
struct MarkdownTableCell {
content: Vec<MarkdownInline>,
}
#[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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentSourceRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentRef {
pub ref_id: String,
pub owner_document_path: String,
pub owner_root_uri: String,
pub raw_href: String,
pub normalized_href: String,
pub label: String,
pub kind: String,
pub resolved_uri: Option<String>,
pub resolved_absolute_path: Option<String>,
pub relative_path: Option<String>,
pub ext: Option<String>,
pub content_type: Option<String>,
pub exists: Option<bool>,
pub authorized: Option<bool>,
pub open_kind: String,
pub source_range: Option<AttachmentSourceRange>,
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (_frontmatter, body) = split_frontmatter(markdown);
let body_owned = body.to_string();
// 本地 Markdown 的树标题和页头标题统一来自文件名;
// frontmatter title 与正文 H1 都保留为正文/元数据内容,不作为资源标题真相。
let title = file_stem_title(file_name);
ParsedLocalMarkdownPage {
title,
body: body_owned,
}
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
}
pub fn markdown_to_blocks_with_attachment_paths(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
}
pub fn parse_markdown_attachment_refs(
markdown: &str,
owner_document_path: &str,
owner_root_uri: &str,
) -> Vec<AttachmentRef> {
let arena = Arena::new();
let options = markdown_options();
let root = parse_document(&arena, markdown, &options);
let mut refs = Vec::new();
let mut cursor = 0usize;
collect_attachment_refs_from_ast(
root,
markdown,
owner_document_path,
owner_root_uri,
&mut cursor,
&mut refs,
);
collect_html_embed_attachment_refs(markdown, owner_document_path, owner_root_uri, &mut refs);
refs
}
fn markdown_options() -> Options<'static> {
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;
options
}
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new())
}
fn parse_markdown_attachment_link_with_paths(
trimmed: &str,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let value = trimmed
.strip_prefix('!')
.unwrap_or(trimmed)
.strip_prefix('[')?;
let (label, rest) = value.split_once("](")?;
let raw_target = rest.strip_suffix(')')?.trim();
let target = raw_target
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_target)
.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"))
&& !attachment_paths.contains(target)
{
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 collect_attachment_refs_from_ast<'a>(
node: &'a AstNode<'a>,
markdown: &str,
owner_document_path: &str,
owner_root_uri: &str,
cursor: &mut usize,
refs: &mut Vec<AttachmentRef>,
) {
match &node.data.borrow().value {
NodeValue::Link(link) => {
let raw_href = link.url.trim();
if should_collect_attachment_href(raw_href) {
let label = collect_plain_text(node);
let source_range = find_href_source_range(markdown, raw_href, cursor);
refs.push(build_attachment_ref(
owner_document_path,
owner_root_uri,
raw_href,
label.trim(),
source_range,
));
}
}
NodeValue::Image(link) => {
let raw_href = link.url.trim();
if should_collect_attachment_href(raw_href) {
let label = collect_plain_text(node);
let source_range = find_href_source_range(markdown, raw_href, cursor);
refs.push(build_attachment_ref(
owner_document_path,
owner_root_uri,
raw_href,
label.trim(),
source_range,
));
}
}
_ => {}
}
for child in node.children() {
collect_attachment_refs_from_ast(
child,
markdown,
owner_document_path,
owner_root_uri,
cursor,
refs,
);
}
}
fn collect_html_embed_attachment_refs(
markdown: &str,
owner_document_path: &str,
owner_root_uri: &str,
refs: &mut Vec<AttachmentRef>,
) {
let lower = markdown.to_ascii_lowercase();
let mut offset = 0usize;
while let Some(relative_start) = lower[offset..].find("<embed") {
let start = offset + relative_start;
let Some(relative_end) = lower[start..].find('>') else {
break;
};
let end = start + relative_end + 1;
let fragment = &markdown[start..end];
if let Some((raw_href, value_start, value_end)) =
extract_html_attr(fragment, "src").or_else(|| extract_html_attr(fragment, "href"))
{
let source_range = Some(AttachmentSourceRange {
start: start + value_start,
end: start + value_end,
});
refs.push(build_attachment_ref(
owner_document_path,
owner_root_uri,
raw_href,
raw_href,
source_range,
));
}
offset = end;
}
}
fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usize, usize)> {
let lower = fragment.to_ascii_lowercase();
let needle = format!("{name}=");
let attr_start = lower.find(&needle)? + needle.len();
let bytes = fragment.as_bytes();
let quote = *bytes.get(attr_start)?;
if quote != b'"' && quote != b'\'' {
return None;
}
let value_start = attr_start + 1;
let value_end = fragment[value_start..]
.find(quote as char)
.map(|index| value_start + index)?;
Some((&fragment[value_start..value_end], value_start, value_end))
}
fn should_collect_attachment_href(raw_href: &str) -> bool {
let trimmed = raw_href.trim();
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
}
fn build_attachment_ref(
owner_document_path: &str,
owner_root_uri: &str,
raw_href: &str,
label: &str,
source_range: Option<AttachmentSourceRange>,
) -> AttachmentRef {
let raw_href = raw_href.trim();
let normalized_href = normalize_attachment_href(raw_href);
let owner_document = Path::new(owner_document_path);
let owner_dir = owner_document.parent();
let (kind, resolved_absolute_path, relative_path) =
resolve_attachment_path(&normalized_href, owner_dir, owner_root_uri);
let resolved_uri = resolved_absolute_path
.as_ref()
.map(|path| file_uri_for_attachment_path(Path::new(path)))
.or_else(|| {
if is_remote_href(&normalized_href) {
Some(normalized_href.clone())
} else {
None
}
});
let ext = attachment_extension(&normalized_href, resolved_absolute_path.as_deref());
let content_type = ext
.as_deref()
.and_then(attachment_content_type)
.map(str::to_string);
let exists = resolved_absolute_path
.as_ref()
.map(|path| Path::new(path).exists());
let open_kind = attachment_open_kind(ext.as_deref()).to_string();
let label = if label.trim().is_empty() {
resolved_absolute_path
.as_ref()
.and_then(|path| Path::new(path).file_name())
.and_then(|name| name.to_str())
.or_else(|| {
Path::new(&normalized_href)
.file_name()
.and_then(|name| name.to_str())
})
.unwrap_or(raw_href)
.to_string()
} else {
label.trim().to_string()
};
AttachmentRef {
ref_id: attachment_ref_id(owner_document_path, raw_href, &source_range),
owner_document_path: owner_document_path.to_string(),
owner_root_uri: owner_root_uri.to_string(),
raw_href: raw_href.to_string(),
normalized_href,
label,
kind,
resolved_uri,
resolved_absolute_path,
relative_path,
ext,
content_type,
exists,
authorized: None,
open_kind,
source_range,
}
}
fn resolve_attachment_path(
href: &str,
owner_dir: Option<&Path>,
owner_root_uri: &str,
) -> (String, Option<String>, Option<String>) {
if is_remote_href(href) {
return ("remoteUrl".to_string(), None, None);
}
if let Some(path) = href.strip_prefix("file://").and_then(file_uri_path) {
return (
"externalFile".to_string(),
Some(normalize_path_string(&path)),
None,
);
}
let href_path = Path::new(href);
if href_path.is_absolute() {
return (
"externalFile".to_string(),
Some(normalize_path_string(href_path)),
None,
);
}
let decoded_href = percent_decode_lossy(href);
if decoded_href.starts_with("../") || decoded_href.contains("/../") {
return ("unknown".to_string(), None, None);
}
let relative_path = decoded_href
.strip_prefix("./")
.unwrap_or(&decoded_href)
.replace('\\', "/");
let resolved = owner_dir.map(|dir| normalize_path_string(&dir.join(&decoded_href)));
let root_path = owner_root_path(owner_root_uri);
let relative_to_root = resolved
.as_deref()
.and_then(|path| {
root_path
.as_ref()
.and_then(|root| relative_to_root_path(path, root))
})
.or(Some(relative_path));
("pageLocal".to_string(), resolved, relative_to_root)
}
fn normalize_attachment_href(raw_href: &str) -> String {
let trimmed = raw_href
.trim()
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_href.trim())
.trim();
if Path::new(trimmed).is_absolute() {
file_uri_for_attachment_path(Path::new(trimmed))
} else {
trimmed.to_string()
}
}
fn file_uri_path(value: &str) -> Option<PathBuf> {
let path = if let Some(rest) = value.strip_prefix("localhost/") {
format!("/{rest}")
} else if value.starts_with('/') {
value.to_string()
} else {
format!("/{value}")
};
Some(PathBuf::from(percent_decode_lossy(&path)))
}
fn owner_root_path(root_uri: &str) -> Option<PathBuf> {
root_uri
.strip_prefix("file://")
.and_then(file_uri_path)
.or_else(|| {
let path = Path::new(root_uri);
if path.is_absolute() {
Some(path.to_path_buf())
} else {
None
}
})
}
fn relative_to_root_path(path: &str, root: &Path) -> Option<String> {
Path::new(path)
.strip_prefix(root)
.ok()
.map(|value| value.to_string_lossy().replace('\\', "/"))
}
fn find_href_source_range(
markdown: &str,
raw_href: &str,
cursor: &mut usize,
) -> Option<AttachmentSourceRange> {
let raw_href = raw_href.trim();
let start = markdown
.get(*cursor..)
.and_then(|tail| tail.find(raw_href).map(|index| *cursor + index))
.or_else(|| markdown.find(raw_href))?;
let end = start + raw_href.len();
*cursor = end;
Some(AttachmentSourceRange { start, end })
}
fn is_remote_href(href: &str) -> bool {
href.starts_with("http://") || href.starts_with("https://")
}
fn attachment_extension(href: &str, path: Option<&str>) -> Option<String> {
path.or(Some(href))
.and_then(|value| Path::new(value).extension())
.and_then(|value| value.to_str())
.map(|value| value.trim_start_matches('.').to_ascii_lowercase())
.filter(|value| !value.is_empty())
}
fn attachment_content_type(ext: &str) -> Option<&'static str> {
match ext {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"svg" => Some("image/svg+xml"),
"pdf" => Some("application/pdf"),
"docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
"xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
"mp3" => Some("audio/mpeg"),
"wav" => Some("audio/wav"),
"mp4" => Some("video/mp4"),
"webm" => Some("video/webm"),
"md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml"
| "yaml" | "yml" => Some("text/plain"),
_ => None,
}
}
fn attachment_open_kind(ext: Option<&str>) -> &'static str {
match ext.unwrap_or_default() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "image",
"pdf" => "pdf",
"doc" | "docx" | "ppt" | "pptx" | "xls" | "xlsx" => "office",
"mp3" | "wav" | "ogg" | "m4a" => "audio",
"mp4" | "webm" | "mov" | "mkv" => "video",
"md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml"
| "yaml" | "yml" => "text",
"" => "unknown",
_ => "download",
}
}
fn normalize_path_string(path: &Path) -> String {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
_ => normalized.push(component.as_os_str()),
}
}
normalized.to_string_lossy().replace('\\', "/")
}
fn file_uri_for_attachment_path(path: &Path) -> String {
format!("file://{}", normalize_path_string(path))
}
fn percent_decode_lossy(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let (Some(high), Some(low)) =
(hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
{
output.push(high * 16 + low);
index += 3;
continue;
}
}
output.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&output).into_owned()
}
fn hex_value(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn attachment_ref_id(
owner_document_path: &str,
raw_href: &str,
source_range: &Option<AttachmentSourceRange>,
) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
owner_document_path.hash(&mut hasher);
raw_href.hash(&mut hasher);
source_range.hash(&mut hasher);
format!("attachment:{:016x}", hasher.finish())
}
fn parse_markdown_ast_document(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> MarkdownAstDocument {
let arena = Arena::new();
let options = markdown_options();
let root = parse_document(&arena, markdown, &options);
let mut blocks = Vec::new();
for node in root.children() {
append_ast_block(node, &mut blocks, attachment_paths);
}
MarkdownAstDocument { blocks }
}
fn append_ast_block<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
match node.data.borrow().value.clone() {
NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths),
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,
attachment_paths,
);
}
}
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<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
if let Some((alt, source_path)) = paragraph_image(node) {
blocks.push(MarkdownBlock::Image { alt, source_path });
return;
}
if let Some((name, source_path)) = paragraph_mindmap(node) {
blocks.push(MarkdownBlock::Mindmap { name, source_path });
return;
}
if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
}
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
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<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
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, attachment_paths),
};
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, attachment_paths),
}
}
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<TableAlignment>) -> 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::<Vec<_>>();
MarkdownTableRow { is_header, cells }
})
.collect::<Vec<_>>();
MarkdownBlock::Table { alignments, rows }
}
fn collect_inline_children<'a>(node: &'a AstNode<'a>) -> Vec<MarkdownInline> {
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<MarkdownInline>,
) {
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<MarkdownInline>,
) {
for child in node.children() {
collect_inline_nodes(child, active_styles, nodes);
}
}
fn push_inline_text_node(
nodes: &mut Vec<MarkdownInline>,
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<MarkdownInline>) -> Vec<MarkdownInline> {
let mut merged = Vec::<MarkdownInline>::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>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_attachment_media(first, attachment_paths)
}
fn paragraph_mindmap<'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_mindmap(first)
}
fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
let NodeValue::Image(link) = &first.data.borrow().value else {
return None;
};
let alt = collect_plain_text(first).trim().to_string();
Some((alt, link.url.clone()))
}
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
let mut children = node.children();
let first = children.next()?;
let (name, source_path) = link_attachment_media(first, attachment_paths)?;
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>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
parse_markdown_attachment_link_with_paths(
&format!("[{}]({})", collect_plain_text(node), link.url),
attachment_paths,
)
}
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
let target = link.url.trim();
let file_name = std::path::Path::new(target)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(target)
.trim();
let lower = file_name.to_ascii_lowercase();
let is_mindmap = lower.ends_with(".mindmap.json")
|| (file_name.starts_with("思维导图") && lower.ends_with(".json"));
if !is_mindmap {
return None;
}
let name = collect_plain_text(node).trim().to_string();
Some((
if name.is_empty() {
"思维导图".to_string()
} else {
name
},
target.to_string(),
))
}
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::Image { alt, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "image",
"props": {
"src": source_path,
"alt": alt,
"title": alt,
},
"content": [],
"children": [],
}),
MarkdownBlock::Mindmap { name, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "mindmap",
"props": {
"name": name,
"sourcePath": source_path,
"mindmapId": source_path,
"rootNodeId": "root",
},
"content": [],
"children": [],
}),
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::<Vec<_>>();
json!({
"type": "tableRow",
"content": cells,
})
})
.collect::<Vec<_>>();
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<Value>, 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<Value> {
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<Value> {
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<Value> {
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<String>, &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)
}
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()
}
#[cfg(test)]
mod tests {
use super::{markdown_to_blocks, parse_markdown_attachment_refs, parse_markdown_page};
#[test]
fn markdown_image_parses_as_image_block() {
let blocks = markdown_to_blocks("![示例图片](assets/photo.jpg)\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("image"));
assert_eq!(first["props"]["src"].as_str(), Some("assets/photo.jpg"));
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
"mnote-attachment-ref-parser-{}",
std::process::id()
));
let owner_dir = root.join("docs");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
std::fs::write(owner_dir.join("同目录 文件.pdf"), b"pdf").expect("write relative file");
std::fs::write(owner_dir.join("figure.png"), b"png").expect("write image file");
let owner = owner_dir.join("page.md");
let external = root.join("external.docx");
std::fs::write(&external, b"docx").expect("write external file");
let markdown = format!(
"[同目录 PDF](./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf)\n\
![图片](./figure.png)\n\
[外部](file://{})\n\
[网页资料](https://example.com/paper.pdf)\n\
[裸绝对]({})\n\
<embed src=\"./missing.pptx\">\n",
external.display(),
external.display()
);
let refs = parse_markdown_attachment_refs(
&markdown,
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert_eq!(refs.len(), 6);
assert_eq!(
refs[0].raw_href,
"./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf"
);
assert_eq!(refs[0].kind, "pageLocal");
assert_eq!(refs[0].open_kind, "pdf");
assert_eq!(refs[0].exists, Some(true));
assert_eq!(
refs[0].relative_path,
Some("docs/同目录 文件.pdf".to_string())
);
assert_eq!(refs[1].open_kind, "image");
assert_eq!(refs[2].kind, "externalFile");
assert_eq!(refs[2].open_kind, "office");
assert_eq!(refs[3].kind, "remoteUrl");
assert_eq!(
refs[3].resolved_uri,
Some("https://example.com/paper.pdf".to_string())
);
assert_eq!(
refs[4].normalized_href,
format!("file://{}", external.display())
);
assert_eq!(refs[5].raw_href, "./missing.pptx");
assert_eq!(refs[5].open_kind, "office");
assert_eq!(refs[5].exists, Some(false));
assert!(refs.iter().all(|item| item.source_range.is_some()));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn markdown_attachment_refs_do_not_resolve_parent_directory_relative_paths() {
let root = std::env::temp_dir().join(format!(
"mnote-attachment-ref-parent-relative-{}",
std::process::id()
));
let owner_dir = root.join("docs");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
let owner = owner_dir.join("page.md");
let refs = parse_markdown_attachment_refs(
"[上级目录](../assets/a.pdf)\n",
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].raw_href, "../assets/a.pdf");
assert_eq!(refs[0].kind, "unknown");
assert_eq!(refs[0].resolved_absolute_path, None);
assert_eq!(refs[0].relative_path, None);
assert_eq!(refs[0].exists, None);
let _ = std::fs::remove_dir_all(&root);
}
/// 固定空引用块行为:`>` 在 GFM AST 中产生 BlockQuote 节点,
/// collect_inline_children 返回空 vec → 输出 type=quote content=[]。
#[test]
fn markdown_empty_blockquote_parse() {
let blocks = markdown_to_blocks("> \n\n>");
let array = blocks.as_array().expect("blocks");
assert!(!array.is_empty(), "应至少产生一个引用块");
for block in array.iter() {
if block["type"].as_str() == Some("quote") {
let content = block["content"].as_array().expect("quote content");
assert!(
content.is_empty(),
"空引用块 content 应为空,实际 {:?}",
content
);
}
}
}
/// 固定空表格单元格行为:comrak 解析空单元格为空的 tiptap paragraph
/// content 字段为 []。
#[test]
fn markdown_table_empty_cells_parse() {
let blocks = markdown_to_blocks("| 左 | |\n| --- | --- |\n| | 右 |\n");
let array = blocks.as_array().expect("blocks");
let table = array
.iter()
.find(|b| b["type"].as_str() == Some("table"))
.expect("should have table block");
let tiptap_table = table["props"]["tiptapTable"]
.as_object()
.expect("tiptapTable object");
let content_rows = tiptap_table["content"]
.as_array()
.expect("table content rows");
// 表头行:两个单元格
let header_row = &content_rows[0]["content"]
.as_array()
.expect("header row cells");
assert_eq!(header_row.len(), 2);
// 表头第一个单元格(非空)
let h1_cell = &header_row[0]["content"]
.as_array()
.expect("header cell paragraphs");
assert!(!h1_cell.is_empty(), "表头第一个单元格不应为空");
// 表头第二个单元格(空):单元格的 content 为 [{type:"paragraph", content:[]}]
// 段落层的 content 应为空数组
let h2_par_content = &header_row[1]["content"][0]["content"];
let h2_inline = h2_par_content.as_array().expect("header cell para content");
assert!(
h2_inline.is_empty(),
"空表头单元格的 paragraph content 应为空,实际值: {}",
serde_json::to_string_pretty(h2_par_content).unwrap()
);
// 数据行
let data_row = &content_rows[1]["content"]
.as_array()
.expect("data row cells");
assert_eq!(data_row.len(), 2);
// 数据行第一个单元格(空):段落层的 content 应为空数组
let d1_par_content = &data_row[0]["content"][0]["content"];
let d1_inline = d1_par_content.as_array().expect("data cell para content");
assert!(
d1_inline.is_empty(),
"空数据单元格的 paragraph content 应为空,实际值: {}",
serde_json::to_string_pretty(d1_par_content).unwrap()
);
// 数据行第二个单元格(非空):段落层的 content 不应为空
let d2_par_content = &data_row[1]["content"][0]["content"];
let d2_inline = d2_par_content.as_array().expect("data cell para content");
assert!(!d2_inline.is_empty(), "非空数据单元格不应为空");
}
// 本地 Markdown 标题统一来自文件名,frontmatter title 和 H1 都只保留为正文/元数据。
#[test]
fn parse_markdown_title_uses_filename_over_frontmatter_and_h1() {
let parsed = parse_markdown_page(
"---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n",
"File Name.md",
);
assert_eq!(parsed.title, "File Name");
}
#[test]
fn parse_markdown_title_uses_filename_when_h1_exists() {
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "File Name.md");
assert_eq!(parsed.title, "File Name");
}
#[test]
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
let parsed = parse_markdown_page("plain text\nmore text\n", "My File.md");
assert_eq!(parsed.title, "My File");
}
#[test]
fn parse_markdown_title_ignores_frontmatter_without_title() {
let parsed = parse_markdown_page("---\ntags: foo\n---\nplain text\n", "NoH1.md");
assert_eq!(parsed.title, "NoH1");
}
#[test]
fn parse_markdown_title_ignores_hash_inside_code_block() {
let parsed = parse_markdown_page("```md\n# Not A Title\n```\nplain text\n", "Fallback.md");
assert_eq!(parsed.title, "Fallback");
}
}