Blume中文文档

开始

常见问题

关于 Blume 的常见问题解答

这里汇集经常被问到的问题的答案。还没找到你要的?提一个 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 在自己的仓库里带了同样的修复,你可以把它应用到任何项目里。

  1. 把补丁保存为 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": {
  2. 用包管理器的 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
  3. 重新安装,让补丁被应用:

    安装
    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 条目。