跳至內容

VitePress 自動生成 Open Graph 圖片

把部落格架好之後,開始在意分享到社群媒體時的呈現效果。

你知道那種在 Facebook、Twitter 或 Discord 上貼連結時,會自動出現一張漂亮的預覽圖嗎?那就是 Open Graph (OG) 圖片。 這篇文章要來聊聊如何在 VitePress 中自動生成這些圖片,省去手動製作的麻煩。


什麼是 Open Graph 圖片?

Open Graph (OG) 是 Facebook 在 2010 年推出的協定,讓網頁可以更好地被社群媒體平台解析和展示。 當你在社群平台分享連結時,平台會讀取網頁 <head> 中的 OG meta 標籤,抓取標題、描述和圖片來產生預覽卡片。

最常見的 OG 標籤:

html
<meta property="og:title" content="文章標題" />
<meta property="og:description" content="文章摘要" />
<meta property="og:image" content="https://example.com/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

標準尺寸:1200×630 像素(這是 Facebook、Twitter 建議的比例)


為什麼要自動生成?

一開始我也想過用 Figma 或 Canva 手動做圖,但馬上發現問題:

  1. 太費時間:部落格有幾十篇文章,每篇都要開 Figma 改標題、匯出圖片,太累了
  2. 不好維護:如果文章標題改了,圖片也要重做
  3. 風格不一致:手動做圖難免有誤差,自動生成可以確保所有圖片風格統一
  4. 忘記做圖:寫新文章時很容易忘記做 OG 圖片

因為開發者就是懶,所以最好的方式就是,在 Blog 建置時自動生成。 而 VitePress 本身就有提供擴充功能的機制,可以在建置過程中插入自定義邏輯,這正好派上用場。


實作架構

既然決定好了要自動生成 OG 圖片,接下來就是設計整個系統的架構,整個系統分成三個部分:

  1. SVG 模板:定義圖片的設計和排版
  2. 生成腳本:讀取模板、替換文字、轉換成 PNG
  3. VitePress 整合:在建置時自動執行,生成所有頁面的 OG 圖片

技術選擇:

  • SVG 作為模板,向量圖好編輯,也方便程式化
  • Sharp 轉換 SVG 為高品質 PNG(Node.js 最快的圖片處理 lib)
  • VitePress transformPageData hook:在建置時同步處理每個頁面,生成對應的 OG 圖片

第一步:安裝 Sharp

Sharp 是一個高效能的 Node.js 圖片處理 lib,用來把 SVG 轉換成 PNG。

bash
pnpm add -D sharp

如果你用 npm 或 yarn:

bash
npm install --save-dev sharp
# or
yarn add -D sharp

第二步:設計 SVG 模板

在專案根目錄建立 scripts/og-template.svg

這裡要注意幾件事:

  1. 使用 2 倍尺寸:SVG 設計成 2400×1260,最後縮放成 1200×630
  2. 為什麼用 2 倍? 確保文字清晰,避免縮放後模糊
  3. 使用
    {{title}}
    佔位符
    :稍後用程式替換成真正的標題
html
<svg width="2400" height="1260" viewBox="0 0 2400 1260" xmlns="http://www.w3.org/2000/svg">
  <!-- 背景漸層 -->
  <defs>
    <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:#1e3a8a;stop-opacity:1" />
      <stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
    </linearGradient>
  </defs>

  <!-- 背景 -->
  <rect width="2400" height="1260" fill="url(#bgGradient)"/>

  <!-- 內容區 -->
  <g transform="translate(160, 0)">
    <!-- 文章標題(這行會被腳本替換) -->
    <text x="0" y="500" font-family="PingFang TC, Microsoft JhengHei, Noto Sans TC, Heiti TC, sans-serif" font-size="144" font-weight="500" fill="#ffffff" text-anchor="start">
      {{title}}
    </text>

    <!-- 網站名稱 -->
    <text x="0" y="960" font-family="PingFang TC, Microsoft JhengHei, Noto Sans TC, Heiti TC, sans-serif" font-size="72" font-weight="400" fill="#e0e7ff" text-anchor="start">
      Kuro Hsu 的筆記
    </text>

    <!-- 網域 -->
    <text x="0" y="1080" font-family="PingFang TC, Microsoft JhengHei, Noto Sans TC, Heiti TC, sans-serif" font-size="56" font-weight="400" fill="#c7d2fe" text-anchor="start">
      kurohsu.dev
    </text>
  </g>
</svg>

設計說明

  • 藍色漸層背景:從深藍 #1e3a8a 到亮藍 #3b82f6,跟部落格主色調一致
  • 繁體中文字型PingFang TC, Microsoft JhengHei, Noto Sans TC 確保中文顯示正常
  • 留白設計translate(160, 0) 把內容往右移,左側留白比較舒服
  • 層次分明:標題用白色、網站名稱和網域用淺色,形成視覺層次

第三步:撰寫生成腳本

建立 scripts/generateOgImage.ts

這個腳本的核心功能:

  1. 讀取 SVG 模板
  2. 處理長標題(自動斷行)
  3. 跳脫 XML 特殊字元
  4. 用 Sharp 轉換成高品質 PNG
typescript
import fs from 'fs'
import path from 'path'
import sharp from 'sharp'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

/**
 * 將長文字分成多行
 * @param text 要分行的文字
 * @param maxCharsPerLine 每行最大字元數
 * @param maxLines 最大行數
 */
function splitTextIntoLines(text: string, maxCharsPerLine: number = 20, maxLines: number = 3): string[] {
  const lines: string[] = []
  let currentLine = ''

  // 處理中文和英文混合的文字
  for (const char of text) {
    if (currentLine.length >= maxCharsPerLine) {
      lines.push(currentLine)
      currentLine = char

      if (lines.length >= maxLines - 1) {
        break
      }
    } else {
      currentLine += char
    }
  }

  if (currentLine && lines.length < maxLines) {
    lines.push(currentLine)
  }

  // 如果最後一行太長,加上省略號
  if (text.length > currentLine.length + lines.join('').length) {
    lines[lines.length - 1] = lines[lines.length - 1].slice(0, -3) + '...'
  }

  return lines
}

/**
 * 跳脫 XML 特殊字元
 */
function escapeXml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;')
}

/**
 * 生成 OG 圖片
 * @param title 文章標題
 * @param outputPath 輸出路徑
 */
export async function generateOgImage(title: string, outputPath: string): Promise<void> {
  try {
    // 確保輸出目錄存在
    const outputDir = path.dirname(outputPath)
    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir, { recursive: true })
    }

    // 讀取 SVG 模板
    const templatePath = path.join(__dirname, 'og-template.svg')
    let svgTemplate = fs.readFileSync(templatePath, 'utf-8')

    // 將標題分成多行(每行最多 20 個字元,最多 3 行)
    const lines = splitTextIntoLines(title, 20, 3)

    // 建構多行文字的 SVG (使用 2x 解析度)
    let textElements = ''
    const startY = 400  // 2x of 200
    const lineHeight = 160  // 2x of 80

    lines.forEach((line, index) => {
      const y = startY + (index * lineHeight)
      textElements += `
      <text x="0" y="${y}" font-family="PingFang TC, Microsoft JhengHei, Noto Sans TC, Heiti TC, sans-serif" font-size="144" font-weight="500" fill="#ffffff" text-anchor="start">
        ${escapeXml(line)}
      </text>`
    })

    // 替換模板中的標題
    svgTemplate = svgTemplate.replace(
      /<text[^>]*>\s*\{\{title\}\}\s*<\/text>/s,
      textElements
    )

    // 將 SVG (2400x1260) 轉換並縮放為 PNG (1200x630)
    // 使用高密度渲染和 Lanczos3 縮放確保文字清晰
    await sharp(Buffer.from(svgTemplate), {
      density: 200  // 提高 SVG 渲染密度到 200 DPI
    })
      .resize(1200, 630, {
        fit: 'cover',
        kernel: 'lanczos3',  // 使用高品質的 Lanczos3 縮放演算法
        position: 'center'
      })
      .png({
        compressionLevel: 6,  // PNG 壓縮等級 (0-9)
        palette: false  // 使用全彩 PNG
      })
      .toFile(outputPath)

    console.log(`✓ Generated OG image: ${outputPath}`)
  } catch (error) {
    console.error(`✗ Failed to generate OG image for "${title}":`, error)
    throw error
  }
}

關鍵技術細節

  1. 自動斷行:中英文混合時,每 20 個字元斷行,最多 3 行
  2. 超長標題處理:第 3 行末尾加 ... 省略號
  3. XML Escape:標題中的 &, <, > 等特殊字元要 Encode,不然 SVG 會壞掉
  4. 高品質轉換
    • density: 200 提高 SVG 渲染密度(預設是 72 DPI)
    • kernel: 'lanczos3' 使用最高品質的縮放演算法
    • 先生成 2 倍圖再縮小,確保文字銳利

第四步:整合到 VitePress

修改 docs/.vitepress/config.ts,使用 VitePress 的 transformPageData hook:

typescript
import { defineConfig } from 'vitepress'
import { generateOgImage } from '../../scripts/generateOgImage'
import path from 'path'

const siteUrl = 'https://kurohsu.dev'
const buildTimestamp = Date.now()

export default defineConfig({
  async transformPageData(pageData) {
    // 為動態標籤頁面設定正確的標題
    if (pageData.relativePath.startsWith('tags/') &&
        pageData.relativePath !== 'tags/index.md' &&
        pageData.params?.tag) {
      pageData.title = `標籤:${pageData.params.tag}`
    }

    // 為有標題的頁面生成 OG 圖片(排除首頁)
    if (pageData.title && pageData.title !== 'Kuro Hsu 的筆記') {
      const ogImagePath = `/og/${pageData.relativePath.replace(/\.md$/, '.png')}`
      const outputPath = path.join(process.cwd(), 'docs/public', ogImagePath)

      // 生成 OG 圖片
      try {
        await generateOgImage(pageData.title, outputPath)

        // 初始化 head 陣列
        if (!pageData.frontmatter.head) {
          pageData.frontmatter.head = []
        }

        // 新增時間戳 query string 來防止快取
        const ogImageUrl = `${siteUrl}${ogImagePath}?v=${buildTimestamp}`

        // 注入 OG meta 標籤
        pageData.frontmatter.head.push(
          ['meta', { property: 'og:image', content: ogImageUrl }],
          ['meta', { property: 'og:image:width', content: '1200' }],
          ['meta', { property: 'og:image:height', content: '630' }],
          ['meta', { property: 'og:title', content: pageData.title }],
          ['meta', { property: 'og:description', content: pageData.description || pageData.frontmatter.description || '技術筆記、學習紀錄與生活記錄' }],
          ['meta', { property: 'twitter:card', content: 'summary_large_image' }],
          ['meta', { property: 'twitter:image', content: ogImageUrl }],
          ['meta', { property: 'twitter:title', content: pageData.title }],
          ['meta', { property: 'twitter:description', content: pageData.description || pageData.frontmatter.description || '技術筆記、學習紀錄與生活記錄' }]
        )
      } catch (error) {
        console.warn(`Warning: Failed to generate OG image for ${pageData.relativePath}:`, error)
      }
    }
  },

  // ... 其他設定
})

這段程式碼做了什麼?

  1. 在建置時執行:VitePress 建置每個頁面時都會呼叫 transformPageData
  2. 動態標籤頁處理:標籤頁面沒有預設標題,要手動設定
  3. 生成 OG 圖片:呼叫剛才寫的 generateOgImage() 函式
  4. 輸出路徑對應頁面結構
    • docs/notes/my-article.mddocs/public/og/notes/my-article.png
  5. 自動注入 meta 標籤:把 OG 和 Twitter Card 標籤加到頁面 <head>
  6. Cache busting:URL 加上時間戳 ?v={buildTimestamp},確保分享時看到最新圖片
  7. 錯誤處理:如果某個圖片生成失敗,只顯示警告,不會中斷建置

第五步:設定 .gitignore

生成的 OG 圖片不需要加入版控,因為每次建置都會重新生成。

.gitignore 加上:

# OG 圖片(建置時自動生成)
docs/public/og/

這樣做的好處:

  • 減少 Git Repo 大小
  • 避免圖片衝突
  • 確保 OG 圖片永遠是最新的

測試一下

現在執行建置:

bash
pnpm build

你應該會看到終端機輸出類似這樣的訊息:

✓ Generated OG image: /path/to/docs/public/og/notes/my-article.png
✓ Generated OG image: /path/to/docs/public/og/learn/another-article.png
...

檢查一下生成的圖片:

bash
ls docs/public/og/notes/

應該會看到每篇文章都有對應的 PNG 圖片!


驗證 OG 圖片是否正確

部署上線後,可以用以下工具測試:

  1. Facebook Sharing Debugger

  2. Twitter Card Validator

  3. Discord

    • 直接在 Discord 貼上連結,看看預覽是否正確

第一次測試記得清快取:這些平台都會快取 OG 圖片,如果改了設定但看不到變化,用上面的工具「重新抓取」。


踩過的坑

整合過程中遇到幾個問題,記錄一下:

1. 中文字型不顯示

一開始只設定 font-family: "sans-serif",結果中文變成方塊字。

解決方法:在 SVG 中明確指定繁體中文字型:

font-family="PingFang TC, Microsoft JhengHei, Noto Sans TC, Heiti TC, sans-serif"

這些是 macOS 和 Windows 常見的繁中字型,Sharp 會依序嘗試。

2. 文字模糊

最初直接用 1200×630 的 SVG,轉 PNG 後文字有點糊。

解決方法:SVG 用 2 倍尺寸(2400×1260),轉 PNG 時縮小:

typescript
await sharp(Buffer.from(svgTemplate), {
  density: 200  // 高密度渲染
})
  .resize(1200, 630, {
    kernel: 'lanczos3'  // 高品質縮放
  })

這樣文字就清晰很多。

3. 標題太長跑出畫面

有些文章標題很長,例如「透過 Composition Events 增強非拉丁語系輸入法對輸入框的支援」。 不處理的話文字會跑出 SVG 畫布。

解決方法:寫了 splitTextIntoLines() 函式:

  • 每行最多 20 個字元
  • 最多 3 行
  • 超過的話第 3 行加 ...

4. 特殊字元炸掉 SVG

文章標題如果有 <, >, & 這些字元,會讓 SVG 解析失敗。

解決方法:用 escapeXml() 跳脫:

typescript
function escapeXml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;')
}

5. 建置時間變長

每個頁面都生成圖片會拉長建置時間。

我的部落格有 50+ 篇文章,建置時間從 10 秒增加到 20 秒,雖然慢一點,但省下手動做圖的時間絕對值得。 而且只有建置時才會跑,開發模式時不受影響。

6. Vercel 建置失敗

部署到 Vercel 時出現 sharp 安裝失敗的錯誤。

解決方法:在 package.json 加上:

json
{
  "pnpm": {
    "onlyBuiltDependencies": [
      "sharp"
    ]
  }
}

告訴 pnpm 在 Vercel 環境要重新編譯 Sharp(因為它是 native module,需要針對部署環境編譯)。

7. Facebook 無法顯示中文檔名的 OG 圖片

文章檔名使用中文時,Facebook Sharing Debugger 會顯示「圖像損毀」錯誤。

問題原因:OG image URL 包含未編碼的中文字元,社群媒體平台無法正確解析。

例如原本的 URL:

https://kurohsu.dev/og/notes/vitepress-自動生成-og-圖片.png

Facebook 無法讀取這個 URL,需要進行 URL 編碼。

解決方法:在 config.ts 中對檔名進行 URL 編碼:

typescript
// 對 URL 進行編碼,確保中文檔名可以被正確處理
// 將路徑分解為目錄和檔名,只對檔名部分進行編碼
const pathParts = ogImagePath.split('/')
const encodedPath = pathParts.map((part, index) =>
  index === pathParts.length - 1 ? encodeURIComponent(part) : part
).join('/')

// 添加時間戳 query string 來防止快取
const ogImageUrl = `${siteUrl}${encodedPath}?v=${buildTimestamp}`

編碼後的 URL:

https://kurohsu.dev/og/notes/vitepress-%E8%87%AA%E5%8B%95%E7%94%9F%E6%88%90-og-%E5%9C%96%E7%89%87.png

這樣 Facebook、Twitter、Discord 等平台就能正確抓取圖片了!

測試步驟

  1. 部署後前往 Facebook Sharing Debugger
  2. 貼上文章 URL 並點擊「重新抓取」
  3. 確認圖片正確顯示

進階:自訂樣式

如果你想改變 OG 圖片的設計,只要修改 scripts/og-template.svg

範例:加上分類標籤

可以在標題旁邊顯示文章分類(notes / learn / misc):

typescript
// 在 generateOgImage() 函式中
function getCategory(path: string): string {
  if (path.includes('/notes/')) return '程式碼筆記'
  if (path.includes('/learn/')) return '學習紀錄'
  if (path.includes('/misc/')) return '雜記'
  return ''
}

// 修改 SVG,加上分類文字
const category = getCategory(outputPath)
if (category) {
  textElements = `
    <text x="0" y="320" font-size="60" fill="#93c5fd">${category}</text>
  ` + textElements
}

範例:換成深色背景

改 SVG 的漸層顏色:

html
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
  <stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
  <stop offset="100%" style="stop-color:#111827;stop-opacity:1" />
</linearGradient>

改完之後重新建置,所有 OG 圖片就會套用新設計!


總結

自動生成 OG 圖片的好處:

  1. 省時間:不用為每篇文章手動做圖
  2. 一致性:所有文章的 OG 圖片風格統一
  3. 可維護:標題改了,重新建置就好,圖片會自動更新
  4. SEO 友善:分享到社群媒體時有漂亮的預覽卡片,提高點擊率

實作重點:

  1. SVG 作為模板(好編輯、向量圖清晰)
  2. Sharp 轉換成 PNG(高效能、高品質)
  3. VitePress transformPageData hook(建置時自動執行)
  4. 處理長標題、特殊字元、中文字型

整合完成後,寫新文章只要專心寫內容,OG 圖片會自動生成,部落格的分享體驗立刻升級!

最終在 Facebook Sharing Debugger 測試的成果會像這樣: 自動生成的 OG 圖, 在 Facebook Sharing Debugger 測試的畫面

現在你可以把文章分享到 Discord、Twitter 或 Facebook,看看那張自動生成的 OG 預覽圖,是不是很有成就感?

💬 留言討論

Released under the MIT License.