Skip to content

Headless CMS 集成

通过将 VitePress 与 Headless CMS 集成,可以让非技术人员通过可视化界面管理文档内容,同时保持 VitePress 的静态站点生成优势。

为什么集成 CMS

场景说明
团队协作编辑人员无需懂代码即可发布和修改文档
内容审核支持内容发布工作流和审批流程
多渠道分发同一内容可分发到多个平台
动态更新结合 CI/CD 实现 CMS 内容变更自动触发重新构建

方案对比

CMS类型优点缺点适合场景
Strapi开源自托管免费开源、自托管、高度可定制需要自己维护服务器需要完全控制数据的团队
ContentfulSaaS功能完善、SDK 丰富、部署简单有内容条目限制、付费中大型企业
SanitySaaS实时协作、强类型、免费额度大方学习曲线稍高内容频繁更新的项目
Payload开源自托管TypeScript 原生、无头 + 全栈需要 Node.js 运行时Node.js 全栈团队
NotionSaaS免费、界面友好、灵活API 限流严格个人博客、小型文档
Markdown 文件本地文件零成本、Git 版本控制无协作界面开发者主导的项目

方案一:Strapi 集成

1. 安装和初始化 Strapi

bash
# 创建 Strapi 项目
npx create-strapi-app@latest my-cms --quickstart

cd my-cms
npm run develop

2. 创建 Content Type

在 Strapi 管理面板(http://localhost:1337/admin)中创建 Content Type:

Doc 模型:

字段类型说明
titleText文档标题
slugUIDURL 路径标识
contentRichText / Markdown文档内容
categoryRelation所属分类
tagsRelation标签
orderInteger排序权重
publishedAtDateTime发布时间

3. 设置 API 权限

Settings > Roles > Public 中为以下 API 设置 findfindOne 权限:

  • Doc
  • Category
  • Tag

4. 安装 Markdown 插件(可选)

如果需要使用 Markdown 而非富文本编辑器:

bash
cd my-cms
npm install strapi-plugin-markdown

5. 创建数据加载脚本

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 dev

2. 定义 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 groq

4. 创建 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 `![图片](${block.asset?.url || ''})`
      }

      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 dev

2. 定义 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 Docs

3. 创建加载器

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 中创建数据库,包含以下列:

列名类型说明
TitleTitle文档标题
SlugTextURL 路径
StatusSelectPublished / Draft
CategorySelect文档分类
TagsMulti-select标签
OrderNumber排序

2. 获取 Notion API Key

  1. 访问 Notion Integrations
  2. 创建新 Integration
  3. 复制 Internal Integration Token(以 ntn_ 开头)
  4. 在 Notion 数据库页面,点击 ... > Connections > Add connections,选择你创建的 Integration

3. 安装依赖

bash
npm install -D @notionhq/client dotenv

4. 创建 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 contentful

2. 创建加载器

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/dist

Webhook 触发(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

Sanity 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 TextSanity 使用 Portable Text 格式,需要转换库
LexicalPayload 使用 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

相关主题

下一步

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献