| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128 |
- /**
- * 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<string, any>;
- 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<string, any>;
- 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<string, any>, 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<string, any>[][];
- 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();
- };
|