markdown-plain-text.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /**
  2. * markdown → **纯文本**(回答的「复制」按钮用)。
  3. *
  4. * 用户 2026-09-22 反馈:复制出来的内容还带着 `###`、`**`、`[文字](链接)` 这些语法符号,
  5. * 粘到别处很难看。这里把它们去掉。
  6. *
  7. * **为什么走 marked 的 token 树、而不是渲染成 HTML 再取文本**:
  8. * 取 textContent 会把**链接地址丢掉**(`[查看产品详情](https://…)` 只剩「查看产品详情」),
  9. * 而复制场景里地址恰恰是要留的 —— 所以取 token 树,把链接写成 `文字(地址)`。
  10. *
  11. * 去掉:标题的 `#`、`**加粗**`、`*斜体*`、`` `代码` ``、`[文字](链接)` 的括号语法、
  12. * 无序列表的 `- `、引用符号、分隔线、HTML 标签。
  13. * 保留:全部可见文字、**有序列表的序号**(它承载步骤顺序,不是纯装饰)、
  14. * 链接地址(写成 `文字(地址)`)。
  15. */
  16. import { marked, type Token } from "marked";
  17. /** 行内 token → 文本 */
  18. const inlineToText = (tokens?: Token[]): string =>
  19. (tokens || []).map(inlineTokenToText).join("");
  20. const inlineTokenToText = (token: Token): string => {
  21. const t = token as Record<string, any>;
  22. switch (token.type) {
  23. case "text":
  24. return t.tokens ? inlineToText(t.tokens) : String(t.text ?? "");
  25. case "escape":
  26. case "codespan":
  27. return String(t.text ?? "");
  28. case "strong":
  29. case "em":
  30. case "del":
  31. return inlineToText(t.tokens) || String(t.text ?? "");
  32. case "br":
  33. return "\n";
  34. case "link": {
  35. const label = inlineToText(t.tokens) || String(t.text ?? "");
  36. const href = String(t.href ?? "");
  37. if (!href) return label;
  38. if (!label || label === href) return href;
  39. return `${label}(${href})`;
  40. }
  41. case "image": {
  42. const href = String(t.href ?? "");
  43. if (/^data:/i.test(href)) return ""; // data URL 复制出去没意义
  44. return String(t.text ?? "") || href;
  45. }
  46. case "html":
  47. return String(t.text ?? "").replace(/<[^>]*>/g, "");
  48. default:
  49. return String(t.text ?? "");
  50. }
  51. };
  52. /** 块级 token → 文本(段落之间空一行) */
  53. const blockToText = (token: Token): string => {
  54. const t = token as Record<string, any>;
  55. switch (token.type) {
  56. case "space":
  57. case "hr":
  58. return "";
  59. case "heading":
  60. case "paragraph":
  61. return `${inlineToText(t.tokens)}\n\n`;
  62. case "text":
  63. return t.tokens ? `${inlineToText(t.tokens)}\n\n` : `${String(t.text ?? "")}\n\n`;
  64. case "blockquote":
  65. return `${(t.tokens || []).map((inner: Token) => blockToText(inner)).join("")}\n`;
  66. case "list": {
  67. const start = Number(t.start) || 1;
  68. const lines = (t.items || []).map((item: Record<string, any>, index: number) => {
  69. // 有序列表保留序号(顺序是内容);无序列表去掉 `- ` 这类装饰
  70. const prefix = t.ordered ? `${start + index}. ` : "";
  71. const body = (item.tokens || [])
  72. .map((inner: Token) => blockToText(inner).trim())
  73. .filter(Boolean)
  74. .join("\n");
  75. return `${prefix}${body}`;
  76. });
  77. return `${lines.join("\n")}\n\n`;
  78. }
  79. case "code":
  80. return `${String(t.text ?? "")}\n\n`;
  81. case "table": {
  82. const rows = [t.header, ...(t.rows || [])] as Record<string, any>[][];
  83. return (
  84. rows
  85. .map((row) => (row || []).map((cell) => inlineToText(cell?.tokens)).join(" "))
  86. .join("\n") + "\n\n"
  87. );
  88. }
  89. case "html":
  90. return `${String(t.text ?? "").replace(/<[^>]*>/g, "").trim()}\n\n`;
  91. default:
  92. return `${String(t.text ?? "")}\n\n`;
  93. }
  94. };
  95. /** 兜底:marked 万一抛错,至少把最常见的语法符号去掉(宁可留符号,也不能复制不出东西) */
  96. const fallbackStrip = (markdown: string): string =>
  97. markdown
  98. .replace(/^#{1,6}\s+/gm, "")
  99. .replace(/\*\*([^*]+)\*\*/g, "$1")
  100. .replace(/`([^`]+)`/g, "$1")
  101. .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1($2)")
  102. .replace(/^\s*[-*+]\s+/gm, "");
  103. export const markdownToPlainText = (markdown: string): string => {
  104. const source = String(markdown ?? "");
  105. if (!source.trim()) return "";
  106. let text: string;
  107. try {
  108. text = marked
  109. .lexer(source)
  110. .map((token) => blockToText(token))
  111. .join("");
  112. } catch (err) {
  113. console.warn("[markdown-plain-text] 解析失败,退回简单剥离:", (err as Error)?.message || err);
  114. text = fallbackStrip(source);
  115. }
  116. return text
  117. .replace(/[ \t]+\n/g, "\n") // 行尾空白
  118. .replace(/\n{3,}/g, "\n\n") // 多余空行
  119. .trim();
  120. };