一个 Go 写的 Markdown 工具,由两个平级入口组成:
| 入口 | 状态 | 做什么 |
|---|---|---|
mdreader 网页阅读器 |
✅ 已交付 | 只读。指向一个 Markdown 目录,在浏览器里读 |
mdeditor 本地编辑器 |
✅ 已交付 | 编辑与实时预览,桌面窗口 |
两者共用同一个渲染内核,但互不依赖 —— 只想读的人不必安装编辑器。这是产品的核心取舍, 写进了项目宪法的第一条原则。
# 一条命令装好(需要 Go 1.25+)
go install github.com/yanking/mdeditor/cmd/mdreader@latest
# 本机模式:绑定回环,不需要令牌,自动开浏览器
mdreader ~/notes从源码构建:
git clone https://github.com/yanking/mdeditor && cd mdeditor
make build # 产物在 bin/mdreader,14M 单文件
make install # 装到 ~/.local/bin
make help # 看全部目标打开后你会看到三栏:左边是文档目录树,中间是正文,右边是当前文档的大纲。 顶栏可以切主题(浅色/深色/跟随系统)和调字号(五档),选择会被记住。
左边写,右边看,改一个字预览就跟着变。
go install github.com/yanking/mdeditor/cmd/mdeditor@latest
mdeditor ~/notes它不动你没动过的东西。 CRLF 行尾不会被顺手转成 LF,缺失的尾随换行不会被补上, BOM 不会被去掉,文件权限保持原样。这条听起来像洁癖,实际决定了它能不能用在 版本控制的目录里 —— 一个「顺手规范化」的编辑器会把「改了一行」的提交变成全文件改动, code review 就没法做了。
保存是原子的:先写同目录临时文件、Sync 落盘、还原权限,最后 rename 顶替。 中途断电或被 kill,你拿到的要么是完整的旧版本,要么是完整的新版本,不会是半个文件。
编辑期间文件被外部改了(另一个编辑器、git checkout、同步工具),保存时会停下来问, 给你「用我的覆盖 / 重新加载磁盘版本 / 取消」三条路 —— 它不会替你选。
全程离线,不外发任何内容。 公式用的 KaTeX 是编译进二进制的,不是 CDN;
断网时公式、高亮、预览、保存全都照常。这一条有测试守着(tests/e2e-editor/offline.mjs
拦截浏览器发出的每一个请求,目的地不是本机的一律算失败)。
写入边界就是你指定的那个目录,之外的文件一个字节都不会变(readonly_boundary_test.go
用全目录快照对比来验证这件事,而不是靠「代码里没有多余的写调用」)。
编辑器有两种形态,能力完全相同,差别只在界面由谁承载:
| 形态 | 怎么跑 | 运行时前提 |
|---|---|---|
| 桌面 | mdeditor ~/notes |
平台原生 webview(见下表) |
| 降级 | mdeditor-server ~/notes 后用浏览器打开 |
无,纯静态单文件 |
降级形态不是阉割版:文件管理、冲突处理、主题字号、离线公式,一样不少。 装不上桌面运行时、或者在服务器和容器里用,就用它。
从源码构建降级形态:make editor-server(CGO_ENABLED=0,可交叉编译到全部平台)。
桌面形态用系统自带的 webview 而不是自带一份 Chromium,二进制因此只有十几兆而不是一百多兆。 代价是各平台要有对应的运行时:
| 平台 | 需要什么 | 怎么装 |
|---|---|---|
| macOS 11+ | 无 | 系统自带 WKWebView |
| Windows 10/11 | WebView2 Runtime | Win11 自带;Win10 从 微软官方页面装 |
| Linux | WebKitGTK 6.0 + GTK 4 | 见下 |
# Debian / Ubuntu 24.04+
sudo apt install libwebkitgtk-6.0-4 libgtk-4-1
# Fedora
sudo dnf install webkitgtk6.0 gtk4
# Arch
sudo pacman -S webkitgtk-6.0 gtk4从源码构建桌面形态还需要对应的 -dev / -devel 包(Debian 系是
libwebkitgtk-6.0-dev libgtk-4-dev)。
老一些的发行版只有 WebKitGTK 4.1,可以用 go build -tags gtk3 回退。
如果这些都嫌麻烦 —— 直接用降级形态,它什么都不需要。
| 操作 | macOS | Windows / Linux |
|---|---|---|
| 保存 | ⌘S | Ctrl+S |
| 新建 | ⌘N | Ctrl+N |
| 打开目录 | ⌘O | Ctrl+O |
| 关闭窗口 | ⌘W | Ctrl+W |
| 字号增减 | ⌘+ / ⌘- | Ctrl++ / Ctrl+- |
菜单项不自己实现功能,只是转发给界面里已有的那条路径 —— 所以降级形态下菜单没了, 功能在顶栏和快捷键上照样都在。
- 基础语法:CommonMark + GFM(表格、任务列表、删除线、脚注、自动链接)
- 代码高亮:服务端完成,配色跟随主题切换
- 数学公式:行内
$...$与块级$$...$$,KaTeX 内嵌在二进制里,断网照样显示 - 图片:本地相对路径与远程地址,自动懒加载
- 视频:
直接渲染成播放器;原始<video>标签也支持,可拖动进度条 - 第三方内嵌:文档里的任意
<iframe>都会渲染,不设域名白名单
相对路径以文档所在目录为基准解析,所以 guide/intro.md 里写 
指的是 guide/pic.png。
MDREADER_TOKEN='一个至少16位的随机串' ./bin/mdreader --listen 0.0.0.0:7654 /srv/docs把打印出来的 http://<地址>:7654/?token=... 发给别人,对方点开就能读,不用注册也不用装东西。
绑定非回环地址时必须提供令牌,否则拒绝启动。没有 --no-auth 之类的绕过开关。
公网模式是一组明确的取舍,不是安全疏漏。用之前请确认你接受这三点:
- 根目录即公开边界。 令牌通过之后没有细粒度权限 —— 根目录里的每个文件都对持链接者可见。 不该被看到的内容不要放进去。
- 令牌随 URL 传递。 那条初始链接会留在浏览器历史和中间代理日志里。
我们做了两道缓解:所有响应带
Referrer-Policy: no-referrer(否则页面里每个第三方 iframe 和远程图片都会把带令牌的完整 URL 经 Referer 发给对方),以及首次访问后换发 HttpOnly Cookie 并把 URL 里的令牌清掉。剩下没法消除的只有那条初始链接本身 —— 换--token可让旧链接立即失效。 - 内嵌不设白名单。 第三方页面会拿到访问者的 IP 与浏览器信息,其内部行为不受本产品控制。 我们能保证的是它拿不到阅读器这一侧的任何东西,以及读者随时能一键关掉。
文档常常来自不受信任的来源,所以渲染管线是三层围栏:
- 净化层:剥离文档自带的
<script>、on*事件属性、javascript:与data:text/html。 - iframe 围栏:每个内嵌强制沙箱加载,不给
allow-top-navigation; 与阅读器同源的内嵌额外去掉allow-same-origin(那才是沙箱逃逸的组合); 一律referrerpolicy="no-referrer"。 - CSP 兜底:
script-src 'self'+ 单条内联脚本的 sha256 白名单。 万一净化层将来出现绕过,浏览器也不会执行漏网的脚本。
内嵌可以一键全部关闭。关闭时 src 会被摘到 data-src —— 真的阻止请求,
而不是拿 CSS 把它藏起来(被隐藏的 iframe 照样联网)。
mdreader [flags] <root-dir>
| Flag | 默认 | 说明 |
|---|---|---|
--listen |
127.0.0.1:7654 |
绑定回环 = 本机模式;其他地址 = 公网模式 |
--token |
空 | 公网模式必填。也可用 MDREADER_TOKEN(推荐,不进 shell 历史) |
--open |
true |
启动后自动开浏览器(仅本机模式) |
--theme |
system |
首次访问的默认主题:light / dark / system |
--highlight |
github |
代码高亮样式(chroma 样式名) |
--cache-mb |
64 |
渲染缓存上限 |
--max-doc-mb |
50 |
单篇渲染上限。超过则拒绝并说明,绝不静默截断 |
超大文档分三档:5MB 以内正常渲染并缓存;5MB 到上限之间完整渲染但跳过缓存 (一篇 30MB 的文档不该把整个缓存挤空);超过上限拒绝渲染并显示实际大小。 内容要么完整呈现,要么明确拒绝 —— 假装成功后给出半篇文章是最坏的结果。
mdeditor [flags] <root-dir>
| Flag | 默认 | 说明 |
|---|---|---|
--highlight |
github |
代码高亮样式(与阅读器同一套,两端配色因此必然一致) |
--max-doc-mb |
50 |
单篇打开上限 |
--version |
— | 打印版本号 |
<root-dir> 既是能看到的范围,也是写入边界 —— 编辑器不会碰它之外的任何文件。
主题、字号、布局与窗口位置存在系统配置目录(os.UserConfigDir()/mdeditor/prefs.json),
不写进你的文档目录。
降级形态多一个环境变量:WAILS_SERVER_PORT 指定监听端口。
bash scripts/gate.sh # 合并门禁:9 步(格式、vet、lint、测试、基准、结构、只读、降级形态、薄外壳)
go test ./... # 含黄金样例、保真、原子性、冲突、只读边界
CHROME=<chrome路径> node tests/e2e/run.mjs # 阅读器浏览器行为测试(48 条)
CHROME=<chrome路径> node tests/e2e-editor/run.mjs # 编辑器端到端(80 条,含离线与不外发)编辑器的端到端测试跑在降级形态上:桌面形态需要真实 webview 与显示服务, 无头环境里驱动不了。这不是将就 —— 降级形态承载了 FR-001~042 的全部能力, 所以在它上面跑通覆盖的正是产品的绝大部分。桌面独有的那几项 (原生窗口、菜单、关窗拦截、多显示器、DPI)在 人工验收清单里,三平台各走一遍。
渲染行为由 testdata/golden/ 下的样例定义,而不是由实现定义。
改渲染内核时先写样例、看它失败、再动实现。
go test ./internal/render -run TestGolden -update 能重新生成期望文件,但它是便利工具,
不是免责声明 —— 跑完直接提交、让 diff 无人过目,等于让实现自己定义正确性。
每一处 diff 都要能解释。
internal/render 是全仓库唯一的 Markdown 解析与渲染实现。
internal/server、internal/config、internal/editor 与 cmd/ 不得直接 import
goldmark / chroma / bluemonday —— 门禁里有一条静态检查守着。
两个入口的渲染结果因此是结构性一致的,而不是靠两边同步维护:
tests/parity/ 拿 11 个黄金样例逐字节比对阅读器与编辑器的输出。
Wails 只能出现在 cmd/mdeditor,internal/ 下一律不许 import。
门禁会把整个 cmd/mdeditor 移走再编译一次 internal/... —— 编不过就说明外壳不够薄。
这既缓解 Wails v3 beta 期的 API 变动风险,也是降级形态能与桌面形态共用同一份业务代码的前提。