使用 fumadocs-obsidian 将 Obsidian 库渲染为 Fumadocs 文档站点
【免费下载链接】fumadocsThe beautiful & flexible React.js docs framework.项目地址: https://gitcode.com/GitHub_Trending/fu/fumadocs
Obsidian 库(vault)是很多技术团队沉淀知识、撰写笔记的场所,但 Obsidian 本身并不直接提供面向读者的文档站点。本文介绍当前仓库中 packages/obsidian 提供的fumadocs-obsidian集成包:如何将一个 Obsidian 库作为运行时内容源接入 Fumadocs,如何通过内置的 remark 插件把 Wikilink、嵌入、Callout、块 ID、注释等 Obsidian 专属语法转换为标准 Markdown/MDX,以及静态生成与动态重新验证两种接入方式的差异。读完本文,你将能够把现有笔记库零迁移地变成一个可搜索、可复用 Fumadocs 全部主题能力的文档站。
一、fumadocs-obsidian 是什么
fumadocs-obsidian是 Fumadocs 生态中的 Obsidian 集成包,定位为"Runtime content source"(运行时内容源):它不是把笔记预先转换为文件再参与构建,而是在运行时直接读取 Obsidian 库,通过 Fumadocs 的内容源(content source)抽象将库中的 Markdown 笔记渲染成页面。
它的官方能力清单(见 packages/obsidian/README.md)包括:
- Wikilinks(
[[链接]])与嵌入(![[嵌入]]); - Callouts(
> [!note]等); - Block IDs(块 ID);
- 注释(
%% ... %%); - 支持静态(static)与动态重新验证(dynamically revalidated)两类 Fumadocs 内容源。
从 packages/obsidian/CHANGELOG.md 可以梳理出它的演进脉络,这也解释了它当前的设计取向:
- v1.0.0:正式发布 "Obsidian content source v1",通过静态或动态 Fumadocs 源直接渲染 Obsidian 库,采用惰性内存编译(lazy in-memory compilation)与本地内容热重载,同时移除了旧的"生成文件"与 remark 插件集成方案;
- v1.0.3:搜索索引改为从
page.data.structuredData()读取结构化数据,与load()共享编译结果,避免重复编译; - v1.0.4:内部将
cnfast替换为cn,纯内部重构,对外 API 无变化。
因此,无论你看到的是老教程中的"把 vault 转成文件再构建",还是本仓库中的运行时源方案,本文描述的都是 v1 的新架构。
二、快速接入:一个可运行的示例
仓库在 examples/obsidian 中提供了一个完整可运行的示例,其内容源定义在 examples/obsidian/lib/source.ts:
import { dynamicLoader } from 'fumadocs-core/source'; import { obsidian } from 'fumadocs-obsidian'; const vault = obsidian({ dir: 'public/vault', url: (path) => `/vault/${path}`, }); if (process.env.NODE_ENV === 'development') { void vault.devServer(); } const vaultLoader = dynamicLoader(vault.dynamicSource(), { baseUrl: '/docs', }); export function getSource() { return vaultLoader.get(); }这段代码演示了接入的最核心三件事:
- 用
obsidian()创建内容源:传入dir指向 Obsidian 库根目录,url回调为库中的媒体文件(图片等)生成公开访问 URL; - 开发环境启动 devServer:通过
vault.devServer()连接本地内容热重载服务(在 Vite 下更推荐使用fumadocs-obsidian/dev/vite导出的watchWithVite(),见 packages/obsidian/src/dev/vite.ts),这样编辑笔记时站点会即时刷新; - 接入 Fumadocs 的 loader:
vault.dynamicSource()拿到动态源后,交给dynamicLoader生成带baseUrl的路由 loader,最终通过getSource()在页面中使用。
示例项目还包含一个可直接查看的演示 vault(位于 examples/obsidian/public),里面有用到的.md笔记与图片资源,可以对照源码验证 Wikilink、Callout 等语法的实际渲染效果。
三、obsidian()配置项详解
obsidian()的完整配置类型定义在 packages/obsidian/src/source.ts 的ObsidianConfig中:
export interface ObsidianConfig< FrontmatterSchema extends StandardSchemaV1, MetaSchema extends StandardSchemaV1, > extends ObsidianCompilerOptions, Pick<VaultStorageOptions, 'url'> { /** Obsidian 库的根目录 */ dir: string; /** 扫描的 glob 模式,相对 vault 目录 */ include?: string[]; frontmatterSchema?: FrontmatterSchema; metaSchema?: MetaSchema; }各字段的作用与默认值如下:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
dir | string | 必填 | Obsidian 库根目录,所有文件解析均相对该目录进行 |
include | string[] | ['**/*'](defaultInclude) | 用 tinyglobby 扫描文件的 glob 模式,可通过它排除部分目录 |
url | (vaultPath, mediaFile) => string \| undefined | 默认/${path}(根相对路径) | 为媒体文件生成公开 URL,返回undefined表示该文件仅可通过相对路径访问 |
frontmatterSchema | StandardSchemaV1 | 内置的frontmatterSchema | 校验页面 frontmatter 的 schema |
metaSchema | StandardSchemaV1 | fumadocs-core/source/schema的metaSchema | 校验 meta 数据文件(JSON)的 schema |
需要说明的是,包内置的默认 frontmatter schema 定义在 packages/obsidian/src/utils/schema.ts,它刻意保持"宽容":
export const frontmatterSchema = z .object({ title: looseString, // 标量(数字/布尔)也会被转为字符串,如 title: 2024 description: z.string().optional(), icon: z.string().optional(), full: z.boolean().optional(), aliases: z.union([z.string().transform((alias) => [alias]), z.array(z.string())]).optional(), _openapi: z.record(z.string(), z.unknown()).optional(), }) .loose();由于 Obsidian 笔记往往结构松散,looseString会把title: 2024这类标量值自动转成字符串而不是拒绝整篇页面;schema 使用.loose(),未知字段不会导致校验失败。aliases同时支持单个字符串或字符串数组。如果你的笔记有自定义 frontmatter 字段,可以传入自定义 schema(支持 Standard Schema 规范的任意实现)。
在 packages/obsidian/src/source.ts 的parse()实现中可以看到校验失败时的行为:frontmatter 不合法会抛出带文件绝对路径与具体 issue 的错误(invalid frontmatter in "..."),而 meta 数据文件校验失败则提示invalid data in "..."。值得注意的是,vault 存储层不做校验——RawFrontmatter在 build-storage.ts 中被保留为未校验状态,单个格式错误的笔记不会拖垮整个 vault,只有真正解析该页面时才会暴露问题。
四、Obsidian 语法如何被编译为标准 Markdown
fumadocs-obsidian的核心是一个 unified 编译管线。createProcessor(见 packages/obsidian/src/source.ts)按顺序组装了以下插件链:
remarkParse → remarkGfm → Obsidian 专属 remark 插件(解析 vault 语法) → remarkHeading / remarkImage / 自定义 remarkPlugins / remarkStructure → remarkRehype(passThrough: mdxJsxFlowElement / mdxJsxTextElement) → rehypeCode / 自定义 rehypePlugins / rehypeToc其中 Obsidian 专属插件由 packages/obsidian/src/remark/index.ts 的getRemarkPlugins()统一导出,顺序固定:
remarkWikilinks:先解析 Wikilink,把[[...]]语法转换成标准链接或图片节点,这样后续插件拿到的是规范 Markdown;remarkConvert:将 Callout 块引用和相对链接/图片 URL 转换/重写;remarkObsidianComment:移除%% ... %%注释;remarkBlockId:把块 ID 包装为带id属性的 section。
4.1 Wikilinks 与嵌入
remarkWikilinks(实现见 packages/obsidian/src/remark/remark-wikilinks.ts)用正则匹配!?\[\[...]],并支持三种语法变体:
[[note]] → 链接到 note.md [[note#标题]] → 链接到 note.md 的某个标题 [[note|别名]] → 使用自定义显示文本 ![[image.png]] → 嵌入图片 ![[note]] → 嵌入笔记(有限支持)对于普通链接,插件会解析出目标文件的相对路径并生成带data.isWikiLink标记的链接节点,同时保留#heading锚点(锚点由 packages/obsidian/src/utils/get-refs.ts 的getHeadingHash用github-slugger生成,与 Fumadocs 的标题 slug 规则保持一致;而以^开头的块 ID 引用则跳过 slugify,直接使用块 ID 本身)。
嵌入场景下,图片会被转换为<img>节点,URL 取自媒体文件的url;嵌入内容块则输出为include的 MDX JSX 节点。需要注意源码中给出的两条限制(见 remark-wikilinks.ts 的console.warn):嵌入内容块(embed content block)的部分功能尚未支持,以及不支持![[image.png|300]]这种指定图片尺寸的写法。
4.2 Callouts
remarkConvert(见 packages/obsidian/src/remark/remark-convert.ts)会把 Obsidian 的> [!type]引用块转换成 Fumadocs 的 callout 组件。语法格式为:
> [!note] 标题 > 正文内容也支持可折叠 Callout:> [!note]+(RegexCalloutHead匹配^\!(?<type>\w+)?)。同一个插件还会重写普通的相对链接与图片 URL:先decodeURI还原 URL 编码的相对路径,再通过 vault 解析器解析到目标文件,锚点链接则统一 slugify——这正是 CHANGELOG v1.0.0 中提到的"Resolve URL-encoded relative file links against their decoded source paths"(按解码后的源路径解析 URL 编码的相对文件链接)。
4.3 块 ID
remarkBlockId(见 packages/obsidian/src/remark/remark-block-id.ts)识别段落末尾的^blockid语法,把它包装成<section id="^blockid">,从而支持从其他笔记[[note#^blockid]]精确跳转。块 ID 的匹配规则是行尾的^后跟单词字符,且反斜杠转义的\^不会被识别。
4.4 注释
remarkObsidianComment(见 packages/obsidian/src/remark/remark-obsidian-comment.ts)递归匹配%% ... %%分隔符并删除其中的内容((?<!\\)%%保证转义后的\%%不会被当作注释起点)。这让你可以像在 Obsidian 中一样在笔记里写临时批注,发布时自动消失。
4.5 编译管线中的其他内置插件
除了 Obsidian 专属语法,编译管线还默认注入了fumadocs-core的 MDX 插件(均可用false关闭,或传入配置对象覆盖,相关配置类型见 packages/obsidian/src/source.ts 的ObsidianCompilerOptions):
remarkGfm:GFM 表格、删除线、任务列表等;remarkHeading:为标题生成 ID(默认generateToc: false,TOC 交由 rehype 阶段收集);remarkImage:为 Next.js Image 注入图片尺寸,注意这里强制useImport: false——因为从 AST 中无法渲染 import,图片仅通过 URL 方式工作,publicDir默认是./public;remarkStructure:收集结构化数据(供搜索索引使用);rehypeCode:代码高亮,默认fallbackLanguage: 'plaintext'——这个默认值很有用:Obsidian 库中常出现 dataview、tasks 等插件专属的代码块语法,将其降级为纯文本渲染,避免整页构建失败;rehypeToc:收集目录,exportToc以data形式输出。
五、页面模型:load()、structuredData()与渲染器
5.1 页面数据结构
obsidian()返回的内容源中,每个页面都符合ObsidianPage结构(见 packages/obsidian/src/source.ts):
export interface ObsidianPage<Frontmatter = Record<string, unknown>> extends PageData { title: string; description?: string; icon?: string; content: string; // 去除 frontmatter 后的 Markdown 原文 frontmatter: Frontmatter; // 经 schema 校验后的 frontmatter load: () => Promise<ObsidianRenderer>; structuredData: () => Promise<StructuredData>; }这里有两个关键设计:
- 惰性编译(lazy compilation):
load()内部通过loaded ??= compilePage(...)缓存编译结果,同一份页面在内存中最多编译一次; - 结构化数据共享编译:CHANGELOG v1.0.3 说明,
structuredData()不再回退到(await page.data.load()).structuredData,而是直接暴露在 page data 上,与load()共享同一编译产物:
const structuredData = await page.data.structuredData();load()返回的渲染器仍然带有structuredData字段,因此旧代码无需改动即可继续工作。
5.2 渲染器:不执行任意 JavaScript
编译后的页面由 packages/obsidian/src/renderer.ts 的createRenderer负责渲染。ObsidianRenderer提供三个方法:
render(components?):异步渲染,返回{ toc, body };renderSync(components?):同步渲染;serialize():返回可序列化的编译产物,可通过fumadocs-obsidian/client的rendererFromSerialized恢复。
渲染过程使用hast-util-to-jsx-runtime把 hast 树映射为 React JSX。其安全模型值得强调:vault 内容是纯 Markdown,渲染只做 AST → JSX 的映射,绝不求值内容中的任意 JavaScript。渲染器内部传入的evaluater只允许解析"标识符"(用于把 JSX 组件名映射到传入的components),任何表达式求值(evaluateExpression)或程序求值(evaluateProgram)都会直接抛错——这从机制上防止了笔记内容变成可执行代码,静态生成时可以放心使用。
六、vault 的文件扫描、内存缓存与热重载
6.1 文件分类
obsidian()用 tinyglobby 按include模式扫描 vault 目录(源码见 packages/obsidian/src/source.ts 的createVault),路径统一slash()化并按字母排序,保证名称解析的确定性。每个文件按扩展名被划分为三类(见 packages/obsidian/src/build-storage.ts 的getFileFormat):
| 类型 | 扩展名 | 处理方式 |
|---|---|---|
| content | .md/.mdx | 读取内容,解析 frontmatter |
| data | .json/.yaml/.yml/.toml | 作为 meta/数据文件处理(JSON 会被metaSchema校验) |
| media | 其他所有 | 只记录路径与 URL,内容永不读入内存 |
content 文件使用fumadocs-core/content/md/frontmatter解析 YAML frontmatter,保留原始(未校验)数据;media 文件的 URL 由url配置生成,默认是根相对路径/${path}。此外,normalize会对越出 vault 目录的../路径直接抛错("points outside of vault folder")。
6.2 增量缓存与失效策略
源码中维护了三层缓存状态:
vaultFiles:跨快照(snapshot)持久保存的 vault 文件 Map,失效时只重读发生变化的文件;invalidated:等待下次快照重读的绝对路径集合;flush:全量清空标记。
invalidateFile(file)的做法尤其值得注意:因为每一页都可能从 vault 中的任何其他文件解析名称与别名,单文件变更后为了不让重命名留下失效链接,会重建整个快照,但只从磁盘重读被失效的那一个文件;invalidateAll()则把清空操作推迟到下一次快照构建时执行,避免与正在进行的构建产生写冲突。快照构建通过buildQueue串行化,构建失败时只丢弃失败的那次快照(vault = undefined),不会让瞬时错误污染后续构建。
文件读取采用 100 个一组的并发分块(ReadChunkSize = 100),并容忍"扫描后、读取前被删除"的竞态——读失败时只记录错误并跳过该文件,而不是让整个 vault 失败。
6.3 开发体验:本地热重载
开发模式下可以通过两种方式启用热重载:
- 独立 dev server:调用
vault.devServer(url?)连接独立的 local-content dev server;fumadocs-obsidian还提供了 CLI 入口(packages/obsidian/package.json 的bin字段为fumadocs-obsidian,实现在 packages/obsidian/src/bin.ts); - Vite 集成:从
fumadocs-obsidian/dev/vite导入watchWithVite()或localContentPlugin(见 packages/obsidian/src/dev/vite.ts),在 Vite/Next.js 开发服务器内直接监听 vault 文件变化。
七、静态生成与动态重新验证两种模式
fumadocs-obsidian底层构建在@fumadocs/local-content(见 packages/obsidian/package.json 的依赖)之上,obsidian()返回的ObsidianSource本质上是一个LocalSource。因此,它与 Fumadocs 的静态/动态源机制完全兼容:
- 静态模式:构建期调用
vault.staticSource()等方法,把所有页面编译结果一次性产出,适合内容基本不变的笔记库; - 动态模式:如示例所示,用
vault.dynamicSource()+dynamicLoader组合,服务端在请求时按需读取、缓存和失效,配合 CHANGELOG v1.0.0 提到的 "dynamically revalidated" 能力,可以做到笔记更新后站点内容自动更新。
两种模式共用同一套ContentIntegration(parse回调,见 packages/obsidian/src/source.ts),差异只在于页面数据的读取时机与缓存策略,因此切换成本很低。
八、已知限制与注意事项
综合 CHANGELOG、README 与源码注释,使用时有几点需要提前了解:
- 嵌入内容块的"部分支持":
![[other note]]形式的笔记嵌入输出为includeJSX 节点,但源码明确警告其部分功能尚未支持,建议以[[链接]]形式为主; - 不支持嵌入图片尺寸语法:
![[image.png|300]]中的|300会被忽略(源码仅console.warn),如需控制尺寸请用标准 Markdown/HTML; - 图片以 URL 方式工作:
remarkImage强制useImport: false,图片必须能通过url配置映射为可访问的公开路径; - dataview 等插件代码块自动降级:
rehypeCode的fallbackLanguage: 'plaintext'让插件专属代码块以纯文本展示,不会导致构建失败; - frontmatter 校验是逐页、惰性的:格式错误的笔记只在它被编译时暴露错误,不影响其他页面,但 schema 校验失败会抛出带路径的错误信息,需注意排查;
- 环境要求:
fumadocs-obsidian的 peerDependencies 要求fumadocs-core@^16.8.0、React 19,并依赖@fumadocs/local-content(同仓库 packages/local-content),接入前请确认版本匹配。
九、总结
fumadocs-obsidian提供了一条从 Obsidian 笔记到专业文档站点的低成本路径:obsidian()把整个 vault 变成 Fumadocs 运行时内容源,内置的 remark 插件链在编译期把 Wikilink、嵌入、Callout、块 ID、注释等 Obsidian 语法转换为标准 Markdown/MDX,渲染器则保证内容只被映射为 React 组件而绝不求值任意代码。通过惰性编译与增量快照缓存,静态生成和动态重新验证两种模式都能获得合理的构建性能与开发期热重载体验。
对于已经深度使用 Obsidian 双链笔记、Callout 与块引用写作的团队,可以在不迁移、不转换文件的前提下,把同一套笔记同时用作内部知识库与对外文档站。更详细的 API 与配置,可以继续阅读 packages/obsidian/src 下的源码与 examples/obsidian 示例项目。
【免费下载链接】fumadocsThe beautiful & flexible React.js docs framework.项目地址: https://gitcode.com/GitHub_Trending/fu/fumadocs
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考