Headless CMS 集成
通过将 VitePress 与 Headless CMS 集成,可以让非技术人员通过可视化界面管理文档内容,同时保持 VitePress 的静态站点生成优势。
为什么集成 CMS
| 场景 | 说明 |
|---|---|
| 团队协作 | 编辑人员无需懂代码即可发布和修改文档 |
| 内容审核 | 支持内容发布工作流和审批流程 |
| 多渠道分发 | 同一内容可分发到多个平台 |
| 动态更新 | 结合 CI/CD 实现 CMS 内容变更自动触发重新构建 |
方案对比
| CMS | 类型 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| Strapi | 开源自托管 | 免费开源、自托管、高度可定制 | 需要自己维护服务器 | 需要完全控制数据的团队 |
| Contentful | SaaS | 功能完善、SDK 丰富、部署简单 | 有内容条目限制、付费 | 中大型企业 |
| Sanity | SaaS | 实时协作、强类型、免费额度大方 | 学习曲线稍高 | 内容频繁更新的项目 |
| Payload | 开源自托管 | TypeScript 原生、无头 + 全栈 | 需要 Node.js 运行时 | Node.js 全栈团队 |
| 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"
}
}方案二:Sanity 集成
Sanity 提供实时协作和结构化内容,适合内容频繁更新的项目。
1. 安装 Sanity CLI 并初始化
bash
# 安装 CLI
npm install -g sanity@latest
# 创建项目
sanity init --project-plan free
# 启动开发服务器
sanity dev2. 定义 Schema
ts
// schemas/doc.ts
import { defineField, defineType } from 'sanity'
export default defineType({
name: 'doc',
title: '文档',
type: 'document',
fields: [
defineField({
name: 'title',
title: '标题',
type: 'string',
validation: (Rule) => Rule.required()
}),
defineField({
name: 'slug',
title: '路径',
type: 'slug',
options: { source: 'title' },
validation: (Rule) => Rule.required()
}),
defineField({
name: 'content',
title: '内容',
type: 'array',
of: [
{ type: 'block' },
{ type: 'code' },
{
type: 'image',
options: { hotspot: true }
}
]
}),
defineField({
name: 'category',
title: '分类',
type: 'reference',
to: [{ type: 'category' }]
}),
defineField({
name: 'tags',
title: '标签',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'tag' }] }]
}),
defineField({
name: 'order',
title: '排序',
type: 'number',
initialValue: 0
}),
defineField({
name: 'publishedAt',
title: '发布时间',
type: 'datetime',
initialValue: () => new Date().toISOString()
})
],
orderings: [
{
title: '排序权重',
name: 'orderAsc',
by: [{ field: 'order', direction: 'asc' }]
}
],
preview: {
select: {
title: 'title',
subtitle: 'category.title'
}
}
})3. 安装依赖
bash
npm install -D @sanity/client groq4. 创建 Sanity 加载器
ts
// docs/.vitepress/cms-loaders/sanity.ts
import { createClient } from '@sanity/client'
import groq from 'groq'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID || '',
dataset: process.env.SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: true
})
interface SanityDoc {
title: string
slug: { current: string }
content: any[]
category?: { title: string; slug: { current: string } }
tags?: Array<{ name: string; slug: { current: string } }>
order: number
publishedAt: string
}
/**
* 将 Portable Text 转换为 Markdown
*/
function portableTextToMarkdown(blocks: any[]): string {
return blocks
.map((block: any) => {
if (block._type === 'block') {
const text = block.children
.map((child: any) => {
let t = child.text
if (child.marks?.includes('strong')) t = `**${t}**`
if (child.marks?.includes('em')) t = `*${t}*`
if (child.marks?.includes('code')) t = `\`${t}\``
return t
})
.join('')
const style = block.style || 'normal'
if (style === 'h1') return `# ${text}`
if (style === 'h2') return `## ${text}`
if (style === 'h3') return `### ${text}`
if (style === 'h4') return `#### ${text}`
if (style === 'blockquote') return `> ${text}`
return text
}
if (block._type === 'code') {
return `\`\`\`${block.language || ''}\n${block.code}\n\`\`\``
}
if (block._type === 'image') {
return ``
}
return ''
})
.join('\n\n')
}
export async function loadSanityDocs(): Promise<Array<{
frontmatter: Record<string, any>
content: string
slug: string
}>> {
const query = groq`*[_type == "doc" && !(_id in path("drafts.**"))] | order(order asc) {
title,
slug,
content,
category-> { title, slug },
"tags": tags[]-> { name, slug },
order,
publishedAt
}`
const docs = await client.fetch<SanityDoc[]>(query)
return docs.map((doc) => ({
slug: doc.slug.current,
frontmatter: {
title: doc.title,
category: doc.category?.title,
tags: doc.tags?.map((t) => t.name),
date: doc.publishedAt
},
content: portableTextToMarkdown(doc.content || [])
}))
}5. 构建脚本
ts
// scripts/build-from-sanity.ts
import fs from 'fs'
import path from 'path'
import { loadSanityDocs } from '../docs/.vitepress/cms-loaders/sanity'
async function build() {
const docs = await loadSanityDocs()
for (const doc of docs) {
const fm = Object.entries(doc.frontmatter)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`)
.join('\n')
const markdown = `---\n${fm}\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 Sanity`)
}
build()方案三:Payload CMS 集成
Payload 是 TypeScript 原生的无头 CMS,适合 Node.js 全栈团队。
1. 安装和初始化
bash
# 创建 Payload 项目
npx create-payload-app@latest my-cms
cd my-cms
npm run dev2. 定义 Collection
ts
// src/collections/Docs.ts
import { CollectionConfig } from 'payload/types'
const Docs: CollectionConfig = {
slug: 'docs',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'order', 'updatedAt']
},
fields: [
{
name: 'title',
type: 'text',
required: true
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar'
}
},
{
name: 'content',
type: 'richText',
required: true
},
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
admin: {
position: 'sidebar'
}
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
admin: {
position: 'sidebar'
}
},
{
name: 'order',
type: 'number',
defaultValue: 0,
admin: {
position: 'sidebar'
}
}
],
versions: {
drafts: true
}
}
export default Docs3. 创建加载器
ts
// docs/.vitepress/cms-loaders/payload.ts
const PAYLOAD_URL = process.env.PAYLOAD_URL || 'http://localhost:3000'
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || ''
interface PayloadDoc {
id: string
title: string
slug: string
content: any
category?: { title: string }
tags?: Array<{ name: string }>
order: number
updatedAt: string
}
export async function loadPayloadDocs(): Promise<Array<{
frontmatter: Record<string, any>
content: string
slug: string
}>> {
const headers: Record<string, string> = {}
if (PAYLOAD_API_KEY) {
headers['Authorization'] = `users API-Key ${PAYLOAD_API_KEY}`
}
const res = await fetch(
`${PAYLOAD_URL}/api/docs?limit=100&depth=1&draft=false`,
{ headers }
)
if (!res.ok) {
throw new Error(`Payload API error: ${res.status} ${res.statusText}`)
}
const json = await res.json()
return json.docs.map((doc: PayloadDoc) => ({
slug: doc.slug,
frontmatter: {
title: doc.title,
category: doc.category?.title,
tags: doc.tags?.map((t) => t.name),
date: doc.updatedAt
},
// Payload 的 richText 使用 Lexical 格式
// 需要根据实际编辑器格式转换
content: convertLexicalToMarkdown(doc.content)
}))
}
function convertLexicalToMarkdown(content: any): string {
// 简化示例:实际需要递归处理 Lexical 节点
if (!content) return ''
if (typeof content === 'string') return content
// 推荐使用 @payloadcms/richtext-lexical 的序列化器
return JSON.stringify(content)
}方案四: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: 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
}))
}方案六:VitePress 原生数据加载器
VitePress v2 原生支持 createContentLoader,可以在不引入外部 CMS 的情况下实现类似的数据加载能力:
ts
// docs/posts.data.ts
import { createContentLoader } from 'vitepress'
export default createContentLoader('posts/*.md', {
includeSrc: true,
transform(raw) {
return raw
.filter(page => !page.frontmatter.draft)
.sort((a, b) => {
return +new Date(b.frontmatter.date) - +new Date(a.frontmatter.date)
})
.map(page => ({
title: page.frontmatter.title,
url: page.url,
date: page.frontmatter.date,
excerpt: page.excerpt,
tags: page.frontmatter.tags || []
}))
}
})在组件中使用:
vue
<script setup lang="ts">
import { data as posts } from '../posts.data'
</script>
<template>
<ul>
<li v-for="post of posts" :key="post.url">
<a :href="post.url">{{ post.title }}</a>
<span>{{ post.date }}</span>
</li>
</ul>
</template>推荐策略
如果你的团队以开发者为主,优先使用 Markdown 文件 + createContentLoader;如果需要非技术人员编辑内容,再考虑引入 Headless CMS。
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 }}
STRAPI_URL: ${{ secrets.STRAPI_URL }}
STRAPI_TOKEN: ${{ secrets.STRAPI_TOKEN }}
# 检查是否有内容变化
- 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 deploySanity Webhook
Sanity 支持 GROQ Powered Webhooks,内容变更时自动触发:
yaml
# .github/workflows/sanity-webhook.yml
name: Build from Sanity
on:
repository_dispatch:
types: [sanity-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:
SANITY_PROJECT_ID: ${{ secrets.SANITY_PROJECT_ID }}
SANITY_DATASET: ${{ secrets.SANITY_DATASET }}
- run: npm run build方案选择建议
mermaid
graph TD
A[选择 CMS 方案] --> B{团队规模}
B -->|个人/小团队| C{预算}
B -->|企业级| D[Contentful / Sanity]
B -->|全栈团队| E[Payload / Strapi]
C -->|零成本| F[Notion / Markdown 文件]
C -->|需要自托管| G[Strapi / Payload]
C -->|需要高级功能| D推荐方案
- 个人博客:使用 Notion,零成本且界面友好
- 团队文档:使用 Strapi,开源免费且功能强大
- 全栈团队:使用 Payload,TypeScript 原生体验
- 企业级项目:使用 Contentful 或 Sanity,专业级的 CMS 功能
- 开发者团队:使用 Markdown 文件 +
createContentLoader,零依赖
注意事项
| 注意点 | 说明 |
|---|---|
| 构建时间 | CMS 拉取数据会增加构建时间,建议使用增量构建 |
| API 限流 | 注意 CMS 的 API 调用频率限制 |
| 内容备份 | 即使使用 CMS,建议在 Git 中保存生成的 Markdown 文件 |
| 离线支持 | 本地开发时需要能够离线工作,建议提供本地 fallback 数据 |
| Portable Text | Sanity 使用 Portable Text 格式,需要转换库 |
| Lexical | Payload 使用 Lexical 编辑器,需要序列化器 |
| 图片处理 | CMS 图片通常托管在 CDN,注意配置 head 中的域名 |
环境变量配置
各 CMS 需要的环境变量汇总:
bash
# .env(不要提交到 Git)
# Strapi
STRAPI_URL=http://localhost:1337
STRAPI_TOKEN=your-api-token
# Notion
NOTION_TOKEN=ntn_xxxxx
NOTION_DATABASE_ID=xxxxx
# Contentful
CTF_SPACE_ID=xxxxx
CTF_DELIVERY_TOKEN=xxxxx
CTF_ENVIRONMENT=master
# Sanity
SANITY_PROJECT_ID=xxxxx
SANITY_DATASET=production
# Payload
PAYLOAD_URL=http://localhost:3000
PAYLOAD_API_KEY=xxxxx相关主题
- 数据加载 — VitePress 原生数据加载方式
- CI/CD 自动化部署 — 自动构建部署方案
- 部署与发布 — 多平台部署方法
下一步
- 学习 数据加载 了解 VitePress 原生的数据加载方式
- 学习 CI/CD 自动化部署 了解自动构建部署方案
- 学习 部署与发布 了解多平台部署方法