hugo切换多主题构建

Hugo 负责把markdown文章变成网页,虽然主题可以在themes中单独设置。

但是一个站点如果想在 Stack 和 Blowfish 之间切换,很容易遇到一些问题:

  • 文章、图片和配置散落在不同仓库,修一篇文章要复制两份,换主题还会产生大量重复文件变更。
  • 即使 使用一个themes文件夹组织不同的主题,layouts、shortcode、_index.md可能不同。

本文按实际仓库结构说明如何把文章集中管理,让两个主题读取同一份内容,同时保留各自的网站样式和配置。

1. 三个仓库

这套多主题结构由三个 Git 仓库组成。同时最后推送到github.io仓库中,实际应该算是四个。

  • YourName/you-blog-content 是内容仓库,保存文章 Markdown、文章图片和附件。它是文章的唯一源头。新增文章、修改正文或更新图片,都在这里完成。
  • YourName/you-blog-stack 是 Stack 站点源码,保存 Stack 主题、主题配置、站点布局、菜单、自定义样式和 Stack 专属页面。
  • YourName/you-blog-blowfish 是 Blowfish 站点源码,保存 Blowfish 主题、主题配置、站点布局、菜单、自定义样式和 Blowfish 专属页面。

两个站点源码仓库中都有一个名为 shared-content/ 的子模块目录。它是内容仓库在当前站点中的工作副本和版本指针,不是第二份需要维护的文章。真正的文章仍以 you-blog-content 的 main 分支为准。更新内容时先推送内容仓库,两个站点的部署工作流会读取它的最新版本。

每个仓库的目录大致如下:

you-blog-content/
└── content/
    └── blog/
        └── my-post/
            ├── index.md
            └── assets/image.png

you-blog-stack/ 或 you-blog-blowfish/
├── config/              # 当前主题和站点设置
├── content/             # 当前主题专用页面
├── layouts/             # 当前站点的模板和短代码
├── themes/              # Stack 或 Blowfish 主题
└── shared-content/      # 子模块,指向内容仓库

这样拆分后,两个主题仓库仍可以独立调整外观。例如,Stack 可以使用自己的首页布局、菜单和文章卡片,Blowfish 可以保留另一套首页和主题选项。文章标题、正文、日期、分类、标签及附件则集中在内容仓库,不需要因为切换主题而复制一份。

主题仓库里的 content/ 仍有用途,但它只保存和站点展示方式有关的页面。常见例子包括 content/_index.md 首页、content/search/index.md 搜索页、content/blog/_index.md 栏目介绍,以及只对某个主题有意义的说明页。两边这些文件可以不同,也可以有相同的路径;它们不会取代共享文章,除非文件路径完全相同。

判断一个文件应该放在哪里,可以问自己一个简单问题:“换成另一个主题后,这个文件还应该原样使用吗?”如果答案是肯定的,通常适合放进内容仓库。例如文章正文、发布日期、分类、标签、引用资料和文章配图,都是读者关心的内容。如果答案是否定的,通常应留在主题仓库。例如首页欢迎语、某主题的导航菜单、搜索页模板、侧边栏配置、主题短代码和 CSS,都属于站点的呈现方式。

栏目页需要根据用途决定归属。若栏目页只是给某一个主题设置标题、封面和简介,就留在对应站点的 content/<section>/_index.md。若两个站点必须使用完全相同的栏目文字,也可以把它放进内容仓库;但要先确认另一个站点没有同路径的本地文件覆盖它。刚开始拆分时,建议先让共享仓库只保存文章和附件,减少两个主题互相影响的机会。

草稿也可以放在共享内容仓库。Hugo front matter 中的 draft = true 可以控制默认构建是否包含草稿,但两个站点的构建选项必须一致。若构建命令启用了草稿,文章可能意外进入发布结果。公开发布前应检查文章的 draft、发布日期、密码字段和图片链接;内容共享不会自动替文章完成审核。

2. Hugo 如何把同一篇文章交给两个主题

在内容仓库中,每篇文章通常是一个页面目录,Markdown 文件名为 index.md。文章自己的图片和附件放在相邻目录中,形成 Hugo 的 page bundle。例如:

you-blog-content/content/blog/hugo-build/index.md
you-blog-content/content/blog/hugo-build/assets/stack-menu.png

这篇文章在内容仓库里的完整路径是 content/blog/hugo-build/index.md。在网站源码中,它会通过 shared-content/content 出现。Hugo 仍按照内容目录生成 URL,所以文章网址是 /blog/hugo-build/,而不是包含 shared-content 的路径。图片也属于同一个页面包,可以从文章中按相对路径引用。

两个站点都需要在 config/_default/hugo.toml 中配置内容挂载。先挂载站点自己的 content,再挂载共享内容:

[[module.mounts]]
source = "content"
target = "content"

[[module.mounts]]
source = "shared-content/content"
target = "content"

Hugo 会把两个来源合并为一个虚拟的 content/。挂载顺序决定同名文件的优先级:本地 content/ 在前,所以站点专属首页或栏目说明优先;共享仓库提供其余文章。尽量避免在两边创建相同的文章路径,这样读者和维护者都能明确知道哪份文件会生效。

在 Stack 源码仓库中,配置文件属于 you-blog-stack/config/_default/hugo.toml;在 Blowfish 源码仓库中,则属于 you-blog-blowfish/config/_default/hugo.toml。两份配置都要有上述挂载,但主题名称、评论、侧边栏、配色等设置各自维护。内容仓库不保存主题文件,也不决定当前网站使用哪个主题。

保留挂载顺序很重要。Hugo 按照挂载层次创建统一的虚拟内容目录;如果共享文章和本地站点页面恰好使用同一路径,本地 content/ 中的文件会优先。比如两个来源都存在 blog/_index.md,页面最后显示的是本地版本。遇到“我明明改了文章,但页面没变化”时,先检查是否有同路径的本地文件遮住了共享版本,再检查子模块是否更新到了预期提交。

正文尽量使用两边都支持的 Markdown。短代码则要格外留意:Stack 和 Blowfish 内置的短代码并不完全相同。如果文章使用了某个主题专有短代码,另一个主题构建时可能报“找不到短代码”,或者输出不符合预期。要让文章在两个主题中都能构建,可以优先使用标准 Markdown;确实需要自定义效果时,在两个站点都提供同名短代码,并分别检查渲染结果。

每添加一个短代码,都应把“文章源码”和“短代码实现”分开考虑。文章源码属于共享内容,两个站点都要读它;短代码模板属于站点布局,应分别保存在两个站点仓库的 layouts/_shortcodes/。模板名称和参数约定要保持一致,否则一个主题可能把参数显示成普通文本,或无法处理图像、表格和折叠面板。可以先挑一篇同时使用该短代码的测试文章,在两个主题的本地预览中确认标题、样式、链接和移动端显示。

封面字段也可能不同。例如,Stack 读取 image,Blowfish 读取 featureimage。如果同一张图片要作为两个主题的封面,可以在文章 front matter 中同时设置这两个字段:

image = "assets/cover.png"
featureimage = "assets/cover.png"

每个主题只使用自己认识的字段,因此共存不会互相覆盖。若文章没有明确封面,就不要添加这两个字段;不要假设主题会从任意图片自动挑选封面。分类、标签、标题和日期通常可以共用,但新文章最好分别在本地预览两个主题,确认分类名称、封面比例、短代码和页面资源路径符合预期。

图片建议使用相对路径并和文章一起提交,不要依赖个人电脑上的绝对路径。把图片放入该文章的 page bundle 后,文章和附件可以一起移动、备份和审查。对于远程图片,要考虑原站删除、访问限制或跨域问题;如果图片必须长期显示,优先保存有权使用的本地副本。两个主题的封面比例可能不同,即使共用同一张图片,也应分别检查裁切效果。

3. 从写作到两个主题分别发布

第一次克隆任一站点仓库后,需要初始化主题和内容子模块。在站点仓库根目录执行:

git submodule update --init --recursive

这会下载 themes/ 中的主题子模块和 shared-content/ 内容子模块。内容仓库是私有仓库,因此本机 GitHub 账号需要有读取权限;GitHub Actions 使用的访问令牌也必须能读取它。缺少权限时,子模块初始化会提示认证失败,Hugo 后续则会因为找不到文章而少生成页面。

写新文章前进入共享内容目录,并切换到内容仓库的 main 分支:

cd shared-content
git switch main
git pull --ff-only origin main

然后创建文章目录,例如 content/blog/hugo-build/index.md,把图片放在同一个文章目录中。写完后,仍在 shared-content/ 内提交并推送:

git add content/blog/hugo-build
git commit -m "Add Hugo build guide"
git push origin main

到这里,文章只在内容仓库提交了一次。两个站点的 GitHub Actions 都配置为手动触发;每次构建时会先拉取内容仓库 main 的最新提交,再安装 Hugo、构建页面并运行加密脚本。发布提交会记录当次使用的内容版本,之后可以根据提交号确认网站由哪一版文章构建。

如果希望本机的站点源码也固定到新内容版本,在回到主题仓库根目录后,可以更新并提交子模块指针:

cd ..
git add shared-content
git commit -m "Update shared content reference"
git push

这个指针记录该主题仓库默认关联的内容版本,便于其他人克隆时得到一致结果。Stack 和 Blowfish 可以分别更新各自的指针;即使暂时没有更新指针,线上手动部署仍会按工作流设置读取内容仓库的最新 main。

这里有两个容易混淆的版本:子模块指针是本地站点仓库记录的默认内容版本;部署工作流拉取的则是运行时 main 分支最新版本。工作流明确更新到最新内容,因此即使子模块指针暂时落后,部署仍会使用新内容。若想让其他协作者克隆后得到相同的本地预览结果,应同时更新并提交两个站点仓库中的子模块指针。提交内容后看到 shared-content 显示修改,是 Git 在提醒子模块当前版本和站点仓库记录的版本不同,并不代表文章又复制了一份。

日常编辑时可以按以下顺序操作:先在内容仓库 main 拉取最新改动,再编辑一篇文章及其附件;本地预览 Stack 和 Blowfish;确认没有缺图、短代码错误或不需要的封面后,提交并推送内容;最后分别手动运行两个主题的部署。若只想试用另一个主题,可以只运行它的部署,再把 GitHub Pages 的发布来源切到对应分支。内容仓库更新、主题部署和 Pages 分支切换是三个独立动作,不要把它们误认为同一次操作。

如果只更新了文章,却没有重新运行部署工作流,线上页面不会自动变化,因为部署是手动触发的。如果 Stack 更新成功而 Blowfish 没有更新,检查是否也运行了 Blowfish 的工作流;如果工作流失败,先看日志中的共享内容拉取步骤是否通过,再看 Hugo 构建错误。如果两个站点显示的文章版本不同,可以比较发布提交信息里的内容提交号。这样能快速判断它们是否使用同一版内容,而不必比较两套生成后的 HTML 文件。

要同时更新两个主题的发布结果,分别在 you-blog-stack 和 you-blog-blowfish 的 Actions 页面手动运行部署工作流。Stack 工作流把生成结果写入 YourName/you-blog-pages 的 stack 分支,Blowfish 工作流写入 blowfish 分支;GitHub Pages 当前选择哪个发布分支,就展示哪个主题。运行另一个主题的工作流只更新它自己的发布分支,不会自动切换 Pages 设置。切换主题时,再到 Pages 的发布来源设置中选择 stack 或 blowfish。

最后要区分“内容源文件”和“发布后的加密页面”:加密发生在构建阶段,内容仓库中仍保存 Markdown 原文以及加密所需的信息。不要把私有内容仓库改为公开仓库,也不要把访问令牌写进文章或配置文件。令牌应放在 GitHub Secrets 中,并只授予读取内容仓库及写入发布仓库所需的权限。按这个分工,文章维护只有一个入口,主题源码各自独立,构建和切换过程也更容易检查。

4. 子theme仓库固定版本

之前我使用的方法是fork一份永不更新,问过AI后发现有更好的做法,就是直接追theme仓库的指定提交。

可以用下面这套流程检查,关键是看仓库记录的 提交 SHA。

在 Stack 仓库根目录运行:

git submodule status themes/hugo-theme-stack shared-content
git ls-tree HEAD themes/hugo-theme-stack shared-content

比如我的是:

3e123a30... themes/hugo-theme-stack (v4.0.3)
f3eb8007... shared-content

前面没有 +,表示本地子模块与站点仓库记录的版本一致。git ls-tree 会显示父仓库保存的 SHA;Stack 现在固定在 3e123a30...,恰好对应 v4.0.3。上游以后发布新版本,不会自动改变这个 SHA。普通的 git pull 和 git submodule update 也不会把 Stack 升级。

只有明确更新 Stack 子模块、再提交新的子模块指针,版本才会改变。更新前可以先选定一个版本,例如 vX.Y.Z:

git -C themes/hugo-theme-stack fetch --tags
git -C themes/hugo-theme-stack checkout vX.Y.Z
git add themes/hugo-theme-stack
git commit -m "Update Stack theme to vX.Y.Z"

而相对的 shared-content虽然也是追了固定 SHA,但部署工作流(workyml)里的 Use latest shared content 会额外拉取内容仓库 main,所以部署时使用最新内容。也就是说,主题不会自动升级,部署时的文章内容会追 main。