---
title: "用純前端做一個「動態＋文章」的多語系 Blog"
description: "我把自己的網站做成「動態＋文章」的多語系 Blog：內容用 Markdown 管理，發文時完成翻譯，建置時產生靜態頁面，瀏覽器負責閱讀與切換語言。這篇拆解內容模型、共用管線、按需載入與 SEO 的取捨，以及純前端到底省掉了什麼。"
url: https://stephen.taipei/articles/frontend-only-multilingual-blog
author: "Stephen Chuang"
date: 2026-10-04T14:11:53+08:00
tags: [software-architecture, web-platform, devops]
lang: zh-TW
---

# 用純前端做一個「動態＋文章」的多語系 Blog

我希望自己的網站可以隨手記一段想法，也能把一個技術問題完整寫清楚。短的放「動態」，長的放「文章」，兩者可以互相連結，而且訪客切換語言時，內容也要一起切換。

這個需求看起來很普通，但如果一開始就加資料庫、後台、內容 API，再處理每個語系的版本，很快就會變成另一個需要長期維運的產品。

我選擇先把需求收斂到自己真的會用的範圍：**一個作者、內容進 Git、發文後重建網站，閱讀端只需要靜態檔案與瀏覽器。** 這就是現在 stephen.taipei 的「動態＋文章」架構。

## 純前端，指的是閱讀端

先把邊界講清楚。這個專案使用 React、Vite 與 Tailwind，內容處理、圖片轉換和建置腳本仍然需要 Node.js；翻譯也會在發文階段呼叫外部服務。部署後由 Nginx 提供靜態檔案。

所以我說的純前端，是**讀者打開網站時，不需要一個專門的 Blog 後端查資料、組頁面或即時翻譯**。它有建置流程，也有主機，但沒有為了閱讀文章而常駐的應用程式 API 或資料庫。

把工作提前做完，閱讀端就可以簡單很多。

## 動態和文章，共用來源，各自有自己的模型

內容集中在 `content/`，原文都是 Markdown。兩種內容沒有硬塞成同一個格式：

```text
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 會重建網站並部署。

```bash
npm run content:localize
npm run content:check
npm run test:content
npm run build
```

多語系沒有齊全，就先修好。文章是草稿，就留在本機預覽。正式輸出還會檢查草稿頁不存在，並掃描 feed、sitemap、首頁與 JavaScript bundle 等主要公開輸出中的草稿標記。內容和程式碼一樣有 diff、有版本，也能一起回退。

這種方式犧牲了即時更新：改一段文字，也要等一次建置部署。但對自己的技術網站而言，我願意用這個等待，換取少一套後台、少一個執行期依賴，以及更容易追查的變更紀錄。

## 什麼時候適合這樣做

如果是一個作者、發文頻率可接受重建、內容以文字為主，而且已經習慣 Git，這套架構很適合。短動態降低寫作門檻，文章保留完整推導，多語系讓同一份內容可以被更多人閱讀。

如果需求變成多人即時協作、權限管理、讀者留言、登入或訂閱，就要重新評估 CMS 與後端。純前端不會讓這些需求自動消失，只是目前我沒有把它們放進這個 Blog 的範圍。

這次的設計原則，和我在[多版型與多主題的架構設計模式](/articles/multi-layout-multi-theme-architecture)裡寫的很接近：先把責任分清楚，再讓共用規則守住一致性。

對這個網站來說，重點是讓我能持續寫，也能知道每一次發布，實際送出了哪些內容。
