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_IDGITHUB_CLIENT_SECRET 在 Cloudflare Pages → Settings → Environment variables 設定,不要 commit 進 repo。

3. 註冊 GitHub OAuth App

到 GitHub → Settings → Developer settings → OAuth Apps → New OAuth App:

  • Homepage URLhttps://your-domain.com
  • Authorization callback URLhttps://your-domain.com/api/auth/callback

4. Cloudflare Pages Build 設定

設定
Build commandhugo --minify
Build output directorypublic
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: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。