Hugo 博客可以继续把文章放在 Git 仓库里,同时增加一个网页后台,用来写文章、修改标签和上传图片。Decap CMS 做的就是这件事:浏览器里编辑,保存成 Markdown,再提交到 Git。

如果已经有 Netlify,认证服务可以直接交给它。想用 GitHub 账号登录,可以使用 Netlify 托管的 OAuth;想用邮箱和密码登录,可以使用 Netlify Identity 配合 Git Gateway。这两种方式都不需要自己部署 Cloudflare Worker。

前台也不必迁移。一个实用的安排是:读者访问 Cloudflare 上的博客,编辑者打开 Netlify 上的管理后台,两个平台连接同一个内容仓库。

本文使用假想的 paper-notes 项目。your-github-name/paper-notes 是仓库占位符,notes.example.com 是示例域名,paper-notes.netlify.app 也需要换成自己实际分配到的项目地址。下文配置依据截至 2026 年 9 月 11 日的官方文档整理,不表示这个假想项目已经上线。

先选登录方式

Decap 自己不保存用户密码,也不会因为多了一个 /admin/ 页面就自动拥有账号系统。登录方式取决于配置的后端。

方案 编辑者如何登录 编辑者需要 GitHub 账号吗 需要自建认证服务吗
GitHub 后端 + Netlify OAuth GitHub 授权登录 需要,并且具有仓库写权限 不需要
Git Gateway + Netlify Identity 邮箱和密码,也可另配外部登录 不需要 不需要
GitHub 后端 + 自建 OAuth 代理 GitHub 授权登录 需要,并且具有仓库写权限 需要

自己维护技术博客,用 GitHub 登录比较直接。要让不使用 GitHub 的人参与编辑,邮箱和密码更方便。这里的密码属于 Netlify Identity 用户,不是 Netlify 控制台账号,也不是 GitHub 密码。

如果要求的是任意用户名而不是邮箱,或者已有公司内部账号系统,就需要另外适配身份服务。不能在 Decap 的 YAML 里写上 usernamepassword,就得到一个新的登录系统。

项目和部署关系

假设项目已经能正常运行 Hugo,文章目录是 content/posts/,GitHub 的发布分支是 main

paper-notes/
├── content/
│   ├── posts/
│   │   └── first-post.md
│   ├── about.md
│   └── links.md
├── static/
│   ├── admin/
│   │   ├── index.html
│   │   └── config.yml
│   └── uploads/
├── themes/
├── hugo.toml
└── netlify.toml

Hugo 会把 static/admin/ 原样复制到 public/admin/,因此编辑入口是 /admin/。这些文件属于博客的内容管理配置,放在博客仓库里即可,不需要塞进主题仓库。

发布过程可以理解成下面这条链路:

编辑者打开 Netlify 上的 /admin/
通过 GitHub OAuth 或 Netlify Identity 登录
Decap 把 Markdown 和图片写回 GitHub
main 分支产生新提交
Netlify、Cloudflare 各自执行 Hugo 构建
读者看到更新后的博客

Decap 保存成功,只表示内容已经进入 Git 仓库。网站什么时候更新,还取决于构建是否完成、部署是否成功。

在 Netlify 连接 Hugo 仓库

在 Netlify 中创建一个连接 Git 仓库的项目,选择假想的 your-github-name/paper-notes。填写 Hugo 构建命令和输出目录,并确认生产分支是 main

可以把构建配置写进项目根目录的 netlify.toml

[build]
command = "hugo --gc --minify"
publish = "public"

[build.environment]
HUGO_VERSION = "0.165.0"

这里的 Hugo 版本只是示例,应当换成项目实际验证过、主题支持的版本。若仓库已经有 Netlify 配置,就在原文件里合并设置。主题使用 Git 子模块时,还要确认部署日志中成功拉取了子模块。

先完成一次部署,记下 Netlify 分配的地址,比如:

https://paper-notes.netlify.app

后面的登录测试先固定使用这个地址。即使前台域名 notes.example.com 仍然指向 Cloudflare,也不影响从 Netlify 的管理入口编辑同一个仓库。

用 HugoMods 模块生成后台

也可以直接引入 hugomods/decap-cms 。它把管理页面、CMS 脚本、配置输出和部分编辑器扩展封装成 Hugo Module,适合已经采用 Hugo Modules 的项目。

这个模块解决的是后台页面如何生成。Netlify OAuth、Identity 账号、Git Gateway 和仓库权限,仍然按后面的认证章节配置。它不会把 Netlify 服务变成本地服务,也不会因为引入模块就自动创建账号。

手写静态页面和模块生成页面,是两种可选的安装方式。使用模块后,配置来源改为 Hugo 的 params.decap_cms,不要再同时维护一份占据相同 /admin/ 路径的 static/admin/index.html

引入模块并固定版本

下面沿用假想的 paper-notes 项目。若项目还没有 go.mod,先初始化 Hugo Module;已有模块配置时跳过初始化,保留现有依赖。

hugo mod init example.com/paper-notes
hugo mod get github.com/hugomods/decap-cms@v0.16.7

如果选择 Netlify Identity 的邮箱密码登录,再引入它的独立子模块:

hugo mod get github.com/hugomods/decap-cms/modules/netlify-identity@v0.1.1

两个模块各自有版本号,不能把主模块的 v0.16.7 直接用作 Identity 子模块的版本。提交时保留 go.modgo.sum,构建环境也需要 Go;使用 vendor 的项目,应统一管理已有依赖后再执行 hugo mod vendor,不要覆盖别的模块目录。

准备 Dart Sass

模块会编译预览样式,默认使用 Dart Sass。这里不能因为项目平时只处理普通 CSS,就假定构建环境已经满足要求。

一种做法是把嵌入式 Dart Sass 编译器作为项目开发依赖安装:

npm install --save-dev --save-exact sass-embedded@1.104.0

在已有 package.json 中合并构建脚本:

{
  "scripts": {
    "build": "hugo --gc --minify",
    "check": "hugo --gc --minify --panicOnWarning"
  }
}

npm run 会把本地 node_modules/.bin 加入 PATH,让 Hugo 找到编译器。相应地,将 Netlify 的构建命令改成 npm run build,并提交依赖锁文件。已有构建脚本时,把需要的步骤并入原流程。

该模块支持配置 sass_transpiler,但在 Hugo 0.165 中切到 LibSass 会产生弃用警告,无法通过这里使用的严格构建检查,所以示例明确选择 dartsass

把 CMS 配置写进 Hugo

以下是邮箱密码方案的最小配置,合并到 hugo.toml 中:

[[module.imports]]
path = "github.com/hugomods/decap-cms/modules/netlify-identity"

[outputs]
home = ["HTML", "RSS", "DecapCMSConfig"]

[params.decap_cms]
locale = "zh_Hans"
publish_mode = "simple"
media_folder = "static/uploads"
public_folder = "/uploads"
sass_transpiler = "dartsass"
_js_url = "https://cdn.jsdelivr.net/npm/decap-cms@3.16.2/dist/decap-cms.js"

[params.decap_cms.backend]
name = "git-gateway"
branch = "main"

[params.decap_cms.collections.posts]
name = "posts"
label = "文章"
folder = "content/posts"
create = true
extension = "md"
format = "yaml-frontmatter"
slug = "{{slug}}"
fields = [
  { name = "title", label = "标题", widget = "string" },
  { name = "date", label = "发布日期", widget = "datetime" },
  { name = "tags", label = "标签", widget = "list", required = false },
  { name = "draft", label = "草稿", widget = "boolean", default = true },
  { name = "body", label = "正文", widget = "richtext", modes = ["raw"] }
]

[params.decap_cms.collections.posts.editor]
preview = false

Identity 子模块会导入 CMS 主模块,因此这里不需要再写第二条重复的主模块 import。若只用 GitHub 登录,就把 import 换成 github.com/hugomods/decap-cms,并把后端改为:

[params.decap_cms.backend]
name = "github"
repo = "your-github-name/paper-notes"
branch = "main"

配置里有几个和手写 YAML 不同的地方:

  • collections 是以名称为键的表,示例是 collections.posts;不要直接把 Decap 的整个 YAML 列表搬进 TOML。
  • DecapCMSConfig 必须加入首页输出。如果原项目还需要 JSON 等输出,应保留它们,再追加这一项。
  • 模块默认的发布模式是 editorial_workflow,示例显式设为 simple,避免无意中改变文章发布流程。
  • 模块版本与内置 CMS 版本不同。v0.16.7 的锁文件对应 Decap CMS 3.6.3,而本文字段使用较新的 richtext widget,因此用 _js_url 明确指定前文的 3.16.2。升级时要一起验证模块初始化脚本和编辑器。

生成的配置使用站点 baseURL 作为 site_url。当前模块也不是把 params.decap_cms 下的所有键都原样输出,例如手写示例中的 display_url 不在这版模块的顶层转发列表中。配置额外选项后,应直接检查生成的 YAML,而不是只看 Hugo 配置文件里有没有写。

创建管理页面,并处理 Hugo 0.165 兼容性

创建 content/admin/_index.md

---
title: "Paper Notes 内容管理"
layout: decap-cms
url: /admin/
outputs: [HTML]
sitemap:
  disable: true
---

这里创建的是后台分区页面,不是普通博客文章。模块布局会输出 noindex,同时通过 rel="cms-config-url" 指向生成的配置文件。

实际用 Hugo 0.165.0 严格构建 v0.16.7 时,模块的页面模板仍调用了已弃用的 .Site.Language.LanguageCode,会使 --panicOnWarning 构建失败。这个版本需要在站点侧做一次小的模板覆盖:

mkdir -p layouts
curl -fsSL \
  https://raw.githubusercontent.com/hugomods/decap-cms/v0.16.7/layouts/_default/decap-cms.html \
  -o layouts/decap-cms.html

确认目标文件还不存在,或先与已有模板合并。随后把其中的 <html> 行改为:

<html lang="{{ .Site.Language.Locale }}">

其余模板内容保留。这样修正的是项目内的覆盖文件,不需要修改下载缓存或 vendor 文件。以后升级模块,应比较上游模板,再决定是否保留覆盖,不能让这份副本一直落后于模块。

示例只配置了单语言 CMS,并显式指定 locale。如果还要启用模块的多语言配置,也需要另行核对其他旧 API 和各语言输出,不能把这次单语言构建当成全部功能的兼容性证明。

接入 Identity 的首页回调

Netlify Identity 子模块的 README 说明:如果主题没有实现相应的 HugoPress 钩子,需要手动在首页模板中注入以下 partial:

{{ if .IsHome }}
  {{ partialCached "hugopress/modules/decap-cms-netlify-identity/script" . }}
  {{ partialCached "hugopress/modules/decap-cms-netlify-identity/callback-script" . }}
{{ end }}

第一项加载 Identity widget,第二项提供登录后的后台跳转。把它们放进主题实际调用的扩展插槽或布局里;依赖树中有 HugoPress,并不代表主题已经渲染了这些钩子。

模块会从 site.Pages 查找 layout: decap-cms 的页面来确定跳转地址。因此,这个示例没有给后台设置 build.list: never,否则可能导致回调脚本找不到它。首页只列文章的主题通常不会把后台分区当作文章;如果主题另有页面导航或搜索逻辑,再按主题的配置排除后台。

模块路线使用这组 widget 和回调即可,不要再重复加入下文手写路线的同类脚本。无论哪种方式,都要实际验证邀请邮件和找回密码邮件。

检查模块生成了什么

配置完成后执行:

npm run check

应当得到:

public/admin/index.html
public/decap-cms-config.yaml

模块路线的配置文件是 decap-cms-config.yaml,不是手写路线中的 admin/config.yml。后台 HTML 应包含类似下面的引用:

<link href="/decap-cms-config.yaml" type="text/yaml" rel="cms-config-url">

本节按上述发布标签的源码,在 Hugo 0.165.0 下完成了隔离构建,并检查了生成的后台、配置和首页 Identity 钩子。浏览器能够初始化管理页面并显示中文 Netlify Identity 登录按钮。验证范围没有包含真实 Netlify 项目的登录授权与仓库写入,这部分仍按后文的首次发布流程验收。

如果只想尽快增加一个后台,手写静态页面的构建依赖更少。已经在维护 Hugo Modules、希望集中管理 CMS 字段和扩展时,可以使用这套模块配置;当前发布版本的兼容覆盖也要一起维护。

手动创建 Decap 管理入口

下面是静态文件安装方式。如果已经采用上面的 HugoMods 模块,就跳过这里的手写入口,直接继续配置所选的 Netlify 认证服务。

static/admin/index.html 中加入:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="robots" content="noindex, nofollow">
    <title>Paper Notes 内容管理</title>
  </head>
  <body>
    <script src="https://cdn.jsdelivr.net/npm/decap-cms@3.16.2/dist/decap-cms.js"></script>
  </body>
</html>

这里固定了写作时核对的 3.16.2 版本,方便复现。升级时一起检查配置和编辑行为,不必让管理后台每次访问都跟随一个浮动的大版本范围。

CMS 脚本只放在管理页面里,不要加入博客所有页面共用的页头。noindex 用来避免管理页面被搜索引擎收录,真正的写入权限仍由后端认证控制。

接下来从下面两种认证方案中选一种。它们共用文章字段配置,但 backend 不能混着写。认证平台的设置也适用于模块路线;下面的 YAML 和手写 HTML 供静态文件路线使用,模块用户对应修改 params.decap_cms.backend,保留模块生成的入口。

方案一:GitHub 登录,认证交给 Netlify

这个方案适合本来就在用 GitHub 的作者。Decap 直接通过 GitHub API 操作仓库,Netlify 只负责完成 OAuth 认证。

创建 GitHub OAuth App

进入 GitHub 账号设置Settings → Developer settings → OAuth Apps,不是某个仓库的 Settings。也可以直接打开 OAuth Apps 管理页面

创建应用时,可以填写:

字段 示例值
Application name Paper Notes Editor
Homepage URL https://paper-notes.netlify.app
Authorization callback URL https://api.netlify.com/auth/done

回调地址要使用 Netlify 官方给出的 https://api.netlify.com/auth/done。这里没有自己部署 /api/auth/callback,所以不能沿用自建代理方案的回调地址。

记录应用的 Client ID,并生成 Client Secret。

在 Netlify 安装认证提供商

在对应项目中进入:

Project configuration
  → Access & security
  → OAuth
  → Authentication Providers
  → Install Provider

选择 GitHub,填入刚才的 Client ID 和 Client Secret。

Client Secret 放在 Netlify 的认证提供商设置里,不要写进 static/admin/config.yml。Hugo 会公开发布 static/ 下的文件,其中的 YAML 可以直接被浏览器下载。

配置 GitHub 后端

static/admin/config.yml 的认证部分为:

backend:
  name: github
  repo: your-github-name/paper-notes
  branch: main

这里省略了 base_urlauth_endpoint,使用 Decap 的 Netlify OAuth 默认地址。先从 https://paper-notes.netlify.app/admin/ 登录,避免把 Cloudflare 域名、Netlify 项目和自建代理地址混在一起。

登录用户必须有目标仓库的写权限。能用 GitHub 登录,并不等于能编辑任意仓库。组织仓库还可能要求组织管理员批准 OAuth App,或者遵守额外的 SSO 策略。

方案二:邮箱和密码登录

这个方案使用两个 Netlify 服务:Identity 管理账号,Git Gateway 在认证后代替用户访问项目绑定的 Git 仓库。编辑者不需要 GitHub 账号,也不需要被添加为 GitHub 仓库协作者。

截至本文写作时,Netlify 的官方文档仍提供 Identity 的邮箱密码登录方式,Git Gateway 则标记为 Beta。具体可用能力以自己项目的控制台和当前计划为准。

开启 Identity,并改为邀请制

在 Netlify 项目中启用 Identity,然后进入:

Identity
  → Registration
  → Registration preferences
  → Invite only

个人博客适合邀请制。Netlify 文档中的默认注册模式是 Open,而 Git Gateway 的角色限制留空时,会允许项目里的所有 Identity 用户访问网关。这两项放在一起,容易把原本只给自己使用的后台开放给新注册用户。

再到 Identity → Users 邀请编辑者的邮箱。对方通过邀请邮件设置自己的密码,之后就用这个邮箱和密码登录。

开启 Git Gateway

进入:

Identity
  → Services
  → Git Gateway
  → Enable Git Gateway

网关使用的是这个 Netlify 项目已经连接的仓库。确认它指向 your-github-name/paper-notes,并且保存的 GitHub 凭据仍然有相应权限。

如果要限制只有指定编辑者能写入,可以在 Git Gateway 的 Roles 设置中填写,比如 editor,并给被邀请用户分配对应角色。角色限制应该由网关执行,不能只靠前端把按钮藏起来。

替换 backend,并加载 Identity widget

将 YAML 中整个 backend 改为:

backend:
  name: git-gateway
  branch: main

不要把上一种方案里的 repoauth_endpoint: api/auth 或自建 OAuth 地址一起保留下来。仓库由 Netlify 项目的 Git Gateway 绑定,登录则交给 Identity。

手写路线为了使用邀请、设置密码和找回密码的界面,将 static/admin/index.html 改成下面这样。已引入 Netlify Identity 子模块的项目会生成相应组件,不需要再创建这份静态文件:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="robots" content="noindex, nofollow">
    <title>Paper Notes 内容管理</title>
    <script src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
  </head>
  <body>
    <script src="https://cdn.jsdelivr.net/npm/decap-cms@3.16.2/dist/decap-cms.js"></script>
  </body>
</html>

这里沿用 Decap 的 Netlify Identity widget 集成方式。Netlify 自己的新应用开发文档还介绍了 @netlify/identity SDK;它是另一种接入方式,不要只替换一个脚本地址,就假设和现有 Decap widget 接口兼容。

邀请邮件打开首页怎么办

邀请、确认邮箱或密码恢复邮件,可能把用户送到站点首页,并在 URL 的 # 后携带 token。首页如果只显示文章,就不会自动出现设置密码界面。

手写路线可以在 Netlify 提供的首页加载下面的脚本,把这些回调转到同源的管理页面,同时保留 URL 片段。模块路线使用前文的 Identity 首页钩子,并实际验证邮件回调,不重复加载这段转发脚本。比如把脚本保存为 static/cms-auth-redirect.js

const params = new URLSearchParams(window.location.hash.slice(1));
const tokenKeys = ['invite_token', 'confirmation_token', 'recovery_token'];

if (tokenKeys.some(key => params.has(key))) {
  const admin = new URL('/admin/', window.location.origin);
  admin.hash = window.location.hash;
  window.location.replace(admin.href);
}

再通过主题支持的首页模板扩展加载它:

<script defer src="/cms-auth-redirect.js"></script>

不同 Hugo 主题的扩展插槽文件名不同,这段 <script> 要放进真正参与渲染的模板,仅仅创建一个静态 JS 文件不会自动执行。

它只识别 Identity 使用的特定 token,不会转发普通文章锚点,也不要把邀请链接或恢复链接记录到分析日志中。测试时同时走一遍邀请和找回密码流程,不要只验证已经注册过的账号。

一份完整的文章配置

这一节使用手写的 static/admin/config.yml。模块路线可以参考相同字段,把它们放到 params.decap_cms.collections 中,由 Hugo 生成配置。

下面以邮箱密码方案为例,给出可以作为起点的 static/admin/config.yml。如果选择 GitHub 登录,只替换最上面的 backend,其他部分可以共用。

backend:
  name: git-gateway
  branch: main

site_url: https://notes.example.com
display_url: https://notes.example.com
locale: zh_Hans

media_folder: static/uploads
public_folder: /uploads

collections:
  - name: posts
    label: 文章
    label_singular: 文章
    folder: content/posts
    create: true
    extension: md
    format: yaml-frontmatter
    slug: "{{slug}}"
    preview_path: "posts/{{slug}}/"
    editor:
      preview: false
    fields:
      - { label: 标题, name: title, widget: string }
      - { label: 发布日期, name: date, widget: datetime, format: "YYYY-MM-DDTHH:mm:ssZ" }
      - { label: 简介, name: description, widget: text, required: false }
      - { label: 标签, name: tags, widget: list, required: false }
      - { label: 分类, name: categories, widget: list, required: false }
      - { label: 草稿, name: draft, widget: boolean, default: true }
      - { label: 正文, name: body, widget: richtext, modes: [raw] }

几个字段会直接影响现有内容:

  • folder 是 Git 仓库内的目录,示例使用 content/posts。Hugo 并不强制叫这个名字,已有项目如果使用 content/post,就应填写实际目录。
  • body 是特殊字段名,表示 front matter 后面的正文,不能随意改成 content
  • media_folder 是上传文件存入仓库的位置,public_folder 是构建后写进文章的访问路径。这里会把图片存成 static/uploads/photo.webp,在文章中引用 /uploads/photo.webp
  • slug 控制新文章文件名的生成方式,preview_path 则要符合站点实际的永久链接规则。已有文章有自定义 urlslug 或日期路径时,需要相应调整,不能直接套用示例。

Hugo 的正文依然是 Markdown。这里使用当前文档推荐、仍标记为 Beta 的 richtext widget,但先只开放 raw 源码模式。代码围栏、公式和 Hugo 短代码较多的技术博客,用这种模式更容易保持原文结构。

旧教程里的 widget: markdown 仍能见到,不过当前文档已将其标记为 deprecated。切换编辑器前,可以选一篇包含代码、脚注、图片和短代码的文章,保存一次并检查 Git diff,确认没有意外改写。

默认预览面板也不等于 Hugo 的最终渲染结果。比如主题短代码、Mermaid 和 KaTeX,未必会在编辑器里按博客的方式显示。因此示例先关闭预览面板,以部署后的 Hugo 页面为准。

图片和独立页面怎么管理

把图片放进 static/uploads 最容易理解,也便于本地复现。不过每次上传都会增加 Git 仓库的体积,删除文章并不会自动缩小已有的 Git 历史。图片很多时,可以再评估 Decap 支持的媒体服务或自定义存储集成。

已经放在外部图床上的图片链接不必为接入 CMS 而搬迁。media_folder 主要决定之后通过后台上传的文件存放位置。

“关于”和“友链”这种固定页面适合使用文件集合。在同一个 collections 下增加:

  - name: pages
    label: 独立页面
    files:
      - name: about
        label: 关于
        file: content/about.md
        fields:
          - { label: 标题, name: title, widget: string }
          - { label: 正文, name: body, widget: richtext, modes: [raw] }
      - name: links
        label: 友链
        file: content/links.md
        fields:
          - { label: 标题, name: title, widget: string }
          - { label: 正文, name: body, widget: richtext, modes: [raw] }

这里只是演示最小字段。正式接入已有文章时,把用到的 descriptionweight、封面、别名、评论开关等 front matter 也核对一遍,不能只凭一个标题和正文字段就假定所有旧文章都已经适配。

本地测试不会自动隔离线上仓库

即使在 localhost 打开 Decap,只要仍使用远端后端,它就可能读写 YAML 中配置的远端仓库和分支。hugo server 只把网页放到本地,并不会自动把 CMS 的写入目标改成本地文件。

要测试本地编辑,可以临时在配置顶层加入:

local_backend: true

模块路线把相同选项写成 params.decap_cms.local_backend = true。测试后也应移除,避免把本地配置和正式后端混用。

在 Hugo 项目根目录分别启动两个进程:

# 终端一:本地内容代理
BIND_HOST=127.0.0.1 npx decap-server
# 终端二:Hugo 预览,包含草稿
hugo server --buildDrafts --disableFastRender

然后访问:

http://localhost:1313/admin/

新版 decap-server 支持 BIND_HOST;需要复现环境时,应像 CMS 一样固定自己验证过的代理版本。代理默认使用 8081 端口,如果被占用,可以修改代理端口,并同步调整 local_backend.url

通过本地后台创建一篇测试草稿,再用 git diff 看实际写出的 Markdown、日期、标签和图片路径。测试完成后删除测试内容,并移除临时的 local_backend 配置,再提交正式改动。本地代理不支持 Editorial Workflow,也不能验证生产环境的 OAuth、邀请邮件或网关权限。

草稿、审核和发布是两件事

默认的简单发布模式会直接向 backend.branch 指定的分支提交修改。draft: true 只是写进 Hugo front matter 的一个字段:它可以让普通生产构建不展示文章,但内容本身可能已经提交到了公开的 main 分支。

因此,Hugo 草稿不是私密草稿。如果文章暂时不适合公开,不应依赖 draft: true 来隐藏仓库里的文本。

需要先保存草稿、查看差异、再决定发布时,可以开启 Decap 的编辑工作流,在 YAML 顶层加上:

publish_mode: editorial_workflow

GitHub 后端会为未发布条目创建 cms/... 分支和 Pull Request,继续编辑会提交到该分支,发布时再合并。公开仓库里的这些分支和 PR 仍然是公开的。

这个工作流也不会自动把 Hugo 的 draft 字段切换成 false。发布前要同时确认文章的草稿开关和日期:未来时间的文章也可能被 Hugo 的生产构建跳过。

如果使用分支保护,先确认 CMS 使用的 GitHub 用户或 Git Gateway 凭据能够执行预期的提交、PR 和合并操作。不要为了让后台保存成功就直接绕过已有审核规则。

前台继续放在 Cloudflare

可以让两个平台连接同一个仓库,各自负责不同的访问入口:

用途 地址 托管平台
读者访问博客 https://notes.example.com Cloudflare Pages
作者进入后台 https://paper-notes.netlify.app/admin/ Netlify
内容和历史记录 your-github-name/paper-notes GitHub

两边都设置生产分支 main,都使用项目验证过的 Hugo 版本和 public 输出目录。后台发布产生新提交后,两边的 Git 集成都可以触发构建。

site_urldisplay_url 可以指向读者使用的 Cloudflare 域名,它们不会把 Identity 或 Git Gateway 服务迁移到 Cloudflare。把同一份 /admin/ 复制到 Cloudflare,不代表 Cloudflare 自动拥有 /.netlify/identity/.netlify/git 接口。

初次接入时,固定从 Netlify 域名进入后台,能省掉跨域端点、widget 初始化地址和邮件回调目标的额外配置。如果以后确实要从 Cloudflare 域名打开后台,再单独适配远端身份服务和网关,不要用一个宽泛的 CORS 配置碰运气。

Netlify 如果也生成了完整博客,可以考虑只在 Netlify 部署环境输出 X-Robots-Tag: noindex,避免备用站点被索引。不要把这项限制无条件放进两个平台共用的 static/_headers,否则可能把正式博客也一起设成不收录。

只用 Cloudflare 时,原方案还缺什么

如果不使用 Netlify,GitHub 后端就需要另外的 OAuth 服务。它可以是独立 Worker,也可以是博客项目里的 Pages Functions。后者不要求再单独创建一个 Worker 项目。

Pages Functions 采用文件路由,下面这个结构才同时提供登录入口和回调:

functions/
└── api/
    └── auth/
        ├── index.js       # /api/auth
        └── callback.js    # /api/auth/callback

functions/ 放在项目根目录,和 content/static/ 同级,不放在 static/public/ 里。这时 Decap 的认证部分才是:

backend:
  name: github
  repo: your-github-name/paper-notes
  branch: main
  base_url: https://notes.example.com
  auth_endpoint: api/auth

对应的 GitHub OAuth App 回调也应改成:

https://notes.example.com/api/auth/callback

这两种认证服务不能共用一套回调地址。Netlify 托管 OAuth 使用 https://api.netlify.com/auth/done,自建服务使用自己的回调。

只有一个把 code 换成 token 的 callback.js 还不够。一个完整代理至少需要完成以下流程:

  1. 登录入口生成随机 state,保存本次登录的校验状态,再跳转 GitHub。
  2. 按 GitHub 的当前建议加入 PKCE,保存 verifier,发送 S256 challenge。
  3. 回调检查 state,带上对应 verifier 交换 token,并处理拒绝授权、过期和上游错误。
  4. 按 Decap 的弹窗协议完成握手,只向经过校验的编辑器来源返回结果。

Decap 的常见 OAuth 适配器先发送字符串 authorizing:github,收到编辑器回应后,再发送 authorization:github:success: 加上 JSON 数据。直接发送一个 { token, provider } 对象,并不等同于实现了这套协议。

携带 token 的消息不能发给任意 * 来源,也不能把任意窗口发来的 message.origin 当成可信目标。还需要检查消息来源窗口、限制目标 origin,并防止认证响应被缓存。Client Secret 始终保存在服务端。

这些细节说明了自建代理需要维护什么。本文没有把一个只包含 token 交换的示例称为可上线的认证服务,也不把独立 Worker 的旧版 wrangler.toml 配置和 Pages Functions 混在一起。需要自建时,可以从 Decap 官方列出的 OAuth 客户端 选择实现,再核对其维护状态、协议适配和安全处理。

首次发布怎么验收

配置完成后,先在本地构建:

hugo --gc --minify

手写路线确认 public/admin/index.htmlpublic/admin/config.yml 存在;模块路线使用 npm run check 构建,检查后台及 public/decap-cms-config.yaml。再检查变更并提交。下面的命令以已存在、连接好远端的示例项目为前提:

git diff --check
git diff

git add static/admin/index.html static/admin/config.yml netlify.toml
git commit -m "Add Decap CMS content editor"
git push origin main

模块路线提交实际变更的 go.modgo.sum、Hugo 配置、后台内容页面、兼容模板及 npm 配置和锁文件,不要照搬上面手写路线的文件清单。如果增加了邮件回调脚本和模板引用,也一起检查并提交;不要把测试文章、邀请 token 或认证密钥带进去。

等 Netlify 部署完成后,用一个测试账号和一篇不重要的测试文章完成以下验证:

  1. 从 Netlify 的 /admin/ 打开后台并登录。
  2. 修改文章标题、标签和一段正文,上传一张图片。
  3. 检查 GitHub 上实际产生的提交,确认文件目录和 front matter 正确。
  4. 将测试文章设为可发布,确认 Hugo 构建成功。
  5. 打开正式域名上的文章,检查图片、代码、短代码和文章地址。
  6. 如果使用 Identity,再验证一次新用户邀请、找回密码,以及无编辑角色用户无法通过网关写入。

更换仓库、域名或身份服务后,这条流程也值得再走一遍。编辑器里显示成功,和生产站点已经更新,是两个不同的验证结果。

常见问题对照

现象 优先检查
/admin/ 能打开,登录失败 先确认使用的是 GitHub 后端还是 Git Gateway,再检查对应认证服务
GitHub 提示回调地址不匹配 Netlify OAuth 应回到 api.netlify.com/auth/done;自建代理才使用自己的 callback
Git Gateway 返回 404 或 HTML 是否从 Cloudflare 域名请求了只存在于 Netlify 的接口;网关是否启用
可以登录,但不能保存 GitHub 写权限、网关角色、仓库绑定和分支保护
邀请链接只打开博客首页 Identity token 是否保留,并交给加载了登录组件的页面处理
保存后首页没有文章 生产分支、构建日志、draft、未来日期,以及主题是否列出 posts 分区
图片在后台可见,正文打不开 media_folderpublic_folder 是否分别指向仓库路径和访问路径
本地编辑改了远端仓库 本地代理是否启动,local_backend 是否真正生效
后台预览不显示短代码 编辑器预览与 Hugo 渲染不同,检查实际构建页面

对于一个已经正常部署的 Hugo 博客,可以先选定登录方式,再接入文章集合。等登录、保存、构建和图片都验证过,再增加独立页面、自定义编辑器组件或更复杂的审核流程。

参考资料