跳至內容

為 VitePress 部落格加上 RSS Feed 訂閱功能

晚上花了一點時間實作日語文章的切換,剛好 Cash 大就在 X (Twitter) 上敲碗問我什麼時候要加 RSS Feed XD:

Cash 大敲碗 RSS Feed

想想 RSS Reader 還是追蹤部落格最方便的方式,不用每天手動檢查有沒有新文章,於是決定著手加上這個功能。 但 VitePress 本身沒有內建 RSS 功能,但透過 buildEnd hook 和 feed 這個 npm 套件,其實實作起來並不複雜。

這篇文章就來記錄一下整個實作過程。


為什麼要提供 RSS Feed?

在社群媒體主導的年代,RSS 看起來有點過時,但其實還是有不少優點:

  1. 無演算法干擾:RSS 閱讀器會照時間順序顯示所有文章,不會被演算法過濾
  2. 隱私友善:不需要註冊帳號,不會被追蹤
  3. 跨平台:可以在各種裝置、各種 RSS 閱讀器中訂閱
  4. 離線閱讀:很多 RSS 閱讀器支援離線下載
  5. 方便整合:RSS 是標準格式,可以串接到各種自動化工具

對於技術部落格來說,讀者群中用 RSS 的比例還蠻高的,所以這個功能還是很值得做。


實作架構

整個 RSS 生成系統分成兩個部分:

  1. 生成腳本 (scripts/generateRssFeed.ts):

    • 載入所有文章資料
    • 建立 RSS Feed 物件
    • 生成三種格式的 feed 檔案
  2. VitePress 整合 (docs/.vitepress/config.ts):

    • buildEnd hook 呼叫生成腳本
    • 確保每次建置都會產生最新的 feed

技術選擇:

  • feed 套件:業界標準的 RSS feed 生成工具
  • VitePress createContentLoader:複用現有的文章載入機制
  • buildEnd hook:在建置完成後自動執行

第一步:安裝 feed 套件

首先要安裝 feed 這個 npm 套件,它可以幫我們產生符合標準的 RSS、Atom 和 JSON Feed。

bash
pnpm add -D feed

如果你用 npm 或 yarn:

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

第二步:建立 RSS 生成腳本

在專案中建立 scripts/generateRssFeed.ts

typescript
import { Feed } from 'feed'
import { createContentLoader, type SiteConfig } from 'vitepress'
import path from 'path'
import { writeFileSync } from 'fs'

const siteUrl = 'https://kurohsu.dev'
const siteTitle = 'Kuro Hsu 的筆記'
const siteDescription = '技術筆記、學習紀錄與生活記錄'

export async function generateRssFeed(config: SiteConfig) {
  // 建立 Feed 物件
  const feed = new Feed({
    title: siteTitle,
    description: siteDescription,
    id: siteUrl,
    link: siteUrl,
    language: 'zh-TW',
    favicon: `${siteUrl}/favicon.ico`,
    copyright: 'Copyright © 2025 Kuro Hsu',
    feedLinks: {
      rss: `${siteUrl}/feed.xml`,
      atom: `${siteUrl}/atom.xml`,
      json: `${siteUrl}/feed.json`,
    },
    author: {
      name: 'Kuro Hsu',
      link: siteUrl,
    }
  })

  // 載入所有文章(包含 excerpt 和 rendered HTML)
  const posts = await createContentLoader([
    'notes/**/*.md',
    'learn/**/*.md',
    'misc/**/*.md'
  ], {
    excerpt: true,  // 載入摘要
    render: true,   // 產生完整 HTML
    transform(rawData) {
      return rawData
        .filter(({ url }) => !url.endsWith('/index.html')) // 排除索引頁
        .filter(({ url }) => !url.includes('.ja.html'))    // 排除日文文章
        .filter(({ frontmatter }) =>
          frontmatter.title && frontmatter.date           // 必須有標題和日期
        )
        .filter(({ frontmatter }) => !frontmatter.draft)  // 排除草稿
        .filter(({ frontmatter }) =>
          frontmatter.lang !== 'ja'                       // 再次確認不是日文
        )
        .map(({ url, frontmatter, excerpt, html }) => {
          // 將 URL 轉換為絕對路徑
          const rewrittenUrl = url.replace(
            /\/(notes|learn|misc)\/\d{4}\/\d{2}\/(.+\.html)/,
            '/$1/$2'
          )

          return {
            title: frontmatter.title,
            url: `${siteUrl}${rewrittenUrl}`,
            date: new Date(frontmatter.date),
            description: excerpt || frontmatter.description || '',
            content: html || '',
            author: frontmatter.author || 'Kuro Hsu',
            category: frontmatter.tags?.map((tag: string) => ({ name: tag })) || [],
          }
        })
        .sort((a, b) => b.date.getTime() - a.date.getTime()) // 按日期降冪排序
    }
  }).load()

  // 將文章加入 feed
  for (const post of posts) {
    feed.addItem({
      title: post.title,
      id: post.url,
      link: post.url,
      description: post.description,
      content: post.content,
      author: [
        {
          name: post.author,
          link: siteUrl,
        }
      ],
      date: post.date,
      category: post.category,
    })
  }

  // 寫入三種格式的 feed 檔案
  const outDir = config.outDir
  writeFileSync(path.join(outDir, 'feed.xml'), feed.rss2())
  writeFileSync(path.join(outDir, 'atom.xml'), feed.atom1())
  writeFileSync(path.join(outDir, 'feed.json'), feed.json1())

  console.log(`✅ RSS feeds generated with ${posts.length} posts:`)
  console.log(`   - ${siteUrl}/feed.xml (RSS 2.0)`)
  console.log(`   - ${siteUrl}/atom.xml (Atom 1.0)`)
  console.log(`   - ${siteUrl}/feed.json (JSON Feed 1.0)`)
}

這個腳本做了幾件重要的事:

  1. 建立 Feed 物件:設定網站基本資訊和元資料
  2. 載入文章資料:使用 VitePress 的 createContentLoader API
  3. 過濾和轉換:排除不需要的頁面,轉換 URL 格式
  4. 產生三種格式:RSS 2.0、Atom 1.0、JSON Feed 1.0

第三步:整合到 VitePress 建置流程

docs/.vitepress/config.ts 加入 buildEnd hook:

typescript
import { defineConfig } from 'vitepress'
import { generateRssFeed } from '../../scripts/generateRssFeed'

export default defineConfig({
  // 建置完成後生成 RSS feed
  async buildEnd(siteConfig) {
    await generateRssFeed(siteConfig)
  },

  // ... 其他設定
})

就這麼簡單!每次執行 pnpm build 時,VitePress 會在建置完成後自動呼叫 generateRssFeed,產生最新的 RSS feed。


第四步:測試

執行建置命令:

bash
pnpm build

如果一切順利,你會在 console 看到:

✅ RSS feeds generated with 46 posts:
   - https://kurohsu.dev/feed.xml (RSS 2.0)
   - https://kurohsu.dev/atom.xml (Atom 1.0)
   - https://kurohsu.dev/feed.json (JSON Feed 1.0)

檢查 docs/.vitepress/dist 目錄,應該會看到三個檔案:

  • feed.xml - RSS 2.0 格式
  • atom.xml - Atom 1.0 格式
  • feed.json - JSON Feed 1.0 格式

你可以用 RSS 閱讀器測試這些 feed,確認內容正確。


為什麼要產生三種格式?

你可能會好奇,為什麼要同時產生 RSS、Atom 和 JSON Feed 三種格式?

  1. RSS 2.0

    • 最古老也最廣泛支援的格式
    • 幾乎所有 RSS 閱讀器都支援
    • 檔案較小,適合傳輸
  2. Atom 1.0

    • 較新的標準,規範更嚴謹
    • 支援更多元資料(例如作者資訊)
    • 某些現代工具偏好使用 Atom
  3. JSON Feed

    • 最新的格式,使用 JSON 而非 XML
    • 對開發者更友善,容易解析
    • 適合用於 API 整合

feed 套件可以同時產生三種格式,幾乎沒有額外成本,所以就全部提供了。 這樣無論讀者用什麼工具,都能找到適合的格式。


關於文章過濾

在我的實作中,我刻意排除了日文文章:

typescript
.filter(({ url }) => !url.includes('.ja.html'))    // 排除日文文章
.filter(({ frontmatter }) => frontmatter.lang !== 'ja')

這是因為:

  1. 目標讀者:RSS 訂閱者主要是繁體中文讀者
  2. 避免重複:有些文章同時有中文和日文版本,不希望在 feed 中出現兩次
  3. 保持簡潔:Feed 只包含中文內容,訂閱者不會收到看不懂的文章

如果你的 VitePress 網站是單一語言,這段可以跳過當作沒看到,因為這是我自己的需求。


實用技巧:URL Rewriting

我的部落格文章檔案是按年月組織的(notes/2025/11/article.md),但對外的 URL 是扁平的(/notes/article.html)。

這是透過 VitePress 的 rewrites 功能實現的,但在 RSS feed 中也需要做同樣的轉換:

typescript
const rewrittenUrl = url.replace(
  /\/(notes|learn|misc)\/\d{4}\/\d{2}\/(.+\.html)/,
  '/$1/$2'
)

這個正則表達式會:

  • 匹配 /notes/2025/11/article.html 這種格式
  • 轉換成 /notes/article.html
  • 確保 RSS feed 中的連結跟實際 URL 一致

如果你的網站沒有用 rewrites,可以直接使用原始的 url


踩過的坑

1. createContentLoader 的路徑問題

一開始我寫成:

typescript
const posts = await createContentLoader([
  'docs/notes/**/*.md',  // ❌ 錯誤
  'docs/learn/**/*.md',
  'docs/misc/**/*.md'
])

結果載入不到任何文章。後來發現 createContentLoader 的路徑是相對於 docs 目錄,應該寫成:

typescript
const posts = await createContentLoader([
  'notes/**/*.md',  // ✅ 正確
  'learn/**/*.md',
  'misc/**/*.md'
])

2. 忘記設定 excerpt 和 render

如果沒有設定這兩個選項:

typescript
{
  excerpt: true,  // 取得摘要
  render: true,   // 產生完整 HTML
}

feed 中就不會有文章內容,只有標題和連結,體驗很差。

3. 建置時間變長

加入 RSS 生成後,建置時間會增加幾秒(我的部落格約增加 2-3 秒)。

這是正常的,因為需要:

  • 載入所有文章資料
  • 渲染完整的 HTML
  • 產生三個 feed 檔案

如果建置時間成為問題,可以考慮:

  • 只產生一種格式(例如只產生 RSS 2.0)
  • 限制 feed 中的文章數量(例如只包含最新 20 篇)
  • 不要 render 完整 HTML,只提供摘要

進階:只顯示最新文章

如果你的部落格文章很多,可以考慮只在 feed 中顯示最新的幾篇:

typescript
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 20)  // 只取前 20 篇

這樣可以:

  • 減少 feed 檔案大小
  • 加快建置速度
  • 降低 RSS 閱讀器的負擔

不過我個人選擇包含所有文章,因為有些讀者可能想從頭開始閱讀,而且我的文章數量還不算太多。


讓讀者知道有 RSS

產生 RSS feed 後,別忘了讓讀者知道可以訂閱!

可以在網站上加個訂閱連結,例如在導覽列或頁尾:

html
<a href="/feed.xml">
  <svg><!-- RSS icon --></svg>
  訂閱 RSS
</a>

或者在 <head> 加入 RSS 自動探索標籤,讓瀏覽器和 RSS 閱讀器可以自動找到 feed:

html
<link rel="alternate" type="application/rss+xml"
      title="Kuro Hsu 的筆記"
      href="https://kurohsu.dev/feed.xml" />

VitePress 可以透過 head 設定來加入:

typescript
export default defineConfig({
  head: [
    ['link', {
      rel: 'alternate',
      type: 'application/rss+xml',
      title: 'Kuro Hsu 的筆記',
      href: 'https://kurohsu.dev/feed.xml'
    }]
  ]
})

總結

為 VitePress 加上 RSS feed 功能其實很簡單,主要步驟就是:

  1. 安裝 feed 套件
  2. 建立 RSS 生成腳本
  3. buildEnd hook 呼叫腳本
  4. 測試並部署

整個實作大約花了一小時左右,包含測試和調整。

使用 VitePress 的 createContentLoader API 讓事情變得更簡單,不需要自己去掃描和解析 markdown 檔案,也不需要擔心 frontmatter 格式。 而且因為是在打包建置時產生,所以不會影響網站的執行效能。

雖然裡面有超多客製化的部分是因應我自己的需求,但整體架構是通用的, 實作時可以根據自己的情況做調整。

如果你也在用 VitePress 架部落格,非常推薦加上這個功能。 雖然如今 RSS 已經不算是主流,但還是最方便的追蹤方式。


相關閱讀:

💬 留言討論

Released under the MIT License.