这篇是操作手册。目标形态:写完 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. 建站 + 主题 submodule | 1 | 本地能跑起来 |
| 3. 中文站两处配置 | 2 | 字数统计和搜索是对的 |
| 4. 定下图片约定 | 2 | 可以开始写文章 |
| 5. 图表用 D2(可选) | 2 | 图能跟着深色模式变 |
| 6. 两个 GitHub 仓 + giscus | 无 | 评论可用 |
| 7. 首次手动部署 + 绑域名 | 0, 1-4 | 线上可访问 |
| 8. 交给 GitHub Actions | 6, 7 | push 即上线 |
第 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 -D,http://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 三档。
width 和 height 一定要输出,否则图片加载前占位为零,会有布局抖动。
文章里这样引用,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 / height | d2 的根元素只有 viewBox,浏览器认为它没有固有尺寸,会把比正文窄的图拉满容器宽度,高度跟着涨 |
把 @media (prefers-color-scheme: dark) 拆掉,每条规则前加 body.dark | d2 的深色跟随系统,PaperMod 的深色是给 <body> 加 .dark 类。手动切深色而系统是浅色时,页面变黑图还是白的 |
| 删掉铺满画布的背景矩形 | d2 的深色底偏紫,和 PaperMod 的中性深色不同调,图会以一个色块浮在页面上 |
第二条改完之后,SVG 必须内联进页面才有效。外链 <img> 里的 SVG 是独立
文档,看不到父页面的 .dark 类。在 shortcode 里判断 MIME 类型,是 svg
就输出 $res.Content。
第 6 步:两个 GitHub 仓,配 giscus
giscus 要求承载 Discussions 的仓库是 public。源码想私有的话,拆成两个。
| 仓库 | 可见性 | 用途 |
|---|---|---|
blog | Private | 源码、markdown、配置 |
blog-comments | Public | 只承载 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
repoId 和 categoryId 可以在 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.id 是 repoId,Announcements 节点的 id 是
categoryId。
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 |
后两条容易漏。routes 加 custom_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-point | wrangler 版本太老不认识 assets,wranglerVersion 没显式指定 |
CI 报 Authentication error [code: 10000] | API token 缺区域级权限,补齐上面那张表里的四条 |
| 本地搜索结果重复 | hugo server 内存增量重建的产物,不是 bug。干净构建后查 public/index.json 可确认 |
hugo 命令找不到 | winget 装的路径没进当前终端的 PATH,开个新终端 |
几条经验
先手动跑通再自动化。第 7 步和第 8 步分开做,出问题时能二分。
CI 里用到的版本全部钉死。本地和 CI 版本不同导致的问题,在本地永远复现 不出来,排查成本最高。上面那两个版本号就是这么来的。
看到 checksum mismatch 别绕过去,那是校验在正常工作。
哪一步卡住了,评论区说一声。