引言:一个本地优先者的痛点
作为长期践行本地优先(local-first)哲学的开发者,我始终相信:数据应当由自己完全掌控,工具应当简单可靠,且不应依赖不可控的外部服务。但在日常的 Markdown 文档管理中,一个棘手的问题始终困扰着我。
我的文档散落在多个目录中——有的在工作区,有的在个人笔记文件夹,还有的分布在不同的项目中。随着时间推移,文档数量激增,搜索变得越来越困难。更糟糕的是,文档之间的内部链接频繁失效,却没有任何工具帮我提前发现。我尝试过各种方案:Obsidian 的插件、Notion 的搜索、甚至自建了一个基于 ElasticSearch 的索引服务,但它们都有各自的痛点——要么是 SaaS 依赖,要么是需要维护额外的基础设施,要么是功能过于臃肿。
问题的核心其实很简单:如何在纯本地环境中,构建一个可查询、可检查断链的 Markdown 文档数据集?
于是,markdown-library 诞生了。它是一个本地优先的 Markdown 只读索引服务,扫描仓库里的 .md 文档,构建可搜索的索引,并支持断链检查。这篇文章将分享这个项目背后的几个关键设计决策——从技术栈选择到安全机制,每一个选择都是"直觉做法 → 实际做法 → 为什么更好"的思考结果。
速览(TL;DR)
- 本地优先的只读索引:扫描多目录
.md,构建可查询、可检查断链的文档数据集,绝不修改源文件。 - 零依赖:核心依赖仅
axum/rusqlite/walkdir/blake3/regex,Markdown 解析与 HTML 渲染全部自包含实现。 - 三层标签:frontmatter ∪ 自动(
auto_tag启发式)∪ 手动(独立manual_tags表,重扫不丢)。 - 安全闭环:后端零依赖 regex 渲染器内嵌 XSS 加固;
/docs/{id}/file与/docs/{id}/html用 blake3 签名短时 token(id 绑定 / TTL 3600s / 常量时间比较)。 - 纯本地、不传云,单二进制即可运行。
第一节:出发点——为什么是 Rust 且零依赖
直觉做法
一开始,我的直觉是选择 Python 或 Go 来实现这个工具。这两个语言都有成熟的 Markdown 处理生态,可以快速搭建原型。毕竟,快速实现、尽快看到效果,是大多数开发者的第一反应。
实际做法
最终,我选择了 Rust,并且坚持了"零依赖"的设计哲学——除了框架级别的库,不引入任何第三方依赖。
项目的核心依赖只有五个:axum 提供 Web 服务,rusqlite 作为数据存储,walkdir 遍历文件系统,blake3 生成文档哈希,regex 处理文本匹配。其余的逻辑,包括 Markdown 解析和 HTML 渲染,全部自包含实现。
为什么更好
这个选择带来了几个关键优势:
单二进制部署,无运行时依赖。最终产物是一个静态链接的 Rust 二进制文件,可以复制到任何 Linux/macOS/Windows 机器上直接运行,不需要安装 Python、Node.js 或任何包管理器。对于本地优先的用户来说,这意味着"下载即用,无需配置"。
SQLite 作为唯一数据源。整个索引系统只使用一个 SQLite 数据库文件,避免了多进程写入的复杂性。通过 WAL(Write-Ahead Logging)模式,搜索和写入操作可以并发执行,互不阻塞。
不引入 Markdown 解析 crate。Markdown 解析和 HTML 渲染逻辑全部自包含。虽然这看起来像是"重复造轮子",但实际上它让我们能够精确控制输出的 HTML 结构,并在同一套代码中嵌入安全加固逻辑。
第二节:踩坑——只读设计的边界在哪里
踩坑经历与反思
这个设计曾让我"踩坑"。有用户反馈:"我想修改文档的标签,为什么索引不更新源文件?"起初我以为这是个 bug,但深入思考后,我意识到这恰恰是设计的亮点。
只读设计保证了数据源的真实性和可追溯性。如果索引可以修改源文件,那么当索引损坏或数据不一致时,用户将无法恢复原始状态。而只读设计确保了:
- 源文件始终可用,不受索引操作影响
- 可以通过重新扫描来重建索引
- 索引中的任何错误数据都可以被清除并重新生成
第三节:调整——三层标签系统的独立层设计
为什么这样好
这种分层设计带来了显著的优势:
独立性。manual_tags 表独立存活,即使用户执行了全量 rescan,手动添加的标签也不会丢失。auto 标签可以随时 recompute,而不会影响 frontmatter 和 manual 标签。
聚合展示。在 /api/tags 接口中,三层标签被聚合展示,但保留了来源标识,让用户清楚每个标签的出处。
灵活性。用户可以基于任何一层标签进行搜索和过滤,系统也会根据标签来源给出不同的权重。
自动标签由 auto_tag.rs 在扫描时启发式生成,核心逻辑是把"目录段"和"正文结构"转成标签——目录段直接成为分类标签,正文里的代码语言、图片、表格、TODO、数学公式等则生成对应的结构标签:
pub fn auto_tags(relative_path: &str, body: &str, frontmatter_tags: &[String]) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
// 1) 目录段(去掉文件名后的各级父目录)成为标签
let segs: Vec<&str> = relative_path.split('/').filter(|s| !s.is_empty()).collect();
if segs.len() > 1 {
for seg in &segs[..segs.len() - 1] {
let slug = slugify(seg);
if !slug.is_empty() {
set.insert(slug);
}
}
}
// …(正文结构标签:code / lang:* / image / link / table / todo / math)
// 4) 去掉 frontmatter 已有的,避免重复展示
let fm: std::collections::HashSet<String> =
frontmatter_tags.iter().map(|t| t.to_ascii_lowercase()).collect();
let out: Vec<String> = set.into_iter().filter(|t| !fm.contains(t)).collect();
out
}
最终 tags 表是 frontmatter ∪ 自动 的并集,而手动标签独立存于 manual_tags 表,三层互不污染。
第四节:验证——XSS 加固的自渲染器
验证方式
为了确保渲染器的安全性,我设计了一系列 XSS 测试用例:
这些测试验证了渲染器能够正确处理恶意的 Markdown 输入,生成的 HTML 不会包含可执行的脚本。加固的关键在 md_render.rs:文本先 escape_html,链接 URL 经 sanitize_url 校验——先还原 HTML 实体再判协议,否则 javascript:... 这类编码能绕过字面量检查;未知协议(javascript: / data: / file:)一律替换为 #,仅放行 http / https / mailto,相对链接与锚点原样保留:
fn sanitize_url(url: &str) -> String {
let t = url.trim();
// 先还原实体再判协议:否则 `javascript:...` 等编码能绕过字面量方案检查。
let lower = decode_entities(t).to_ascii_lowercase();
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("mailto:")
{
t.to_string()
} else if has_scheme(&lower) {
"#".to_string() // 未知协议(javascript:/data:/file:)一律替换为 #
} else {
t.to_string() // 相对/锚点链接原样保留
}
}
第五节:结果——签名 token 的安全闭环
浏览器"新标签打开原文 / 预览"无法为跨源请求附带自定义头,所以 /docs/{id}/file 与 /docs/{id}/html 不能用 x-api-key 鉴权。markdown-library 改用一个后端签发的查询参数 token:blake3 keyed-hash,TTL 3600s,id 绑定。make_token 把 {id}:{exp} 用密钥做 keyed-hash,verify_token 在校验签名的同时检查过期与 id,并用常量时间比较防时序攻击:
const TOKEN_TTL_SECS: u64 = 3600;
pub(crate) fn make_token(id: u64, secret: &[u8; 32]) -> String {
let exp = now_secs() + TOKEN_TTL_SECS;
let payload = format!("{id}:{exp}");
let hash = blake3::keyed_hash(secret, payload.as_bytes());
format!("{exp}.{}", to_hex(hash.as_bytes()))
}
pub(crate) fn verify_token(id: u64, token: &str, secret: &[u8; 32]) -> bool {
let Some((exp_str, mac)) = token.split_once('.') else { return false; };
let Ok(exp) = exp_str.parse::<u64>() else { return false; };
if exp <= now_secs() { return false; }
let payload = format!("{id}:{exp}");
let hash = blake3::keyed_hash(secret, payload.as_bytes());
constant_time_eq(&to_hex(hash.as_bytes()), mac) // id 绑定 + TTL + 常量时间比较
}
API_KEY 留空时本地免鉴权直接放行;一旦设置密钥,/file 与 /html 才会要求这个签名 token,从而在不暴露 x-api-key 的前提下守护私密文档。
markdown-library 的实现过程让我深刻体会到:本地优先的工具不仅需要"能用",更需要"可靠"。从零依赖的 Rust 实现,到只读设计哲学,再到三层标签系统和 XSS 加固渲染器,每一个决策都是在"直觉做法"和"实际做法"之间反复权衡的结果。希望这篇文章能为你构建自己的本地工具提供一些参考。
常见问题(FAQ)
markdown-library 会修改我的源 Markdown 文件吗?
不会。整个服务是只读索引,所有标签、预览 HTML 都只存在于 SQLite 索引库;源文件始终不被触碰,重扫即可重建索引。
问:为什么不用现成的 Rust Markdown 库(如 comrak / pulldown-cmark)?
答:为了零依赖与自包含渲染。自写 renderer 让我们能在同一套代码里内嵌 XSS 加固(escape_html + sanitize_url),并且离线 / crates.io 不可达时仍能顺利编译。代价是功能更克制,但足够覆盖预览与断链检查。
问:标签有几层?手动加的标签重扫会丢吗?
答:三层。frontmatter 标签、auto_tag.rs 启发式自动标签(目录段 / 代码语言 / 图片 / 表格 / TODO / 数学等)、以及独立的 manual_tags 表。手动标签存在独立表里,全量 rescan 不会丢失,可单独编辑。
问:/file 与 /html 接口怎么鉴权?
答:浏览器新标签打开无法带自定义头,所以不用 x-api-key,而是后端签发的查询参数 token:blake3 keyed-hash、id 绑定、TTL 3600s、常量时间比较。设了 API_KEY 才启用,空 key 时本地免鉴权。
问:多根目录扫描遇到同名文件会冲突吗?
答:不会。索引表用 UNIQUE(base_path, relative_path) 复合约束,不同扫描根下的同名 README.md 各自独立;旧版单字段 UNIQUE 已在启动时原子迁移。
问:怎么运行?
答:cargo run(默认 3100)即同时托管 frontend/dist 仪表盘与 API;开发态也可用 npm run dev(5173)。API_KEY 留空则本地免鉴权,设密钥后走签名 token。
源码导航
- src/auto_tag.rs — 启发式自动标签(三层标签的 auto 层)
- src/md_render.rs — 零依赖 Markdown 渲染器与 XSS 加固(sanitize_url / escape_html)
- src/file_token.rs — blake3 签名短时文件 token
- src/store.rs — SQLite 索引与多根 UNIQUE 约束
- src/api.rs — REST 接口(/api/docs、/docs/{id}/file、/html)
- src/main.rs — 启动与绑定护栏
项目地址
相关项目
markdown-library 与 photo-library、video-library 是同一作者的本地优先媒体库三件套:
- photo-library — 本地优先照片库索引:内容哈希精确去重 + 感知哈希近重 + EXIF 维度索引
- video-library — 本地优先视频库索引:转码缩略 + 去重 + 隐私护栏
发表回复