VitePress 自動生成 Open Graph 圖片
把部落格架好之後,開始在意分享到社群媒體時的呈現效果。
你知道那種在 Facebook、Twitter 或 Discord 上貼連結時,會自動出現一張漂亮的預覽圖嗎?那就是 Open Graph (OG) 圖片。 這篇文章要來聊聊如何在 VitePress 中自動生成這些圖片,省去手動製作的麻煩。
什麼是 Open Graph 圖片?
Open Graph (OG) 是 Facebook 在 2010 年推出的協定,讓網頁可以更好地被社群媒體平台解析和展示。 當你在社群平台分享連結時,平台會讀取網頁 <head> 中的 OG meta 標籤,抓取標題、描述和圖片來產生預覽卡片。
最常見的 OG 標籤:
<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 手動做圖,但馬上發現問題:
- 太費時間:部落格有幾十篇文章,每篇都要開 Figma 改標題、匯出圖片,太累了
- 不好維護:如果文章標題改了,圖片也要重做
- 風格不一致:手動做圖難免有誤差,自動生成可以確保所有圖片風格統一
- 忘記做圖:寫新文章時很容易忘記做 OG 圖片
因為開發者就是懶,所以最好的方式就是,在 Blog 建置時自動生成。 而 VitePress 本身就有提供擴充功能的機制,可以在建置過程中插入自定義邏輯,這正好派上用場。
實作架構
既然決定好了要自動生成 OG 圖片,接下來就是設計整個系統的架構,整個系統分成三個部分:
- SVG 模板:定義圖片的設計和排版
- 生成腳本:讀取模板、替換文字、轉換成 PNG
- VitePress 整合:在建置時自動執行,生成所有頁面的 OG 圖片
技術選擇:
- SVG 作為模板,向量圖好編輯,也方便程式化
- Sharp 轉換 SVG 為高品質 PNG(Node.js 最快的圖片處理 lib)
- VitePress
transformPageDatahook:在建置時同步處理每個頁面,生成對應的 OG 圖片
第一步:安裝 Sharp
Sharp 是一個高效能的 Node.js 圖片處理 lib,用來把 SVG 轉換成 PNG。
pnpm add -D sharp如果你用 npm 或 yarn:
npm install --save-dev sharp
# or
yarn add -D sharp第二步:設計 SVG 模板
在專案根目錄建立 scripts/og-template.svg。
這裡要注意幾件事:
- 使用 2 倍尺寸:SVG 設計成 2400×1260,最後縮放成 1200×630
- 為什麼用 2 倍? 確保文字清晰,避免縮放後模糊
- 使用
{{title}}佔位符:稍後用程式替換成真正的標題
<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。
這個腳本的核心功能:
- 讀取 SVG 模板
- 處理長標題(自動斷行)
- 跳脫 XML 特殊字元
- 用 Sharp 轉換成高品質 PNG
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
/**
* 生成 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
}
}關鍵技術細節:
- 自動斷行:中英文混合時,每 20 個字元斷行,最多 3 行
- 超長標題處理:第 3 行末尾加
...省略號 - XML Escape:標題中的
&,<,>等特殊字元要 Encode,不然 SVG 會壞掉 - 高品質轉換:
density: 200提高 SVG 渲染密度(預設是 72 DPI)kernel: 'lanczos3'使用最高品質的縮放演算法- 先生成 2 倍圖再縮小,確保文字銳利
第四步:整合到 VitePress
修改 docs/.vitepress/config.ts,使用 VitePress 的 transformPageData hook:
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)
}
}
},
// ... 其他設定
})這段程式碼做了什麼?
- 在建置時執行:VitePress 建置每個頁面時都會呼叫
transformPageData - 動態標籤頁處理:標籤頁面沒有預設標題,要手動設定
- 生成 OG 圖片:呼叫剛才寫的
generateOgImage()函式 - 輸出路徑對應頁面結構:
docs/notes/my-article.md→docs/public/og/notes/my-article.png
- 自動注入 meta 標籤:把 OG 和 Twitter Card 標籤加到頁面
<head>裡 - Cache busting:URL 加上時間戳
?v={buildTimestamp},確保分享時看到最新圖片 - 錯誤處理:如果某個圖片生成失敗,只顯示警告,不會中斷建置
第五步:設定 .gitignore
生成的 OG 圖片不需要加入版控,因為每次建置都會重新生成。
在 .gitignore 加上:
# OG 圖片(建置時自動生成)
docs/public/og/這樣做的好處:
- 減少 Git Repo 大小
- 避免圖片衝突
- 確保 OG 圖片永遠是最新的
測試一下
現在執行建置:
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
...檢查一下生成的圖片:
ls docs/public/og/notes/應該會看到每篇文章都有對應的 PNG 圖片!
驗證 OG 圖片是否正確
部署上線後,可以用以下工具測試:
Facebook Sharing Debugger
- https://developers.facebook.com/tools/debug/
- 貼上文章 URL,看看 Facebook 抓到什麼
Twitter Card Validator
- https://cards-dev.twitter.com/validator
- 檢查 Twitter 卡片顯示
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 時縮小:
await sharp(Buffer.from(svgTemplate), {
density: 200 // 高密度渲染
})
.resize(1200, 630, {
kernel: 'lanczos3' // 高品質縮放
})這樣文字就清晰很多。
3. 標題太長跑出畫面
有些文章標題很長,例如「透過 Composition Events 增強非拉丁語系輸入法對輸入框的支援」。 不處理的話文字會跑出 SVG 畫布。
解決方法:寫了 splitTextIntoLines() 函式:
- 每行最多 20 個字元
- 最多 3 行
- 超過的話第 3 行加
...
4. 特殊字元炸掉 SVG
文章標題如果有 <, >, & 這些字元,會讓 SVG 解析失敗。
解決方法:用 escapeXml() 跳脫:
function escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}5. 建置時間變長
每個頁面都生成圖片會拉長建置時間。
我的部落格有 50+ 篇文章,建置時間從 10 秒增加到 20 秒,雖然慢一點,但省下手動做圖的時間絕對值得。 而且只有建置時才會跑,開發模式時不受影響。
6. Vercel 建置失敗
部署到 Vercel 時出現 sharp 安裝失敗的錯誤。
解決方法:在 package.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-圖片.pngFacebook 無法讀取這個 URL,需要進行 URL 編碼。
解決方法:在 config.ts 中對檔名進行 URL 編碼:
// 對 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 等平台就能正確抓取圖片了!
測試步驟:
- 部署後前往 Facebook Sharing Debugger
- 貼上文章 URL 並點擊「重新抓取」
- 確認圖片正確顯示
進階:自訂樣式
如果你想改變 OG 圖片的設計,只要修改 scripts/og-template.svg。
範例:加上分類標籤
可以在標題旁邊顯示文章分類(notes / learn / misc):
// 在 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 的漸層顏色:
<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 圖片的好處:
- 省時間:不用為每篇文章手動做圖
- 一致性:所有文章的 OG 圖片風格統一
- 可維護:標題改了,重新建置就好,圖片會自動更新
- SEO 友善:分享到社群媒體時有漂亮的預覽卡片,提高點擊率
實作重點:
- SVG 作為模板(好編輯、向量圖清晰)
- Sharp 轉換成 PNG(高效能、高品質)
- VitePress
transformPageDatahook(建置時自動執行) - 處理長標題、特殊字元、中文字型
整合完成後,寫新文章只要專心寫內容,OG 圖片會自動生成,部落格的分享體驗立刻升級!
最終在 Facebook Sharing Debugger 測試的成果會像這樣: 
現在你可以把文章分享到 Discord、Twitter 或 Facebook,看看那張自動生成的 OG 預覽圖,是不是很有成就感?