ByteNoteByteNote
Go 转 Markdown:变量名挡住了 html 包
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

Go 转 Markdown:变量名挡住了 html 包

API中转
¥120

一段 JavaScript 把 Markdown 收成可复制的 HTML:markdown-it 负责转标签,highlight.js 给代码上色,juice 把样式表收成内联 style。迁到 Go 时,库换成了 blackfriday、bluemonday、goquery 和 chroma。编译却停在两行很奇怪的报错:html.New 和 html.WithClasses 不存在,而且类型提示说 html 是 []byte。包明明导入了,调用的却是一块字节。

局部变量把 html 包名挡住了

名字被局部变量抢走了

转换函数里先有一行:

go
html := blackfriday.Run(markdown)
safe := bluemonday.UGCPolicy().SanitizeBytes(html)

:= 在这个函数里声明了一个叫 html 的字节切片。后面 chroma 的格式化器写的是 html.New 和 html.WithClasses。编译器不再去包路径里找 html,它看到的是离得最近的那个变量。字节切片没有这两个方法,于是报 undefined (type []byte has no field or method New)。

更早还有一次 undefined: blackfriday。那不是算法错了,是导入没写上,或者写了但没被用到之外的路径不对。blackfriday 要用 github.com/russross/blackfriday/v2。v2 的入口是 blackfriday.Run,不是 v1 的那组选项函数。导入路径和调用对上之后,这个名字错误会消失,剩下的就是变量把包名挡住。

修法是把格式化器的导入改名,变量也改名,两边不再抢同一个标识符:

go
chromahtml "github.com/alecthomas/chroma/formatters/html"

highlighted := blackfriday.Run(markdown)
formatter := chromahtml.New(chromahtml.WithClasses(true))

chromahtml 这个别名只存在于文件顶部。函数体内不要再声明同名变量。WithClasses(true) 表示高亮用 class,而不是在每个 span 上写死颜色。如果最终目标是内联样式,class 还得再走一遍收集,不能停在这一步就当完成。

净化、上色、再收成内联

JavaScript 那条链路的顺序值得照搬,不要为了少一次遍历打乱。先把 Markdown 变成 HTML。blackfriday 的输出当不可信内容处理,交给 bluemonday 的 UGCPolicy 洗一遍。用户写的 Markdown 里可以有链接和代码,不应该留下脚本。

上色发生在净化之后。goquery 找出带 language- 的 code 节点,用 chroma 的 lexer 按语言名切词,再写回 HTML。语言名对不上时退回纯文本,不要让一篇文章因为一块未知语言的代码整篇失败。

JavaScript 用 juice 把外链样式表并进标签。Go 这边最后一步是同一件事:读入正文样式和高亮主题的 CSS,套进一份完整文档,再把规则写到元素的 style 属性上。对话最后的要求更具体:结构改成 section,并且完全不要外部 CSS 引用。公众号或邮件客户端经常会丢掉 <link> 和 <style>,内联是唯一还能看见颜色的办法。

minify 可以放在写出文件之前,压掉空白。它不负责把 class 变成 style。如果只 minify、不做内联,粘贴到不执行样式表的编辑器里,代码块会回到单色。

读文件和写文件的错误要分开打印。输入 Markdown 不存在、样式表路径不对、输出目录不可写,是三件不同的事。样本里用了 ioutil.ReadFile,新版本标准库里等价函数在 os 包。行为一样,报错信息仍然要带上是哪一个文件。

不要把本机工具链路径写进文章或脚本

编译失败时,构建日志里会出现本机的 Go 工具链目录和 GOPATH。那些路径是机器配置,不是转换逻辑的一部分。别人照着日志去对目录,对不上很正常。值得留下的只有两行:undefined: blackfriday 说明导入,html.New undefined 且类型是 []byte 说明名字被局部变量遮住。

输出文件写成 output.html,权限用 0644 即可。不要在仓库里提交一份已经内联过的巨大 HTML 当源。源是 Markdown 和样式表,HTML 是生成物。

包名、变量名、内联是三道关

这段迁移真正卡住的不是 Markdown 语法,是 Go 的名字解析:函数里的 html := 比导入的包更近。改完别名之后,再按净化、高亮、内联的顺序走一遍。目标如果是不能加载外部样式的编辑器,最后一步必须把 CSS 写进 style,并且用 section 包住正文,而不是在 head 里留一个 <link>。

内联之后不应再依赖外链样式

高亮主题和正文样式是两份 CSS

JavaScript 原版同时读了正文样式和 highlight.js 的 atom-one-dark。Go 移植如果只内联正文样式,代码块的颜色类对不上,看起来像没高亮。chroma 换成 WithClasses 之后,类名不一定和 highlight.js 相同。要么让 chroma 直接输出内联颜色,要么确认两套类名一致再 juice。混用两套类名,再怎么内联也是空规则。

goquery 选择器写的是 code 上带 language- 的 class。markdown-it 和 blackfriday 对围栏代码的类名不一定都放在 code 上,有的放在 pre 上。选择器找不到节点时,函数不会报错,只是跳过,输出里就是未上色的代码。迁移后先用一篇带围栏的短文看输出,确认 pre 或 code 上真有语言类,再谈主题。

bluemonday 的 UGC 策略会删掉它不认识的属性。高亮如果先写成 style 再净化,颜色可能被洗掉。所以顺序是先净化 Markdown 产生的 HTML,再在代码节点上做高亮,最后才做整页内联。把内联放在净化前面,颜色留不住。

section 替换 body 里的文章容器时,不要把 html 和 head 都删掉再只留一个 section,除非目标编辑器明确只要片段。完整文档便于在浏览器里打开检查。给编辑器粘贴时,再取 section 的 innerHTML。对话的要求是结构用 section,并且不要外部引用。检查输出里没有 link 和 style 标签,只有 style 属性,才算 juice 那一步做完。

minify 对已经内联的 style 属性是安全的,它不会把属性拆回类名。但它和 goquery 都在内存里做,大文档会把整份 HTML 读进来。这是命令行工具的用法,不适合放进每个请求的热路径。请求里要转换时,限制输入大小,并避免每次都读磁盘上的主题文件,启动时读一次即可。

黑名单式的报错信息要保留文件名。样式表路径写错时,如果和 Markdown 读失败共用一句 Error reading file,排查会来回试两个文件。样本里三条读文件是分开的,这个习惯留下。写输出失败同样单独报,避免人以为转换逻辑错了,其实是目录权限。 写出文件之前,用浏览器打开一次完整文档,看代码块的颜色是否还在,再把 section 片段贴到不加载外链的编辑器里看第二次。两处都有颜色,内联才算成功。只有浏览器有颜色,说明规则还在 style 标签里,juice 那一步没做到位。

变量改名之后再全文件搜一次短名字 html。测试代码、闭包和稍后加的辅助函数里,还可能再声明一次。Go 的遮蔽按代码块计算,不是按文件。一个函数修好了,下一个函数仍会踩同一个报错。编译器给出的行号就是那一个块,不要只改第一次出现的地方。

blackfriday 的扩展如果要表格和围栏,用 v2 的选项打开,不要假设默认和 markdown-it 完全一致。默认差异会让同一份 Markdown 在两边的标签不同,goquery 的选择器跟着失效。先固定一份只有标题、段落和一段围栏代码的样例,两边输出对齐之后,再加净化策略。净化策略过严时,代码块里的 class 会被去掉,高亮选择器再次落空。这时放宽的是代码相关的属性,不是关掉整个净化。

相关文章

分享: