[{"content":"这篇是操作手册。目标形态：写完 markdown，git push，几十秒后上线， 中间不维护任何进程，评论也不需要数据库。\n按依赖顺序讲，不做完上一步下一步就没法开始。每步给命令和验证方法。\n技术栈：\nHugo 生成静态站，单个二进制，22 页构建约 100 毫秒 PaperMod 主题，开箱就是扁平时间流加搜索加深色模式 Cloudflare Workers 托管，静态资源免费且走全球 CDN giscus 评论，落在 GitHub Discussions，零后端 GitHub Actions 部署，push 触发 D2 画图，构建期出 SVG，读者侧零 JS \u003c?xml version=\"1.0\" encoding=\"utf-8\"?\u003eheartleo/blog · Privatehugo --minify --gcpublic/ · 22 页 · 100msCloudflare Workersblog.heartleo.devgit push mainGitHub Actionsindex.md + 同目录图片hugo.toml CI 跑同一条流水线图片转 webp 切三档wrangler deployroutes + custom_domain 构建与部署。虚线框是私有仓，CI 跑的是和本地完全相同的那条流水线 依赖顺序总览 先看全貌，后面每节对应一行：\n步骤 依赖 做完之后你有 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 步可选，别的都不能跳。\n第 7 步必须在第 8 步之前手动跑通一次。跳过它直接配 CI，出问题时分不清 是配置错了还是 CI 环境的事。\n第 0 步：域名托管到 Cloudflare Workers 绑自定义域名要求域名在同一个 Cloudflare 账号下，且 zone 状态是 Active。这一步生效要时间，所以放在最前面并行等着。\n到 Cloudflare Dashboard 添加域名，把注册商那边的 NS 记录改成 Cloudflare 给的两条。几分钟到几小时不等，看注册商。\nDashboard 上该域名状态显示 Active 而不是 Pending，这一步就算过了。\n第 1 步：装 Hugo extended 必须是 extended 版本。PaperMod 用了 SCSS，普通版 Hugo 构建直接失败。\nwinget install Hugo.Hugo.Extended macOS 用 brew install hugo，brew 装的就是 extended。\n跑 hugo version，输出里要能看到 extended 字样。\n别用 npm i -D hugo-extended。它在 postinstall 阶段去 GitHub Release 下载二进制再校验 hash，网络稍有波动就报 Checksum mismatch。 那个校验失败意味着下到的文件和上游发布的不是同一个，可能是连接被截断， 也可能是中间设备改写了响应，而它同时是供应链投毒会出现的信号。 遇到就换渠道装，不要去搜怎么跳过校验。\nWindows 上 winget 装完可能不在当前终端的 PATH 里，开个新终端再试。\n第 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，不会和自己的改动混在一起。\n代价是 CI 那边 checkout 必须显式拉子模块，第 8 步会用到：\n- uses: actions/checkout@v4 with: submodules: recursive 漏了这行，CI 会在构建阶段报主题不存在。\nhugo.toml 最小可用配置：\nbaseURL = \u0026#34;https://blog.example.com/\u0026#34; title = \u0026#34;站点标题\u0026#34; theme = \u0026#34;PaperMod\u0026#34; 跑 hugo server -D，http://localhost:1313 能打开就说明主题装对了。\n第 3 步：中文站必须改的两处配置 这两处不改，站能构建、能访问，但功能是坏的。\n字数统计和阅读时长 Hugo 默认按空格分词。一整段中文没有空格，会被算成 1 个词，结果每篇文章 都显示\u0026quot;1 分钟 · 1 字\u0026quot;。\nhasCJKLanguage = true 开了之后按字符统计 CJK。\n搜索 PaperMod 的搜索基于 fuse.js，纯静态，索引来自首页多输出的一份 JSON。 先让 Hugo 生成这份 JSON：\n[outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] 然后把最小匹配长度设成 1。默认值对中文太大，中文一个字就是一个有意义的 搜索单位：\n[params.fuseOpts] minMatchCharLength = 1 还要建一个搜索页 content/search.md：\n--- title: \u0026#34;搜索\u0026#34; layout: \u0026#34;search\u0026#34; url: \u0026#34;/search/\u0026#34; --- 改完在搜索页输入一个中文词，能命中正文里含这个词的文章就对了。\n顺带一提，文章的摘要别依赖 Hugo 自动截断，中文断句会很难看。 frontmatter 里显式写 summary。\n第 4 步：定下图片约定 这个决定越晚改代价越大，所以在写第一篇之前定。\n用 Hugo 的 page bundle，正文和图片放同一个文件夹：\ncontent/posts/how-i-built-my-blog/ ├── index.md ← 正文，文件名必须是 index.md ├── arch.png └── screenshot.png URL 由文件夹名决定，是 /posts/how-i-built-my-blog/。\n好处是删文章时图片跟着一起删干净，文件名只需在单篇内唯一， 不用在两棵目录树之间来回对照。\n写一个 img shortcode 直接用 markdown 的 ![]() 也能显示图，但拿不到 Hugo 的图片处理。 layouts/shortcodes/img.html 里包一层，栅格图自动转 webp 并切多档：\n{{- $src := .Get \u0026#34;src\u0026#34; -}} {{- $res := $.Page.Resources.GetMatch $src -}} {{- $srcset := slice -}} {{- range $w := (slice 480 960 1440) -}} {{- if le $w $res.Width -}} {{- $v := $res.Resize (printf \u0026#34;%dx webp q82\u0026#34; $w) -}} {{- $srcset = $srcset | append (printf \u0026#34;%s %dw\u0026#34; $v.RelPermalink $w) -}} {{- end -}} {{- end -}} \u0026lt;img src=\u0026#34;{{ $res.RelPermalink }}\u0026#34; srcset=\u0026#34;{{ delimit $srcset \u0026#34;, \u0026#34; }}\u0026#34; sizes=\u0026#34;(max-width: 720px) 100vw, 720px\u0026#34; width=\u0026#34;{{ $res.Width }}\u0026#34; height=\u0026#34;{{ $res.Height }}\u0026#34; loading=\u0026#34;lazy\u0026#34; decoding=\u0026#34;async\u0026#34; alt=\u0026#34;{{ .Get \u0026#34;alt\u0026#34; }}\u0026#34;\u0026gt; 一张 55 KB 的 PNG 截图，转出来是 4.4 / 12.7 / 21.6 KB 三档。 width 和 height 一定要输出，否则图片加载前占位为零，会有布局抖动。\n文章里这样引用，src 直接写文件名：\n{{\u0026lt; img src=\u0026#34;arch.png\u0026#34; alt=\u0026#34;架构图\u0026#34; caption=\u0026#34;整体架构\u0026#34; \u0026gt;}} 再加一层保护：图片文件不存在时不输出任何标签，只在构建日志里 warnf 一条。这样先写好图位、后补图的时候，线上不会出现裂图。\n第 5 步：图表用 D2 可选，但比截图省事得多，改一行文字不用重新截图。\nwinget install Terrastruct.D2 .d2 和生成的 .svg 都放进文章目录，两者都提交：\ndirection: 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 -\u0026gt; hugo hugo -\u0026gt; cf: wrangler deploy d2 --theme 0 --dark-theme 200 --pad 24 pipeline.d2 pipeline.svg 用 direction: down，不要 direction: right。 正文宽度只有 720px， 横向布局缩进去之后文字只剩几像素高，读不了。\n选 D2 不选 Mermaid 的理由：Mermaid 要么在客户端加载约 1 MB JS， 要么预渲染时依赖无头 Chrome。D2 是单个 Go 二进制，出图约 130 毫秒， 读者侧零 JS。Mermaid 的优势是源码能在 GitHub 上直接预览， 仓库公开的话值得考虑。\n生成之后有三处要后处理，写个脚本一起做掉：\n要做的事 不做会怎样 从 viewBox 取值补上根 \u0026lt;svg\u0026gt; 的 width / height d2 的根元素只有 viewBox，浏览器认为它没有固有尺寸，会把比正文窄的图拉满容器宽度，高度跟着涨 把 @media (prefers-color-scheme: dark) 拆掉，每条规则前加 body.dark d2 的深色跟随系统，PaperMod 的深色是给 \u0026lt;body\u0026gt; 加 .dark 类。手动切深色而系统是浅色时，页面变黑图还是白的 删掉铺满画布的背景矩形 d2 的深色底偏紫，和 PaperMod 的中性深色不同调，图会以一个色块浮在页面上 第二条改完之后，SVG 必须内联进页面才有效。外链 \u0026lt;img\u0026gt; 里的 SVG 是独立 文档，看不到父页面的 .dark 类。在 shortcode 里判断 MIME 类型，是 svg 就输出 $res.Content。\n第 6 步：两个 GitHub 仓，配 giscus giscus 要求承载 Discussions 的仓库是 public。源码想私有的话，拆成两个。\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?\u003eheartleo/blog · Privateblog.heartleo.devgiscus · GitHub Appheartleo/blog-comments · Public源码 / markdown / 配置Discussions · Announcements 分类 hugo 构建后部署文章底部加载脚本按 pathname 建 discussion 两者之间没有任何关联要求 两个仓库之间那条虚线是重点：giscus 不要求它们有任何关系 仓库 可见性 用途 blog Private 源码、markdown、配置 blog-comments Public 只承载 Discussions，一行代码都没有 giscus 指向的仓库和源码仓之间没有任何关联要求，它只是评论数据的存储 位置。唯一的可见差别是评论区\u0026quot;查看讨论\u0026quot;链接跳到 comments 仓。\n开 Discussions gh repo edit \u0026lt;你的用户名\u0026gt;/blog-comments --enable-discussions 也可以在 Settings → General → Features 里勾。\n顺手把这个仓收紧，它只承载评论，另外三个功能开着等于白留入口：\ngh repo edit \u0026lt;你的用户名\u0026gt;/blog-comments \\ --enable-issues=false --enable-wiki=false --enable-projects=false 分类直接用 Announcements 开启 Discussions 时 GitHub 自动创建六个默认分类，其中 Announcements 的类型正好是 Announcement，只有仓库维护者能开新帖。这挡住了别人把 Discussions 当留言板刷，而 giscus 通过 App 权限建评论帖不受影响。\n不用另建分类。\n装 giscus App 打开 https://github.com/apps/giscus，点 Install，授权页选 Only select repositories，只勾 blog-comments。源码仓不要勾。\n这是整套流程里唯一必须在浏览器点的一步，App 授权没有 CLI。\n取两个 ID repoId 和 categoryId 可以在 https://giscus.app 的配置生成器里拿， 也可以直接查：\ngh api graphql -F query=@query.graphql query.graphql：\nquery { repository(owner: \u0026#34;你的用户名\u0026#34;, name: \u0026#34;blog-comments\u0026#34;) { id discussionCategories(first: 25) { nodes { id name isAnswerable } } } } 返回里 repository.id 是 repoId，Announcements 节点的 id 是 categoryId。\nPowerShell 里别用 -f query='...' 内联传 GraphQL，原生命令的参数解析 会把里面的引号搞坏。走文件。\n填进 hugo.toml：\n[params] comments = true [params.giscus] repo = \u0026#34;你的用户名/blog-comments\u0026#34; repoId = \u0026#34;R_kgD...\u0026#34; category = \u0026#34;Announcements\u0026#34; categoryId = \u0026#34;DIC_kwD...\u0026#34; mapping = \u0026#34;pathname\u0026#34; strict = \u0026#34;1\u0026#34; lang = \u0026#34;zh-CN\u0026#34; theme = \u0026#34;preferred_color_scheme\u0026#34; 这两个 ID 不是密钥，可以放心提交，它们是前端脚本用的公开标识符， 本来就渲染在 HTML 里对所有访客可见。\nmapping = \u0026quot;pathname\u0026quot; 表示按页面路径匹配评论。这意味着改已发布文章的 URL 会让旧评论失联，改 slug 前要想清楚。strict = \u0026quot;1\u0026quot; 防止路径相近的 页面串评论。\nPaperMod 在 params.comments = true 时会渲染 layouts/partials/comments.html， 把 giscus 的 script 放进去即可。\n打开任意文章页，控制台会出现这条：\n[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，完全不同。\n第 7 步：首次部署，手动跑通 wrangler.jsonc：\n{ \u0026#34;name\u0026#34;: \u0026#34;blog\u0026#34;, \u0026#34;compatibility_date\u0026#34;: \u0026#34;2026-07-20\u0026#34;, \u0026#34;assets\u0026#34;: { \u0026#34;directory\u0026#34;: \u0026#34;./public\u0026#34;, \u0026#34;not_found_handling\u0026#34;: \u0026#34;404-page\u0026#34; }, \u0026#34;routes\u0026#34;: [ { \u0026#34;pattern\u0026#34;: \u0026#34;blog.example.com\u0026#34;, \u0026#34;custom_domain\u0026#34;: true } ] } 没有 main 字段，也就没有 Worker 脚本，纯静态资源托管。这一点后面会 影响 CI 里的 wrangler 版本选择。\nnpx wrangler login # 浏览器 OAuth hugo --minify --gc npx wrangler deploy Worker 不需要提前在 Dashboard 建，首次 wrangler deploy 时自动创建， 名字取自配置里的 name。\n自定义域名也不用去 DNS 面板加记录。custom_domain: true 会自动建 DNS 记录并签发证书。手动加 CNAME 反而冲突。\n证书签发要一两分钟，期间访问是 ERR_CONNECTION_CLOSED，属于正常现象。\n配置里一旦有 routes 而没写 workers_dev，wrangler 会默认关掉 *.workers.dev 路由。这个默认值是合理的，同一份内容挂两个域名对 SEO 不利。想保留就显式加 \u0026quot;workers_dev\u0026quot;: true。\nWorkers 静态资源的限制：免费版每个版本 20,000 个文件，付费版 100,000， 单文件都是 25 MiB。普通博客离这些数字很远。\n等证书好了之后 curl -I https://你的域名 返回 200，这一步就完成了。\n第 8 步：交给 GitHub Actions 建 API Token 到 https://dash.cloudflare.com/profile/api-tokens，创建自定义令牌。 不要用模板，模板给的权限比需要的大。\n需要四条权限：\n类型 资源 权限 为什么 帐户 Workers 脚本 编辑 上传 Worker 和静态资源 帐户 帐户设置 读取 解析 Account ID 区域 Workers 路由 编辑 绑定自定义域名要动 zone 路由 区域 区域 读取 解析 zone ID 后两条容易漏。routes 加 custom_domain 是区域级操作，只给帐户级 权限的话部署会报 Authentication error [code: 10000]， 请求路径里能看到 /zones/。\n帐户资源限定到自己的账号，区域资源限定到那一个域名。 不要用 Global API Key，它覆盖整个账号的所有服务。\n权限不够时可以编辑现有 token 补上，token 值不变，GitHub Secret 不用重设。\n存进 Secrets gh secret set CLOUDFLARE_API_TOKEN --repo \u0026lt;你的用户名\u0026gt;/blog gh secret set CLOUDFLARE_ACCOUNT_ID --repo \u0026lt;你的用户名\u0026gt;/blog --body \u0026#34;\u0026lt;account id\u0026gt;\u0026#34; token 那条不要带 --body。不带时 gh 会提示交互式粘贴，输入不回显、 不进 shell history。写成参数就留在历史记录里了。\nAccount ID 不是密钥，npx wrangler whoami 随时能打印。\nworkflow .github/workflows/deploy.yml：\nname: 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: \u0026#34;0.164.0\u0026#34; 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: \u0026#34;4.111.0\u0026#34; command: deploy 里面有三处不能省：\n配置 为什么 wranglerVersion: \u0026quot;4.111.0\u0026quot; wrangler-action@v3 默认装 wrangler 3.90.0，那个版本不认识 assets 配置，会去找 main 然后报 Missing entry-point。本地用 4.x 的话完全复现不出来 hugo-version: \u0026quot;0.164.0\u0026quot; 用 latest 的话上游一发新版就可能在 CI 里炸，同样在本地复现不出来。升级时本地和这里一起改 cancel-in-progress: false 部署中途被取消可能留下上传了一半的静态资源。排队等前一次跑完更安全，反正部署只要几十秒 workflow_dispatch 让 Actions 页面出现 Run workflow 按钮，不改代码也能 重新部署。\npush 一次，Actions 变绿且线上内容更新，整套就通了。\n私有仓 Actions 免费额度每月 2000 分钟，这个站构建一次约 30 秒。 公开仓不限量。\n发布前的验证清单 检查 方法 阅读时长正常 首页条目显示的分钟数不是全部为 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 没执行也不报错 写成了 {{\u0026lt; ... \u0026gt;}}。那是转义写法，用来展示语法本身 图片不显示 构建日志里找 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 步分开做，出问题时能二分。\nCI 里用到的版本全部钉死。本地和 CI 版本不同导致的问题，在本地永远复现 不出来，排查成本最高。上面那两个版本号就是这么来的。\n看到 checksum mismatch 别绕过去，那是校验在正常工作。\n哪一步卡住了，评论区说一声。\n","permalink":"https://blog.heartleo.dev/posts/how-i-built-my-blog/","summary":"Hugo + PaperMod + Cloudflare Workers + giscus，push 即上线，全程不碰服务器。按依赖顺序一步步给出正确的操作和验证方法，含中文站必改的两处配置和 CI 里两处必须钉死的版本。","title":"从零搭一个 Hugo 博客并部署到 Cloudflare Workers"},{"content":" Done is better than perfect.\n这个博客记录工程实践、踩坑复盘和读书笔记。所有内容是纯 markdown， Hugo 生成，部署在 Cloudflare Workers 上。\nGitHub: @heartleo RSS: /index.xml 评论走 giscus，登录 GitHub 即可留言。\n","permalink":"https://blog.heartleo.dev/about/","summary":"about","title":"关于"}]