Blume 站点的大部分内容是 Markdown,但有时你需要一条不是文档的路由 —— 落地页、定价页、手工搭的博客或更新日志索引,或者一个交互式仪表盘。在你的 pages 目录下放一个 .astro 文件,Blume 就会把它挂载成一条真正的路由,与你的内容并列。
添加页面#
在项目根目录创建一个 pages/ 文件夹,并加入一个 .astro 文件:
---
import data from "blume:data";
---
<h1>Pricing for {data.config.title}</h1>
blume dev 会立刻认出它,blume build 则会把它预渲染成静态 HTML。文件夹名可通过 content.pages 配置(默认 "pages")。
自定义页面在磁盘上保留原来的位置,因此相对导入、组件导入以及 getStaticPaths 的行为都与普通 Astro 项目完全一致 —— Blume 把每个文件原地挂载,而不是复制一份。
文件与路由#
每个文件在 pages 目录下的路径就是它的路由。index 映射到上级目录,动态的 [param] 段会被保留:
| 文件 | 路由 |
|---|---|
pages/pricing.astro |
/pricing |
pages/blog/index.astro |
/blog |
pages/blog/[slug].astro |
/blog/:slug |
pages/changelog.astro |
/changelog |
自定义页面在同一路径上会胜出于自动生成的路由。比如加上 pages/changelog.astro,就把 Blume 的更新日志时间线换成了你自己的。
Blume 无法枚举一条动态 [param] 路由会服务哪些路径,因此这类页面不会进入站点地图、不会生成 Open Graph 卡片,blume validate 也不认识它们 —— 链到它们的链接会被报成失效。要让它们拥有分享图,可以给 PageLayout 传 ogImage;或者为每条路径各配一个静态文件(用 pages/compare/mintlify.astro 而不是 pages/compare/[tool].astro),这样三样都能拿到。
读取站点数据#
导入 blume:data,即可读到与站点其它部分相同的已解析配置、导航、路由和订阅源:
---
import data from "blume:data";
---
<h1>All pages</h1>
<ul>
{
data.routes
.filter((route) => route.indexable)
.map((route) => (
<li>
<a href={route.path}>{route.title}</a>
</li>
))
}
</ul>
在 Blume 项目里这个模块的类型是自动的。你也可以显式引入它的类型定义 —— 用于带类型的辅助函数、props 或你自己的 tsconfig —— 即 import type { BlumeData } from "blume":
import type { BlumeData, BlumeRoute } from "blume";
const indexable = (data: BlumeData): BlumeRoute[] =>
data.routes.filter((route) => route.indexable);
该模块暴露以下内容:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
config |
BlumeDataConfig |
- | 已解析的站点设置:title、description、logo、favicon、appleIcon、banner、theme、site、repoUrl、github(owner、repo、host 以及 REST API 基础地址 —— 未设置时为 null)、search、i18n、mcp、assistant、og、analytics、feedback、structuredData、toc、codeThemes、codeWrap 和 imageZoom。 |
navigation |
Navigation |
- | 从你的内容(默认语言)推导出来的侧边栏、标签页和选择器。 |
navigationByLocale |
Record<string, Navigation> |
- | 按语言代码索引的各语言导航树。没有配置 i18n 时为空。 |
routes |
BlumeRoute[] |
- | 每一个内容页面:{ id, path, title, locale, indexable, hidden, draft, fallback, editUrl, lastModified, alternates, collection, entryId }。 |
feeds |
BlumeFeed[] |
- | 生成的 RSS 订阅源:{ href, title }。 |
fontCssVars |
FontHead[] |
- | 供 Astro 的 <Font> 组件在 head 中使用的已配置字体,每个字族一项:{ cssVariable, preloadWeights, preloadSubsets? }。 |
ui |
UIStrings |
- | 默认语言的已解析 UI 文案(搜索、侧边栏和页脚标签)。 |
uiByLocale |
Record<string, UIStrings> |
- | 按语言代码索引的各语言 UI 文案。没有配置 i18n 时为空。 |
routes 带有页面元数据,但不含 type、date 这类 frontmatter。要按内容类型过滤出一份列表 —— 比如博客或更新日志索引 —— 把它与 Astro 的 docs 内容集合搭配使用,后者保存着 frontmatter:
---
import { getCollection } from "astro:content";
import data from "blume:data";
import { getBlumeCollection } from "blume/runtime";
// 在 i18n 下,每个语言都会为每个条目提供一条路由(它的译文,或未翻译页面
// 的副本),它们共享同一个条目 id。这里只用默认语言的路由做键,这样每篇
// 文章只会在一种语言里出现一次链接。
const routes = getBlumeCollection(data, {
locale: data.config.i18n?.defaultLocale,
});
const routeByEntry = new Map(routes.map((route) => [route.entryId, route.path]));
const posts = (await getCollection("docs"))
.filter((entry) => entry.data.type === "blog" && routeByEntry.has(entry.id))
.map((entry) => ({
description: entry.data.description,
href: routeByEntry.get(entry.id),
title: entry.data.title,
}));
---
<ul>
{
posts.map((post) => (
<li>
<a href={post.href}>{post.title}</a>
<p>{post.description}</p>
</li>
))
}
</ul>
运行时辅助函数#
blume/runtime 打包了常用的数据模式,你不必去碰 blume:data 的内部实现。
getBlumeCollection(data, query?) 用来挑选内容路由 —— 可按集合、语言或路径前缀过滤,排除草稿、隐藏页面和 i18n 回退副本,并按路径排序 —— 这正是一个自定义索引所需要的:
---
import data from "blume:data";
import { getBlumeCollection } from "blume/runtime";
const posts = getBlumeCollection(data, { prefix: "/blog" });
---
<ul>
{posts.map((post) => (
<li><a href={post.path}>{post.title}</a></li>
))}
</ul>
<BlumePage> 在自定义页面里渲染某个内容条目的正文,Blume 内置的 MDX 组件(callout 提示框、卡片、步骤……)已经接好 —— 适合在落地页上重点展示某篇文档,或搭建一个展示真实内容的定制索引:
---
import BlumePage from "blume/components/BlumePage.astro";
import data from "blume:data";
import { getBlumeCollection } from "blume/runtime";
const [intro] = getBlumeCollection(data, { prefix: "/docs" });
---
{intro && <BlumePage id={intro.entryId} />}
传入 components 可以加入你自己的覆盖项或交互岛(它们位于生成的运行时中,默认不会导入),传入 collection 则可以从 "docs" 以外的集合读取。
使用站点布局#
RootLayout 通过把自定义页面包进与生成页面相同的三栏网格,为它提供完整的文档外壳 —— 头部、侧边栏、搜索、目录和主题。但对落地页或营销页来说,那个网格是累赘,所以这时该用 PageLayout:它提供文档骨架、头部、主题和字体,然后是一个全宽的 <slot />(没有侧边栏、没有正文排版、没有目录)。可选的 footer 插槽会渲染在 <main> 之后:
---
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
import Footer from "./_home/Footer.astro";
const { config } = data;
---
<PageLayout
site={{ title: config.title, description: config.description }}
logo={config.logo}
banner={config.banner}
analytics={config.analytics}
navigation={data.navigation}
favicon={config.favicon}
fontCssVars={data.fontCssVars}
themeMode={config.theme.mode}
searchEnabled={config.search.enabled}
siteUrl={config.site}
ogEnabled={config.og.enabled}
page={{ title: "Acme — the fastest docs", description: config.description }}
>
<section class="mx-auto max-w-5xl px-6 py-24">
<h1>Build docs that fly</h1>
</section>
<Footer slot="footer" />
</PageLayout>
自定义页面拿到的头部与文档页面拿到的是同一个,因此住在里面的那些外壳也都跟了过来:搜索、主题切换,以及在配置了助手时的助手入口。它们都不需要逐页接线。传 assistantEnabled={false} 可以在保留其它页面助手入口的同时,只让某一个页面不显示它。
语言切换器是例外。自定义页面只在自己的路由上提供服务,因此 Blume 无法知道其它哪些语言也有它的版本,除非你显式传入,否则头部不会显示切换器:localeSwitch 为每种语言接收一个条目 —— { code, label, dir, href, current, untranslated } —— 指向你为那种语言构建的页面。
面向 agent 发现的 head 链接同样会带上:布局会读取已解析的配置,因此自定义页面无需传 prop 就带有与文档页面相同的 describedby、ai-catalog 和 ard 链接。首页还会把自己的 /index.md Markdown 镜像作为 text/markdown 备选链接公布出去,因为这个镜像总是存在;其它自定义页面没有镜像,因此也不会公布。传 discovery={null} 可以让某一个页面不带这些链接。
传 transparentHeader 可以让头部起始时是透明的、外壳部分为白色,这样它能压在页面顶部的深色 hero 之上;页面一滚动它就变回常见的毛玻璃条,搜索对话框也始终保持页面自己的配色。要看出这个效果,hero 必须延伸到头部下方 —— 用头部的高度(-mt-16)把它提上去,并相应补上顶部内边距。
传入 siteUrl(以及 ogEnabled)会自动推导出页面的 canonical 和一张生成的 og:image:Blume 会为每个静态自定义页面渲染一张 Open Graph 卡片(动态 [param] 页面除外)—— 包括首页这个最常被分享的 URL —— 它在 /og/<route>.png 处提供(/ 对应 /og/index.png)。首页卡片的标题是站点标题,副标题是站点描述;层级更深的页面则用路径的最后一段作为标题。显式设置 ogImage 或 canonical 即可覆盖其中任意一项。ogImage 接受一个相对于根的路径 —— public/ 里的文件,会依据 deployment.site 解析成爬虫所需的绝对 URL —— 或者一个外部 URL,后者会原样透传:
<PageLayout
siteUrl={config.site}
ogEnabled={config.og.enabled}
ogImage="/opengraph-image.png"
ogImageAlt="Acme — the fastest docs"
ogImageSize={{ width: 1200, height: 630 }}
page={{ title: config.title }}
>
<!-- 页面内容 -->
</PageLayout>
只有这一个页面会变 —— 其它每条路由都保留自己生成的卡片 —— 因此这是给首页单独配一张定制分享图的办法。生成的卡片会自行向爬虫声明尺寸和替代文字;换成你自己的 ogImage 时,请一并传入 ogImageAlt 和 ogImageSize,让分享卡得到同样的待遇。
页面还会输出 schema.org JSON-LD —— 与文档页面所带的是同一份 WebSite 图谱 —— 这样首页(通常就是一个自定义页面)就不会成为唯一缺少结构化数据的 URL。传 structuredDataEnabled={config.structuredData} 可以让它与 structuredData 配置保持一致,或传 structuredDataEnabled={false} 为某一个页面关掉它。
page.title 会被逐字用作文档标题(不会追加 - siteTitle 后缀),因为营销页通常有自己的标题。若想让自定义页面拥有完整的文档外壳 —— 侧边栏、目录等等 —— 就把它包进生成页面所用的 RootLayout。所需 props 可以直接从 blume:data 取:
---
import RootLayout from "blume/components/layout/RootLayout.astro";
import data from "blume:data";
---
<RootLayout
site={{ title: data.config.title, description: data.config.description }}
logo={data.config.logo}
banner={data.config.banner}
navigation={data.navigation}
page={{ title: "Pricing", route: "/pricing" }}
headings={[]}
themeMode={data.config.theme.mode}
searchEnabled={data.config.search.enabled}
indexable={true}
>
<h1>Pricing</h1>
</RootLayout>
404 页面#
Blume 开箱即带一个默认的未找到页面:一条居中的 “404” 信息,外面裹着站点外壳(头部、搜索、主题),任何未匹配的 URL 都会得到它。blume build 把它写成 404.html,静态托管会自动提供这个文件,blume dev 则在遇到未知路由时显示它。信息下方有一个 接下来可以看看 列表,链到每个顶层板块,以及存在时的 sitemap.xml 和 llms.txt 索引,这样读者 —— 或者跟着过期 URL 而来的 agent —— 总有条回来的路。
这个页面还有一对孪生页面:/404.md 的 Markdown 版和 /404.json 的 JSON 版(RFC 9457 的 problem details),带着同样的恢复链接(设置了 deployment.site 之后就是绝对 URL),开启 JSON API 时还会带上 openapi.json 的描述。在 Vercel 或 Cloudflare 的服务端构建上,对一个缺失页面的请求如果带上 Accept: text/markdown,会得到 Markdown 正文和 404 状态码,而不是 HTML 外壳;带上 Accept: application/json 则会得到 problem document —— 于是 agent 永远不必去解析一整页外壳才知道该往哪儿走。在 Vercel 上,没有任何文件支撑的 .md 或 .json URL 同理。
要换成自己的页面,添加一个 pages/404.astro。它拥有 /404 路由的方式与 pages/changelog.astro 接管更新日志一样 —— 你的页面胜出,默认页面连同它的 /404.md 和 /404.json 孪生页面一起被丢弃。像构建任何其它自定义页面那样构建它,用 PageLayout 或 RootLayout:
---
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
---
<PageLayout
site={{ title: data.config.title, description: data.config.description }}
logo={data.config.logo}
navigation={data.navigation}
themeMode={data.config.theme.mode}
searchEnabled={data.config.search.enabled}
page={{ title: "Page not found", route: "/404" }}
noindex={true}
>
<section class="mx-auto max-w-2xl px-6 py-24 text-center">
<h1>This page took a wrong turn</h1>
<a href="/">Back to home</a>
</section>
</PageLayout>
想保留默认设计、只换文案 —— 包括为其它语言换文案 —— 可以通过 i18n.ui 覆盖 notFound 的 UI 文案(title、description、home)。
交互式页面#
自定义页面就是普通的 Astro,因此你可以用一条 hydration 指令放进 React(或任何框架)的交互岛。项目里一旦出现 .tsx 或 .jsx 文件,React 就会自动启用。
组件覆盖、React 交互岛、registry 和导出。
撰写文章并搭建自定义博客索引。