文章
用純前端做一個「動態+文章」的多語系 Blog
我把自己的網站做成「動態+文章」的多語系 Blog:內容用 Markdown 管理,發文時完成翻譯,建置時產生靜態頁面,瀏覽器負責閱讀與切換語言。這篇拆解內容模型、共用管線、按需載入與 SEO 的取捨,以及純前端到底省掉了什麼。
· 6 分鐘閱讀
我希望自己的網站可以隨手記一段想法,也能把一個技術問題完整寫清楚。短的放「動態」,長的放「文章」,兩者可以互相連結,而且訪客切換語言時,內容也要一起切換。
這個需求看起來很普通,但如果一開始就加資料庫、後台、內容 API,再處理每個語系的版本,很快就會變成另一個需要長期維運的產品。
我選擇先把需求收斂到自己真的會用的範圍:一個作者、內容進 Git、發文後重建網站,閱讀端只需要靜態檔案與瀏覽器。 這就是現在 stephen.taipei 的「動態+文章」架構。
純前端,指的是閱讀端#
先把邊界講清楚。這個專案使用 React、Vite 與 Tailwind,內容處理、圖片轉換和建置腳本仍然需要 Node.js;翻譯也會在發文階段呼叫外部服務。部署後由 Nginx 提供靜態檔案。
所以我說的純前端,是讀者打開網站時,不需要一個專門的 Blog 後端查資料、組頁面或即時翻譯。它有建置流程,也有主機,但沒有為了閱讀文章而常駐的應用程式 API 或資料庫。
把工作提前做完,閱讀端就可以簡單很多。
動態和文章,共用來源,各自有自己的模型#
內容集中在 content/,原文都是 Markdown。兩種內容沒有硬塞成同一個格式:
content/
tags.yaml
tags.locales.yaml
notes/2026/20261004-0944.md
notes/2026/20261004-0944.locales.yaml
articles/frontend-only-multilingual-blog/
index.md
locales.yaml
cover.webp
動態是短卡片,檔名用台北時間的分鐘當作 id,可以標記想法、學到、上線、踩坑或閱讀。只寫一段文字就能成立,不必每次都想標題。超過 500 字會提醒,超過 1000 字就該寫成文章。
文章則有固定的 slug、標題、摘要、日期,也可以有封面與更新時間。網址公開後不再改,避免原本分享出去的連結失效。
兩者之間用 article: 關聯。動態可以先記下問題,再連到完整文章;文章頁會反向列出引用它的動態。短內容保留當下的起點,長內容負責把推導寫完,不需要在兩邊貼一樣的全文。
讓內容規則在建置前擋下錯誤#
如果 Markdown 只是被丟進畫面,很多問題要到上線後才會被看到:日期打錯、文章連不到、圖片沒有 alt,或同一主題被寫成三種近似標籤。
我把這些規則放進共用內容管線。loadContent 讀取檔案,以 Zod 檢查欄位,再處理 Markdown、圖片、語系與內容關聯。CLI 的檢查和 Vite 的建置都使用這套管線,減少「本機說可以、正式環境卻不行」的落差。
標籤只接受 tags.yaml 已登記的 key;動態引用的文章和其他動態必須存在;本機預覽可以讀草稿,正式建置會排除草稿。這些規則不是靠發文時記得,而是讓檢查真的擋住。
Markdown 也有明確的輸出邊界:raw HTML 不直接輸出,產生的 HTML 會經過清理,程式碼區塊在建置時完成上色。本機相對路徑的內容圖片會轉成最寬 1600px 的 WebP,移除 EXIF,包含 GPS;文章、卡片與分享預覽共用處理後的圖片。遠端圖片與根目錄絕對路徑圖片,則不走這套本地轉換流程。
多語系的成本,放在發文時付#
現在網站支援 22 個語系。繁中原文寫一次,其他 21 個語系的標題、摘要、正文與圖片描述,存進旁邊的 locales.yaml。
這裡有一個我很在意的決定:讀者不必等翻譯。 content:localize 在發文時產生譯文,正式 build 只讀已經存在的檔案,不會每次部署都重新呼叫翻譯服務。
原文與譯文之間用 source digest 對照。原文改了,舊譯文就不能繼續被當作最新版本;少一個語系或譯文過期,content:check 就會失敗。翻譯過程會保護程式碼、inline code 與連結目標,避免把技術識別字或網址一起翻掉。
這不代表自動譯文就有人工編輯的品質。它解決的是完整性與更新一致性,專業用語和語氣仍然需要校對。未變更的原文會保留既有譯文,人工修過的文字也不會因為重跑而被覆蓋。
列表讀摘要,點進去才載入文章#
內容增加之後,如果首頁就把每篇文章、每個語系的全文全部載進來,純前端也會很慢。
Vite plugin 把內容分成三個入口:首頁用的 virtual:content/latest、內容頁用的 virtual:content/index,以及每篇文章各自的 virtual:content/article/<slug>。首頁取最新三則動態與三篇文章卡片;動態資料仍包含全文與譯文,只是在卡片上截斷顯示。長文章的卡片只帶摘要等資料,完整正文由 dynamic import 按篇載入。
React 的 Suspense 負責等待正文,外層的錯誤邊界負責顯示載入失敗訊息。正文請求失敗時,標題、摘要和頁面導覽仍然留著。
這裡也有取捨:目前是一篇文章一個正文 chunk,裡面包含各語系譯文,還沒有拆到一個語系一個 chunk。動態全文與譯文則進入內容索引。內容量持續增加時,索引大小和多語系正文的下載量,就是下一步要量測的地方。
有前端切換,也要有靜態閱讀的入口#
只做 SPA,分享出去時常常只看到首頁的標題與圖片。對文章來說,這不夠。
所以建置時,每個動態、文章與標籤網址都有自己的 HTML shell,裡面寫入 title、description、canonical、Open Graph 和 JSON-LD,並在 noscript 放入可閱讀的原文。即使不執行 JavaScript,也能取得這篇文章的內容與專屬封面。
同一份內容還會產生 feed.xml、sitemap-content.xml、文章的 Markdown 版本,以及 llms.txt 的文章索引。這些都是建置產物,不是另一套需要人工同步的內容。
但多語系 SEO 的邊界也要講清楚:目前動態與文章在各語系共用同一個網址,語言由前端切換;靜態內容頁、feed 與 Markdown 版本維持原文。 首頁另有語系與亮暗模式的預渲染,不能因此說每篇文章也都有 22 份可獨立索引的語言頁面。
如果未來需要讓每個語系各自被搜尋引擎收錄,就要增加語系路由、對應的靜態頁面與 hreflang。那是另一個需求,現在這套還沒有做到。
發文也是一條交付流程#
我的發文流程是:寫原文、產生譯文、檢查內容、提交 Git,再合併到 main。既有的 GitHub Actions 會重建網站並部署。
npm run content:localize
npm run content:check
npm run test:content
npm run build
多語系沒有齊全,就先修好。文章是草稿,就留在本機預覽。正式輸出還會檢查草稿頁不存在,並掃描 feed、sitemap、首頁與 JavaScript bundle 等主要公開輸出中的草稿標記。內容和程式碼一樣有 diff、有版本,也能一起回退。
這種方式犧牲了即時更新:改一段文字,也要等一次建置部署。但對自己的技術網站而言,我願意用這個等待,換取少一套後台、少一個執行期依賴,以及更容易追查的變更紀錄。
什麼時候適合這樣做#
如果是一個作者、發文頻率可接受重建、內容以文字為主,而且已經習慣 Git,這套架構很適合。短動態降低寫作門檻,文章保留完整推導,多語系讓同一份內容可以被更多人閱讀。
如果需求變成多人即時協作、權限管理、讀者留言、登入或訂閱,就要重新評估 CMS 與後端。純前端不會讓這些需求自動消失,只是目前我沒有把它們放進這個 Blog 的範圍。
這次的設計原則,和我在多版型與多主題的架構設計模式裡寫的很接近:先把責任分清楚,再讓共用規則守住一致性。
對這個網站來說,重點是讓我能持續寫,也能知道每一次發布,實際送出了哪些內容。