用 markdown-it 给公众号生成内联样式:表格、代码空格和链接脚注的几个细节
md-multi-format 是我写的一个小工具,把 Markdown 转成公众号、知乎能直接粘贴的 HTML。之前写过一篇讲"公众号会吞掉 class,所以要把样式全部内联",那篇说的是大方向。这篇说几个更细的地方:都是 markdown-it 自定义渲染规则时容易写错的点,有两个是我自己写出来又改掉的 bug,还有几个到现在都没处理,一并列出来。
下面的代码都来自仓库当前版本(src/core/render.js),markdown-it 版本 15.0.3。
一、给 token 加样式别用 attrJoin
内联样式的思路很直接:覆盖 paragraph_open、th_open、td_open 这些规则,渲染之前往 token 上塞一个 style 属性。最早我是这么写的:
md.renderer.rules[rule] = (tokens, idx, opts, env, self) => {
const t = tokens[idx]
if (inlineStyle && S[k]) t.attrJoin('style', S[k])
return self.renderToken(tokens, idx, opts)
}普通段落没问题,表格出事了。Markdown 表格写了对齐:
| 名称 | 数量 |
|:---:|---:|
| 苹果 | 3 |markdown-it 会自己给 th/td 加上 style="text-align:center"。attrJoin 的行为是:属性已存在就用空格把新值拼上去。于是生成了这样的东西:
<th style="text-align:center border:1px solid #d1d5db;padding:8px 10px;background:#ecfdf5;font-weight:700;text-align:left;">
<th style="text-align:right border:1px solid #d1d5db;padding:8px 10px;background:#ecfdf5;font-weight:700;text-align:left;">class 属性用空格拼是对的,style 用空格拼就成了 text-align:center border:1px solid ... 这一整条无效声明。我把这串 style 丢进 Chrome(156,headless)里,读 element.style.cssText:
padding: 8px 10px; background: rgb(236, 253, 245); font-weight: 700; text-align: left;第一条声明整个被丢掉了,丢的不只是对齐,还有紧跟在后面的 border。表头边框没了,居中也没了(后面主题自带的 text-align:left 反而生效)。td 那边更惨,主题里没有 text-align,text-align:right border:...;padding:8px 10px; 解析完只剩 padding: 8px 10px;。
这个问题不报错,预览时也不容易发现,只是"表格怎么没边框"。现在的写法是自己读、自己拼:
if (inlineStyle && S[k]) {
const own = (t.attrGet('style') || '').trim()
t.attrSet('style', S[k] + (own ? own.replace(/;?$/, ';') : ''))
}顺序也有讲究:主题样式放前面,markdown-it 自带的对齐放后面。同一个属性写两次,后面的生效,所以主题里 th 默认的 text-align:left 会被表格里写的对齐覆盖。现在的输出:
<th style="border:1px solid #d1d5db;padding:8px 10px;background:#ecfdf5;font-weight:700;text-align:left;white-space:nowrap;text-align:center;">own.replace(/;?$/, ';') 是为了保证拼接处有分号,不管原来那段结尾有没有。
仓库里还有一处 attrJoin:不转脚注的预览模式下给 link_open 加样式。markdown-it 不会给链接自带 style,那里拼不出问题,就没动。
二、宽表格包一层可以横向滚动的容器
手机上列多的表格会被挤成一个字一行。现在 table_open 渲染完会在外面包一层 section,table_close 补上闭合标签:
const out = self.renderToken(tokens, idx, opts)
if (inlineStyle && rule === 'table_open') return `<section style="${S.tableWrap}">` + outmd.renderer.rules.table_close = (tokens, idx, opts, env, self) =>
self.renderToken(tokens, idx, opts).replace(/\n$/, '') + '</section>\n'配套的样式:
tableWrap: `max-width:100%;overflow-x:auto;margin:16px 0;`,
table: `border-collapse:collapse;min-width:100%;margin:0;font-size:14px;word-break:normal;`,
th: `...;white-space:nowrap;`,
td: `...;min-width:3em;`,表头不换行、单元格给个最小宽度,表格宽了就让外层滚动。这在浏览器预览里是对的。公众号编辑器粘贴后会不会保留 section 上的 overflow-x,我这次没有重新实测,代码注释里写的是"保留"。如果你照着做,粘进去以后在手机预览里自己看一眼。
三、white-space:nowrap 会把行中间的连续空格合并
代码块最早的问题是公众号会吃掉换行和缩进,所以换行转 <br/>、行首空格转 。后来发现只处理行首不够。这段代码:
const a = 1 // 对齐
const bb = 22 // 对齐注释是用空格对齐的。早期版本输出是:
<span>const</span> a = <span>1</span> <span>// 对齐</span>1 后面三个普通空格。代码块的 code 样式在主题的 white-space:pre 后面又追加了 white-space:nowrap(后写的生效),而 nowrap 和默认的 normal 一样会合并连续空白,只是不换行。我在 Chrome 里量了一下同一行 x = 1 //c 的宽度:
white-space:pre,普通空格 94px
white-space:nowrap,普通空格 70px
white-space:nowrap, 94px四个空格在 nowrap 下只剩一个的宽度,对齐就乱了。所以现在每一行高亮完之后,把文本里所有空格都换成 :
export function preserveSpaces(line) {
return line.split(/(<[^>]*>)/).map((part, i) =>
(i % 2 ? part : part.replace(/\t/g, ' ').replace(/ /g, ' '))).join('')
}这里不能直接对整行 replace(/ /g, ' '),因为 highlight.js 输出里有 <span style="color:#xxx;"> 这样的标签,标签里的空格换掉就把 HTML 弄坏了。split 用带捕获组的正则,切出来的数组里奇数下标正好是标签,偶数下标是文本,只改文本部分。
现在同一段代码的输出:
<span>const</span> a = <span>1</span> <span>// 对齐</span><br/>(为了好看,我把 style 属性去掉了。)
四、链接转脚注:靠 token 的 markup 区分三种链接
公众号正文里的外链点不了,常见做法是把链接变成上标编号,文末列出地址。第一版的写法是每遇到一个 link_open 就编一个号,结果有两个问题:
看 [文档](https://a.com/x) 和 [再看](https://a.com/x),官网 https://b.com 和 <https://c.com>早期版本输出:
看 文档[1] 和 再看[2],官网 https://b.com[3] 和 https://c.com[4]
[1] https://a.com/x
[2] https://a.com/x
[3] https://b.com
[4] https://c.com同一个地址编了两次号;正文里本来就写着网址的,又在文末重复列一遍。
第二个问题的关键是:markdown-it 对这三种写法生成的都是 link_open token,区别在 token.markup:
[文字](url):markup是空字符串- 开了
linkify后识别出的裸网址:markup是'linkify' <https://...>尖括号自动链接:markup是'autolink'
后两种正文里已经显示了完整地址,不用再加脚注。去重就是在 env 上维护一个数组,用 indexOf 查编号:
md.renderer.rules.link_open = (tokens, idx, opts, env) => {
const t = tokens[idx]
const href = t.attrGet('href') || ''
env.links ||= []
env.linkStack ||= []
if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) { env.linkStack.push('a'); return `<a href="${escapeHtml(href)}"${st('a')}>` }
if (t.markup === 'linkify' || t.markup === 'autolink') { env.linkStack.push(''); return `<span${st('a')}>` }
let n = env.links.indexOf(href) + 1
if (!n) n = env.links.push(href)
env.linkStack.push(n)
return `<span${st('a')}>`
}
md.renderer.rules.link_close = (tokens, idx, opts, env) => {
const n = env.linkStack.pop()
if (n === 'a') return '</a>'
return n ? `</span><sup style="...">[${n}]</sup>` : '</span>'
}几个点:
link_close拿不到 href,也不知道这个链接分到了几号。早期版本用一个env.inlineLink布尔值标记,编号直接取env.links.length,不去重时这样凑巧是对的;去重以后编号不再等于数组长度,所以改成link_open时把"这个链接该怎么收尾"压栈,link_close弹出来。公众号自己的文章链接(mp.weixin.qq.com)是可以点的,保留<a>。- 状态放在
env上,不放在闭包变量里。env是每次md.render(src, env)传进来的,以后就算把md实例缓存起来复用,编号也不会串到下一篇文章去。 Array.prototype.push返回新长度,正好就是从 1 开始的编号。
现在的输出:
看 文档[1] 和 再看[1],官网 https://b.com 和 https://c.com
[1] https://a.com/x五、目前还没处理的几处
这些是我这次对着当前代码跑出来的,仓库里还没改。
页内锚点也被当成外链编号。 跳到 [第二节](#sec) 会生成 第二节[2],文末出现一行 [2] #sec,在公众号里没有任何意义。mailto: 同理。
链接文字就是网址本身时也会加脚注。 [https://e.com](https://e.com) 的 markup 是空字符串,走的是普通链接分支,结果正文和文末各出现一次同一个地址。
这两个可以在 link_open 里多判断一下:href 不是 http(s) 开头的不编号;下一个 token 是 text、再下一个是 link_close,并且文字和 href 相同,也不编号。我本地试了这个改法,原有 33 个用例都通过,上面那段的输出变成只剩 [1] https://a.com/x。不过它只覆盖"链接里只有纯文字"的情况,链接文字里有加粗之类就判断不到,所以还没合进去。
h5、h6 没有主题样式。 规则表里标题是按 t.tag 取样式的,主题只定义到 h4,##### 渲染出来就是一个光秃秃的 <h5>,到了公众号用的是编辑器默认样式。
没写语言的代码块会被自动识别。 缩进式代码块和没有语言标记的围栏代码块走 hljs.highlightAuto。我试了一段两行的 indented code / line2,它猜了一个语言,把 code 这个单词染了色。对纯文本、日志、命令输出来说,这种上色是干扰。比较稳的做法是没写语言就不高亮,只转义。
Tab 固定换成 4 个空格。 preserveSpaces 里 \t 直接替换成 4 个空格,不是按制表位对齐。行首缩进没问题,行中间用 Tab 对齐的内容会错位。
小结
- markdown-it 的
attrJoin用空格拼接,适合 class,不适合 style。往已有 style 的 token 上追加样式,用attrGet+attrSet自己拼,注意分号和覆盖顺序。 - 样式出错时浏览器不报错,只是静默丢掉整条声明。拿
element.style.cssText看解析结果最直接。 white-space:nowrap也会合并连续空白。代码块要么用pre,要么把空格都转成 ,转的时候避开标签内部。- 区分普通链接、裸网址和尖括号链接,看
link_open的markup;跨 open/close 的状态用栈,放在env上。