← 返回观点

为什么要在 markdownToHtml 出口统一压掉标签间空白

解释 OmniPost 为什么把结构标签间空白修复上移到 markdownToHtml 共享出口:一次修复覆盖微信、知乎、头条、搜狐等所有 HTML 平台,同时避免代码块等正常换行被误伤。

先说结论:把结构标签之间的空白统一压掉,最稳的位置不是某一个平台适配器里,而是 markdownToHtml 这种共享渲染出口。 这样做的原因很简单:同一份 Markdown 会流向多个 HTML 平台,而“标签间空白会不会被编辑器错误解析”不是微信独有的问题,只是微信新版编辑器最先把这个坑暴露了出来。

这次修复之所以值得单独写一篇,是因为它不是“把一个微信 bug 补丁挪了位置”这么简单。对 OmniGoAI 的 OmniPost 这类多平台分发工具来说,真正重要的是修复放在哪一层:放在单个平台里,只能兜住一条路径;放到共享出口,才能同时覆盖知乎、头条、搜狐、WordPress、Ghost 这类所有吃 HTML 的目标平台。

如果你只记一句话,可以记这个:凡是多个平台共用同一段 HTML 生成逻辑的问题,优先在共享出口修,而不是在每个平台后面各补一刀。

这次修复到底修的是什么问题

问题表面上看是“列表渲染异常”,但根因其实更底层:某些编辑器不会把结构标签之间的 whitespace-only 文本节点当作可忽略空白,而会把它们当成真实内容去解释。

OmniPost 仓库里这次修复的提交说明写得很直接:此前 v0.5.40 只在微信适配器里压掉 <li> 之间的换行空白;后来判断知乎、头条、搜狐等 HTML 平台走的是同一份 marked 输出,与其逐个平台赌编辑器行为,不如在源头统一压掉。

更具体的事故背景也已经写进源码注释和测试里:微信新版编辑器基于 ProseMirror,会把列表项之间的换行解析成空列表项。真实事故里,4 项列表显示成 9 项,奇数编号全空;另一次真机记录里,1 个有序列表加 3 个无序列表、共 16 项,被解析成了 36 个 li,也就是每个列表都膨胀成 2n+1

这类问题难就难在:

  1. 生成出来的 HTML 从浏览器角度看并不一定“非法”;
  2. 守规范的消费方本来应该忽略这些空白;
  3. 但富文本编辑器未必按浏览器那一套去解释节点;
  4. 于是同一份 HTML,到了不同平台后台,结果可能完全不同。

为什么修复点必须放在 markdownToHtml 出口

因为 markdownToHtml 才是真正的公共汇合点。

从 OmniPost 现有代码可以看到,多个 HTML 平台适配器都直接依赖 src/lib/markdown.js 里的 markdownToHtml。仓库里可以直接 grep 到 csdnsohutoutiaowordpressghostzhihu 等适配器都在用这条路径,而不是各自单独实现一套 Markdown 转 HTML 逻辑。

这意味着,如果你只在 weixin.js 里补一个 _collapseListWhitespace()

  1. 微信路径会变安全;
  2. 但知乎、头条、搜狐等平台仍然会收到带结构空白的 HTML;
  3. 以后任何新接入的 HTML 平台,也还得再想一遍同样的问题;
  4. 代码维护会退化成“谁爆雷就给谁补丁”。

而把修复上移到 markdownToHtml 出口之后,收益是成倍的:

  1. marked 路径和 fallback 路径都统一生效;
  2. 所有共用这条渲染链的 HTML 平台自动获得防护;
  3. 新平台默认继承这个行为,不需要再次补丁;
  4. 预览链路和正式发布链路更容易保持一致。

这正是这次提交的核心变化:markdownToHtml(md) 在加载到 marked 时,不再直接返回 fn(md),而是改成 collapseStructuralWhitespace(fn(md));走 fallback 渲染时也一样,先转 HTML,再统一做同一层压缩。

统一修复为什么比“只修微信”更对

因为微信只是第一个给出明确事故信号的平台,不代表其它平台天然安全。

提交说明里已经把这个判断说得很清楚:知乎真机初判暂时没有出现空项膨胀,但这次上移依然做了,因为这属于纯防御性修复。这个判断非常重要。

很多团队在这里会犯一个典型错误:

  1. 某平台 A 爆了;
  2. 于是只在 A 的适配器里打补丁;
  3. 觉得“其它平台没报错,所以先不动”;
  4. 最后维护出一堆彼此不一致的后处理逻辑。

更稳的工程判断应该是:如果问题来自共享中间产物,而不是来自平台专属字段,那么默认应该优先在共享层处理。

这篇文章和这两篇内容可以一起看:

前一篇讲的是“预览正确不等于平台一定能吃下这份内容”;后一篇讲的是这个空项事故最初是怎样暴露出来的。放在一起看,就能理解为什么修复必须上移,而不是只留在微信分支里。

具体压掉了哪些空白

这次新增的 collapseStructuralWhitespace(html) 并不是“把所有换行都删掉”,而是有边界地处理结构标签之间的空白。

源码里的规则主要覆盖三类位置:

  1. <ul><ol><table><thead><tbody><tr> 开标签后的空白;
  2. 上述结构容器闭标签前的空白;
  3. </li></tr></td></th></thead></tbody> 之后、下一个标签开始之前的空白。

这类空白的共同特征是:它们位于结构边界,对守规范的 HTML 消费方来说语义本来就应当等价于“没有内容”。因此把它们统一压掉,通常不会改变正确渲染结果,却能降低富文本编辑器把它们误识别成空节点的风险。

为什么不会误伤代码块和正常正文

这是最需要讲清楚的地方。

很多人一看到“压掉空白”就会担心:那代码块里的换行是不是也没了?正文里的段落是不是会粘在一起?这次实现专门用测试把这个边界钉住了。

OmniPost 在 tests/lib.test.js 里新增了两类关键断言:

  1. markdownToHtml 输出的列表/表格结构标签之间不得再有空白
  2. collapseStructuralWhitespace 不碰 <pre> 代码块里的文本换行

第二条尤其关键。因为 <pre><code>...</code></pre> 里的换行并不位于“结构标签边界”上,而是代码文本本身的一部分。提交后的测试明确验证了:a\nb\n 这样的换行仍然保留,而列表则会从

<ul>
<li>x</li>
</ul>

收紧成:

<ul><li>x</li></ul>

也就是说,这次修复不是“激进压缩 HTML”,而是只对容易出问题的结构空白下手

为什么微信适配器里还保留了一道相同逻辑

因为仍然存在一条绕过 markdownToHtml 的路径:用户可以直接提供 article.html

这也是这次实现很成熟的一点。提交之后,weixin.js 没有完全删掉 _collapseListWhitespace(),而是把它改成直接委托共享函数 collapseStructuralWhitespace(html)。源码注释里写得很明白:共享出口已经统一防护,但微信这边仍保留 juice 之后的一道,专门兜住“用户直供 HTML”的路径。

这说明“上移修复”不等于“把下游兜底全删了”。更准确地说:

  1. 默认路径的问题在共享层解决;
  2. 分叉路径的兜底仍留在适配器里。

这才是既整洁又稳妥的分层方式。

这类修复为什么对多平台发布特别重要

因为多平台发布最怕的不是“一个 bug 很难修”,而是“同一类 bug 在五个平台上用五种方式反复出现”。

OmniPost 本质上是在做一条共享内容管线:

  1. 先把 Markdown 变成中间 HTML;
  2. 再让不同平台适配器消费这份 HTML;
  3. 平台侧各自有不同的编辑器、校验器和后台行为。

当问题出在第 1 步和第 2 步之间的公共产物时,最值钱的修法从来不是“哪个平台先疼就先贴膏药”,而是把共享产物修干净。这样带来的不是一次 bugfix,而是一次未来维护成本的下降

如果你平时会在知乎、CSDN、掘金、博客园之外继续扩平台,这种修法尤其重要。因为新平台接进来时,你不需要重新回忆“这个历史坑当年补在哪个适配器里”,它已经在出口层被处理掉了。

一个值得复用的工程判断规则

这次修复背后,其实可以抽出一个很通用的判断规则:

如果多个下游共享同一份中间产物,而问题来自这份中间产物的结构噪声,那么优先在产物出口统一净化,而不是在每个下游各自容错。

把这个规则落到日常工程里,大概就是:

  1. 先定位问题属于共享层还是平台层;
  2. 如果共享层可无损修复,优先上移;
  3. 再为绕过共享层的特殊输入保留下游兜底;
  4. 最后用测试把“修到了哪”和“不会误伤哪”一起锁住。

这次 OmniPost 的实现正好四条都占全了:有共享函数、有下游兜底、有 commit 说明、也有单元测试把边界写死。

常见问题

为什么不只修微信,等其它平台真出问题再说?

因为这类问题来自共享的 HTML 输出,而不是微信独有字段。既然多个平台共用同一条 markdownToHtml 路径,那么在共享出口统一压掉结构空白,成本更低,覆盖更广,也能避免以后继续复制补丁。

这是不是说明所有 HTML 平台都会出现空列表项?

不是。提交说明里反而强调了:知乎真机初判当时没有发现空项膨胀。这次改动的意义不是证明“所有平台都坏了”,而是承认“平台编辑器行为不完全可预测”,所以提前做纯防御性修复更稳。

把空白压掉会不会改变正常 HTML 语义?

对守规范的 HTML 消费方来说,结构标签边界上的 whitespace-only 文本节点本来就应被忽略,所以这次处理通常是语义无损的。真正需要保护的是代码块和正文文本,而测试已经明确覆盖了 <pre> 不受影响。

为什么微信适配器里还留着 _collapseListWhitespace()

因为微信仍有一条“用户直接提供 article.html”的路径,可能绕过 markdownToHtml。保留一道适配器级兜底,可以确保这类输入在进入微信编辑器前也会被统一清理。

这和 preview 有什么关系?

它们相关,但不是一回事。preview 解决的是“你看到的渲染效果是不是接近实际提交内容”;这次修复解决的是“平台编辑器会不会把结构空白误解释成内容节点”。前者偏可视化一致性,后者偏中间 HTML 的结构卫生。

把结构标签间空白的修复上移到 markdownToHtml 出口,本质上是在做一件很朴素但很值钱的事:让共享产物先变干净,再去适配不同平台。 对多平台分发工具来说,这类修复往往比表面上看起来更重要,因为它一次解决的不是某一个平台 bug,而是一整条内容管线的防御性质量。如果你想把这种“写一次、稳发多平台”的本地优先内容分发流程用起来,可以直接从 OmniPost 开始:<https://omnigoai.com/zh/download/omnipost/>。

#OmniPost#markdownToHtml#HTML 渲染#多平台发布

更多文章

9 分钟

OmniPost 账号页“打开”能做什么

解释 OmniPost 账号页“打开”按钮的实际用途:如何一键进入已登录平台后台,快速核对登录态、评论后台、创作者中心和异常页面,而不是在浏览器里重新找入口。

阅读