这里汇集经常被问到的问题的答案。还没找到你要的?提一个 issue,或者问问页面内的 assistant。
Blume 与 Mintlify、Fumadocs 等有什么不同?#
大多数文档工具都落在两个极端之一。像 Mintlify 这样的托管平台能很快给你一个打磨好的结果,但构建和托管都是他们的服务 —— 你在他们的系统里写作,部署到他们的基础设施上。像 Fumadocs、Nextra 或 Docusaurus 这样的组件库和 starter 是开源且灵活的,但他们会塞给你一个应用(一个 Next.js 或 React 项目),你得在写第一个字之前和之后都去脚手架、接线并维护它。
Blume 走第三条路:框架就是模板。 你把一个装满 Markdown 的文件夹指给它,它就生成并驱动整个站点 —— 导航、搜索、主题、Open Graph 图片、SEO 和 AI 端点 —— 你不需要拥有任何应用。它完全开源且可自托管,所以既没有托管服务也没有厂商锁定,但同样也没有样板代码要维护。
| Blume | Mintlify | Fumadocs / Nextra / Docusaurus | |
|---|---|---|---|
| 模式 | 零配置框架;只有内容 | 托管平台 | 库 + 你要搭脚手架的应用 |
| 源码 | 开源(MIT) | 闭源核心 | 开源 |
| 托管 | 任意位置 —— 静态或服务器函数 | 他们的托管基础设施 | 任意位置;你自己构建和部署 |
| 你要维护 | 你的 Markdown | 你的 Markdown + 平台配置 | 你的 Markdown + 围绕它的那个应用 |
| 渲染 | Astro;核心主题零客户端 JS | 他们的运行时 | React/Next.js 运行时 |
| AI 功能 | llms.txt、原始 Markdown、页面内 assistant、MCP —— 内置,无托管服务 |
内置(托管) | 自备 |
有几点后果值得单独指出:
- 产物归你所有。
blume build产出的是一个普通站点,你可以把它托管在 Vercel、Netlify、Cloudflare、S3 或你自己的机器上。没有任何数据回传。 - 没有锁定,两条出路。 你的内容是可移植的 Markdown,而
blume eject会把项目变成一个独立的 Astro 应用,在你想完全掌控时它依然使用blume包。 - 默认就快。 核心主题不含 React 并渲染静态 HTML,所以页面无需调优就能在 Core Web Vitals 上拿到好成绩。只有在需要时你才会开启服务端功能(assistant、MCP)。
- 类型安全的配置。
blume.config.ts和每一个meta.ts都是由 schema 校验的真正 TypeScript —— 而不是弱类型的 YAML。
更长的版本见为什么会有 Blume。
Blume 免费且开源吗?#
是的 —— Blume 采用 MIT 许可,免费。你安装 blume 包,把内容留在自己的仓库里,并把构建产物托管在任何你喜欢的地方。没有付费档、没有按人计费,也不需要注册账号。源码在 GitHub。
我需要了解 Astro、React 或 Tailwind 吗?#
不需要。一个装满 Markdown 的文件夹就是一个完整站点 —— 导航、搜索和主题要么被推断出来,要么用几个 token 设定。只有当你想做定制时才会触及底层技术栈:交互岛(React)、组件覆盖 或主题 token(Tailwind)。即便如此,blume.config.ts 也是有类型的,所以你的编辑器会引导你。
我可以用 React 组件和 MDX 吗?#
可以。任意页面都可以是 .md 或 .mdx,而 MDX 让你无需 import就能放入内置组件。你还可以添加自己的 .tsx/.jsx 交互岛。只有当你的项目用到 React 时,Blume 才会开启它 —— 项目里存在任何 .tsx 或 .jsx 文件(包括你的交互岛)、一个 React 的 <Component> 示例、一处组件覆盖,或assistant —— 所以没有这些的站点不会带上任何框架 JavaScript。
我能部署到哪里?#
任何地方。blume build 默认输出静态 HTML,你可以从任意静态托管或 CDN 提供它 —— Vercel、Netlify、Cloudflare Pages、GitHub Pages、S3 或你自己的服务器。仅服务端的特性(assistant、MCP server、按需渲染)需要服务端输出:把来自 blume/deploy 的一个托管适配器 —— vercel()、netlify()、cloudflare() 或 node() —— 指定为 deployment。见部署。
搜索需要托管服务吗?#
不需要。Orama 会构建一份本地索引,在开发和生产环境中都能工作,没有任何东西需要托管或付费。对于非常大的站点,Pagefind 只需换一个适配器:search: pagefind()。无论用哪一个,索引都会随你的站点一起发布。
我该怎么定制外观?#
从主题 token 开始 —— 强调色、字体、圆角,以及一个用于处理 Tailwind 能表达的一切其它样式的 theme.css。想更进一步,可以覆盖内置组件或添加自定义页面。当你想要 Astro 项目本身时,blume eject 会交给你一个独立应用,且它依然使用 blume 包。
为什么 oxfmt / Ultracite 会把我的指令压成一行?#
如果你用 Ultracite(它跑的是 oxlint + oxfmt)来格式化 Markdown —— Blume 自己也这么做 —— 你可能会发现容器指令在一次格式化之后被压成了单行:
:::note
用 blume dev 重新生成项目。
:::
变成了
:::note 用 blume dev 重新生成项目。 :::
一旦开头的 :::note 围栏和正文连成了一行,它就不再是指令,于是会作为字面文本渲染,而不是一个提示框。
为什么会这样#
这是 oxfmt 的 Markdown 格式化器里的一个 bug(继承自 Prettier 的 Markdown printer —— 见 prettier/prettier#19040)。当它换行正文时,会把 ::: 围栏行当成普通文本,并把它们与相邻行连起来,于是指令就坏了。它影响所有类型的容器指令 —— :::note、:::tip、:::info、:::warning、:::danger、:::success。
我们在上游的 oxc-project/oxc#24096 报告了它;在那里修好之前,下面的补丁是变通方案。
修复办法#
给 oxfmt 打补丁,让它保留紧挨着 ::: 围栏的那个换行。Blume 在自己的仓库里带了同样的修复,你可以把它应用到任何项目里。
-
把补丁保存为
patches/oxfmt@0.67.0.patch:diff --git a/dist/markdown-BMigo7Hm.js b/dist/markdown-BMigo7Hm.js index bc9037f6c0de5516b139d8cdb195b1e25cd33bc0..a02c284e28bb535f9964a8a086ebb6549657e416 100644 --- a/dist/markdown-BMigo7Hm.js +++ b/dist/markdown-BMigo7Hm.js @@ -4872,7 +4872,43 @@ function lu(e, t, r) { case "sentence": return Oh(e, r); case "word": return t.parser !== "mdx" ? zh(e, t) : Uh(e); case "whitespace": { - let { next: a } = e, u = a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !NE(e) && !(t.proseWrap === "preserve" && RE(e)) ? "never" : t.proseWrap; + let { next: a, previous: oxfmtFencePrev } = e; + // Preserve line breaks that sit directly against a `:::` container + // directive fence, so `proseWrap: "never"` keeps the opening/closing + // fence on their own lines instead of joining them into the prose (which + // breaks the directive). Ordinary prose still wraps per proseWrap. + // See prettier/prettier#19040. + let oxfmtIsFence = (w) => w != null && typeof w.value === "string" && w.value.startsWith(":::"); + // A titled directive (`:::warning[Heads up]`) parses its `[title]` as a + // linkReference between two sentence nodes at the paragraph level: the + // fence word ends the sentence before the reference, and the body's + // leading newline opens the sentence after it. So when this whitespace + // starts its sentence, climb to the paragraph and check whether the two + // preceding siblings are a (link) reference and a sentence ending in a + // `:::` fence word. + let oxfmtPrevIsTitledFence = !1; + if (oxfmtFencePrev == null && e.index === 0 && e.grandparent != null && Array.isArray(e.grandparent.children)) { + let oxfmtSibs = e.grandparent.children, oxfmtSentIdx = oxfmtSibs.indexOf(e.parent); + if (oxfmtSentIdx >= 2) { + let oxfmtLink = oxfmtSibs[oxfmtSentIdx - 1], oxfmtBefore = oxfmtSibs[oxfmtSentIdx - 2]; + let oxfmtLastWord = oxfmtBefore && oxfmtBefore.type === "sentence" && Array.isArray(oxfmtBefore.children) ? oxfmtBefore.children[oxfmtBefore.children.length - 1] : null; + oxfmtPrevIsTitledFence = oxfmtLink != null && (oxfmtLink.type === "linkReference" || oxfmtLink.type === "link") && oxfmtIsFence(oxfmtLastWord); + } + } + // The plain-markdown parser keeps a titled fence's `[title]` as literal + // words, so the whole directive is one sentence. For a newline + // whitespace, walk back to the start of its visual line within the + // sentence; a line led by a `:::` word is a fence whose break must stay. + if (!oxfmtPrevIsTitledFence && e.node.value.includes("\n") && e.parent != null && Array.isArray(e.parent.children)) { + let oxfmtLineFirst = null; + for (let oxfmtJ = e.index - 1; oxfmtJ >= 0; oxfmtJ--) { + let oxfmtSib = e.parent.children[oxfmtJ]; + if (oxfmtSib.type === "whitespace" && typeof oxfmtSib.value === "string" && oxfmtSib.value.includes("\n")) break; + oxfmtLineFirst = oxfmtSib; + } + oxfmtPrevIsTitledFence = oxfmtIsFence(oxfmtLineFirst); + } + let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtPrevIsTitledFence || oxfmtIsFence(a) ? "preserve" : a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !NE(e) && !(t.proseWrap === "preserve" && RE(e)) ? "never" : t.proseWrap; return ou(e, n.value, u, !1, t); } case "emphasis": { -
用包管理器的
patchedDependencies注册它。用 Bun 的话,在package.json里加上:{ "patchedDependencies": { "oxfmt@0.67.0": "patches/oxfmt@0.67.0.patch" } }用 pnpm 的话,加到
pnpm-workspace.yaml(pnpm 11 及更新版本不再从package.json读取设置):patchedDependencies: oxfmt@0.67.0: patches/oxfmt@0.67.0.patch -
重新安装,让补丁被应用:
安装bun install
为什么 Knip 报告我的 blume.config.ts 依赖未被使用?#
Knip 只会跟踪它已知入口文件的导入,而它是从内置插件里学到这些入口的。目前还没有 Blume 插件,而 Knip 的 Astro 插件也不会因此打开开关:它在你的 package.json 里找 astro,但 Blume 项目依赖的是 blume,且生成的 .blume/ Astro 项目被 gitignore 了,所以 Knip 从来看不到它。没有任何地方引用 blume.config.ts,于是它导入的每个包都会被报告为未使用。
把 Blume 从你的项目根目录加载的那些文件注册为入口。在 knip.json 里:
{
"entry": [
"blume.config.{ts,mjs,js}",
"components.{ts,tsx}",
"islands/**/*.{ts,tsx}",
"pages/**/*"
]
}
在 monorepo 里,请把同一份 entry 列表放到 workspaces 中 docs workspace 之下。凡是你不用的约定都把对应那行删掉 —— 用于组件覆盖的 components.ts、用于交互岛的 islands/,以及用于自定义页面的 pages/(如果你改过 content.pages,最后那行要相应调整)。
Knip 只能跟踪真实的导入。一个只出现在字符串里的包 —— 比如一个调用 injectScript("page", "import('some-package')") 的 Astro 集成 —— 仍然需要一条 ignoreDependencies 条目。