fix local markdown attachment regressions
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
||||
use comrak::{Arena, Options, parse_document};
|
||||
use serde_json::{Map, Value, json};
|
||||
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 {
|
||||
@@ -78,6 +81,34 @@ struct MarkdownInlineStyles {
|
||||
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();
|
||||
@@ -101,6 +132,28 @@ pub fn markdown_to_blocks_with_attachment_paths(
|
||||
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;
|
||||
@@ -159,6 +212,390 @@ fn parse_markdown_attachment_link_with_paths(
|
||||
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>,
|
||||
@@ -787,7 +1224,7 @@ pub(crate) fn file_stem_title(file_name: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{markdown_to_blocks, parse_markdown_page};
|
||||
use super::{markdown_to_blocks, parse_markdown_attachment_refs, parse_markdown_page};
|
||||
|
||||
#[test]
|
||||
fn markdown_image_parses_as_image_block() {
|
||||
@@ -802,6 +1239,94 @@ mod tests {
|
||||
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\
|
||||
\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]
|
||||
|
||||
Reference in New Issue
Block a user