在 Astro 中实现 Obsidian 风格的 Wiki Link 内部链接

4789 字
24 分钟
在 Astro 中实现 Obsidian 风格的 Wiki Link 内部链接

用 Obsidian 写博客草稿,用 Astro 发布,这套组合的接缝就在 [[wiki link]] 上:Obsidian 里双方括号是一等公民,到了 Astro 的 Markdown 管线里却只是四个普通字符。

Firefly 的做法是写一个 remark 插件把这条语法接过来,顺手做两件 Obsidian 也没有的事:行内链接自动填上目标文章的标题,独立成段的链接升级成带封面的文章卡片。

这篇是实现笔记。技术部分不算复杂,真正花时间的是两类问题:Astro 内部有几个不写在文档里的行为(entry.id 怎么来、图片变体怎么哈希、内容缓存什么时候失效),以及 Obsidian 默认插入的链接格式和”给人看的路径”其实不是一回事。

为什么不用现成插件#

社区有 remark-wiki-link@portaljs/remark-wiki-link,它们的核心是维护一张「页面名 → URL」的映射表,为的是支撑 Obsidian 那套双向链接、别名、未创建笔记占位的完整生态。放到静态博客上,这些抽象基本用不上,缺的反而是另外几样:

  • 链接目标要认三种写法:frontmatter 的 slug、文件路径、裸文件名
  • 要认 guide/index.md 这种目录首页
  • [[slug]] 独立成段时要升级为卡片,需要读目标文章的 frontmatter
  • 封面图要走 Astro 的图片优化管线

需求全都压在「读 frontmatter + 认路径」这一件事上,自己写反而更短。

Firefly Wiki Link 内部链接示例
在 Firefly 文章中使用 Obsidian 风格的 Wiki Link 内部链接,并自动生成文章链接卡片。
1970-01-03博客指南#Markdown#Obsidian#Wiki-Link#文章示例

插手的位置#

Astro 用 unified 处理 Markdown:

Markdown 源文本
→ remark (mdast → mdast) ← 插件在这里
→ rehype (hast → hast) ← 图片管线在这里
→ HTML

astro.config.mjsmarkdown.remarkPlugins 里注册,位置排在 remarkMathremarkReadingTime 之后,remarkImageGridremarkExcerpt 之前:

import { remarkWikiLink } from "./src/plugins/remark-wiki-link.js";
markdown: {
remarkPlugins: [
// ...
remarkReadingTime,
remarkWikiLink,
remarkImageGrid,
// ...
],
}

顺序有讲究:得排在 remarkExcerpt 前面,否则摘要里会留下未转换的 [[...]] 原文。 选 remark 而不是 rehype,是因为这一层拿到的还是 mdast,节点类型少、语义清楚:段落就是 paragraph,行内文本就是 text,代码块是 code。到了 hast 阶段一切都已经变成 element,判断「这个段落里只有一个 wiki link」会麻烦得多。

插件入口只做两件事:记下当前文件所在目录(后面算封面相对路径要用),然后递归整棵树。

export function remarkWikiLink() {
return (tree, file) => {
const context = {
currentDir: file?.path ? path.dirname(file.path) : null,
};
transformNode(tree, context);
};
}

解析语法#

Obsidian 的 wiki link 有几种变体:

语法含义
[[slug]]链接到文章
[[slug|别名]]自定义显示文字
[[slug#标题]]链接到文章内某个标题
[[#标题]]链接到本页标题
![[附件]]嵌入附件(不支持)

解析就是两次字符串分割,先切别名再切锚点:

const WIKI_LINK = /!?\[\[([^[\]\n]+)\]\]/g;
function parseWikiLinkValue(value) {
const aliasSeparator = value.indexOf("|");
const destination = (
aliasSeparator === -1 ? value : value.slice(0, aliasSeparator)
).trim();
const alias =
aliasSeparator === -1 ? "" : value.slice(aliasSeparator + 1).trim();
const headingSeparator = destination.indexOf("#");
const pageName =
headingSeparator === -1
? destination
: destination.slice(0, headingSeparator).trim();
const heading =
headingSeparator === -1 ? "" : destination.slice(headingSeparator + 1).trim();
return { destination, alias, contentPath: normalizeContentPath(pageName), heading };
}

顺序不能反。锚点先切的话,[[a|b#c]] 这种别名里带 # 的写法会被切错。

normalizeContentPath 负责把各种写法收敛到统一形式:去掉扩展名、去掉开头的 .//、去掉结尾的斜杠,再去掉多余的 posts/ 前缀。同时它是唯一的安全边界——任何一段是 ... 就返回空串,链接按原文显示,避免链接文字里的相对路径穿透到仓库外面去读文件。

遍历 AST#

function transformNode(node, context) {
if (SKIPPED_NODE_TYPES.has(node.type) || !Array.isArray(node.children)) {
return;
}
for (let index = 0; index < node.children.length; index++) {
const child = node.children[index];
// 1. 独立成段的 [[slug]] → 卡片
const card = tryCreateCardFromParagraph(child, context);
if (card) {
node.children[index] = card;
continue;
}
// 2. 文本里的行内 wiki link
if (child.type === "text") {
const replacement = replaceWikiLinks(child.value);
if (replacement) {
node.children.splice(index, 1, ...replacement);
index += replacement.length - 1;
}
continue;
}
// 3. 往下递归
transformNode(child, context);
}
}

两个细节:

SKIPPED_NODE_TYPES 收了 linklinkReferencemdxJsxFlowElementmdxJsxTextElement,避免把已经是链接的内容再包一层,也避免动 MDX 的 JSX 表达式。行内代码和代码块不用特殊处理——它们的节点类型是 inlineCodecode,根本不是 text,天然免疫。

替换成多个节点后要手动推进 index,否则会重新扫描刚插入的节点。这里不用 unist-util-visit 就是因为要在遍历中原地替换、并且替换的数量不固定。

链接目标:三级解析#

一条 [[foo]] 到底指哪篇文章,按三步找,命中即停:

function readPostMeta(contentPath) {
const metas = collectPostMetas();
// 1. frontmatter slug —— 它就是 Astro 的 entry.id,优先级最高
const bySlug = findMetaBySlug(metas, contentPath);
if (bySlug) return bySlug;
// 2. 文件路径精确匹配
const candidates = [
`${contentPath}.md`,
`${contentPath}.mdx`,
`${contentPath}.markdown`,
`${contentPath}/index.md`,
`${contentPath}/index.mdx`,
];
for (const candidate of candidates) {
const meta = readMetaFile(path.join(POSTS_DIR, candidate));
if (meta) return meta;
}
// 3. 裸文件名兜底
return findMetaByBaseName(metas, contentPath);
}

前两步的顺序不是随便定的,第三步的存在也不是为了「更宽容」。下面分开说。

slug 才是 URL 的来源#

直觉上「文件路径决定 URL」:src/content/posts/foo.md/posts/foo/guide/index.md/posts/guide/。转换代码也就这么写:

function createPostUrl(contentPath) {
const segments = contentPath.split("/");
if (segments.at(-1)?.toLowerCase() === "index") {
segments.pop();
}
const encodedPath = segments.map(encodeURIComponent).join("/");
return `/posts/${encodedPath ? `${encodedPath}/` : ""}`;
}

但这只是默认规则。文章页是 [...slug].astro 动态路由,参数取自 removeFileExtension(entry.id)——真正决定 URL 的是 entry.id。而 glob loader 生成 id 时有个容易看漏的行为:它先读原始 frontmatter,里面有 slug 就直接拿来当 id,没有才回退到文件路径。

这一点从 schema 里完全看不出来。Firefly 的 posts schema 根本没声明 slug 字段,z.object 默认剥掉未声明的键,所以 entry.data.slug 永远是 undefined。看代码很容易得出「slug 是个被忽略的遗留字段」的结论——我当时就是这么判断的,还差点据此把插件里的 slug 匹配删掉。

真相是它在 schema 校验之前就已经改掉了路由。验证只要一分钟:给 zz-test.md 写上 slug: zz-alias/posts/zz-alias/ 返回 200,/posts/zz-test/ 返回 404。

所以插件里绝不能拿「链接里写了什么」去拼 URL,得先解析到具体文件,再由文件反推 id:

function toPostId(meta) {
const declaredSlug =
typeof meta.data.slug === "string" ? meta.data.slug.trim() : "";
// frontmatter slug 优先,与 glob loader 的 generateId 保持一致
return declaredSlug || toContentPath(meta.filePath);
}

顺带说,slug 优先也让文章能自由改名、挪目录,而不影响已有的 wiki link 引用。

一次遍历,mtime 缓存#

第一步和第三步都需要「全站文章的 frontmatter」,所以干脆一次走完整棵目录树,读到的每份 frontmatter 按文件修改时间缓存:

function collectPostMetas() {
const metas = [];
const stack = [POSTS_DIR];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
// 目录读不到就跳过;注意这里只包住 readdirSync,
// 避免把下面的逻辑错误一起吞掉
continue;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (MARKDOWN_EXTENSION.test(entry.name)) {
const meta = readMetaFile(fullPath); // 内含 mtime 缓存
if (meta) metas.push(meta);
}
}
}
return metas;
}

用显式栈而不是递归,纯粹是懒得想目录深度。

裸文件名只在唯一时接受#

第三步是为 Obsidian 加的,理由在下一节。它的两条约束值得单独说:

function findMetaByBaseName(metas, target) {
if (target.includes("/")) return null;
const matches = metas.filter(
(meta) =>
path.basename(meta.filePath).replace(MARKDOWN_EXTENSION, "") === target,
);
if (matches.length === 1) return matches[0];
if (matches.length > 1) {
console.warn(`[remark-wiki-link] "[[${target}]]" 匹配到多个同名文件,已跳过:...`);
}
return null;
}

重名时不猜。 放弃匹配并打一条构建期警告,而不是取第一个。这和 Obsidian 自己的行为一致——它在重名时也会自动改用更长的路径。要是静默挑一个,改动一个不相关目录里的文件名,就可能让另一篇文章的链接悄悄指向别处,而且构建照样成功。

必须排在最后。 裸文件名要让位给精确路径匹配,否则 [[index]] 会绕过根目录下真实存在的 index.md,跑去撞某个子目录里的同名文件。

和 Obsidian 对齐#

前面那些是”实现一个 wiki link 插件”的部分。真正反复改的是这一节:把 src/content/posts 目录直接当 Obsidian 仓库(vault)打开之后,Obsidian 插入的链接和插件期望的格式不总是一回事。

仓库根目录和插件解析路径的起点是同一个目录,这是前提,也是唯一让两边能对齐的原因。

「尽可能简短的形式」#

设置 → 文件与链接 → 链接 → 内部链接类型 的默认值是尽可能简短的形式:只要文件名在整个仓库里唯一,插入的链接就只有文件名,不带任何目录。于是 dev-notes/ 下的文章会被写成 [[astro-wiki-link-implementation]]

按前两级规则,这个链接既匹配不到 slug(slug 是完整路径),也匹配不到根目录下的同名文件,最后拼出 /posts/astro-wiki-link-implementation/——404。

本来可以在文档里写一句「请把这个设置改成基于仓库根目录的绝对路径」了事,但这是 Obsidian 的默认值,还藏在三级菜单里。指望每个用户先踩坑再去翻设置,不如让插件认下这种写法。

这一步同时把上面「URL 从解析结果反推」从设计偏好变成了硬性要求:链接文字是 astro-wiki-link-implementation,解析到的文件却在 dev-notes/ 下,URL 只能由 toPostId(meta) 算。继续拿链接文字拼 URL 的话,裸文件名支持就是一个稳定生成 404 的功能。

别名不一定是别名#

改成基于仓库根目录的绝对路径之后,Obsidian 插入的是这个:

[[guide/firefly-layout-system|firefly-layout-system]]

它自动补了一个等于文件名的别名,好让笔记里显示的不是一长串路径。对 Obsidian 来说这是纯粹的显示优化,可插件这边一视同仁地把别名当作者指定的标题,卡片标题就成了 firefly-layout-system,而不是文章真正的 title。

有两个方向可选:换一个 Obsidian 不会生成的自定义标题语法,或者识别出这种「别名只是把链接目标又抄了一遍」的情况并忽略它。前者的代价是新语法在 Obsidian 里不被识别,会按原文显示、也点不动——为了修一个显示问题,牺牲另一边的显示,不划算。

于是走后者:

function resolveAlias(parsed, meta) {
if (!parsed.alias) return "";
const noise = new Set([
parsed.destination,
parsed.contentPath,
path.basename(parsed.contentPath),
]);
if (meta) {
noise.add(toContentPath(meta.filePath));
noise.add(path.basename(meta.filePath).replace(MARKDOWN_EXTENSION, ""));
}
return noise.has(parsed.alias) ? "" : parsed.alias;
}

除了链接里写的路径,也拿解析到的真实文件名比一遍。因为 Obsidian 补的别名对应的是它自己算出的最短形式,在 slug 写法下可能和链接文字里的任何一段都不相等。

代价是没法故意让显示文字等于文件名了。这个功能的用户是零,需要的话写 Markdown 原生链接更直白。

相对路径不支持#

下拉框第三个选项基于当前笔记的相对路径只在同目录内可用:同目录文章生成的是裸文件名,第三级规则能接住;跨目录会生成 ../ 前缀,被 normalizeContentPath.. 检查挡掉,链接按原文显示。

这个不打算支持。.. 检查是安全边界,为了一个能靠改设置绕开的便利去开口子不值得。

文章链接卡片#

[[slug]] 独占一个段落时,升级成一张带封面的卡片。

怎么判断「独立成段」#

function tryCreateCardFromParagraph(node, context) {
if (node.type !== "paragraph" || node.children?.length !== 1) return null;
const child = node.children[0];
if (child.type !== "text") return null;
const match = child.value.trim().match(STANDALONE_WIKI_LINK);
if (!match) return null;
const parsed = parseWikiLinkValue(match[1]);
if (!parsed || parsed.heading || !parsed.contentPath) return null;
return createWikiLinkCard(parsed, context);
}

唯一被排除的是 [[slug#标题]]。锚点的语义是「跳到那一段」而不是「引用整篇文章」,做成卡片会误导:卡片展示整篇文章的元数据,点进去却落在中间某处。

[[slug|别名]] 起初也在排除名单里,后来放开了。别名和「是否引用整篇文章」是两件正交的事——写 [[firefly|主题介绍]] 的人只是嫌原标题太长,并不想降级成普通链接。放开后别名接管卡片标题,其余字段照旧从目标文章读:

const title =
resolveAlias(parsed, meta) ||
(typeof meta.data.title === "string" && meta.data.title
? meta.data.title
: resolvedPath);

代价是失去一个隐式逃生舱——原先「想让独立成段的链接保持普通样式,就随便加个别名」的技巧没了。不过那本来是靠副作用实现的,不算正经 API。

组装 DOM#

remark 阶段只能操作 mdast,输出不了 HTML。hName/hProperties 是 unified 生态的标准做法,让 mdast 节点在转 hast 时映射到指定标签和属性:

function createElement(tagName, properties, children) {
return {
type: "paragraph",
data: { hName: tagName, hProperties: properties },
children,
};
}
createElement("a", { class: "card-wiki-link no-styling", href: url }, [
createElement("div", { class: "wlc-info" }, [
createElement("div", { class: "wlc-title" }, [createText(title)]),
createElement("div", { class: "wlc-description" }, [createText(description)]),
createElement("div", { class: "wlc-meta" }, metaItems),
]),
createElement("div", { class: "wlc-cover" }, [cover]),
]);

type 写成 paragraph 只是为了让 mdast-util-to-hast 愿意处理它,实际标签由 hName 决定。描述、日期、分类、标签、封面都是缺了就不生成对应节点,而不是留个空容器——省掉一堆「空元素也占 margin」的样式补丁。

加密文章(frontmatter 里有 password)的描述会被跳过,只留标题和时间,避免把内容摘要泄在卡片上。

封面图与图片管线#

这是整个实现里最麻烦的部分。Astro 的图片优化管线在构建时处理图片、生成多尺寸 srcset、输出带内容哈希的文件名。卡片封面要和文章列表的封面共用同一套产物,而不是各自生成一份。

三种来源#

function createCoverNode(meta, resolvedPath, context) {
const image = meta.data.image;
// 1. 随机封面 API
if (image === "api") {
const seed = resolvedPath.replace(/\/index$/i, "");
return createElement("div", {
class: "cover-image-container",
dataApiUrls: JSON.stringify(getApiUrlList(image, seed)),
}, [createRemoteCoverImg(processCoverImageSync(image, seed))]);
}
// 2. 远程 URL 或 public 目录
if (/^(?:https?:)?\/\//i.test(image) || image.startsWith("/")) {
return createRemoteCoverImg(image);
}
// 3. 本地相对路径 → 交给 Astro 图片管线
return {
type: "image",
url: coverUrl,
alt: "",
data: { hProperties: { width: 480 } },
};
}

随机封面那条的 seed 要和 PostCard、文章页算出来的完全一致,否则同一篇文章在列表和卡片里会抽到不同的图。seed 用的是 entry.id,而 entry.id 已经去掉了末尾的 /index,所以这里得手动补上同样的处理——又一处「跟着 Astro 的隐式规则走」。

哈希对不上的变体#

本地图片走 rehypeImages,最终调 getImage() 生成优化变体。Astro 用 hashTransform 算输出文件名,输入包括 srcwidthheightformatquality

问题是:文章列表的 <Image> 组件传入的 src 是 ESM import 对象(Vite 模块引用),markdown 管线传入的是相对路径字符串。其他参数完全一致,hashTransform 的结果也不同——同一张图生成两份几乎相同的变体。

试过用 import.meta.glob 在插件里拿到 ESM import 对象来对齐哈希,但 remark 是同步上下文,而 import.meta.glob({ eager: true }) 返回的对象在不同构建阶段身份不同,始终对不上。

最后接受多一份变体,但把 width 压到 480px(列表用 640px)。实测 480w 比 640w 小约 46%,三张封面总共多约 66KB,换来的是不用跟 Astro 的内部哈希逻辑硬碰。

样式#

卡片样式写在 markdown-extend.styl,核心是 flex 横排:

a.card-wiki-link
display: flex
flex-flow: row nowrap
align-items: stretch
gap: 1rem
overflow: hidden
border-radius: var(--radius-large)
.wlc-info
flex: 1 1 auto
min-width: 0
.wlc-cover
flex: 0 0 auto
width: 30%
max-width: 13rem
min-width: 7rem
overflow: hidden

min-width: 0 是让 .wlc-info 里的长标题能正常省略号截断的关键,flex 子项默认的 min-width: auto 会撑破容器。

封面用 width: 30%max-width/min-width 做响应式:桌面最大 13rem,平板按比例缩,手机保底 7rem。悬浮时放大 + 压暗,和文章列表的交互一致:

&:hover .wlc-cover img
transform: scale(1.08)
filter: brightness(0.75)

日期和分类图标用 CSS mask,currentColor 自动跟随主题色,不用为深浅色各准备一套图:

.wlc-date, .wlc-category
&:before
content: ' '
display: inline-block
height: 1.2em
width: 1.2em
background-color: currentColor
mask-size: contain

两个静默失败#

技术难点都能靠读代码解决,真正浪费时间的是这两个「看起来一切正常」的问题。

被 try/catch 吞掉的 ReferenceError#

三级解析刚写完时,slug 匹配一次都没生效过,但页面完全正常——因为文件路径匹配兜住了所有链接,肉眼看不出区别。

原因是 readdirSync 外面套着一层 try/catch(目录不存在时要静默跳过),而当时只从 node:fs 导入了 readFileSyncstatSync,代码里写的是 fs.readdirSync——fs 是个未定义标识符。抛出的不是预期的 ENOENT,而是 ReferenceError,被同一个 catch 咽下,函数返回 null,安静地退化成纯路径匹配。

try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return null; // 本意是跳过读不到的目录,实际把 ReferenceError 也一起咽了
}

catch {} 的捕获范围比想象的宽:它不只接住你预期的那类 IO 错误,也接住拼写错误、未定义变量、TypeError 这些本该在开发阶段就炸出来的问题。写宽泛的 catch 至少要判一下错误类型,或者留条日志。静默失败叠加一条刚好能兜住的回退路径,是最难发现的一类 bug。

内容缓存不认插件改动#

改完插件、重启 dev server,卡片的 href 还是旧的。

Astro 把内容集合的渲染结果缓存在 .astro/data-store.json,失效判断看的是内容文件本身有没有变。插件代码改了但文章没改,缓存照样命中,页面渲染的是上一版插件的输出。

Terminal window
rm -f .astro/data-store.json

删掉再重启就好了。调试 remark 插件时,「改了没效果」优先怀疑这个,而不是怀疑自己的逻辑——我在一个已经修好的 bug 上多花了十几分钟,就因为看到的还是修复前的 href。

最终效果#

请参阅 [[firefly]] 了解主题特性。
[[guide/index]]
[[firefly|另一种叫法]]
[[guide/firefly-layout-system|firefly-layout-system]]

第一行是行内链接,文字自动取文章标题。第二行是卡片,标题、描述、日期、分类、标签、封面全从 guide/index.md 读。第三行也是卡片,标题换成「另一种叫法」。第四行是 Obsidian 自动补的别名格式,别名被识别成噪声忽略,标题仍然是文章真正的 title。

插件约 600 行 JavaScript,构建期依赖 gray-mattergithub-slugger,不增加任何客户端 JS。

还能改进的地方#

  • 重复锚点[[slug#标题]] 的锚点用 github-slugger 生成,和 rehype-slug 一致,但文章里有同名标题时只能匹配到第一个
  • 反向链接:当前是单向的,从引用方读被引用方的元数据。要做 Obsidian 的「谁链接到了这篇文章」,需要一个额外的构建步骤扫全站
  • 断链检查:解析失败时链接按原文显示,只有肉眼能发现。构建期收集失败的链接并汇总输出会更实用

代码在 remark-wiki-link.js,除了封面那段依赖 Firefly 的 image-utils,其余部分和主题解耦,搬到任何 Astro 项目里改改常量就能用。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
在 Astro 中实现 Obsidian 风格的 Wiki Link 内部链接
https://blog.cuteleaf.cn/posts/dev-notes/astro-wiki-link-implementation/
作者
夏叶
发布于
2026-07-26
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
夏叶
Hello, I'm XIAYE.
公告
欢迎来到我的博客,从2025年起,将会使用AI对文章进行润色。
分类
标签
最新动态
站点统计
文章
63
分类
7
标签
48
总字数
42,361
运行时长
0
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.2
文章许可
CC BY-NC-SA 4.0