Skip to content

CMS 集成

将 VitePress 与 Headless CMS 系统集成,实现内容管理的现代化,让非技术人员也能参与文档编写。

CMS 方案对比

特性StrapiNotionContentfulSanityTinaCMS
类型自托管云服务云服务云服务Git-based
免费✅ 开源✅ 免费版⚠️ 有免费额度⚠️ 有免费额度✅ 开源
API 类型REST/GraphQLAPIREST/GraphQLGROQGit API
实时预览
学习曲线中等简单中等中等中等
非技术友好✅✅
自托管

选择建议

场景推荐原因
开源项目TinaCMSGit-based,与 VitePress 天然契合
团队协作Notion编辑体验好,非技术人员友好
企业项目Contentful功能全面,多语言支持强
自托管需求Strapi开源可控,插件生态丰富
开发者友好Sanity强类型查询 GROQ,实时协作
轻量级方案Notion无需部署,API 调用简单

CMS 数据流

mermaid
graph LR
    A[CMS 内容编辑] --> B[API 获取数据]
    B --> C[数据加载器]
    C --> D[生成 Markdown]
    D --> E[VitePress 构建]
    E --> F[静态站点]

Strapi

开源 Node.js Headless CMS,可自托管,支持 REST/GraphQL API。

安装 Strapi

bash
npx create-strapi-app@latest my-project --quickstart

创建内容类型

在 Stravi 管理后台创建 Article 内容类型:

字段类型说明
titleString文章标题
slugUIDURL 路径
contentRich Text文章内容
descriptionText文章描述
publishedAtDateTime发布时间
tagsRelation标签关联

数据加载器

ts
// docs/.vitepress/theme/data/strapi.data.ts
import { defineLoader } from 'vitepress'

interface StrapiArticle {
  id: number
  title: string
  slug: string
  content: string
  description: string
  publishedAt: string
  tags: { name: string; slug: string }[]
}

export default defineLoader({
  watch: ['**/*.md'], // 监听文件变化
  async load(): Promise<StrapiArticle[]> {
    const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
    const API_TOKEN = process.env.STRAPI_API_TOKEN || ''

    const res = await fetch(`${STRAPI_URL}/api/articles?populate=tags&pagination[pageSize]=100`, {
      headers: {
        Authorization: `Bearer ${API_TOKEN}`
      }
    })

    if (!res.ok) {
      throw new Error(`Strapi API 请求失败: ${res.status}`)
    }

    const { data } = await res.json()
    return data.map((item: any) => ({
      id: item.id,
      title: item.attributes.title,
      slug: item.attributes.slug,
      content: item.attributes.content,
      description: item.attributes.description,
      publishedAt: item.attributes.publishedAt,
      tags: item.attributes.tags?.data?.map((t: any) => ({
        name: t.attributes.name,
        slug: t.attributes.slug
      })) || []
    }))
  }
})

生成 Markdown 页面

ts
// scripts/strapi-sync.ts
import { writeFileSync, mkdirSync } from 'fs'
import { join } from 'path'

interface Article {
  title: string
  slug: string
  content: string
  description: string
  publishedAt: string
  tags: { name: string }[]
}

async function syncStrapi() {
  const res = await fetch('http://localhost:1337/api/articles?populate=tags')
  const { data } = await res.json()

  for (const item of data) {
    const attrs = item.attributes
    const frontmatter = `---
title: ${attrs.title}
description: ${attrs.description}
date: ${attrs.publishedAt}
tags:
${attrs.tags?.data?.map((t: any) => `  - ${t.attributes.name}`).join('\n') || ''}
---

`
    const dir = join('docs', 'articles')
    mkdirSync(dir, { recursive: true })
    writeFileSync(join(dir, `${attrs.slug}.md`), frontmatter + attrs.content)
  }

  console.log(`同步完成:${data.length} 篇文章`)
}

syncStrapi().catch(console.error)

Notion

将 Notion 作为 CMS,编辑体验极佳,非技术人员友好。

安装依赖

bash
npm install @notionhq/client -D

数据加载器

ts
// docs/.vitepress/theme/data/notion.data.ts
import { defineLoader } from 'vitepress'
import { Client } from '@notionhq/client'

interface NotionPage {
  id: string
  title: string
  slug: string
  content: string
  description: string
  lastEditedTime: string
}

const notion = new Client({ auth: process.env.NOTION_API_KEY })

// Notion Block 转 Markdown
function blockToMarkdown(block: any): string {
  const type = block.type
  const text = block[type]?.rich_text
    ?.map((t: any) => t.plain_text)
    .join('') || ''

  switch (type) {
    case 'heading_1': return `# ${text}`
    case 'heading_2': return `## ${text}`
    case 'heading_3': return `### ${text}`
    case 'paragraph': return text
    case 'bulleted_list_item': return `- ${text}`
    case 'numbered_list_item': return `1. ${text}`
    case 'code':
      return `\`\`\`${block[code]?.language || ''}\n${block[code]?.rich_text?.[0]?.plain_text || ''}\n\`\`\``
    case 'quote': return `> ${text}`
    case 'divider': return '---'
    case 'image':
      return `![${block[image]?.caption?.[0]?.plain_text || ''}](${block[image]?.file?.url || block[image]?.external?.url || ''})`
    default: return text
  }
}

export default defineLoader({
  async load(): Promise<NotionPage[]> {
    const databaseId = process.env.NOTION_DATABASE_ID || ''

    // 查询数据库
    const response = await notion.databases.query({
      database_id: databaseId,
      filter: {
        property: 'Status',
        select: { equals: 'Published' }
      }
    })

    const pages: NotionPage[] = []

    for (const page of response.results) {
      // 获取页面属性
      const props = (page as any).properties
      const title = props.Name?.title?.[0]?.plain_text || ''
      const slug = props.Slug?.rich_text?.[0]?.plain_text || ''
      const description = props.Description?.rich_text?.[0]?.plain_text || ''

      // 获取页面内容
      const blocks = await notion.blocks.children.list({
        block_id: page.id
      })

      const content = blocks.results
        .map(blockToMarkdown)
        .join('\n\n')

      pages.push({
        id: page.id,
        title,
        slug,
        content,
        description,
        lastEditedTime: (page as any).last_edited_time
      })
    }

    return pages
  }
})

Notion 数据库设置

在 Notion 中创建数据库,添加以下属性:

属性名类型说明
NameTitle文章标题
SlugTextURL 路径
DescriptionText文章描述
StatusSelect发布状态(Draft/Published)
TagsMulti-select文章标签
DateDate发布日期

Contentful

企业级 Headless CMS,云端托管,多语言支持强。

安装依赖

bash
npm install contentful -D

数据加载器

ts
// docs/.vitepress/theme/data/contentful.data.ts
import { defineLoader } from 'vitepress'
import * as contentful from 'contentful'

interface ContentfulArticle {
  id: string
  title: string
  slug: string
  content: string
  description: string
  tags: string[]
  publishedAt: string
}

export default defineLoader({
  async load(): Promise<ContentfulArticle[]> {
    const client = contentful.createClient({
      space: process.env.CONTENTFUL_SPACE_ID || '',
      accessToken: process.env.CONTENTFUL_ACCESS_TOKEN || ''
    })

    const entries = await client.getEntries({
      content_type: 'article',
      order: '-sys.createdAt'
    })

    return entries.items.map((entry: any) => ({
      id: entry.sys.id,
      title: entry.fields.title,
      slug: entry.fields.slug,
      content: entry.fields.content || '',
      description: entry.fields.description || '',
      tags: entry.fields.tags || [],
      publishedAt: entry.sys.createdAt
    }))
  }
})

Contentful Rich Text 转 Markdown

ts
// docs/.vitepress/theme/utils/rich-text-to-md.ts
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'

export function richTextToMarkdown(richText: any): string {
  // 先转 HTML,再简化为 Markdown
  const html = documentToHtmlString(richText)
  return html
    .replace(/<h1>/g, '# ')
    .replace(/<\/h1>/g, '')
    .replace(/<h2>/g, '## ')
    .replace(/<\/h2>/g, '')
    .replace(/<h3>/g, '### ')
    .replace(/<\/h3>/g, '')
    .replace(/<p>/g, '')
    .replace(/<\/p>/g, '\n\n')
    .replace(/<strong>/g, '**')
    .replace(/<\/strong>/g, '**')
    .replace(/<em>/g, '*')
    .replace(/<\/em>/g, '*')
    .replace(/<code>/g, '`')
    .replace(/<\/code>/g, '`')
    .replace(/<pre><code[^>]*>/g, '```\n')
    .replace(/<\/code><\/pre>/g, '\n```')
    .replace(/<li>/g, '- ')
    .replace(/<\/li>/g, '\n')
    .replace(/<[^>]+>/g, '')
    .trim()
}

Sanity

实时协作的 Headless CMS,GROQ 查询语言强大。

安装依赖

bash
npm install @sanity/client -D

数据加载器

ts
// docs/.vitepress/theme/data/sanity.data.ts
import { defineLoader } from 'vitepress'
import { createClient } from '@sanity/client'

interface SanityArticle {
  id: string
  title: string
  slug: string
  content: string
  description: string
  tags: string[]
  publishedAt: string
}

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID || '',
  dataset: process.env.SANITY_DATASET || 'production',
  apiVersion: '2024-01-01',
  useCdn: true
})

// Portable Text 转 Markdown
function portableTextToMarkdown(blocks: any[]): string {
  return blocks
    .map((block: any) => {
      if (block._type !== 'block' || !block.children) return ''

      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('')

      switch (block.style) {
        case 'h1': return `# ${text}`
        case 'h2': return `## ${text}`
        case 'h3': return `### ${text}`
        case 'blockquote': return `> ${text}`
        default: return text
      }
    })
    .filter(Boolean)
    .join('\n\n')
}

export default defineLoader({
  async load(): Promise<SanityArticle[]> {
    const query = `*[_type == "article" && published == true] | order(publishedAt desc) {
      _id,
      title,
      "slug": slug.current,
      body,
      description,
      tags,
      publishedAt
    }`

    const results = await client.fetch(query)

    return results.map((item: any) => ({
      id: item._id,
      title: item.title,
      slug: item.slug,
      content: portableTextToMarkdown(item.body || []),
      description: item.description || '',
      tags: item.tags || [],
      publishedAt: item.publishedAt
    }))
  }
})

TinaCMS

Git-based CMS,与 VitePress 深度集成,支持实时预览。

初始化 TinaCMS

bash
npx @tinacms/cli@latest init

配置 Schema

ts
// tina/config.ts
import { defineConfig } from 'tinacms'

export default defineConfig({
  clientId: process.env.TINA_CLIENT_ID || '',
  branch: 'main',
  token: process.env_TINA_TOKEN || '',
  build: {
    outputFolder: 'admin',
    publicFolder: 'docs/public'
  },
  media: {
    tina: {
      mediaRoot: 'images',
      publicFolder: 'public'
    }
  },
  schema: {
    collections: [
      {
        name: 'doc',
        label: '文档',
        path: 'docs',
        format: 'md',
        fields: [
          {
            type: 'string',
            name: 'title',
            label: '标题',
            isTitle: true,
            required: true
          },
          {
            type: 'string',
            name: 'description',
            label: '描述'
          },
          {
            type: 'datetime',
            name: 'date',
            label: '日期'
          },
          {
            type: 'string',
            name: 'tags',
            label: '标签',
            list: true,
            ui: {
              component: 'tags'
            }
          },
          {
            type: 'rich-text',
            name: 'body',
            label: '正文',
            isBody: true,
            templates: [
              {
                name: 'callout',
                label: '提示框',
                fields: [
                  { name: 'type', type: 'string', label: '类型', options: ['tip', 'warning', 'danger'] },
                  { name: 'content', type: 'string', label: '内容', ui: { component: 'textarea' } }
                ]
              },
              {
                name: 'codeGroup',
                label: '代码组',
                fields: [
                  { name: 'label', type: 'string', label: '标签' },
                  { name: 'code', type: 'string', label: '代码', ui: { component: 'textarea' } }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
})

集成到 VitePress

ts
// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import TinaEditLink from './components/TinaEditLink.vue'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    app.component('TinaEditLink', TinaEditLink)
  }
}
vue
<!-- .vitepress/theme/components/TinaEditLink.vue -->
<script setup lang="ts">
import { useData } from 'vitepress'

const { page } = useData()
const editUrl = `/admin/#/~/doc/${page.value.relativePath}`
</script>

<template>
  <a :href="editUrl" target="_blank" class="tina-edit-link">
    ✏️ 在 TinaCMS 中编辑
  </a>
</template>

<style scoped>
.tina-edit-link {
  display: inline-block;
  padding: 4px 12px;
  font-size: 13px;
  color: var(--vp-c-brand);
  border: 1px solid var(--vp-c-brand);
  border-radius: 6px;
  text-decoration: none;
  transition: all 0.2s;
}
.tina-edit-link:hover {
  background: var(--vp-c-brand);
  color: white;
}
</style>

CI/CD 自动同步

在构建流程中自动从 CMS 同步内容。

GitHub Actions 示例

yaml
# .github/workflows/sync-cms.yml
name: Sync CMS Content

on:
  schedule:
    - cron: '0 */6 * * *'  # 每 6 小时同步一次
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci

      # 从 CMS 同步内容
      - name: Sync from Strapi
        if: env.STRAPI_URL
        run: npx tsx scripts/strapi-sync.ts
        env:
          STRAPI_URL: ${{ secrets.STRAPI_URL }}
          STRAPI_API_TOKEN: ${{ secrets.STRAPI_API_TOKEN }}

      # 如果有变更,提交并触发构建
      - name: Commit changes
        run: |
          git config user.name "CMS Bot"
          git config user.email "bot@example.com"
          git add docs/
          git diff --staged --quiet || git commit -m "chore: sync CMS content"
          git push

常见问题

CMS 数据更新不及时?

  1. 检查缓存:数据加载器可能有缓存,清除后重试
  2. 触发构建:手动触发 CI/CD 流水线重新构建
  3. Webhook:配置 CMS 发送 Webhook 在内容更新时自动构建

构建时 CMS API 不可用?

  1. 降级策略:API 不可用时使用本地缓存数据
  2. 超时设置:为 API 请求设置合理的超时时间
  3. 重试机制:添加请求重试逻辑
ts
// 带重试的数据加载器
async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
  for (let i = 0; i < retries; i++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(10000) })
      if (res.ok) return res
    } catch (e) {
      if (i === retries - 1) throw e
      await new Promise(r => setTimeout(r, 1000 * (i + 1)))
    }
  }
  throw new Error(`请求失败: ${url}`)
}

如何处理富文本内容?

不同 CMS 的富文本格式不同,需要转换:

CMS富文本格式转换方案
StrapiMarkdown / Rich TextMarkdown 直接使用,Rich Text 需转换
NotionBlock 格式逐 Block 转换为 Markdown
ContentfulRich Text JSON使用 @contentful/rich-text-html-renderer
SanityPortable Text自定义转换函数
TinaCMSMarkdown直接使用

进阶配置

Strapi、Notion、Contentful 的完整数据加载器代码、构建脚本、CI/CD 自动同步等:

相关链接

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献