博客、文档与法律页面

用 MDX 写博客、文档和法律页面:多语言文件命名、frontmatter、目录,以及如何新增一个内容集合。

博客、你正在看的这份文档,以及法律页面,都是仓库里的 MDX 文件,在构建时编译并预渲染成静态 HTML。背后没有 CMS,也没有数据库:发一篇文章就是提交一个文件。

集合目录URLFrontmatter
博客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> 放更新日志:

  1. 把文件放进 content/changelog/。
  2. 在 src/features/content/collections.ts 里加一个 zod schema,以及一次带两个glob 的 collect(...) 调用,照抄 posts 即可。两个 glob 的路径都必须是字符串字面量,因为 Vite 会在构建时改写它们。
  3. 仿照博客,在 src/routes/_marketing/changelog/ 下建路由:首页从 frontmatter列出条目;$slug 的 loader 用 pickLocalized 找到条目并 await load();组件渲染 <MDXContent load={entry.load} />(需要目录的话再加<TableOfContents>)。
  4. 在 src/routes/_marketing.tsx 的页头或页脚链接到它,预渲染才找得到。
  5. 如果开发服务器没在运行,执行 bun run generate-routes。
  6. 扩展 scripts/verify-deletion.ts 里的 content 配方,让它移除新增的目录、路由和链接,再跑 bun run verify:deletion content,保证这个功能仍然可删。

删掉博客和文档

content 配方会删除 src/features/content/、博客和文档的内容、路由、文章封面,以及页头、页脚和落地页里指向 /blog、/docs 的链接。MDX 流水线会保留,因为法律页面要用。见删除功能。