幫 blog 補上 RSS feed:套件省下重造輪子,卻省不掉兩個踩過的 library bug

部落格內容累積後,我想起以前用 RSS 訂閱網站,決定幫 blog 補上這功能。選現成套件省下重造輪子,卻踩到兩個沒寫進文件的 library bug,規劃時留下一場三方互打的裁決,最後用 W3C 與 Feedly 實測收尾。

  • RSS
  • Next.js

上一篇寫 sitemap 與 robots.txt 的結尾,我提到一件事——真正有效的抓坑方法,不是等設計做完了才讓 AI 挑錯,而是在規劃階段的 prompt 裡,就主動請 AI 從多個角度對抗式審查這個設計。這次幫 blog 補上 RSS feed,我把這套方法真的搬進了規劃階段——結果不只抓到幾個我自己想不到的坑,還撞出一場三個審查角度彼此打架、最後得先釐清語義才能收尾的裁決。

示意圖:水底立牌寫著 RSS FEED 與文章標題,標註 W3C Validation passed 與 Feedly discoverable,一旁珊瑚長成 RSS icon 造型

想通了要幫 blog 補上 RSS,但這老東西不用重造輪子

會想寫這個功能,是因為部落格內容漸漸累積之後,我開始想——一般 blog 還會提供哪些功能?於是想起很久以前,我自己也會用 RSS 去訂閱一些網站。RSS 這種老東西,說到底早就有套件支援了,沒必要自己重造輪子。

於是我先做了一輪現成方案調查。手刻 XML 字串這條路第一個被否決——escape、CDATA、RFC-822 的日期格式,這些邊界條件光憑自己土法煉鋼很容易出錯,而且都是已經被別人解決過的問題。最後選定 jpmonette/feed(v5.2.1):純 ESM、內建 escape/CDATA/RFC-822 處理,唯一的 runtime 依賴是 xml-js。省下重造輪子的時間,我原本以為接下來只剩接線工作——後面會提到,事情沒有那麼簡單。

規劃階段先畫線:這次做多少、不做多少

跟這系列前兩篇——換掉 MDX 引擎加上 sitemap 與 robots.txt——一樣,動手前我先把範圍畫清楚。(這個網站其實是用 monorepo 管理,blog 跟另一個獨立的 web app 兩個 Next.js 應用分開部署——這次只處理 blog 這邊。)

這次要做的:一個固定的 /rss.xml 端點,收最新 20 篇已發布文章,每篇附標題、連結、發布時間跟摘要;blog footer 跟 <head> 都補上訂閱入口,讓 reader 能找到、也能自動探索到 feed。

刻意不做的:Atom/JSON Feed、web app 那邊的 feed、依 tag 分眾或分頁/archive 的 feed、email/newsletter 訂閱、全文輸出跟封面圖。

範圍畫好之後,才進到我這次真正想記的部分:對抗式審查。

一場三方打架的裁決:origin 到底該不該寫死

規劃文件裡有一段,是三個獨立審查角度對同一個技術決策給出完全互斥的結論——這是我第一次在自己的規劃流程裡看到這種場面。

問題出在一個很小的決定:feed 裡每篇文章的絕對 URL,要不要跟 blog 其他地方一樣,讀 env 變數算出來的 originFor(),還是乾脆寫死一個 canonical origin 常數?

三個角度,三種結論,而且不是那種靠「多數決」就能解的衝突。最後的裁決,靠的不是投票,是回頭釐清——擴充性審查把「preview 指向 production」當成了缺陷,但這其實理解有誤,安全審查和維運審查是從兩個完全獨立的角度,各自確認了寫死才是對的選擇。真正還站得住的技術債,只剩「以後如果真的要做 web app 的 feed,沒辦法直接共用這套 origin 機制」——但這是 out-of-scope 的未來功能債,不構成這次的阻擋。

最後維持寫死,擴充性審查原本的 BLOCKING 降級成「已接受風險,留待未來」,常數旁邊補了一句註解:換域名要記得同步改這裡。

這裡有一個我一開始也容易漏想的點:抓到坑之後,還得再判斷這個坑是不是真坑——三個審查角度給出互斥結論,不代表其中兩個一定錯,也可能像這次一樣,是其中一個對問題本身的理解就不對。

其他審查抓到、我照單全收的坑

除了那場三方裁決,這輪審查還抓到幾個我照單全收的坑:標題或描述裡萬一寫到 ]]> 會弄壞整份 XML;slug 沒限制字元集,卻被直接拼進網址跟 guid 裡;force-static 沒生效的話本機測不出來,容易悄悄變成上線才爆的問題;沒有 CI,合法性檢查得靠單元測試頂著。每一項的緩解成本都不高,索性照單全收。

實作時撞到的兩個 library bug

範圍跟審查都拍板之後,實作本身理論上只是接線——結果我踩到兩個 feed@5.2.1 沒寫進文件的行為,都跟我原本預期的不一樣。第一個是套件的 rss2() 根本不支援 managingEditorwebMaster 這兩個 channel 欄位,序列化出來就是不會輸出;第二個更隱蔽:CDATA 的跳脫邏輯只處理欄位裡第一次出現的 ]]>,第二次以後直接漏過去,會產生非法的 XML——這個是我自己拿 fixture 測出來的,查文件查不到。兩個都是繞開套件本身的行為,自己在外面補一層處理解掉。

Route Handler 要 force-static,footer 入口要留插槽

接文章資料的 Route Handler 本身很薄,把 builder 的輸出包成 Response 就結束了。真正要注意的是一行設定——export const dynamic = "force-static",逼這個 route 在 build time 就預渲染成靜態檔,而不是每次 request 才動態讀檔案——這對本機 build 沒差,但在這個 monorepo 部署到 Vercel 的設定下,我們判斷少了這行會有「本機正常、production 卻壞掉」的風險,所以訂成了驗收的硬條件。

footer 那顆 RSS icon,沿用的是既有的插槽設計:blog 顯示、另一個 app 不傳就不顯示。

用 W3C 跟 Feedly 驗證,而不是只看本機輸出順不順眼

程式碼寫完、測試也綠了之後,我沒有就此收工。這跟前一篇處理 sitemap/robots.txt 時的心得一樣——本機看起來正確,不代表爬蟲或 reader 也這樣認為,真正能確認的辦法,是站到消費端的視角去看。

我先把 https://blog.shangwang.dev/rss.xml 丟進 W3C Feed Validator——完全通過,沒有 error 也沒有 warning。

接著把 https://blog.shangwang.dev(注意,不含 /rss.xml)丟進 Feedly,結果直接成功抓到 feed。這件事的意義不只是「feed 存在」,而是驗證了我塞進 <head> 的 discovery link 真的能被 reader 正確探索到——不是理論上該有效,是真的有效。

規劃留一道打架的程序,勝過想到所有坑

回頭看這次規劃,最有價值的不是任何一個具體的技術決定,而是那場三方打架的裁決本身——它讓我第一次真正體會到,對抗式審查不是「找到越多坑就越安全」,而是抓到坑之後,還要判斷這個坑是不是真坑。安全審查跟維運審查最後站在一起,不是因為它們商量好了,是因為它們各自從不同的角度,得出了同一個結論。

如果你也想訂閱,feed 網址在這裡:https://blog.shangwang.dev/rss.xml。blog 的 footer 現在也加了一顆 RSS icon,點下去就是這個網址。

這次的教訓,大概可以濃縮成一句話:規劃階段真正的價值,不是把所有坑一次想全,而是留一道讓不同視角彼此打架的程序——最後能不能收斂,靠的不是多數決,而是回頭把語義搞清楚。