复制成功,不等于文章能发布:markdown2x 如何拆开富文本与图片资产

8 阅读5分钟

把一篇 Markdown 复制进 X Articles,看起来只有两个步骤:解析 Markdown,写入剪贴板。

真正动手后,很快就会撞到几个不在同一层的问题。标题和列表要保留格式;代码块粘贴后不能变成一团等宽文本;Mermaid 和表格需要转成图片;浏览器还可能拒绝富文本剪贴板权限。即使按钮提示“复制成功”,文章里仍可能留着没有上传的图片和平台不支持的语法。

我最近重新读了 markdown2x 的复制链路。它没有把所有内容硬塞进一段 HTML,而是先把一篇文章拆成文档结构,再同时生成富文本、纯文本和待处理资产。这种拆法比“Markdown 转 HTML”更接近真实的发布流程。

先得到文档结构,再决定怎么交付

入口在 lib/markdown.tsparseMarkdown()。它把源码解析成一组 ArticleBlock:标题、段落、引用、列表、代码、Mermaid、表格和 Tweet 各自拥有明确类型;行内内容再拆成文本、粗体、斜体、链接、行内代码和图片。

这里没有直接输出 HTML。预览、纯文本回退、剪贴板富文本和资产面板都消费同一份结构。

这个中间层解决了一个很实际的问题:同一个 Markdown 块,在不同出口里应该有不同形态。

  • 二级标题在富文本里是 <h2>,在纯文本里只保留文字。
  • 链接在 HTML 里保留 <a href>,在纯文本里变成“标题 + URL”。
  • 代码块可以变成引用,也可以变成一张待粘贴的 PNG。
  • Mermaid 源码不能直接进入文章正文,只留下图片插入位置。

如果解析阶段就急着拼 HTML,后面每增加一种出口都要重新猜一次原始语义。ArticleBlock 把这次猜测固定在一个地方。

一个复制按钮,实际写入两份正文

toXArticleClipboard() 返回的不是一个字符串,而是一份 XArticleClipboard

type XArticleClipboard = {
  title: string;
  titleHtml: string;
  bodyText: string;
  bodyHtml: string;
  fullText: string;
  fullHtml: string;
  assets: XArticleAsset[];
  assetOffsets: number[];
};

正文复制时,components/editor-page.tsx 会同时写入两种 MIME:

new ClipboardItem({
  "text/html": new Blob([html], { type: "text/html" }),
  "text/plain": new Blob([text], { type: "text/plain" }),
});

支持富文本的目标编辑器会读取 text/html,普通输入框仍可退回 text/plain。用户只点了一次按钮,剪贴板里却带着两个兼容层。

浏览器拒绝 ClipboardItem 时,代码还会退回到一个屏幕外的 contenteditable 容器,通过 Selection 和 document.execCommand("copy") 复制。这个路径也失败,界面才展示手动复制区。

所以“复制正文”不是一次 API 调用。它是一条有降级顺序的交付链:现代富文本剪贴板、Selection 回退、人工复制。

代码、表格和 Mermaid 被当成另一类工件

X Articles 的正文编辑器不能可靠接住所有 Markdown 结构。markdown2x 没有假装一次粘贴能解决全部内容。

当代码模式设为 image 时,代码块与表格、Mermaid 会进入 assets。正文对应位置只留下明确提示,例如:

📷 [Code image 1 — paste from Assets panel]

每个资产还保存自己的原始数据:代码与语言、表头与行、Mermaid 源码。assetOffsets 则记录它在 Markdown 源文中的字符位置,后续可以从检查结果或资产面板跳回编辑器。

这一步很像构建系统处理源码和产物:正文是可直接粘贴的主工件,图片是需要单独渲染和上传的伴随工件,offset 是两者回到源文件的定位信息。

项目没有把图片伪装成已经交付。测试会明确断言 Mermaid 源码不进入 bodyHtml,而 assets 中保留完整图表内容;表格同样转成 PNG 任务,Tweet 则保留链接和嵌入提示。

Preflight 能发现问题,但目前不会拦住复制

runPreflight() 会扫描两类风险。

一类来自已解析结构:代码将变成引用还是 PNG、表格有多少列、远程图片不会自动上传。另一类直接扫描原始 Markdown,包括脚注、LaTeX、iframe、分隔线和删除线等当前不支持的语法。

每条结果都带 sourceOffset,用户可以跳回问题位置。报告状态分为 okwarningblocked

但这里有一个需要说清的边界:copyBody() 在没有 preflight 报告时只显示五秒提示,随后仍会继续复制。也就是说,当前 preflight 是发布前检查工具,还不是一个强制 gate。blocked 描述的是检查结果,不代表复制按钮一定被阻止。

这并非文字游戏。写文档工具时,提示、阻断和自动修复是三种不同承诺。把提示写成“已经保证可以发布”,会让用户在目标平台上才发现缺图或语法丢失。

转换工具真正交付的是失败边界

这条源码链让我重新理解了文档转换工具。格式转换只是其中一段,完整交付还包括:目标编辑器能吃下什么,浏览器剪贴板允许什么,哪些内容必须变成独立资产,以及失败后用户从哪里继续。

markdown2x 目前已经把这些事实拆开:同一份 AST 生成 HTML 和纯文本,图片类内容进入资产队列,preflight 标出无法自动处理的语法,复制权限失败后继续降级。

它也保留了真实限制。图片仍需要用户逐个粘贴,远程图片不会自动上传,preflight 还没有成为强制发布门。现阶段把这些边界展示出来,比用一个“完美转换”的按钮文案更有用。

项目源码在 echoVic/x-article-md,线上工具使用 markdown2x.com。本文对应的转换契约集中在 lib/markdown.tslib/preflight.tscomponents/editor-page.tsx