这篇是操作手册。目标形态:写完 markdown,git push,几十秒后上线, 中间不维护任何进程,评论也不需要数据库。

按依赖顺序讲,不做完上一步下一步就没法开始。每步给命令和验证方法。

技术栈:

  • Hugo 生成静态站,单个二进制,22 页构建约 100 毫秒
  • PaperMod 主题,开箱就是扁平时间流加搜索加深色模式
  • Cloudflare Workers 托管,静态资源免费且走全球 CDN
  • giscus 评论,落在 GitHub Discussions,零后端
  • GitHub Actions 部署,push 触发
  • D2 画图,构建期出 SVG,读者侧零 JS

依赖顺序总览

先看全貌,后面每节对应一行:

步骤依赖做完之后你有
0. 域名托管到 Cloudflare一个 Active 的 zone
1. 装 Hugo extended能构建
2. 建站 + 主题 submodule1本地能跑起来
3. 中文站两处配置2字数统计和搜索是对的
4. 定下图片约定2可以开始写文章
5. 图表用 D2(可选)2图能跟着深色模式变
6. 两个 GitHub 仓 + giscus评论可用
7. 首次手动部署 + 绑域名0, 1-4线上可访问
8. 交给 GitHub Actions6, 7push 即上线

第 5 步可选,别的都不能跳。

第 7 步必须在第 8 步之前手动跑通一次。跳过它直接配 CI,出问题时分不清 是配置错了还是 CI 环境的事。


第 0 步:域名托管到 Cloudflare

Workers 绑自定义域名要求域名在同一个 Cloudflare 账号下,且 zone 状态是 Active。这一步生效要时间,所以放在最前面并行等着。

到 Cloudflare Dashboard 添加域名,把注册商那边的 NS 记录改成 Cloudflare 给的两条。几分钟到几小时不等,看注册商。

Dashboard 上该域名状态显示 Active 而不是 Pending,这一步就算过了。

第 1 步:装 Hugo extended

必须是 extended 版本。PaperMod 用了 SCSS,普通版 Hugo 构建直接失败。

winget install Hugo.Hugo.Extended

macOS 用 brew install hugo,brew 装的就是 extended。

hugo version,输出里要能看到 extended 字样。

别用 npm i -D hugo-extended。它在 postinstall 阶段去 GitHub Release 下载二进制再校验 hash,网络稍有波动就报 Checksum mismatch。 那个校验失败意味着下到的文件和上游发布的不是同一个,可能是连接被截断, 也可能是中间设备改写了响应,而它同时是供应链投毒会出现的信号。 遇到就换渠道装,不要去搜怎么跳过校验

Windows 上 winget 装完可能不在当前终端的 PATH 里,开个新终端再试。

第 2 步:建站,主题用 submodule

hugo new site blog --format toml
cd blog
git init
git submodule add -b master https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

用 submodule 而不是把主题文件直接拷进仓库,理由是升级主题只要 git submodule update --remote,不会和自己的改动混在一起。

代价是 CI 那边 checkout 必须显式拉子模块,第 8 步会用到:

- uses: actions/checkout@v4
  with:
    submodules: recursive

漏了这行,CI 会在构建阶段报主题不存在。

hugo.toml 最小可用配置:

baseURL = "https://blog.example.com/"
title = "站点标题"
theme = "PaperMod"

hugo server -Dhttp://localhost:1313 能打开就说明主题装对了。

第 3 步:中文站必须改的两处配置

这两处不改,站能构建、能访问,但功能是坏的。

字数统计和阅读时长

Hugo 默认按空格分词。一整段中文没有空格,会被算成 1 个词,结果每篇文章 都显示"1 分钟 · 1 字"。

hasCJKLanguage = true

开了之后按字符统计 CJK。

搜索

PaperMod 的搜索基于 fuse.js,纯静态,索引来自首页多输出的一份 JSON。 先让 Hugo 生成这份 JSON:

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

然后把最小匹配长度设成 1。默认值对中文太大,中文一个字就是一个有意义的 搜索单位:

[params.fuseOpts]
  minMatchCharLength = 1

还要建一个搜索页 content/search.md

---
title: "搜索"
layout: "search"
url: "/search/"
---

改完在搜索页输入一个中文词,能命中正文里含这个词的文章就对了。

顺带一提,文章的摘要别依赖 Hugo 自动截断,中文断句会很难看。 frontmatter 里显式写 summary

第 4 步:定下图片约定

这个决定越晚改代价越大,所以在写第一篇之前定。

用 Hugo 的 page bundle,正文和图片放同一个文件夹:

content/posts/how-i-built-my-blog/
├── index.md          ← 正文,文件名必须是 index.md
├── arch.png
└── screenshot.png

URL 由文件夹名决定,是 /posts/how-i-built-my-blog/

好处是删文章时图片跟着一起删干净,文件名只需在单篇内唯一, 不用在两棵目录树之间来回对照。

写一个 img shortcode

直接用 markdown 的 ![]() 也能显示图,但拿不到 Hugo 的图片处理。 layouts/shortcodes/img.html 里包一层,栅格图自动转 webp 并切多档:

{{- $src := .Get "src" -}}
{{- $res := $.Page.Resources.GetMatch $src -}}
{{- $srcset := slice -}}
{{- range $w := (slice 480 960 1440) -}}
  {{- if le $w $res.Width -}}
    {{- $v := $res.Resize (printf "%dx webp q82" $w) -}}
    {{- $srcset = $srcset | append (printf "%s %dw" $v.RelPermalink $w) -}}
  {{- end -}}
{{- end -}}
<img src="{{ $res.RelPermalink }}"
     srcset="{{ delimit $srcset ", " }}"
     sizes="(max-width: 720px) 100vw, 720px"
     width="{{ $res.Width }}" height="{{ $res.Height }}"
     loading="lazy" decoding="async" alt="{{ .Get "alt" }}">

一张 55 KB 的 PNG 截图,转出来是 4.4 / 12.7 / 21.6 KB 三档。 widthheight 一定要输出,否则图片加载前占位为零,会有布局抖动。

文章里这样引用,src 直接写文件名:

{{< img src="arch.png" alt="架构图" caption="整体架构" >}}

再加一层保护:图片文件不存在时不输出任何标签,只在构建日志里 warnf 一条。这样先写好图位、后补图的时候,线上不会出现裂图。

第 5 步:图表用 D2

可选,但比截图省事得多,改一行文字不用重新截图。

winget install Terrastruct.D2

.d2 和生成的 .svg 都放进文章目录,两者都提交:

direction: down

repo: heartleo/blog · Private {
  style.stroke-dash: 3
  src: index.md + 同目录图片 { shape: page }
}

hugo: hugo --minify --gc { shape: hexagon }
cf: Cloudflare Workers { shape: cloud }

repo.src -> hugo
hugo -> cf: wrangler deploy
d2 --theme 0 --dark-theme 200 --pad 24 pipeline.d2 pipeline.svg

direction: down,不要 direction: right 正文宽度只有 720px, 横向布局缩进去之后文字只剩几像素高,读不了。

选 D2 不选 Mermaid 的理由:Mermaid 要么在客户端加载约 1 MB JS, 要么预渲染时依赖无头 Chrome。D2 是单个 Go 二进制,出图约 130 毫秒, 读者侧零 JS。Mermaid 的优势是源码能在 GitHub 上直接预览, 仓库公开的话值得考虑。

生成之后有三处要后处理,写个脚本一起做掉:

要做的事不做会怎样
viewBox 取值补上根 <svg>width / heightd2 的根元素只有 viewBox,浏览器认为它没有固有尺寸,会把比正文窄的图拉满容器宽度,高度跟着涨
@media (prefers-color-scheme: dark) 拆掉,每条规则前加 body.darkd2 的深色跟随系统,PaperMod 的深色是给 <body>.dark 类。手动切深色而系统是浅色时,页面变黑图还是白的
删掉铺满画布的背景矩形d2 的深色底偏紫,和 PaperMod 的中性深色不同调,图会以一个色块浮在页面上

第二条改完之后,SVG 必须内联进页面才有效。外链 <img> 里的 SVG 是独立 文档,看不到父页面的 .dark 类。在 shortcode 里判断 MIME 类型,是 svg 就输出 $res.Content

第 6 步:两个 GitHub 仓,配 giscus

giscus 要求承载 Discussions 的仓库是 public。源码想私有的话,拆成两个。

仓库可见性用途
blogPrivate源码、markdown、配置
blog-commentsPublic只承载 Discussions,一行代码都没有

giscus 指向的仓库和源码仓之间没有任何关联要求,它只是评论数据的存储 位置。唯一的可见差别是评论区"查看讨论"链接跳到 comments 仓。

开 Discussions

gh repo edit <你的用户名>/blog-comments --enable-discussions

也可以在 Settings → General → Features 里勾。

顺手把这个仓收紧,它只承载评论,另外三个功能开着等于白留入口:

gh repo edit <你的用户名>/blog-comments \
  --enable-issues=false --enable-wiki=false --enable-projects=false

分类直接用 Announcements

开启 Discussions 时 GitHub 自动创建六个默认分类,其中 Announcements 的类型正好是 Announcement,只有仓库维护者能开新帖。这挡住了别人把 Discussions 当留言板刷,而 giscus 通过 App 权限建评论帖不受影响。

不用另建分类。

装 giscus App

打开 https://github.com/apps/giscus,点 Install,授权页选 Only select repositories,只勾 blog-comments。源码仓不要勾。

这是整套流程里唯一必须在浏览器点的一步,App 授权没有 CLI。

取两个 ID

repoIdcategoryId 可以在 https://giscus.app 的配置生成器里拿, 也可以直接查:

gh api graphql -F query=@query.graphql

query.graphql

query {
  repository(owner: "你的用户名", name: "blog-comments") {
    id
    discussionCategories(first: 25) {
      nodes { id name isAnswerable }
    }
  }
}

返回里 repository.idrepoIdAnnouncements 节点的 idcategoryId

PowerShell 里别用 -f query='...' 内联传 GraphQL,原生命令的参数解析 会把里面的引号搞坏。走文件。

填进 hugo.toml

[params]
  comments = true

  [params.giscus]
    repo = "你的用户名/blog-comments"
    repoId = "R_kgD..."
    category = "Announcements"
    categoryId = "DIC_kwD..."
    mapping = "pathname"
    strict = "1"
    lang = "zh-CN"
    theme = "preferred_color_scheme"

这两个 ID 不是密钥,可以放心提交,它们是前端脚本用的公开标识符, 本来就渲染在 HTML 里对所有访客可见。

mapping = "pathname" 表示按页面路径匹配评论。这意味着改已发布文章的 URL 会让旧评论失联,改 slug 前要想清楚。strict = "1" 防止路径相近的 页面串评论。

PaperMod 在 params.comments = true 时会渲染 layouts/partials/comments.html, 把 giscus 的 script 放进去即可。

打开任意文章页,控制台会出现这条:

[giscus] Discussion not found. A new discussion will be created if a
comment/reaction is submitted.

这是成功信号,不是错误。它说明 giscus 解析到了仓库和分类,只是这篇文章 还没人评论。App 没装或仓库填错的话,报的是 giscus is not installed on this repository,完全不同。

第 7 步:首次部署,手动跑通

wrangler.jsonc

{
  "name": "blog",
  "compatibility_date": "2026-07-20",
  "assets": {
    "directory": "./public",
    "not_found_handling": "404-page"
  },
  "routes": [
    { "pattern": "blog.example.com", "custom_domain": true }
  ]
}

没有 main 字段,也就没有 Worker 脚本,纯静态资源托管。这一点后面会 影响 CI 里的 wrangler 版本选择。

npx wrangler login     # 浏览器 OAuth
hugo --minify --gc
npx wrangler deploy

Worker 不需要提前在 Dashboard 建,首次 wrangler deploy 时自动创建, 名字取自配置里的 name

自定义域名也不用去 DNS 面板加记录。custom_domain: true 会自动建 DNS 记录并签发证书。手动加 CNAME 反而冲突。

证书签发要一两分钟,期间访问是 ERR_CONNECTION_CLOSED,属于正常现象。

配置里一旦有 routes 而没写 workers_dev,wrangler 会默认关掉 *.workers.dev 路由。这个默认值是合理的,同一份内容挂两个域名对 SEO 不利。想保留就显式加 "workers_dev": true

Workers 静态资源的限制:免费版每个版本 20,000 个文件,付费版 100,000, 单文件都是 25 MiB。普通博客离这些数字很远。

等证书好了之后 curl -I https://你的域名 返回 200,这一步就完成了。

第 8 步:交给 GitHub Actions

建 API Token

https://dash.cloudflare.com/profile/api-tokens,创建自定义令牌。 不要用模板,模板给的权限比需要的大。

需要四条权限:

类型资源权限为什么
帐户Workers 脚本编辑上传 Worker 和静态资源
帐户帐户设置读取解析 Account ID
区域Workers 路由编辑绑定自定义域名要动 zone 路由
区域区域读取解析 zone ID

后两条容易漏。routescustom_domain区域级操作,只给帐户级 权限的话部署会报 Authentication error [code: 10000], 请求路径里能看到 /zones/

帐户资源限定到自己的账号,区域资源限定到那一个域名。 不要用 Global API Key,它覆盖整个账号的所有服务。

权限不够时可以编辑现有 token 补上,token 值不变,GitHub Secret 不用重设。

存进 Secrets

gh secret set CLOUDFLARE_API_TOKEN --repo <你的用户名>/blog
gh secret set CLOUDFLARE_ACCOUNT_ID --repo <你的用户名>/blog --body "<account id>"

token 那条不要带 --body。不带时 gh 会提示交互式粘贴,输入不回显、 不进 shell history。写成参数就留在历史记录里了。

Account ID 不是密钥,npx wrangler whoami 随时能打印。

workflow

.github/workflows/deploy.yml

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: "0.164.0"
          extended: true

      - name: Build
        run: hugo --minify --gc

      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          wranglerVersion: "4.111.0"
          command: deploy

里面有三处不能省:

配置为什么
wranglerVersion: "4.111.0"wrangler-action@v3 默认装 wrangler 3.90.0,那个版本不认识 assets 配置,会去找 main 然后报 Missing entry-point。本地用 4.x 的话完全复现不出来
hugo-version: "0.164.0"latest 的话上游一发新版就可能在 CI 里炸,同样在本地复现不出来。升级时本地和这里一起改
cancel-in-progress: false部署中途被取消可能留下上传了一半的静态资源。排队等前一次跑完更安全,反正部署只要几十秒

workflow_dispatch 让 Actions 页面出现 Run workflow 按钮,不改代码也能 重新部署。

push 一次,Actions 变绿且线上内容更新,整套就通了。

私有仓 Actions 免费额度每月 2000 分钟,这个站构建一次约 30 秒。 公开仓不限量。


发布前的验证清单

检查方法
阅读时长正常首页条目显示的分钟数不是全部为 1
中文搜索能命中搜索页输入一个中文词
评论区加载控制台出现 Discussion not found 那条
深色模式手动切换主题,图片和图表跟着变
自定义域名curl -I https://你的域名 返回 200
CI 通过push 后 Actions 变绿

排查表

症状原因和处理
文章写完了站上看不到draft: true,或者 date 是未来时间被 buildFuture = false 过滤了
阅读时长全是 1 分钟hasCJKLanguage = true 没加
中文搜不到fuseOpts.minMatchCharLength 没设成 1,或 outputs.home 里没加 JSON
shortcode 没执行也不报错写成了 {{< ... >}}。那是转义写法,用来展示语法本身
图片不显示构建日志里找 WARN img:,它会打出期望的完整路径
CI 报 Missing entry-pointwrangler 版本太老不认识 assetswranglerVersion 没显式指定
CI 报 Authentication error [code: 10000]API token 缺区域级权限,补齐上面那张表里的四条
本地搜索结果重复hugo server 内存增量重建的产物,不是 bug。干净构建后查 public/index.json 可确认
hugo 命令找不到winget 装的路径没进当前终端的 PATH,开个新终端

几条经验

先手动跑通再自动化。第 7 步和第 8 步分开做,出问题时能二分。

CI 里用到的版本全部钉死。本地和 CI 版本不同导致的问题,在本地永远复现 不出来,排查成本最高。上面那两个版本号就是这么来的。

看到 checksum mismatch 别绕过去,那是校验在正常工作。

哪一步卡住了,评论区说一声。