上一篇聊了为什么选 Obsidian,拖了 2 年终于续上了:怎么让 Obsidian 里写的东西,自动变成一个能访问的网站。
先讲流程:
- 在 Obsidian 里写 Markdown,粘贴图片,保存写完后统一提交
- 推到 GitHub 之后,线上自动重新构建,不用手动操作
- 用自己的域名,不绑死在某一个托管服务上
- 整个过程零服务器零费用(仅域名费,如果用 github pages ,还省去域名费)
这套东西的核心是一个**「私有源 + 公开产物」的分层**。
大概就这么个逻辑
装 Hugo
Hugo 是个用 Go 写的静态站点生成器,特点就一个字:快。我这博客几百篇文章,本地全量构建也就一两秒。
Mac 上直接 brew:
brew install hugo
装完看一眼版本,确认有 extended 字样,PaperMod 主题需要它:
hugo version
要是版本里没有 extended,brew 装的有时候会是精简版,换成 hugo_extended 就行。Windows 用 scoop 或者直接去 release 页面下 exe,这里不展开了。
初始化站点和目录
找个目录建站:
hugo new site blog
cd blog
生成的目录里,我们真正会动的主要是这几个:
content/post/—— 文章放这里config/_default/—— 配置文件static/—— favicon、robots.txt 这些静态资源layouts/—— 自定义模板(覆盖主题用的)themes/—— 主题
我自己把配置从默认的一个 config.toml 拆成了 _default/ 下一堆小文件(好吧,其实是当时用另一个主题他默认的这种形式,我看能跑得正常就没改了config、params、markup、languages……),拆开之后好维护,找东西也快。当然你嫌麻烦保持一个文件也行,Hugo 两种都吃。
装 PaperMod 主题
主题我用的是 PaperMod,干净、快、该有的功能都有,没花里胡哨的东西。
我用的是 Hugo module 的方式引入,不是 submodule。区别在于:submodule 是把主题代码塞进你自己的仓库,升级、改起来都重;module 是只记一个引用,主题代码不进你的仓库,CI 构建时自己拉。
引入就一行命令(前提是你本地装了 Go):
hugo mod init github.com/eatmoreduck/blog
然后在 config/_default/module.toml 里挂上主题:
[[imports]]
path = "github.com/adityatelange/hugo-PaperMod"
这样主题就是个依赖声明,不在你仓库里占地方。以后想覆盖主题里某个模板,只要在项目自己的 layouts/ 下放同名文件,Hugo 会优先用你的,不用动主题源码,升级主题也不会冲突。
你不想装 Go 的话,用 submodule 也能跑,把主题 git submodule add 进 themes/PaperMod 就行。两种我都用过,最后留了 module,纯粹是因为仓库更干净。
文章目录怎么放
这里有个我踩过坑的地方,重点说一下。
Hugo 支持两种放法:单文件(xxx.md)和 page bundle(一个文件夹里放 index.md 加图片)。我自己混着用:
- 纯文字、没几张图的文章,直接一个
.md文件,文件名带日期前缀,比如2024-04-07-xxx.md - 图多、或者要做中英双版本的,就开个文件夹,中文版叫
index.md,英文版叫index.en.md,图放同目录下
文件夹命名有个原则:单篇文章的文件夹带日期前缀,系列文件夹不带。比如「外网访问局域网设备指南」这种系列里有多篇不同文章,就用语义化命名;而某篇独立文章的文件夹就老老实实 2026-03-26-xxx 这样。
每篇文章开头那段 front matter 是关键,URL 全靠里面的 slug 决定:
---
title: "文章标题"
slug: "my-article-url"
date: 2024-04-07T17:23:58+08:00
tags:
- Hugo
toc: true
draft: true
description: "一句话摘要,会显示在列表和 SEO 里"
---
几个提醒:slug 一定用英文加横线,别塞中文,不然浏览器一编码 URL 长得没法看;draft: true 是草稿,正式发布前记得改 false,不然线上根本看不到(我自己就因为这个查了半天「为什么文章 404」)。
用 Obsidian 的 QuickAdd 一键建文
手动写 front matter 比较烦,我照抄网上的,用了QuickAdd 脚本,输入标题、slug、选标签,自动生成带 front matter 的文件。
脚本在 obs_sctipts/NewPost.js,配合 templates/NewPost.md 模板用。模板长这样:
---
title: "{{VALUE:articleTitle}}"
slug: "{{VALUE:articleSlug}}"
date: "{{VALUE:articleTimestamp}}"
tags:
{{VALUE:articleTagsFormatted}}
toc: true
draft: true
description:
---
NewPost.js 脚本长这样,不知道怎么改,可以直接让 AI 帮你改,默认预置了这些东西。反正 AI 改这些很在行,不用人再去多看什么,大体知道流程就行了
module.exports = async (params) => {
QuickAdd = params;
const title = await QuickAdd.quickAddApi.inputPrompt("文章标题 (中文)");
if (!title) {
new Notice("请输入文章标题");
return;
}
// 询问一个纯英文的 URL 路径名,避免中文路径被浏览器转码
const englishSlug = await QuickAdd.quickAddApi.inputPrompt("URL Slug (建议纯英文/数字/横线,如: my-new-post)");
// 生成日期前缀(YYYY-MM-DD)
const datePrefix = QuickAdd.quickAddApi.date.now('YYYY-MM-DD');
// 生成文件夹名称:日期前缀 + 英文 Slug 或标题清理后的
const folderBase = (englishSlug || title)
.toLowerCase()
.trim()
.replace(/[^a-z0-9\u4e00-\u9fa5\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 100);
const folderName = `${datePrefix}-${folderBase}`;
// 生成最终的文章 Slug
const finalSlug = (englishSlug || title)
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '') // 最终 Slug 过滤掉非 ASCII
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// 标签选择列表(同时用于分类和标签,简化选择)
const tagOptions = [
"AI", "Claude", "GPT", "LLM", "ChatGPT", "Golang", "Go", "Java", "Python",
"Hugo", "Blog", "Obsidian", "Git", "GitHub", "CI/CD", "DevOps",
"ZeroTier", "网络", "路由器", "群晖", "NAS", "ImmortalWrt",
"工具", "软件", "教程", "生活", "阅读", "思考", "随想",
"Markdown", "写作", "效率", "自动化", "AI工具", "Prompt",
"编程语言", "Backend", "Daily life", "other"
];
const defaultTags = ["other"];
let tags = await QuickAdd.quickAddApi.checkboxPrompt(tagOptions, defaultTags);
if (typeof tags === 'undefined' || tags.length === 0) {
tags = defaultTags;
}
const tagsFormatted = tags.map(t => ` - "${t}"`).join('\n');
// 注入变量给 Obsidian 模板
QuickAdd.variables["articleTitle"] = title;
QuickAdd.variables["articleSlug"] = finalSlug;
QuickAdd.variables["articleFolderName"] = folderName; // 文件夹建议用这个
QuickAdd.variables["articleTagsFormatted"] = tagsFormatted;
QuickAdd.variables["articleTimestamp"] = QuickAdd.quickAddApi.date.now('YYYY-MM-DDTHH:mm:ssZ');
console.log("即将创建 Page Bundle:", folderName);
console.log("Article Slug:", finalSlug);
// 温馨提示:即使你不填英文 Slug,代码也会尽量生成一个。
};
脚本会问你标题和 slug,自动拼日期前缀的文件夹名,再把标签格式化好塞进去。配好之后,新建一篇文章就是按个快捷键、填两行的事。
图片这块我还比较满意,现在用的是 PicList(PicGo 的增强分支,专门干图床这事),配 Obsidian 的 obsidian-image-auto-upload-plugin 插件。我在 PicList 里把图床指向自己的 GitHub 仓库 picture-repository,CDN 走 jsDelivr,配一次就不用管了,但是后续使用的时候,如果需要上传到图床,用这个功能需要保持 PicList 开着。
配好之后,这一步对我是透明的:在 Obsidian 里粘贴截图、或者把图拖进来,插件自动把图传到 picture-repository,然后把正文里的本地图片链接替换成 cdn.jsdelivr.net/gh/eatmoreduck/picture-repository@master/blog/xxx.png。我只管贴图,上传、换链接全是后台自动完成,跟当年在语雀里贴图一个体验,但在自己仓库里,如果有其他的图床,也可以使用其他的图床。我这里图方便,就一直用的是 GitHub,单独开了个仓库做图床。
你问我为什么图片不和文章放一起、单独开个仓库?因为图走 CDN 后,构建时根本不依赖本地图片文件,博客仓库和部署仓库的体积都小,加载也快。这跟前面说的「私有源 + 公开产物」呼应了撒。
本地预览
写完想看看效果:
hugo server -D # -D 连草稿一起渲染
浏览器开 http://localhost:1313 就能看到。改啥刷新啥,基本是即时的,这也是我前面说 Hugo 快的好处。
私有源 + 公开产物:为什么要分两个仓库
这是整套架构里我最在意的一环,专门拎出来说。
我用两个 GitHub 仓库:
blog(私有):源码仓库。原始的.md文章、Obsidian 的全部笔记、配置、主题引用,全在这。里面可能有还没写完的草稿、私人笔记、甚至一些不想公开的东西。因为私有,随便存。而且我这个仓库比较乱,有的时候会把密码放进去,但是不会放到博文的目录去。这块后续还需要做一些改动eatmoreduck.github.io(公开):部署仓库。里面只有hugo编译出来的纯 HTML、CSS、JS。一个.md源文件都没有,更没有敏感内容。
为什么不直接一个仓库搞定?因为博客源文件 ≠ 博客网站。源文件是我的工作台,里面有草稿、有想法、有备忘,公开出去不合适;而网站是给读者看的成品,必须公开。
分仓之后,私有仓库存一切,公开仓库只接收构建产物——中间那道「构建」就是天然的过滤器,只放干净的成品出去。就算哪天公开仓库被人扒,扒到的也只是 HTML,原始内容还在我私仓里。
图片我单独开了第三个仓库 picture-repository,公开,专门存图片,前面 PicList 自动上传就是往这里传的,文章里的图都走 jsDelivr CDN。这样构建时不依赖本地图片,部署仓库体积小,加载也快。
GitHub Actions 自动部署
推一次代码,线上全自动更新。前面说的「私有源 → 公开产物」,就是靠它实现的,这个也是常规操作,如果有自动 commit 的需求,可以使用 Obsidian 相关的插件。
源码仓库推到 main 分支后,Action 自动跑起来:拉源码、构建、把产物推到部署仓库。workflow 放在 .github/workflows/hugo.yaml,核心两段。
第一段构建,在 CI runner 上跑 hugo:
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.157.0
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 安装 Hugo(extended 版)
run: |
wget -O hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
sudo dpkg -i hugo.deb
- name: 构建
run: hugo --gc --minify --baseURL "https://blog.xiaohuangyu.space/"
因为我用 module 引主题,构建时 Hugo 会自己拉 PaperMod,所以 checkout 不用带 submodules。Hugo 版本写死成 extended,别图省事用精简版,PaperMod 的 SCSS 需要 extended 才能编。
第二段部署,把产物推到公开的 eatmoreduck.github.io:
deploy:
needs: build
steps:
- uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.PERSONAL_TOKEN }}
external_repository: eatmoreduck/eatmoreduck.github.io
publish_branch: main
publish_dir: ./public
force_orphan: true
几个点解释一下:
external_repository指向另一个仓库,这就是「跨仓库部署」,构建产物从私仓的 Action 推到公开仓库。personal_token是个有repo权限的 token,存在私仓的 Secrets 里,用来跨仓库写权限。force_orphan: true是关键:让部署仓库每次都是孤儿提交,清空所有历史。这样公开仓库永远只有「当前这一份产物」,不保留构建历史,体积不会膨胀,也不暴露之前构建过啥。
自定义域名(走 Cloudflare)
默认地址是 用户名.github.io,绑自己域名我走的是 Cloudflare,没用 GitHub Pages 自带的域名绑定。
为什么用 Cloudflare:免费、全球 CDN、自带 HTTPS 和缓存,国内访问也比我直连 GitHub 强。我的 blog.xiaohuangyu.space 实际是 DNS 解析到 Cloudflare、由 Cloudflare 代理回源的。
大致步骤:
- 域名托管到 Cloudflare,把 NS 改成 Cloudflare 的
- 加一条 DNS 记录,指向你的 Pages 源(具体是 CNAME 到 GitHub,还是 Cloudflare Pages,看你部署方式)
- 在 Cloudflare 开代理(小云朵变橙),HTTPS 自动签发,顺手开缓存
不用在 GitHub 仓库里放 CNAME 文件,也不用去 Settings → Pages 那一套——域名解析和证书全在 Cloudflare 这边管。整个过程一分钱不花,配置好之后基本不用再碰。
收尾
整套搭下来,我的日常就是:Obsidian 里写东西,写完手动提交推送,剩下的 GitHub Actions 全包了。线上什么时候更新我都不用管。
回头看,真正花时间的是前期配主题、调 front matter、把 SEO 那些东西理顺。跑起来之后基本零维护,偶尔升一下 Hugo 版本就行。
第一篇讲了选型,这篇讲了落地。其实静态博客这事,方案多得很,Hugo 只是我习惯的一套。只要数据攥在自己手里(本地 Markdown + Git),用哪个生成器、托管在哪,随时能换,这才是关键。
有一个忘记提到的点,写到最后。这个默认生成的文章有个 draft 字段,默认就是 false。如果要发布,要记得把它改成true,我曾经因为这个多次浪费时间。

