/** * markdown → **纯文本**(回答的「复制」按钮用)。 * * 用户 2026-09-22 反馈:复制出来的内容还带着 `###`、`**`、`[文字](链接)` 这些语法符号, * 粘到别处很难看。这里把它们去掉。 * * **为什么走 marked 的 token 树、而不是渲染成 HTML 再取文本**: * 取 textContent 会把**链接地址丢掉**(`[查看产品详情](https://…)` 只剩「查看产品详情」), * 而复制场景里地址恰恰是要留的 —— 所以取 token 树,把链接写成 `文字(地址)`。 * * 去掉:标题的 `#`、`**加粗**`、`*斜体*`、`` `代码` ``、`[文字](链接)` 的括号语法、 * 无序列表的 `- `、引用符号、分隔线、HTML 标签。 * 保留:全部可见文字、**有序列表的序号**(它承载步骤顺序,不是纯装饰)、 * 链接地址(写成 `文字(地址)`)。 */ import { marked, type Token } from "marked"; /** 行内 token → 文本 */ const inlineToText = (tokens?: Token[]): string => (tokens || []).map(inlineTokenToText).join(""); const inlineTokenToText = (token: Token): string => { const t = token as Record; switch (token.type) { case "text": return t.tokens ? inlineToText(t.tokens) : String(t.text ?? ""); case "escape": case "codespan": return String(t.text ?? ""); case "strong": case "em": case "del": return inlineToText(t.tokens) || String(t.text ?? ""); case "br": return "\n"; case "link": { const label = inlineToText(t.tokens) || String(t.text ?? ""); const href = String(t.href ?? ""); if (!href) return label; if (!label || label === href) return href; return `${label}(${href})`; } case "image": { const href = String(t.href ?? ""); if (/^data:/i.test(href)) return ""; // data URL 复制出去没意义 return String(t.text ?? "") || href; } case "html": return String(t.text ?? "").replace(/<[^>]*>/g, ""); default: return String(t.text ?? ""); } }; /** 块级 token → 文本(段落之间空一行) */ const blockToText = (token: Token): string => { const t = token as Record; switch (token.type) { case "space": case "hr": return ""; case "heading": case "paragraph": return `${inlineToText(t.tokens)}\n\n`; case "text": return t.tokens ? `${inlineToText(t.tokens)}\n\n` : `${String(t.text ?? "")}\n\n`; case "blockquote": return `${(t.tokens || []).map((inner: Token) => blockToText(inner)).join("")}\n`; case "list": { const start = Number(t.start) || 1; const lines = (t.items || []).map((item: Record, index: number) => { // 有序列表保留序号(顺序是内容);无序列表去掉 `- ` 这类装饰 const prefix = t.ordered ? `${start + index}. ` : ""; const body = (item.tokens || []) .map((inner: Token) => blockToText(inner).trim()) .filter(Boolean) .join("\n"); return `${prefix}${body}`; }); return `${lines.join("\n")}\n\n`; } case "code": return `${String(t.text ?? "")}\n\n`; case "table": { const rows = [t.header, ...(t.rows || [])] as Record[][]; return ( rows .map((row) => (row || []).map((cell) => inlineToText(cell?.tokens)).join(" ")) .join("\n") + "\n\n" ); } case "html": return `${String(t.text ?? "").replace(/<[^>]*>/g, "").trim()}\n\n`; default: return `${String(t.text ?? "")}\n\n`; } }; /** 兜底:marked 万一抛错,至少把最常见的语法符号去掉(宁可留符号,也不能复制不出东西) */ const fallbackStrip = (markdown: string): string => markdown .replace(/^#{1,6}\s+/gm, "") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1($2)") .replace(/^\s*[-*+]\s+/gm, ""); export const markdownToPlainText = (markdown: string): string => { const source = String(markdown ?? ""); if (!source.trim()) return ""; let text: string; try { text = marked .lexer(source) .map((token) => blockToText(token)) .join(""); } catch (err) { console.warn("[markdown-plain-text] 解析失败,退回简单剥离:", (err as Error)?.message || err); text = fallbackStrip(source); } return text .replace(/[ \t]+\n/g, "\n") // 行尾空白 .replace(/\n{3,}/g, "\n\n") // 多余空行 .trim(); };