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

914 lines
29 KiB
Rust
Raw Normal View History

use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{Arena, Options, parse_document};
use serde_json::{Map, Value, json};
use std::collections::BTreeSet;
#[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>,
},
2026-05-20 10:43:38 +08:00
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>,
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
2026-05-24 01:49:51 +08:00
let (_frontmatter, body) = split_frontmatter(markdown);
let body_owned = body.to_string();
2026-05-24 01:49:51 +08:00
// 本地 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))
}
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("](")?;
2026-05-20 10:43:38 +08:00
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 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>,
) {
2026-05-20 10:43:38 +08:00
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)
}
2026-05-20 10:43:38 +08:00
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,
)
}
2026-05-20 10:43:38 +08:00
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)
}
2026-05-20 10:43:38 +08:00
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()
}
2026-05-20 10:43:38 +08:00
#[cfg(test)]
mod tests {
use super::{markdown_to_blocks, parse_markdown_page};
2026-05-20 10:43:38 +08:00
#[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("示例图片"));
}
2026-05-21 13:28:23 +08:00
/// 固定空引用块行为:`>` 在 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");
2026-05-21 13:28:23 +08:00
assert_eq!(header_row.len(), 2);
// 表头第一个单元格(非空)
let h1_cell = &header_row[0]["content"]
.as_array()
.expect("header cell paragraphs");
2026-05-21 13:28:23 +08:00
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");
2026-05-21 13:28:23 +08:00
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(), "非空数据单元格不应为空");
}
2026-05-24 01:49:51 +08:00
// 本地 Markdown 标题统一来自文件名,frontmatter title 和 H1 都只保留为正文/元数据。
#[test]
2026-05-24 01:49:51 +08:00
fn parse_markdown_title_uses_filename_over_frontmatter_and_h1() {
let parsed = parse_markdown_page(
"---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n",
2026-05-24 01:49:51 +08:00
"File Name.md",
);
2026-05-24 01:49:51 +08:00
assert_eq!(parsed.title, "File Name");
}
#[test]
2026-05-24 01:49:51 +08:00
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]
2026-05-24 01:49:51 +08:00
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]
2026-05-24 01:49:51 +08:00
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");
}
2026-05-20 10:43:38 +08:00
}