博客、文档与法律页面
用 MDX 写博客、文档和法律页面:多语言文件命名、frontmatter、目录,以及如何新增一个内容集合。
博客、你正在看的这份文档,以及法律页面,都是仓库里的 MDX 文件,在构建时编译并预渲染成静态 HTML。背后没有 CMS,也没有数据库:发一篇文章就是提交一个文件。
| 集合 | 目录 | URL | Frontmatter |
|---|---|---|---|
| 博客 | content/blog/ | /blog/<slug> | title、description、date、category、cover |
| 文档 | content/docs/ | /docs/<slug> | title、description、group、order |
| 法律 | content/legal/ | /legal/<slug> | title、updated |
博客和文档属于可删除的 content 功能(src/features/content/)。法律页面属于core:只要收钱,就需要服务条款和隐私政策,不管有没有博客,所以删掉 content 功能时它们会留下。
文件与语言
页面的 slug 就是文件名。一个文件可以不区分语言,也可以绑定某个语言:
content/docs/deploy.mdx # 不区分语言
content/docs/deploy.zh.mdx # 中文
content/blog/launch.en.mdx # 英文
content/blog/launch.zh.mdx # 中文
对每个访客,按这个顺序挑文件:先找他所用语言的版本,再找不区分语言的版本,最后用英文版。还没翻译的页面在 /zh/docs/<slug> 下照样出现——显示英文——而不会从列表里消失。语言如何从 URL 决定,见国际化。
Frontmatter
Frontmatter 是文件开头的 YAML,由 zod 校验:博客和文档在src/features/content/collections.ts,法律页面在 src/core/content/legal.ts。缺字段或拼错字段会让开发服务器或构建直接报错,而不是让访客撞上。
一篇文档:
---
title: 部署
description: 一句话,约 155 个字符以内。它既是 meta description,也是标题下方的副标题。
group: start
order: 5
---
description 用在两处:页面的 meta description,以及页面在标题下渲染的副标题。所以不要在正文第一段重复它,也不要在正文开头写 # 标题——页面会自己输出标题。
一篇博客:
---
title: "D1 不支持交互式事务"
description: "那该怎么办。"
date: "2026-09-05"
category: specs
cover: blog-d1
---
date是字符串,博客首页按它倒序排列。category取guide、specs、example之一(postCategories),在/blog上显示为筛选标签。cover是文件名主干:blog-d1读取public/covers/blog-d1.webp。封面图和文章一起提交进仓库,而不是上传到别处,这样文章和配图在同一个提交里发布。模板自带的封面是免费图库照片,换成你自己的。
编写 MDX
MDX 是 Markdown 加 JSX。标准 Markdown 全部可用,另外支持 GFM 的表格、删除线和任务列表(remark-gfm)。表格太宽时,在手机上会在自己的框里横向滚动。
可以在文件顶部 import 模块并在正文里使用。法律页面就是这样从配置里输出公司名称:
import { appConfig } from '@/config/app-config'
This Service is operated by {appConfig.legalEntity.name}.
MDX 会把内容当 JSX 解析,所以有两个坑:正文里单独出现的 < 或 { 必须放进反引号或转义;不能写 HTML 注释,要写成 {/* 注释 */}。
每个页面都可以直接用一个自定义组件,无需 import:<Clip>,一段带封面帧的视频,从你的媒体源(appConfig.mediaUrl,见落地页与主题)读取。在设置 mediaUrl 之前,它只渲染一个空的占位块。
MDX 在构建时由 @mdx-js/rollup 编译。没有运行时的 MDX 求值——workerd 不允许——所以想从数据库或 API 取内容,就得替换这条流水线。
目录
每个 ## 和 ### 标题都会在编译时获得一个锚点 id,页面同时拿到这些标题的列表(src/core/content/remark-headings.ts)。这个列表就是目录:宽屏下显示在文档页右侧、博客文章左侧。由于它和页面一起生成,它就在预渲染的 HTML 里,也不可能链接到不存在的锚点;只有“当前阅读位置”的高亮需要 JavaScript。
由此可知:
- 只有顶层的
##和###会计入。####以及写在 JSX 里的标题不算。 - 页面标题少于两个时,目录自动隐藏。
- 锚点由标题文字生成(转小写、空格变连字符、去掉标点)。中文标题得到中文锚点。改标题会改锚点,指向
#旧标题的链接就不再跳转。 - 文字相同的两个标题,会加上
-1、-2后缀。
文档侧边栏与排序
文档侧边栏由 frontmatter 生成。group 必须是 docGroups 里的分组之一(start、concepts、features、customize、reference),按这个顺序显示;order 决定组内顺序。页面底部的上一页/下一页链接遵循同样的顺序,/docs 会重定向到第一页。
新增一个分组:在 src/features/content/collections.ts 的 docGroups 里加上 id,在 messages/en.json 和 messages/zh.json 里加一条 docs_group_<id>,再在src/routes/_marketing/docs/$slug.tsx 的 groupLabel 里加一项。这个映射的类型是覆盖所有分组的 Record,漏了 typecheck 会提醒你。
法律页面
法律页面每种语言一个文件:content/legal/terms.en.mdx、terms.zh.mdx,privacy、refunds、dmca 同理。它们带一个 updated 日期,显示在标题下面。自带的文本只是模板——在依赖它之前,请找律师审阅。
法律页面的 slug 列表固定写在 src/core/content/legal.ts 的 legalSlugs 里,页脚则在 src/routes/_marketing.tsx 里逐个手写链接。新增一个法律页面,需要它的文件、列表里的一项,以及一个页脚链接。
页面如何加载
每个集合都是对同一批文件的两次 import.meta.glob:一次只加载 frontmatter(用于列表、侧边栏和标题),另一次懒加载正文,每个文件一个 chunk。页面的路由 loader 会调用 entry.load(),让正文在渲染前就绪。
这样拆分是为了速度。每个页面都要读 frontmatter,如果从编译后的 MDX 模块里读,就会把所有文章的正文都拖进主包。新增集合时请保持这个写法。
预渲染与 sitemap
博客、文档和法律页面在 bun run build 时预渲染:预渲染从首页出发沿链接爬取,所以只要有地方链接到一个页面,它就会被收录。构建之后,scripts/generate-sitemap.ts 根据预渲染出的文件写出 sitemap.xml。不需要手动登记。
新增一个集合
假设你想在 /changelog/<slug> 放更新日志:
- 把文件放进
content/changelog/。 - 在
src/features/content/collections.ts里加一个 zod schema,以及一次带两个glob 的collect(...)调用,照抄posts即可。两个 glob 的路径都必须是字符串字面量,因为 Vite 会在构建时改写它们。 - 仿照博客,在
src/routes/_marketing/changelog/下建路由:首页从 frontmatter列出条目;$slug的 loader 用pickLocalized找到条目并 awaitload();组件渲染<MDXContent load={entry.load} />(需要目录的话再加<TableOfContents>)。 - 在
src/routes/_marketing.tsx的页头或页脚链接到它,预渲染才找得到。 - 如果开发服务器没在运行,执行
bun run generate-routes。 - 扩展
scripts/verify-deletion.ts里的content配方,让它移除新增的目录、路由和链接,再跑bun run verify:deletion content,保证这个功能仍然可删。
删掉博客和文档
content 配方会删除 src/features/content/、博客和文档的内容、路由、文章封面,以及页头、页脚和落地页里指向 /blog、/docs 的链接。MDX 流水线会保留,因为法律页面要用。见删除功能。