让 Chrome 直接渲染 Markdown:零点击安装 md2html 阅读扩展的完整指南
> 适用环境:Linux(Ubuntu 系)+ Google Chrome 149。目标是让浏览器打开 .md 文件时,呈现和 md2html.py 生成的 HTML 一模一样的阅读效果,并且全程零手动点击完成安装。
一、问题背景
我平时用 md2html.py 把 Markdown 转成 HTML(支持 Mermaid 结构图、WaveDrom 时序图、GitHub 风格蓝色主题),但每次看一篇 .md 都要先跑一遍转换,很麻烦。我想要的是:在 Chrome 里直接打开 .md,立刻就是 HTML 那种好看的排版。
方案很自然:写一个 Chrome 扩展,用 content script 拦截 .md 页面,把 Markdown 现场渲染成 HTML。
难的不是渲染(markdown-it 一行搞定),难的是 Chrome 149 已经把「程序化安装未打包扩展」的所有路子都封死了。这篇文章重点讲清楚:扩展怎么写、渲染怎么对齐、以及最后怎么用「企业策略强制安装」实现零点击。
二、扩展本体:content script + markdown-it
扩展目录结构:
chrome-md-viewer/
├── manifest.json
├── renderer.js # 渲染逻辑
├── vendor/markdown-it.min.js # 本地打包,离线可用
└── icons/
2.1 manifest.json
{
"manifest_version": 3,
"name": "MD Viewer (md2html)",
"version": "1.0.0",
"description": "在 Chrome 里以 md2html 的 HTML 效果渲染 .md 文档",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"content_scripts": [
{
"matches": [
"file:///*",
"http://*/*.md",
"https://*/*.md",
"http://*/*.markdown",
"https://*/*.markdown"
],
"js": ["vendor/markdown-it.min.js", "renderer.js"],
"run_at": "document_idle"
}
]
}
两个关键点:
file:///*必须写成三个斜杠(file URL 的 host 为空),否则匹配不到本地文件。.md的过滤放在renderer.js里做(file:///*会匹配所有本地文件,用location.pathname过滤),因为 match pattern 里*是否跨/的行为有歧义。
2.2 renderer.js 核心逻辑
只处理「原始文本」页面(排除 GitHub 已经渲染好的 HTML 页),读原文 → markdown-it 转 HTML → 替换 body → 注入 CSS + Mermaid/WaveDrom。
(function () {
'use strict';
// 只处理 markdown 后缀 + 非 HTML 页面
if (!/\.(md|markdown|mdown)$/i.test(location.pathname)) return;
const ct = (document.contentType || '').toLowerCase();
if (ct === 'text/html' || ct === 'application/xhtml+xml') return;
// Chrome 打开 text/plain 时把内容放在 <pre> 里,textContent 能精确还原文件内容
const raw = document.body.textContent;
if (!raw || !raw.trim()) return;
const markdownit = window.markdownit;
if (!markdownit) return;
// ── 标题 id 生成(与 md2html.py 的 make_heading_id 完全一致)──
function makeHeadingId(text) {
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [text](url) -> text
let c = text.replace(/[^\p{L}\p{N}_\s-]/gu, ''); // 保留 Unicode 单词字符
c = c.replace(/\s+/g, '-').replace(/-+/g, '-');
return c.replace(/^-+|-+$/g, '');
}
// ── markdown-it 配置(对齐 md2html.py 行为)──
const md = new markdownit({
html: true, // 裸 HTML 透传(md2html.py 不全局转义)
breaks: true, // 段内换行 -> <br>
linkify: false,
typographer: false
});
md.core.ruler.push('heading_ids', function (state) {
const t = state.tokens;
for (let i = 0; i < t.length; i++) {
if (t[i].type !== 'heading_open') continue;
let text = '';
for (let j = i + 1; j < t.length && t[j].type !== 'heading_close'; j++)
if (t[j].type === 'inline') text += t[j].content;
t[i].attrSet('id', makeHeadingId(text));
}
});
// 围栏代码块:mermaid / wavedrom 特殊处理,其余转义
const CODE_LANGS = ['bash', 'python', 'json', 'verilog', 'diff', 'markdown', 'c', 'cpp'];
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
md.renderer.rules.fence = function (tokens, idx) {
const tok = tokens[idx], lang = (tok.info || '').trim().split(/\s+/)[0] || '';
const c = tok.content;
if (lang === 'mermaid') return '<div class="mermaid">\n' + c + '\n</div>';
if (lang === 'wavedrom') return '<script type="WaveDrom">\n' + c + '\n</script>';
if (CODE_LANGS.indexOf(lang) !== -1)
return '<pre><code class="language-' + lang + '">\n' + esc(c) + '\n</code></pre>';
return '<pre><code>\n' + esc(c) + '\n</code></pre>';
};
md.renderer.rules.table_open = () => '<table class="md-table">\n';
md.renderer.rules.image = function (tokens, idx) {
const tok = tokens[idx];
return '<img src="' + esc(tok.attrGet('src') || '') + '" alt="' + esc(tok.content || '')
+ '" style="max-width:100%;margin:16px 0;">';
};
const rendered = md.render(raw);
// 注入 CSS(原样从 md2html.py 的 <style> 提取),替换 body,右下角加「源码/渲染」切换按钮……
})();
2.3 一个隐蔽的坑:隔离世界(isolated world)
content script 运行在隔离世界里——它和页面共享 DOM,但 JS 全局对象是分开的。所以把 <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"> 注入页面后,window.mermaid 挂在主世界,content script 里访问 window.mermaid 会是 undefined。
解决办法:图表的初始化用「内联 <script>」注入主世界,而不是在 content script 里直接调 window.mermaid:
const DIAGRAM_INIT = [
'(function () {',
' if (window.mermaid) {',
' try {',
' window.mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "loose" });',
' window.mermaid.run({ querySelector: ".mermaid" }).catch(function () {});',
' } catch (e) {}',
' }',
' if (window.WaveDrom) { try { window.WaveDrom.ProcessAll(); } catch (e) {} }',
'})();'
].join('\n');
function injectDiagramInit() {
const s = document.createElement('script');
s.textContent = DIAGRAM_INIT; // 内联脚本在主世界执行
document.body.appendChild(s);
}
这个坑不踩一次,Mermaid 图永远不显示。
三、渲染对齐 md2html.py 的几个细节
要让「阅读效果和 HTML 一模一样」,有四点要抠:
html: true:md2html.py只对代码块做escape,正文里的裸 HTML 是透传的,所以 markdown-it 要开html。- 标题 id 算法:中文标题也要正确生成锚点。JS 的
\w只匹配 ASCII,必须换成[\p{L}\p{N}_]并加u标志,否则中文标题的 id 会算错。 - 表格 class:
md2html.py输出<table class="md-table">,需要覆盖table_open。 - CSS 原样复用:直接拷贝
md2html.py里<style>...</style>的内容,保证字体、配色、代码块、表格边框完全一致。
四、安装困境:Chrome 149 把自动安装封死了
扩展写好只是第一步,真正的难点是装进去。我实测了所有「程序化安装」的路子,全部失败:
| 方案 | 结果 |
|---|---|
--load-extension |
Chrome 137 起从正式版移除 |
--remote-debugging-port(默认 profile) |
Chrome 136+ 报错 DevTools remote debugging requires a non-default data directory |
CDP Extensions.loadUnpacked |
能加载、能渲染,但冷重启后不加载(不持久) |
直接改 Preferences 移植条目 |
启动时被 Chrome 清理(location=4 未打包条目) |
结论:正式版 Chrome 只认两条路——要么手动点「加载已解压的扩展程序」,要么走企业策略强制安装。零点击只有后者。
五、破局:企业策略强制安装(force_installed)
原理:Chrome 企业版支持 ExtensionSettings 策略,可以声明「某个扩展必须强制安装,从某个 URL 拉取」。于是只需要:
- 把扩展打包成 CRX3;
- 本机起一个 HTTP 服务发
update.xml+ext.crx; - 写一条 策略指向这个服务;
- Chrome 启动时自动拉取安装,全程零点击,且装完用户无法删除、Chrome 会自动重装。
5.1 打包 CRX3
# 首次打包会自动生成 .pem 签名密钥(务必保存,升级要用它,否则扩展 ID 会变)
google-chrome --pack-extension=/path/to/chrome-md-viewer --pack-extension-key=/path/to/chrome-md-viewer.pem
生成 chrome-md-viewer.crx(CRX3 格式)和 chrome-md-viewer.pem(私钥)。
import hashlib
der = open('pubkey.der', 'rb').read() # 公钥 DER(openssl rsa -pubout -outform DER)
h = hashlib.sha256(der).digest()
ext_id = ''.join(chr(97 + ((b >> 4) & 0x0F)) + chr(97 + (b & 0x0F)) for b in h[:16])
print(ext_id) # 例如 bgcnhiaopbeaneccbkahfhfahdjdanjn
即:SHA256(公钥DER) 取前 16 字节,每个字节拆成「高半字节 + 低半字节」各映射到 a-p,共 32 字符。
5.2 本地 HTTP 分发服务
Chrome 的组件更新器要拉 update.xml,其中 codebase 指向 CRX。CRX 必须以 application/x-chrome-extension 的 Content-Type 提供。
dist/update.xml:
<?xml version='1.0' encoding='UTF-8'?>
<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>
<app appid='bgcnhiaopbeaneccbkahfhfahdjdanjn'>
<updatecheck codebase='http://127.0.0.1:9382/ext.crx' version='1.0.0' />
</app>
</gupdate>
dist-server.py(绑 127.0.0.1,只发 localhost):
#!/usr/bin/env python3
import http.server, socketserver
DIST_DIR = '/home/huamingh/tools/chrome-md-viewer/dist'
PORT = 9382
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **k):
super().__init__(*a, directory=DIST_DIR, **k)
def guess_type(self, path):
if path.endswith('.crx'): return 'application/x-chrome-extension'
if path.endswith('.xml'): return 'text/xml'
return super().guess_type(path)
def log_message(self, *a): pass
class Server(socketserver.TCPServer):
allow_reuse_address = True
if __name__ == '__main__':
with Server(('127.0.0.1', PORT), Handler) as httpd:
httpd.serve_forever()
用 systemd 用户服务让它自启(~/.config/systemd/user/mdviewer-dist.service):
[Unit]
Description=MD Viewer extension distribution server (localhost)
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/huamingh/tools/chrome-md-viewer/dist-server.py
Restart=on-failure
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now mdviewer-dist.service
5.3 写策略
/etc/opt/chrome/policies/managed/mdviewer.json:
{
"ExtensionSettings": {
"bgcnhiaopbeaneccbkahfhfahdjdanjn": {
"installation_mode": "force_installed",
"update_url": "http://127.0.0.1:9382/update.xml"
}
}
}
重启 Chrome,组件更新器就会自动拉取并安装,扩展出现在 chrome://extensions(带「由企业政策管理」标记)。
5.4 开启 file:// 访问
扩展要渲染本地 file:// 的 .md,需要「允许访问文件网址」。这个开关没有对应的企业策略,只能改 Preferences:
# 先完全退出 Chrome,再合并写入(file_access 字段名是 newAllowFileAccess,不是 file_access)
python3 -c "
import json
p = '$HOME/.config/google-chrome/Default/Preferences'
d = json.load(open(p))
d['extensions']['settings']['bgcnhiaopbeaneccbkahfhfahdjdanjn']['newAllowFileAccess'] = True
json.dump(d, open(p, 'w'), ensure_ascii=False, indent=2)
"
> 如果你的 .md 本来就通过 HTTP 访问(比如我有个 fileserver.py 在 8080 端口服务这些文件),那么连 file 访问都不用开——content script 的 http://*/*.md 匹配直接生效。
六、踩坑记录(每一条都实测过)
--load-extension没了:Chrome 137 起移除,别浪费时间。- 远程调试被禁:默认 profile 上
--remote-debugging-port直接被忽略,报错要求非默认数据目录。 - CDP 加载不持久:
Extensions.loadUnpacked当场能用,冷重启后就不加载了(哪怕开了开发者模式)。 - 字段名是
newAllowFileAccess:不是老文档里的file_access,写错了(还加了旧字段)Chrome 会直接把条目清掉。 - 隔离世界:图表初始化必须用内联脚本注入主世界。
- ID 算法:
SHA256(公钥DER)[:16]逐字节拆高低半字节映射 a-p。
七、总结
- 渲染用
content script + markdown-it,对齐md2html.py的 CSS、标题 id、mermaid/wavedrom。 - 安装用
企业策略 force_installed + CRX3 + localhost 分发服务,绕开 Chrome 149 对未打包扩展的封锁。 - file 访问改
Preferences的newAllowFileAccess。
整套下来零点击、持久、用户删不掉。扩展要升级时:改源码 → 用同一个 .pem 重新打包 → 覆盖 dist/ext.crx → 改 update.xml 版本号 → 重启 Chrome 自动更新。
如果你也受够了「看个 Markdown 还要先转一遍 HTML」,这套方案可以直接照抄。