# 在 Cloudflare Pages 上替 Hugo 接 Decap CMS

> 實作記錄：Hugo 靜態站搭配 Decap CMS，透過 Cloudflare Pages Functions 處理 GitHub OAuth，支援中英雙語 collection。

原文：https://sheng.page/posts/adding-decap-cms-to-hugo-on-cloudflare-pages/ · 發布：2026-04-15 · 作者：Sheng

> 2026-10 更新：本站已改用 Astro，也移除了 Decap CMS 後台，這篇保留當作當時的實作紀錄。

Hugo 很快也很彈性，但直接改 Markdown 不是每個人都喜歡。[Decap CMS](https://decapcms.org/)（前身 Netlify CMS）可以直接當靜態頁面放在 repo 裡，透過 GitHub API 讀寫 Markdown，不需要額外的 backend server。

以下是這個站實際使用的架構與設定。

## 架構

```text
瀏覽器 → /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`**
```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`**
```yaml
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 並設定 state cookie：
```js
export async function onRequest({ request, env }) {
  const state = crypto.randomUUID();
  const url = `https://github.com/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&scope=repo,user&state=${state}`;

  return new Response(null, {
    status: 302,
    headers: {
      Location: url,
      "Set-Cookie": `oauth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax`,
    },
  });
}
```

**`functions/api/auth/callback.js`** — 驗證 state 並換 token：
```js
export async function onRequest({ request, env }) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const cookieHeader = request.headers.get("Cookie") || "";

  const storedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1];
  if (!state || state !== storedState) {
    return new Response("Invalid state parameter", { status: 403 });
  }

  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" })}',
        window.location.origin
      );
    </script>`;

  return new Response(html, { headers: { "Content-Type": "text/html" } });
}
```

這裡把 `postMessage` 目標限定成 `window.location.origin`，token 就不會外流到其他 origin。正式環境 CMS admin 與這個 callback 同一個 Pages origin，訊息送得到；本機開發時 CMS 在不同 origin（如 `http://localhost:1313`），callback 仍在正式 Pages 網域，瀏覽器會靜默丟棄訊息，僅本機測試時，改成對含開發 origin 的 allowlist 逐一 `postMessage` 即可。

`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 時抓到：
```bash
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`：filter `language: zh`，slug 模板 `"{{slug}}.zh"`
- `posts_en`：filter `language: 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。

## 補充筆記

- Decap CMS 當靜態頁放在 `static/shs/`，透過 GitHub API 讀寫 Markdown，沒有資料庫也沒有 CMS server。
- GitHub OAuth 的 secret 交換交給 Cloudflare Pages Functions，callback 的 `postMessage` 目標限定 `window.location.origin`，client id 和 secret 放在 Pages 的環境變數。
- 中英文共用同一個 `content/posts` 資料夾，靠 `language` 欄位加 Decap 的 `filter` 分成兩個 collection。
- 提早釘死 `HUGO_VERSION`，不要建佔位翻譯文章，也不要把 build 產出的 HTML 存進 repo。

---

> Sheng 撰寫，Claude 協助文字與範例 · 列入 20260613 blog 翻新計劃，新漆未乾。