上一篇聊了为什么选 Obsidian,拖了 2 年终于续上了:怎么让 Obsidian 里写的东西,自动变成一个能访问的网站。

先讲流程:

  1. 在 Obsidian 里写 Markdown,粘贴图片,保存写完后统一提交
  2. 推到 GitHub 之后,线上自动重新构建,不用手动操作
  3. 用自己的域名,不绑死在某一个托管服务上
  4. 整个过程零服务器零费用(仅域名费,如果用 github pages ,还省去域名费)

这套东西的核心是一个**「私有源 + 公开产物」的分层**。 大概就这么个逻辑Untitled-2026-06-24-1206.png

装 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 addthemes/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 代理回源的。

大致步骤:

  1. 域名托管到 Cloudflare,把 NS 改成 Cloudflare 的
  2. 加一条 DNS 记录,指向你的 Pages 源(具体是 CNAME 到 GitHub,还是 Cloudflare Pages,看你部署方式)
  3. 在 Cloudflare 开代理(小云朵变橙),HTTPS 自动签发,顺手开缓存

不用在 GitHub 仓库里放 CNAME 文件,也不用去 Settings → Pages 那一套——域名解析和证书全在 Cloudflare 这边管。整个过程一分钱不花,配置好之后基本不用再碰。

收尾

整套搭下来,我的日常就是:Obsidian 里写东西,写完手动提交推送,剩下的 GitHub Actions 全包了。线上什么时候更新我都不用管。

回头看,真正花时间的是前期配主题、调 front matter、把 SEO 那些东西理顺。跑起来之后基本零维护,偶尔升一下 Hugo 版本就行。

第一篇讲了选型,这篇讲了落地。其实静态博客这事,方案多得很,Hugo 只是我习惯的一套。只要数据攥在自己手里(本地 Markdown + Git),用哪个生成器、托管在哪,随时能换,这才是关键。

有一个忘记提到的点,写到最后。这个默认生成的文章有个 draft 字段,默认就是 false。如果要发布,要记得把它改成true,我曾经因为这个多次浪费时间。