Hugo 博客多主题切换:从 PaperMod 到 Stack 的实战

背景

搭建好 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 渲染

这意味着切换主题时,不能只改一个字段——需要替换整套配置。

内容目录

项目PaperModStack
文章目录content/posts/content/post/(单数)
独立页面任意位置content/page/(专用)
搜索页自动生成需要 content/page/search/index.md
归档页自动生成需要 content/page/archives/index.md
标签页自动生成需要 content/tags.md
分类页自动生成需要 content/categories.md

功能特性

功能PaperModStack
导航方式顶部横栏左侧边栏(带图标)
特色图片cover 字段image 字段
Widget 系统搜索、归档、分类、标签云
暗色切换defaultTheme 参数colorScheme.toggle
菜单图标不支持params.icon
Hugo 版本>= 0.146.0>= 0.157.0

设计切换方案

明确了差异后,切换方案需要处理以下几件事:

  1. 配置切换 — PaperMod 用根目录 hugo.toml,Stack 用 config/_default/
  2. 内容同步posts/post/ 之间的文件同步
  3. Stack 专用页面 — 切到 Stack 时自动创建,切回 PaperMod 时清理
  4. 配置备份 — 切换前保存当前主题配置,下次切回时恢复

目录结构设计:

 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/ 目录下查看,比如 homesearchtagcategoriesarchivesbrand-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 的多主题切换本质上是配置文件 + 目录结构的切换。两个主题的主要差异在于:

  1. 配置格式(单文件 vs 多文件目录)
  2. 内容路径(posts/ vs post/
  3. 自动生成页面 vs 手动创建页面

通过一个 shell 脚本把这些差异封装起来,就能实现一键切换。脚本的核心逻辑很简单——备份、替换、同步、创建——但把重复操作自动化了,避免每次切换都要手动处理十几个文件。