跳转到内容

搜索

Obsidian × Astro 链接兼容

写一个 remark 插件,构建时将 Obsidian 的 .md 文件路径自动转为 Astro 路由。区分两种写法——有 ./../ 前缀的从当前文件解析,没有的从 vault 根(src/content/)解析。post/webmentions.md/posts/webmentions/

问题

Obsidian 和 Astro 对链接的理解完全不同:

维度ObsidianAstro
链接目标文件系统路径URL 路由
文件扩展名需要 .md
路径风格相对 vault 根的路径(无/开头)根绝对路径(/开头)
末尾斜杠

Obsidian 的两种链接写法:

  • Vault-relativepost/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 结果。源文件服务于编辑体验,构建时自动适配路由规则,互不干扰。