背景
搭建好 Hugo 博客后,选择主题是个让人纠结的事。PaperMod 简洁极简,Stack 卡片式布局功能丰富——不如两个都要?
本文记录如何让两个主题共存,并通过一个脚本实现一键切换。
两个主题的核心差异
在动手之前,先搞清楚两个主题有什么不同。差异主要集中在三个层面:配置结构、内容目录、功能特性。
配置结构
PaperMod 使用单文件配置 hugo.toml,所有参数写在一个文件里:
1
2
3
4
5
6
7
| # PaperMod 的 hugo.toml
theme = 'PaperMod'
[params]
defaultTheme = "auto"
ShowReadingTime = true
ShowCodeCopyButtons = true
|
Stack 使用多文件配置目录 config/_default/,拆分成多个文件:
1
2
3
4
5
6
| config/_default/
├── hugo.toml # 基础设置
├── params.toml # 主题参数
├── menu.toml # 菜单配置
├── languages.toml # 多语言
└── markup.toml # Markdown 渲染
|
这意味着切换主题时,不能只改一个字段——需要替换整套配置。
内容目录
| 项目 | PaperMod | Stack |
|---|
| 文章目录 | content/posts/ | content/post/(单数) |
| 独立页面 | 任意位置 | content/page/(专用) |
| 搜索页 | 自动生成 | 需要 content/page/search/index.md |
| 归档页 | 自动生成 | 需要 content/page/archives/index.md |
| 标签页 | 自动生成 | 需要 content/tags.md |
| 分类页 | 自动生成 | 需要 content/categories.md |
功能特性
| 功能 | PaperMod | Stack |
|---|
| 导航方式 | 顶部横栏 | 左侧边栏(带图标) |
| 特色图片 | cover 字段 | image 字段 |
| Widget 系统 | 无 | 搜索、归档、分类、标签云 |
| 暗色切换 | defaultTheme 参数 | colorScheme.toggle |
| 菜单图标 | 不支持 | params.icon |
| Hugo 版本 | >= 0.146.0 | >= 0.157.0 |
设计切换方案
明确了差异后,切换方案需要处理以下几件事:
- 配置切换 — PaperMod 用根目录
hugo.toml,Stack 用 config/_default/ - 内容同步 —
posts/ 和 post/ 之间的文件同步 - Stack 专用页面 — 切到 Stack 时自动创建,切回 PaperMod 时清理
- 配置备份 — 切换前保存当前主题配置,下次切回时恢复
目录结构设计:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| blog/
├── configs/ # 备份目录
│ ├── papermod/hugo.toml # PaperMod 完整配置
│ └── stack/ # Stack 完整配置
│ ├── hugo.toml
│ ├── params.toml
│ ├── menu.toml
│ ├── languages.toml
│ └── markup.toml
├── config/_default/ # 当前 Stack 配置(运行时)
├── hugo.toml # 当前 PaperMod 配置(运行时)
├── content/
│ ├── posts/ # PaperMod 文章目录
│ ├── post/ # Stack 文章目录
│ └── page/ # Stack 独立页面
├── switch-theme.sh # 切换脚本
└── .current-theme # 当前主题标记
|
编写切换脚本
脚本骨架
先搭一个基本框架——接收参数、检测当前主题、防止重复切换:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| #!/bin/bash
set -e
BLOG_DIR="$(cd "$(dirname "$0")" && pwd)"
THEME="${1:-}"
if [ -z "$THEME" ]; then
echo "Usage: $0 {papermod|stack}"
echo "Current theme: $(cat "$BLOG_DIR/.current-theme" 2>/dev/null || echo 'unknown')"
exit 1
fi
THEME=$(echo "$THEME" | tr '[:upper:]' '[:lower:]')
CURRENT=$(cat "$BLOG_DIR/.current-theme" 2>/dev/null || echo "unknown")
if [ "$CURRENT" = "$THEME" ]; then
echo "Already using $THEME theme."
exit 0
fi
|
配置备份函数
切换前需要保存当前配置,切回时能恢复:
1
2
3
4
5
6
7
8
9
10
11
12
| save_config() {
local from="$1"
# 保存 PaperMod 的单文件配置
if [ -f "$BLOG_DIR/hugo.toml" ]; then
cp "$BLOG_DIR/hugo.toml" "$BLOG_DIR/configs/$from/hugo.toml"
fi
# 保存 Stack 的多文件配置
if [ -d "$BLOG_DIR/config/_default" ]; then
mkdir -p "$BLOG_DIR/configs/$from"
cp -r "$BLOG_DIR/config/_default/"* "$BLOG_DIR/configs/$from/"
fi
}
|
内容同步函数
两个主题的文章目录不同(posts/ vs post/),需要双向同步:
1
2
3
4
5
6
7
8
| sync_content() {
local src="$1" dst="$2"
if [ -d "$BLOG_DIR/content/$src" ]; then
mkdir -p "$BLOG_DIR/content/$dst"
# -n: 不覆盖已有文件
cp -n "$BLOG_DIR/content/$src/"*.md "$BLOG_DIR/content/$dst/" 2>/dev/null || true
fi
}
|
用 cp -n 而不是 cp -f,避免覆盖用户在目标目录中的手动修改。
切换到 PaperMod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| case "$THEME" in
papermod)
save_config "stack"
# 1. 删除 Stack 的多文件配置
rm -rf "$BLOG_DIR/config"
# 2. 恢复 PaperMod 的单文件配置
cp "$BLOG_DIR/configs/papermod/hugo.toml" "$BLOG_DIR/hugo.toml"
# 3. 同步文章: post/ -> posts/
sync_content "post" "posts"
# 4. 清理 Stack 专用页面(PaperMod 自动生成这些)
rm -f "$BLOG_DIR/content/tags.md"
rm -f "$BLOG_DIR/content/categories.md"
rm -rf "$BLOG_DIR/content/page"
;;
|
切换到 Stack
Stack 切换更复杂,需要创建多个专用页面:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| stack)
save_config "papermod"
# 1. 删除 PaperMod 的单文件配置
rm -f "$BLOG_DIR/hugo.toml"
# 2. 恢复 Stack 的多文件配置
mkdir -p "$BLOG_DIR/config/_default"
cp -r "$BLOG_DIR/configs/stack/"* "$BLOG_DIR/config/_default/"
# 3. 同步文章: posts/ -> post/
sync_content "posts" "post"
# 4. 创建 Stack 专用页面
mkdir -p "$BLOG_DIR/content/page/search"
mkdir -p "$BLOG_DIR/content/page/archives"
# 搜索页(带 JSON 输出,供搜索 widget 使用)
cat > "$BLOG_DIR/content/page/search/index.md" << 'EOF'
---
title: "搜索"
slug: "search"
layout: "search"
outputs:
- html
- json
menu:
main:
weight: -80
params:
icon: search
---
EOF
# 归档页
cat > "$BLOG_DIR/content/page/archives/index.md" << 'EOF'
---
title: "归档"
layout: "archives"
slug: "archives"
---
EOF
# 标签页和分类页
for f in tags categories; do
case "$f" in
tags) title="标签"; layout="terms" ;;
categories) title="分类"; layout="terms" ;;
esac
cat > "$BLOG_DIR/content/$f.md" << EOF
---
title: "$title"
layout: "$layout"
slug: "$f"
---
EOF
done
;;
esac
|
完成切换
最后更新标记文件,提示用户下一步操作:
1
2
3
4
5
6
| echo "$THEME" > "$BLOG_DIR/.current-theme"
echo "Done! Current theme: $THEME"
echo ""
echo "Next steps:"
echo " hugo server -D # Preview locally"
echo " ./deploy.sh # Build and deploy"
|
Stack 配置详解
既然 Stack 的配置比较复杂,这里展开讲一下每个文件的作用。
hugo.toml — 基础设置
1
2
3
4
5
6
7
8
9
10
11
12
| baseURL = "http://blog.quickpay.wang:8026/"
locale = "zh-cn"
title = "我的博客"
defaultContentLanguage = "zh"
hasCJKLanguage = true # 中文必须开启
theme = "Stack"
[pagination]
pagerSize = 10
[permalinks]
post = "/p/:slug/" # Stack 特有的 URL 格式
|
hasCJKLanguage = true 很重要——不开启的话,中文的字数统计和摘要截断会出问题。
params.toml — 主题参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| mainSections = ["post"] # 主内容区域
rssFullContent = true # RSS 输出全文
[sidebar]
emoji = "✍️" # 侧边栏表情
subtitle = "技术笔记与生活记录"
[article]
toc = true # 显示目录
readingTime = true # 显示阅读时长
[widgets] # Widget 系统
homepage = [
{ type = "search" },
{ type = "archives", params = { limit = 5 } },
{ type = "categories", params = { limit = 10 } },
{ type = "tag-cloud", params = { limit = 10 } },
]
page = [{ type = "toc" }] # 文章页右侧目录
[colorScheme]
toggle = true # 允许切换暗色模式
default = "auto" # 跟随系统
|
Stack 的菜单在左侧边栏,支持图标:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| [[main]]
identifier = "home"
name = "首页"
url = "/"
weight = -100
[main.params]
icon = "home" # 图标名来自 themes/Stack/assets/icons/
[[main]]
identifier = "search"
name = "搜索"
url = "/search/"
weight = -80
[main.params]
icon = "search"
|
可用的图标可以在 themes/Stack/assets/icons/ 目录下查看,比如 home、search、tag、categories、archives、brand-github 等。
languages.toml — 多语言
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| [zh]
label = "中文"
title = "我的博客"
weight = 1
[zh.params.sidebar]
subtitle = "技术笔记与生活记录"
[en]
label = "English"
title = "My Blog"
weight = 2
[en.params.sidebar]
subtitle = "Personal blog - Tech notes and life records"
|
注意:Stack 用 label 而不是 languageName(后者在 Hugo 0.158.0+ 已废弃)。
markup.toml — Markdown 渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [goldmark]
[goldmark.renderer]
unsafe = true # 允许 HTML 标签
[tableOfContents]
startLevel = 2
endLevel = 4
ordered = true
[highlight]
codeFences = true
guessSyntax = true
lineNos = true
noClasses = false # 使用 CSS 类而非内联样式
|
踩坑记录
1. 图标名错误
Stack 的菜单图标必须存在于 themes/Stack/assets/icons/ 目录中。我一开始用了 category,但实际文件名是 categories:
1
| ERROR Error: icon 'category.svg' is not found under 'assets/icons' folder
|
解决: 检查 ls themes/Stack/assets/icons/ 确认可用图标名。
2. 根目录 hugo.toml 残留
切到 Stack 后,如果根目录的 hugo.toml(PaperMod 配置)没有删除,Hugo 会同时加载两套配置,导致参数冲突:
1
| WARN deprecated: project config key languageCode was deprecated...
|
解决: 切到 Stack 时必须删除根目录的 hugo.toml,切回 PaperMod 时必须删除 config/_default/ 目录。
3. TOML 数组语法
Stack 的 social 配置容易写错。下面的写法是错误的:
1
2
3
| [social] # ← 这行是多余的
[[social]] # ← 数组表不能嵌套在同名表下
identifier = "github"
|
正确写法直接用 [[social]]:
1
2
3
4
5
6
7
| [[social]]
identifier = "github"
name = "GitHub"
url = "https://github.com/"
[social.params]
icon = "brand-github"
|
4. 搜索页和归档页的警告
Stack 构建时会报:
1
2
| WARN Search page not found. Create a page with layout: search.
WARN Archives page not found. Create a page with layout: archives.
|
即使 content/page/search/index.md 已经存在也会报——这是 Hugo 渲染顺序的问题,widget 在页面生成前就尝试查找。不影响最终输出,页面会正确生成。
最终使用方式
1
2
3
4
5
6
7
8
9
10
11
12
| # 查看当前主题
cat .current-theme
# 切换主题
./switch-theme.sh papermod
./switch-theme.sh stack
# 构建并部署
./deploy.sh
# 本地预览
hugo server -D
|
切换过程大约 1 秒,主要耗时在文件复制。构建时间 PaperMod 约 350ms,Stack 约 500ms。
总结
Hugo 的多主题切换本质上是配置文件 + 目录结构的切换。两个主题的主要差异在于:
- 配置格式(单文件 vs 多文件目录)
- 内容路径(
posts/ vs post/) - 自动生成页面 vs 手动创建页面
通过一个 shell 脚本把这些差异封装起来,就能实现一键切换。脚本的核心逻辑很简单——备份、替换、同步、创建——但把重复操作自动化了,避免每次切换都要手动处理十几个文件。