Headless CMS 集成
通过将 VitePress 与 Headless CMS 集成,可以让非技术人员通过可视化界面管理文档内容,同时保持 VitePress 的静态站点生成优势。
为什么集成 CMS
| 场景 | 说明 |
|---|---|
| 团队协作 | 编辑人员无需懂代码即可发布和修改文档 |
| 内容审核 | 支持内容发布工作流和审批流程 |
| 多渠道分发 | 同一内容可分发到多个平台 |
| 动态更新 | 结合 CI/CD 实现 CMS 内容变更自动触发重新构建 |
方案对比
| CMS | 类型 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| Strapi | 开源自托管 | 免费开源、自托管、高度可定制 | 需要自己维护服务器 | 需要完全控制数据的团队 |
| Contentful | SaaS | 功能完善、SDK 丰富、部署简单 | 有内容条目限制、付费 | 中大型企业 |
| Sanity | SaaS | 实时协作、强类型、免费额度大方 | 学习曲线稍高 | 内容频繁更新的项目 |
| Notion | SaaS | 免费、界面友好、灵活 | API 限流严格 | 个人博客、小型文档 |
| Markdown 文件 | 本地文件 | 零成本、Git 版本控制 | 无协作界面 | 开发者主导的项目 |
方案一:Strapi 集成
1. 安装和初始化 Strapi
bash
# 创建 Strapi 项目
npx create-strapi-app@latest my-cms --quickstart
cd my-cms
npm run develop2. 创建 Content Type
在 Strapi 管理面板(http://localhost:1337/admin)中创建 Content Type:
Doc 模型:
| 字段 | 类型 | 说明 |
|---|---|---|
| title | Text | 文档标题 |
| slug | UID | URL 路径标识 |
| content | RichText / Markdown | 文档内容 |
| category | Relation | 所属分类 |
| tags | Relation | 标签 |
| order | Integer | 排序权重 |
| publishedAt | DateTime | 发布时间 |
3. 设置 API 权限
在 Settings > Roles > Public 中为以下 API 设置 find 和 findOne 权限:
- Doc
- Category
- Tag
4. 安装 Markdown 插件(可选)
如果需要使用 Markdown 而非富文本编辑器:
bash
cd my-cms
npm install strapi-plugin-markdown5. 创建数据加载脚本
ts
// docs/.vitepress/cms-loaders/strapi.ts
interface StrapiDoc {
id: number
attributes: {
title: string
slug: string
content: string
order: number
publishedAt: string
category?: {
data: {
attributes: { name: string; slug: string }
}
}
tags?: {
data: Array<{
attributes: { name: string }
}>
}
}
}
interface DocFrontmatter {
title: string
category?: string
tags?: string[]
date?: string
}
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
const STRAPI_TOKEN = process.env.STRAPI_TOKEN || ''
export async function loadStrapiDocs(
category?: string
): Promise<Array<{ frontmatter: DocFrontmatter; content: string; slug: string }>> {
const params = new URLSearchParams({
'populate': 'category,tags',
'sort': 'order:asc',
'pagination[pageSize]': '100'
})
if (category) {
params.set('filters[category][slug][$eq]', category)
}
const res = await fetch(
`${STRAPI_URL}/api/docs?${params}`,
{
headers: STRAPI_TOKEN ? { Authorization: `Bearer ${STRAPI_TOKEN}` } : {}
}
)
if (!res.ok) {
throw new Error(`Strapi API error: ${res.status} ${res.statusText}`)
}
const json = await res.json()
return json.data.map((doc: StrapiDoc) => ({
slug: doc.attributes.slug,
frontmatter: {
title: doc.attributes.title,
category: doc.attributes.category?.data?.attributes?.name,
tags: doc.attributes.tags?.data?.map(t => t.attributes.name),
date: doc.attributes.publishedAt
},
content: doc.attributes.content || ''
}))
}6. 构建时生成 Markdown
ts
// scripts/build-from-strapi.ts
import fs from 'fs'
import path from 'path'
import { loadStrapiDocs } from '../docs/.vitepress/cms-loaders/strapi'
async function build() {
try {
const docs = await loadStrapiDocs()
for (const doc of docs) {
// 生成 frontmatter
const frontmatter = Object.entries(doc.frontmatter)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`)
.join('\n')
const markdown = `---\n${frontmatter}\n---\n\n${doc.content}\n`
const filePath = path.join('docs', 'cms', `${doc.slug}.md`)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, markdown, 'utf-8')
}
console.log(`✅ Generated ${docs.length} docs from Strapi`)
} catch (error) {
console.error('❌ Failed to fetch docs:', error)
process.exit(1)
}
}
build()7. 更新 package.json
json
{
"scripts": {
"cms:pull": "tsx scripts/build-from-strapi.ts",
"build": "npm run cms:pull && vitepress build docs"
}
}方案二:Notion 集成
Notion 作为 CMS 具有零成本、界面友好的优势,适合个人或小团队。
1. 创建 Notion 数据库
在 Notion 中创建数据库,包含以下列:
| 列名 | 类型 | 说明 |
|---|---|---|
| Title | Title | 文档标题 |
| Slug | Text | URL 路径 |
| Status | Select | Published / Draft |
| Category | Select | 文档分类 |
| Tags | Multi-select | 标签 |
| Order | Number | 排序 |
2. 获取 Notion API Key
- 访问 Notion Integrations
- 创建新 Integration
- 复制
Internal Integration Token(以ntn_开头) - 在 Notion 数据库页面,点击
... > Connections > Add connections,选择你创建的 Integration
3. 安装依赖
bash
npm install -D @notionhq/client dotenv4. 创建 Notion 加载器
ts
// docs/.vitepress/cms-loaders/notion.ts
import { Client } from '@notionhq/client'
const notion = new Client({ auth: process.env.NOTION_TOKEN })
const DATABASE_ID = process.env.NOTION_DATABASE_ID || ''
interface NotionDoc {
title: string
slug: string
status: string
category?: string
tags: string[]
content: string
}
function extractPlainText(block: any): string {
if (block.type === 'paragraph') {
return block.paragraph.rich_text.map((t: any) => t.plain_text).join('')
}
if (block.type === 'heading_1') {
return `# ${block.heading_1.rich_text.map((t: any) => t.plain_text).join('')}`
}
if (block.type === 'heading_2') {
return `## ${block.heading_2.rich_text.map((t: any) => t.plain_text).join('')}`
}
if (block.type === 'heading_3') {
return `### ${block.heading_3.rich_text.map((t: any) => t.plain_text).join('')}`
}
if (block.type === 'code') {
return '```' + block.code.language + '\n' + block.code.rich_text.map((t: any) => t.plain_text).join('') + '\n```'
}
if (block.type === 'bulleted_list_item') {
return '- ' + block.bulleted_list_item.rich_text.map((t: any) => t.plain_text).join('')
}
if (block.type === 'numbered_list_item') {
return '1. ' + block.numbered_list_item.rich_text.map((t: any) => t.plain_text).join('')
}
if (block.type === 'quote') {
return '> ' + block.quote.rich_text.map((t: any) => t.plain_text).join('')
}
return ''
}
export async function loadNotionDocs(status = 'Published'): Promise<NotionDoc[]> {
const response = await notion.databases.query({
database_id: DATABASE_ID,
filter: {
property: 'Status',
select: { equals: status }
},
sorts: [{ property: 'Order', direction: 'ascending' }]
})
const docs: NotionDoc[] = []
for (const page of response.results) {
const props = (page as any).properties
// 获取页面内容
const blocks = await notion.blocks.children.list({
block_id: page.id
})
const content = blocks.results
.map(extractPlainText)
.filter(Boolean)
.join('\n\n')
docs.push({
title: props.Title.title.map((t: any) => t.plain_text).join(''),
slug: props.Slug.rich_text.map((t: any) => t.plain_text).join(''),
status: props.Status.select?.name || '',
category: props.Category?.select?.name,
tags: props.Tags?.multi_select?.map((t: any) => t.name) || [],
content
})
}
return docs
}5. 构建脚本
ts
// scripts/build-from-notion.ts
import fs from 'fs'
import path from 'path'
import { loadNotionDocs } from '../docs/.vitepress/cms-loaders/notion'
async function build() {
const docs = await loadNotionDocs('Published')
for (const doc of docs) {
const fm: Record<string, any> = { title: doc.title }
if (doc.category) fm.category = doc.category
if (doc.tags.length) fm.tags = doc.tags
const frontmatter = Object.entries(fm)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`)
.join('\n')
const markdown = `---\n${frontmatter}\n---\n\n${doc.content}\n`
const filePath = path.join('docs', 'notion', `${doc.slug}.md`)
fs.mkdirSync(path.dirname(filePath), { recursive: recursive: true })
fs.writeFileSync(filePath, markdown, 'utf-8')
}
console.log(`✅ Generated ${docs.length} docs from Notion`)
}
build()方案三:Contentful 集成
1. 安装 SDK
bash
npm install -D contentful2. 创建加载器
ts
// docs/.vitepress/cms-loaders/contentful.ts
import { createClient, type Entry } from 'contentful'
const client = createClient({
space: process.env.CTF_SPACE_ID!,
accessToken: process.env.CTF_DELIVERY_TOKEN!,
environment: process.env.CTF_ENVIRONMENT || 'master'
})
interface ContentfulDoc {
title: string
slug: string
body: string
category?: string
tags?: string[]
}
export async function loadContentfulDocs(): Promise<ContentfulDoc[]> {
const entries = await client.getEntries<ContentfulDoc>({
content_type: 'doc',
order: 'fields.order'
})
return entries.items.map((entry: Entry<ContentfulDoc>) => ({
title: entry.fields.title,
slug: entry.fields.slug,
body: entry.fields.body,
category: entry.fields.category,
tags: entry.fields.tags
}))
}CI/CD 自动同步
GitHub Actions 工作流
当 CMS 内容更新时自动触发构建:
yaml
# .github/workflows/cms-sync.yml
name: CMS Sync & Build
on:
# 定时同步(每小时检查一次)
schedule:
- cron: '0 * * * *'
# 或手动触发
workflow_dispatch:
jobs:
sync-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
# 拉取 CMS 内容生成 Markdown
- run: npm run cms:pull
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
# 检查是否有内容变化
- name: Check for changes
id: changes
run: |
git diff --exit-code docs/cms/ || echo "has_changes=true" >> $GITHUB_OUTPUT
# 有变化则构建部署
- name: Build
if: steps.changes.outputs.has_changes == 'true'
run: npm run build
- name: Deploy
if: steps.changes.outputs.has_changes == 'true'
run: npx wrangler pages deploy docs/.vitepress/distWebhook 触发(Strapi)
在 Strapi 中配置 Webhook,内容发布时自动触发构建:
yaml
# .github/workflows/strapi-webhook.yml
name: Build from Strapi Webhook
on:
repository_dispatch:
types: [strapi-update]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run cms:pull
env:
STRAPI_URL: ${{ secrets.STRAPI_URL }}
STRAPI_TOKEN: ${{ secrets.STRAPI_TOKEN }}
- run: npm run build
- run: npm run deploy方案选择建议
mermaid
graph TD
A[选择 CMS 方案] --> B{团队规模}
B -->|个人/小团队| C{预算}
B -->|企业级| D[Contentful / Sanity]
C -->|零成本| E[Notion / Markdown 文件]
C -->|需要自托管| F[Strapi]
C -->|需要高级功能| D推荐方案
- 个人博客:使用 Notion,零成本且界面友好
- 团队文档:使用 Strapi,开源免费且功能强大
- 企业级项目:使用 Contentful 或 Sanity,专业级的 CMS 功能
注意事项
| 注意点 | 说明 |
|---|---|
| 构建时间 | CMS 拉取数据会增加构建时间,建议使用增量构建 |
| API 限流 | 注意 CMS 的 API 调用频率限制 |
| 内容备份 | 即使使用 CMS,建议在 Git 中保存生成的 Markdown 文件 |
| 离线支持 | 本地开发时需要能够离线工作,建议提供本地 fallback 数据 |
下一步
- 学习 数据加载 了解 VitePress 原生的数据加载方式
- 学习 CI/CD 自动化部署 了解自动构建部署方案
- 学习 部署与发布 了解多平台部署方法