微信公众号是台样式粉碎机,doocs/md 用 7 年写了个编译器对付它
- 微信后台对粘贴内容进行三层过滤:class/外部样式、外链、SVG 部分特性
- doocs/md 将 class 全部展开为内联样式、外链转为脚注、手工重绘 SVG marker 来对抗清洗
- 项目采用 monorepo 架构,内核为 Markdown 编译器,输出“天生抗清洗”的 HTML,支持七种分发形态
- 通过 MCP server 将渲染内核封装为 AI 工具,支持直接生成可粘贴的微信 HTML
- 项目深度绑定微信,不适用于其他平台,且依赖外部服务(dcloud 同步、qrserver 二维码)
写公众号的人都经历过那个瞬间。你在编辑器里把排版调到满意,引用块的颜色、代码高亮、行间距全都对了,全选,复制,贴进公众号后台,按下预览。
样式没了。
标题塌成默认黑色,引用块的边框消失,代码块缩成一团灰色。我第一次遇到这事儿的时候,以为是复制姿势不对,连换了三款编辑器,结果一样。后来才搞明白,问题根本不在编辑器,在微信本身。公众号后台是个样式粉碎机,贴进去的 HTML 它会按自家白名单重新洗一遍,class 全部丢弃,外部样式表压根不认,不是 mp.weixin.qq.com 的链接直接降级成纯文本。
doocs/md 这个项目,就是跟这台粉碎机死磕的产物。2019 年 11 月立项,13208 个 star,2216 次 fork,60 位贡献者,最近一次提交就在今晚,离我写稿只有几个小时。它表面上是个微信 Markdown 编辑器,读完源码你会发现,它更像一台编译器,输入 Markdown,输出一段微信怎么洗都洗不坏的 HTML。
微信到底洗掉了什么
先把对手的规矩摸清楚,后面每一处设计才看得懂。
微信编辑器对粘贴内容的清洗,拆开看是三层过滤器。第一层滤 class 和外部样式,你写 <p class="highlight">,进去就只剩 <p>,样式表链接直接被剥掉。第二层滤链接,正文里只放行自家域名,外链一律变成不可点的文字。第三层最隐蔽,滤 SVG 的部分特性,marker 箭头、部分渐变、某些嵌套结构,渲染出来就是缺胳膊少腿。
这三层不是 bug,是安全策略。微信不可能放任任意 HTML 进它的渲染管线,XSS 风险摆在那。但代价是,所有想做好公众号排版的人,都得想办法产出一份「天生抗清洗」的 HTML。doocs/md 的全部核心工程,就是围绕这三层过滤器展开的。
第一招,把 class 全部展开
先说最硬的那块骨头,样式。
既然微信不认 class,那就不写 class。这个项目的渲染产物里,所有样式直接挂在每个标签的 style 属性上,行内到底。你粘进微信的每一段 <section>,都自带完整的字号、颜色、边距声明。
这件事说着简单,做着有个隐藏难题。主题系统为了好维护,源码里的 CSS 是带 CSS 变量的,--primaryColor 这种写法满篇都是。但微信同样不支持 var() 函数,所以必须在渲染时把变量全部展开成真实值。
我翻到 packages/core/src/theme/cssProcessor.ts,整个文件 84 行,纯手写的解析器。它先用正则把所有 --xxx: value 抽成一个 Map,再用另一个正则把 var(--xxx) 逐个替换成真实值,变量嵌套变量的情况最多迭代 10 层。离谱的是连 calc() 都自己求值,同单位的加减、带单位乘无单位的除法,全都处理了,结果四舍五入到小数点后 4 位。
代码里还有行注释专门解释为什么不用浏览器现成的 getComputedStyle,因为注入的主题会滞后一次点击,手写正则反而又快又准。你想想看,正常 Web 开发谁会手写 CSS 变量解析器,浏览器明明有 API。但这里的目标环境不是浏览器,是微信的消毒机,工程判断就整个反过来了,不求标准,只求活下来。
外链的命,脚注来续
第二层过滤器是链接。
packages/core/src/renderer/renderer-impl.ts 里躺着一条正则,MP_WEIXIN_LINK_REGEX,只认 mp.weixin.qq.com 开头的 URL。不匹配的外链,项目没有简单粗暴地丢掉,而是把链接收进文章末尾的脚注区,正文位置显示文字加一个 [1] 角标。buildFootnoteArray 函数负责拼脚注列表,链接和标题相同时就只显示一份,省一行是一行。
还有个叫 transform 的函数处理链接的显示文案。legend 参数支持 alt、title、filename 三种策略,filename 策略就是从 URL 里抠出文件名当显示文字。这些细节 README 里一个字没提,一看就是踩坑之后补上的。
顺带一提,那个 Mac 风格代码块左上角的红黄绿三个圆点,在源码里是一段手写的 SVG,三个 ellipse 硬编码了颜色值。微信编辑器里「看得见的每一分精致」,背后都是这种笨功夫。
677 像素,和被重画的箭头
第三层过滤器最刁钻,SVG。
代码高亮、KaTeX 数学公式、Mermaid 图表,渲染到最后都是 SVG 或者图片,才能在微信里活下来。apps/web/src/services/export/wechat-svg.ts 开头第一行就硬编码了一个数字,WECHAT_MAX_WIDTH_PX,值是 677。这是微信图文正文的列宽,677 像素,所有图都按这个宽度重新排版,宽一个像素都会被压变形。
SVG 内部的坑更深。微信会过滤 marker,就是流程图箭头、连线端点那类符号定义,过滤之后你的 Mermaid 图就没头没尾了。这个文件里的 collectMarkers 和 cloneMarkerGraphics 两个函数干的就是手工重画的活,把 marker 定义逐个展开成独立的 path 元素,等于绕开过滤规则,把图重新描了一遍。
这个坑到今天还在踩。就在我写这篇稿子的前两天,8 月 16 日,仓库合入了 #1914,修复复制到微信时 Mermaid 图表破损的问题。README 里轻飘飘一句「支持 Mermaid 图表」,背后是七年份的兜底工程。
一台内核,七种壳
把镜头拉远,看整体架构。
这是个 pnpm monorepo。apps/web 是编辑器主体,Vue 3 加 CodeMirror 6,编辑、预览、AI 助手都在这层。packages/core 是渲染内核,marked 加 11 个自研扩展,KaTeX、Mermaid、PlantUML、Ruby 注音、GFM 警告块、脚注、目录、滑动组件全部在这一层实现,核心的 renderer-impl.ts 有 507 行。往外辐射的分发形态有点夸张,npm 的 @doocs/md-cli、Docker 镜像、Chrome 扩展、Firefox 扩展、uTools 插件、VS Code 插件,外加 Cloudflare Workers 部署,一共七种壳。
主题这边,内置 default、grace、simple 三套,源码里就是三个 CSS 文件,走前面说的 cssProcessor 内联化。图床支持 13 种,GitHub、阿里云 OSS、腾讯云 COS、七牛、MinIO、S3、又拍云、Cloudflare R2、Telegram、Cloudinary,连公众号自己的素材库都能当图床用,还有自定义上传接口兜底。
翻 2025 年 10 月的 v2.1.0 更新日志,能看到一次大手术的痕迹。CodeMirror 从 v5 升到 v6,Vite 升到 v7,整个项目重构成 monorepo,同一年还加了 AI 助手侧边栏,DeepSeek、OpenAI、通义千问这些模型都接了进来。一个七年项目,骨架换了好几轮,内核思路没变过。
上手成本几乎为零。最轻的路径是直接开 md.doocs.org,左边粘 Markdown,右边实时出预览,点一下复制,贴进公众号后台就完事,本地内容有草稿管理兜底,刷新不丢。想私有化就 npm i -g @doocs/md-cli,一条 md-cli 命令起在 8800 端口,数据不出内网。Docker 党一行 docker run 拉起容器。走 AI 工作流的接法是配 MCP,让模型直接调渲染工具,连复制这一步都省了。
最有意思的一步棋,MCP
真正让我眼前一亮的,是 packages/mcp-server 这个包。
它很小,核心就三个源文件。干的事是把渲染内核包成一个 MCP 工具,render-article.ts 直接复用 core 的 renderMarkdown 和 processCSS,你在 Claude 或者任何支持 MCP 的编辑器里写完稿子,一句话就能让模型把 Markdown 渲染成带主题的微信 HTML,直接可贴。代码块高亮主题的 CSS 从远端拉取,设了 10 秒超时,还有个 assertAllowedCodeBlockThemeUrl 函数做域名校验,安全意识在线。
翻 issue 区你会发现这个方向不是拍脑袋。整个仓库评论数最高的开放 issue 是 #716,13 条评论,标题就是求一个 markdown 转微信格式的 API 接口,2021 年提的,一压就是好几年。MCP server 算是用 2026 年的方式把这个需求接住了,比 HTTP API 更贴合现在的工作流,AI 写稿,AI 排版,人只管贴。
它不管小红书,也别指望离线
当然,说这么多好话,得把丑话也说了。
它对微信的深度绑定是明牌。677 是微信的列宽,外链白名单是微信的规则,脚注格式是微信的排版惯例。一旦你拿它去发别的平台,issue 区的真实反馈就来了。#1749 的原话是「发布小红书很不漂亮」,#1856 反馈发头条时代码块样式错乱。名字里就带着微信,这不算隐瞒,但你要清楚,买到手的是一台微信专用编译器,别指望它通吃全平台。
还有两处外部依赖值得点名。云同步功能走的是 dcloud 的服务空间,md-cli 的参数表里 spaceId 和 clientSecret 两个配置暴露了这一点,想私有化部署完整功能,这个外部服务绕不开。内置的二维码组件 QRCodeBlock,图片是调 api.qrserver.com 这个外部接口实时生成的,断网环境里它会给你一个裂图。这两个点 README 都不会主动告诉你。
竞品也交代一下,坦白讲这个赛道很冷清。phodal 的 wechat-format 算先驱,早已停更。mdnice 的开源仓库几年没动静,重心转向了闭源线上产品。开源且持续活跃的,到今天基本就剩 doocs/md 一个。协议是 WTFPL,全称「你想干啥就干啥」许可证,比 MIT 还放得开,商用二开零心理负担。
给烂环境写编译器
我一直觉得这个项目最值得带走的,不是编辑器本身,是它面对烂环境的思路。可以给它起个名字,叫「为目标环境的最坏行为做编译」。
微信不会为任何工具改它的消毒策略,就像浏览器不会为你的旧代码停下升级,LLM 不会因为你期望它稳定就真的稳定。doocs/md 的选择是,既然改变不了渲染端,就把全部兼容成本压进编译期。class 提前展开成内联,外链提前折叠成脚注,marker 提前重画成 path,用户拿到的产物进了任何粉碎机都还能活。
这套思路眼下正好用得上。AI Agent 的输出端就是个新式粉碎机,格式漂移、上下文截断、字段幻觉,样样都洗你的产物。与其在运行时救火,不如在生成端加一道编译层,把约束固化成模板和校验,让产物天生合规。
下次你遇到一个不讲道理的目标平台,先别急着骂。
骂是情绪,编译器是资产。

评论互动