Hugo 很快也很彈性,但直接改 Markdown 不是每個人都喜歡。Decap CMS(前身 Netlify CMS)可以直接當靜態頁面放在 repo 裡,透過 GitHub API 讀寫 Markdown,不需要額外的 backend server。
以下是這個站實際使用的架構與設定。
架構
瀏覽器 → /shs/(Decap CMS SPA)
↓ GitHub OAuth
Cloudflare Pages Function(/api/auth, /api/auth/callback)
↓ GitHub API
content/posts/*.{zh,en}.md
↓ git push
Cloudflare Pages build(hugo --minify)
↓
public/ → 你的 domain
沒有資料庫、沒有 CMS server,全部在 Git 裡。
1. 放 CMS 到 static 資料夾
建兩個檔案:
static/shs/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Content Manager</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
</body>
</html>
static/shs/config.yml
backend:
name: github
repo: your-username/your-repo
branch: main
base_url: https://your-domain.com
auth_endpoint: /api/auth
media_folder: static/images/uploads
public_folder: /images/uploads
collections:
- name: posts_en
label: Posts (English)
folder: content/posts
create: true
extension: md
format: frontmatter
slug: "{{slug}}.en"
filter:
field: language
value: en
fields:
- { label: Title, name: title, widget: string }
- { label: Date, name: date, widget: datetime }
- { label: Draft, name: draft, widget: boolean, default: true }
- { label: Description, name: description, widget: string, required: false }
- { label: Tags, name: tags, widget: list, required: false }
- { label: Body, name: body, widget: markdown }
- { label: Language, name: language, widget: hidden, default: en }
Hugo 的 static/ 目錄會原封不動複製到 public/,build 完 /shs/ 就能用了。
2. 用 Cloudflare Pages Functions 處理 OAuth
GitHub OAuth 需要 server-side 交換 secret,不可能在瀏覽器端完成。Cloudflare Pages Functions 剛好能處理,不需另開 Worker。
functions/api/auth.js — 發起 OAuth:
export async function onRequest({ request, env }) {
const clientId = env.GITHUB_CLIENT_ID;
const scope = "repo,user";
const state = crypto.randomUUID();
const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=${scope}&state=${state}`;
return Response.redirect(url, 302);
}
functions/api/auth/callback.js — 換 token:
export async function onRequest({ request, env }) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await res.json();
const html = `
<script>
window.opener.postMessage(
'authorization:github:success:${JSON.stringify({ token: access_token, provider: "github" })}',
'*'
);
</script>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET 在 Cloudflare Pages → Settings → Environment variables 設定,不要 commit 進 repo。
3. 註冊 GitHub OAuth App
到 GitHub → Settings → Developer settings → OAuth Apps → New OAuth App:
- Homepage URL:
https://your-domain.com - Authorization callback URL:
https://your-domain.com/api/auth/callback
4. Cloudflare Pages Build 設定
| 設定 | 值 |
|---|---|
| Build command | hugo --minify |
| Build output directory | public |
HUGO_VERSION 環境變數 | 0.160.0(釘死版本,避免踩雷) |
theme 用 git submodule,Cloudflare 才能在 build 時抓到:
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
5. 雙語 Collection
在同一個 content/posts 資料夾管理 foo.zh.md / foo.en.md 的做法:加 language frontmatter 欄位,搭配 Decap 的 filter。
兩個 collection 都指向 content/posts,差別在:
posts_zh:filterlanguage: zh,slug 模板"{{slug}}.zh"posts_en:filterlanguage: en,slug 模板"{{slug}}.en"
從 posts_zh 新建文章會自動帶 hidden language: zh,儲存為 my-post.zh.md。Hugo 會自動配對 my-post.en.md 作為翻譯。
踩坑心得
- 不需要建佔位翻譯文章。 翻譯不存在就不要建檔,Hugo 能正常處理單語文章。
- 提早釘死
HUGO_VERSION。 不設的話 Cloudflare 會用預設的舊版 Hugo,config key 可能不被認。 - 不要把 build 產出的 HTML 存進 repo。 直接從 Hugo source deploy,讓 Cloudflare 去 build。把 build output 存進 repo 會弄亂 git history,Decap CMS 預期的是 Markdown 而不是 HTML。