Obsidian × Astro 链接兼容
写一个 remark 插件,构建时将 Obsidian 的 .md 文件路径自动转为 Astro 路由。区分两种写法——有 ./ 或 ../ 前缀的从当前文件解析,没有的从 vault 根(src/content/)解析。post/webmentions.md → /posts/webmentions/。
问题
Obsidian 和 Astro 对链接的理解完全不同:
| 维度 | Obsidian | Astro |
|---|---|---|
| 链接目标 | 文件系统路径 | URL 路由 |
| 文件扩展名 | 需要 .md | 无 |
| 路径风格 | 相对 vault 根的路径(无/开头) | 根绝对路径(/开头) |
| 末尾斜杠 | 无 | 有 |
Obsidian 的两种链接写法:
- Vault-relative:
post/webmentions.md,相对于 vault 根(src/content/) - File-relative:
../post/webmentions.md,相对于当前文件所在目录
两种在 Obsidian 中都能正常跳转,但 Astro 编译后都指向不存在的路径,404。
思路
在 Obsidian 中用文件路径写,Astro 构建时自动转换成 URL 路由。
Astro 的 Markdown 管线支持 remark 插件,在 Markdown 编译为 HTML 之前操作 AST,可以修改任何链接节点。写一个 remark 插件,把 .md 链接转换为路由即可。
实现
依赖准备
依赖分析:
unist-util-visit— 遍历 mdast 语法树,按节点类型查找link节点node:path— Node.js 内置模块,解析相对路径、拼接路由
依赖安装:
pnpm add unist-util-visit实现插件
创建 src/plugins/remark-obsidian-links.mjs,核心逻辑分四步。
1. 遍历 & 过滤
import { visit } from 'unist-util-visit';
visit(tree, 'link', (node) => {
let href = node.url;
if (href.startsWith('http') || href.startsWith('#') || href.startsWith('mailto:')) return;
if (!href.endsWith('.md')) return;
// ...
});remark 将 Markdown 解析为 mdast,visit 遍历 link 节点。外部链接、锚点、mailto、非 .md 链接直接跳过。
2. 解码 URL 编码
try {
href = decodeURIComponent(href);
} catch {}Obsidian 的 useMarkdownLinks: true 模式会对非 ASCII 字符做 percent-encode:
reference/%E6%B2%81%E5%9B%AD%E6%98%A5.md
→ reference/沁园春.md
不解码,后续路径解析会失败。
3. 区分两种路径风格
这是核心。根据是否有 ./ 或 ../ 前缀区分处理策略:
const isFileRelative = href.startsWith('./') || href.startsWith('../');
if (isFileRelative) {
// 相对于当前文件解析
const resolved = path.resolve(path.dirname(currentFile), href);
// 从 content root 截取相对路径
withoutExt = resolved.replace(contentRoot, '');
} else {
// vault-relative:直接从 content root 算
// "post/webmentions.md" → "post/webmentions"
withoutExt = href.replace(/\.md$/, '');
}为什么必须区分?series/citrus-docs.md 中写 post/webmentions.md,如果当 file-relative 处理,会从 series/ 目录出发,算出 series/post/webmentions,路径就错了。vault-relative 写法直接按 content root 解析,结果是正确的 post/webmentions。
4. 生成路由 URL
const segments = withoutExt.split('/');
const collection = segments[0]; // "post" | "til" | ...
if (collection === 'post') {
prefix = '/posts';
slug = segments.slice(1).join('/'); // 去掉集合名前缀
} else if (collection === 'til') {
prefix = '/tils';
slug = segments.slice(1).join('/');
}
node.url = `${prefix}/${slug}/`.replace(/\/+/g, '/');集合名 (post/til) 是文件系统目录,不是 URL 的一部分,需要去掉。最终映射:
post/webmentions.md → /posts/webmentions/
post/deepseek.md → /posts/deepseek/
til/daily-log.md → /tils/daily-log/
../post/other.md → /posts/other/
注册插件
在 astro.config.ts 中引入:
import { remarkObsidianLinks } from './src/plugins/remark-obsidian-links.mjs';
export default defineConfig({
markdown: {
remarkPlugins: [remarkObsidianLinks],
},
});重启生效
remark 插件在 dev server 启动时加载并缓存,修改插件代码后必须完整重启(Ctrl+C 再 pnpm dev),热更新不会触发重编译。
总结
Obsidian 编辑时 Astro 构建时
┌──────────────────────┐ ┌────────────────────────┐
│ [text](post/x.md) │──remark──→ │ [text](/posts/x/) │ vault-relative
│ [text](../post/x.md) │──remark──→ │ [text](/posts/x/) │ file-relative
│ 文件系统路径 │ 插件转换 │ URL 路由 │
└──────────────────────┘ └────────────────────────┘
两种 Obsidian 写法,一种 Astro 结果。源文件服务于编辑体验,构建时自动适配路由规则,互不干扰。