距离上一次写《Hexo折腾记》已经过去很多年了。那时候折腾 Hexo + Next,主要是在装 Node、装插件、改主题配置、解决部署抽风。现在重新整理博客,我换成了 Hugo + PaperMod。

这次不想只停留在“能跑起来”。我的目标是:主题保持可更新,文章按技术、股票、随笔分区管理,首页和导航更像自己的站点,搜索、归档、评论、目录、提示框、代码高亮、备案这些功能都补齐,并且尽量把改动放在 Hugo 推荐的覆盖层里,不直接魔改主题源码。

这篇就记录一下整个折腾过程。

01#目录结构

最后站点工程大概是这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
hugo/
├── archetypes/                 # 文章模板
├── assets/                     # Hugo Pipes 处理的样式
│   └── css/
│       └── extended/           # PaperMod 自动加载的扩展样式
├── content/                    # 文章和页面
│   ├── tech/
│   ├── stock/
│   ├── essay/
│   ├── topics/
│   ├── about.md
│   ├── archives.md
│   └── search.md
├── data/                       # notice 图标等数据
├── i18n/                       # 中文翻译
├── layouts/                    # 覆盖 PaperMod 的模板
├── static/                     # 原样发布到站点根路径
├── themes/
│   └── PaperMod/               # git submodule
├── hugo.yaml
└── vercel.json

我把 PaperMod 放在 themes/PaperMod,用 git submodule 管理:

1
2
git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
git submodule update --init --recursive

这样做的好处是,后续主题更新时只要:

1
git submodule update --remote --merge

真正的自定义改动尽量放在根目录:

1
2
3
4
5
layouts/
assets/css/extended/
static/
data/
i18n/

Hugo 的模板查找优先级会先找站点根目录,再找主题目录。所以只要在根目录放同名模板,就能覆盖主题行为,不需要直接改 themes/PaperMod

02#基础配置

主配置文件是 hugo.yaml

一开始先把站点基本信息、主题和语言配好:

 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
# 站点的根 URL,生产环境填正式域名
baseURL: "https://www.zeyes.org/"

# 站点区域/语言标识,配合 i18n、日期格式和搜索引擎语义使用
locale: "zh-Hans-CN"
defaultContentLanguage: "zh-cn"

# 站点标题和描述,主题模板、RSS、SEO 元信息都会用到
title: "问道凌虚"
description: "从问道开始,最终凌虚而上!"

# 使用的 Hugo 主题,PaperMod 放在 themes/PaperMod
theme: ["PaperMod"]

# 站点作者,会用于文章元信息或主题模板
author: "Zeyes"

# 是否生成 robots.txt,方便搜索引擎爬虫识别抓取规则
enableRobotsTXT: true

# 是否构建 draft: true 的草稿文章;生产环境一般关闭
buildDrafts: false

# 是否构建发布日期在未来的文章;生产环境一般关闭
buildFuture: false

# 是否构建已经过期的文章;这里保留,避免旧内容被意外过滤
buildExpired: true

# 中文、日文、韩文等 CJK 语言需要打开,字数统计和阅读时间更准确
hasCJKLanguage: true

hasCJKLanguage: true 对中文站点比较重要,Hugo 统计字数、阅读时间时会更符合中文内容。

首页除了 HTML 和 RSS,我还额外打开了 JSON 输出:

1
2
3
4
5
6
outputs:
  # 首页除了生成 HTML/RSS,再额外生成 JSON,PaperMod 搜索依赖这个 JSON
  home:
    - "HTML"
    - "RSS"
    - "JSON"

这个 JSON 后面会给 PaperMod 的搜索页用。如果少了它,搜索页面能打开,但没有数据。

03#内容分区

我不想所有文章都堆在 posts 下面,于是按内容类型分成三个 section:

1
2
3
4
content/
├── tech/
├── stock/
└── essay/

然后在 hugo.yaml 里告诉 PaperMod,首页、归档、上一篇下一篇等文章列表主要看这些 section:

1
2
3
params:
  # 站点主要内容分区;首页、归档、上一篇/下一篇等列表会优先读取这些 section
  mainSections: ["tech", "stock", "essay"]

为了 URL 更统一,我又配置了永久链接:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 自定义文章和 section 的 URL 规则,避免不同分区生成的路径风格不一致
permalinks:
  # 单篇文章 URL
  page:
    tech: "/posts/:sections/:slug"
    stock: "/posts/:sections/:slug"
    essay: "/posts/:sections/:slug"
  # 分区列表 URL
  section:
    tech: "/posts/:sections/"
    stock: "/posts/:sections/"
    essay: "/posts/:sections/"

比如一篇文章放在:

1
content/tech/2026/06/2026-06-07-Hugo折腾记.md

只要 front matter 里有:

1
2
# slug 会参与 permalink 生成最终 URL,建议手工写成稳定的英文短横线格式
slug: hugo-papermod-customization

最后 URL 就会是:

1
/posts/tech/2026/06/hugo-papermod-customization

文章模板也简单改了一下,让新文章默认是草稿:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
+++
# 新文章创建时间,hugo new 时自动填入
date = '{{ .Date }}'

# 新文章默认作为草稿,发布前再改成 false
draft = true

# 默认用文件名生成标题,后面可以手工改
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++

发布前再把 draft 改成 false,或者像我旧文章迁移时一样保留 status: publish

04#首页改成个人资料模式

PaperMod 有一个 profileMode,适合个人博客首页。

我在 hugo.yaml 里这样配置:

 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
params:
  # ---------- 首页个人资料模式 ----------
  profileMode:
    # 开启后首页显示头像、标题、按钮,而不是普通文章列表
    enabled: true
    # 首页主标题
    title: "从问道开始,最终凌虚而上!"
    # 首页副标题;两个空格 + \n 可以在 Markdown 渲染后换行
    subtitle: "你好呀,欢迎来访  \n这里记录技术、投资与生活里的折腾"
    # 头像路径,文件放在 static/avatar.jpg,访问路径就是 /avatar.jpg
    imageUrl: "/avatar.jpg"
    # 头像尺寸
    imageWidth: 120
    imageHeight: 120
    # 图片 title 属性
    imageTitle: "avatar"
    # 首页入口按钮
    buttons:
      - name: "技术"
        url: "/topics/tech/"
      - name: "股票"
        url: "/topics/stock/"
      - name: "随笔"
        url: "/topics/essay/"

  # ---------- 社交图标 ----------
  socialIcons:
    # PaperMod 内置 github 图标
    - name: "github"
      title: "GitHub"
      url: "https://github.com/lixize"
    # 邮箱链接
    - name: "email"
      title: "Email"
      url: "mailto:lixize8888@163.com"
    # RSS 订阅入口
    - name: "rss"
      title: "RSS"
      url: "/index.xml"

头像放在 static/avatar.jpg,最终访问路径就是:

1
/avatar.jpg

PaperMod 的 static/ 规则很好用:放进去什么,构建后就原样出现在站点根路径。

05#导航菜单和下拉分类

默认 PaperMod 的菜单是平铺的。我想要“分类”下面挂三个子菜单:

 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
menu:
  main:
    # identifier 是菜单项唯一标识;weight 控制排序,数字越小越靠前
    - identifier: "home"
      name: "主页"
      url: "/"
      weight: 10

    - identifier: "search"
      name: "搜索"
      url: "/search/"
      weight: 20

    # 分类父菜单;下面的 tech/stock/essay 通过 parent 挂到这里
    - identifier: "categories"
      name: "分类"
      url: "/topics/"
      weight: 30

    - identifier: "tech"
      name: "技术"
      url: "/topics/tech/"
      # parent 指向 categories,表示这是“分类”的子菜单
      parent: "categories"
      weight: 31

    - identifier: "stock"
      name: "股票"
      url: "/topics/stock/"
      parent: "categories"
      weight: 32

    - identifier: "essay"
      name: "随笔"
      url: "/topics/essay/"
      parent: "categories"
      weight: 33

    - identifier: "tags"
      name: "标签"
      url: "/tags/"
      weight: 40

    - identifier: "archives"
      name: "归档"
      url: "/archives/"
      weight: 50

    - identifier: "about"
      name: "关于"
      url: "/about/"
      weight: 60

菜单数据有父子关系后,还需要覆盖主题的 header。

文件:

1
layouts/partials/header.html

核心逻辑是判断 .HasChildren,如果有子菜单,就输出一层 .submenu

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{{- range site.Menus.main }}
{{- $raw_url := .URL | default "#" }}
<li{{- if .HasChildren }} class="menu-item-has-children"{{- end }}>
  {{- if .HasChildren }}
  <a href="{{ $raw_url | absLangURL }}" class="menu-trigger" aria-haspopup="true">
    <span>{{ .Name }}</span>
  </a>
  <ul class="submenu">
    {{- range .Children }}
    <li>
      <a href="{{ .URL | absLangURL }}" title="{{ .Title | default .Name }}">
        <span>{{ .Name }}</span>
      </a>
    </li>
    {{- end }}
  </ul>
  {{- else }}
  <a href="{{ $raw_url | absLangURL }}" title="{{ .Title | default .Name }}">
    <span>{{ .Name }}</span>
  </a>
  {{- end }}
</li>
{{- end }}

样式放在:

1
assets/css/extended/custom.css
 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
.menu {
    overflow: visible;
}

.menu li {
    position: relative;
}

.menu-trigger {
    display: block;
    height: var(--header-height);
    padding: 0;
    color: var(--primary);
    font: inherit;
    font-size: 16px;
    line-height: var(--header-height);
    background: transparent;
    border: 0;
    cursor: pointer;
}

.menu-trigger::after {
    content: "";
    display: inline-block;
    width: 0.42em;
    height: 0.42em;
    margin-left: 0.42em;
    border-right: 1.5px solid currentColor;
    border-bottom: 1.5px solid currentColor;
    transform: translateY(-0.18em) rotate(45deg);
}

.submenu {
    position: absolute;
    top: calc(100% - 0.25rem);
    left: 50%;
    z-index: 20;
    min-width: 7rem;
    padding: 0.35rem;
    list-style: none;
    background: var(--entry);
    border: 1px solid var(--border);
    border-radius: 6px;
    box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12);
    opacity: 0;
    visibility: hidden;
    transform: translate(-50%, -0.35rem);
    transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease;
}

.menu-item-has-children:hover .submenu,
.menu-item-has-children:focus-within .submenu {
    opacity: 1;
    visibility: visible;
    transform: translate(-50%, 0);
}

这里没有写复杂 JS,靠 hoverfocus-within 就够了。鼠标可用,键盘 Tab 聚焦也能展开。

06#自定义分类入口页

这里的“分类”不是 Hugo 默认 taxonomy,而是我自己做的专题入口。

入口页面:

1
content/topics/_index.md

内容很短,主要把三个分类的数据写在 front matter:

 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
---
# 页面标题
title: "分类"

# 固定页面访问路径
url: "/topics/"

# 指定使用 layouts/topic-index.html
layout: "topic-index"

# 自定义专题数据,模板会遍历这里生成入口卡片
topics:
  - title: "技术"
    icon: "💻"
    url: "/topics/tech/"
    # 用来统计 content/tech 下的文章数量
    section: "tech"
    description: "技术实践、开发记录与折腾笔记"

  - title: "股票"
    icon: "📈"
    url: "/topics/stock/"
    section: "stock"
    description: "投资观察与交易复盘"

  - title: "随笔"
    icon: "📝"
    url: "/topics/essay/"
    section: "essay"
    description: "生活、想法与片段记录"
---

对应模板:

1
layouts/topic-index.html

核心代码:

 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
{{- define "main" }}

<header class="page-header">
  {{- partial "breadcrumbs.html" . }}
  <h1>{{ .Title }}</h1>
  {{- if .Description }}
  <div class="post-description">
    {{ .Description | markdownify }}
  </div>
  {{- end }}
</header>

<div class="topic-list">
  {{- range .Params.topics }}
  {{- $count := len (where site.RegularPages "Section" .section) }}
  <a class="topic-item" href="{{ .url | absLangURL }}">
    <span class="topic-icon">{{ .icon }}</span>
    <span class="topic-body">
      <span class="topic-title">{{ .title }}</span>
      <span class="topic-description">{{ .description }}</span>
    </span>
    <span class="topic-count">{{ $count }}</span>
  </a>
  {{- end }}
</div>

{{- end }}

这里比较关键的是:

1
{{- $count := len (where site.RegularPages "Section" .section) }}

它会按 section 统计文章数量,所以分类入口右侧能显示“技术有多少篇、股票有多少篇、随笔有多少篇”。

样式:

 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
.topic-list {
    display: grid;
    gap: 0.85rem;
    margin-top: var(--content-gap);
}

.topic-item {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: 0.85rem;
    align-items: center;
    padding: 1rem;
    background: var(--entry);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    transition: border-color 0.2s ease, transform 0.2s ease;
}

.topic-item:hover,
.topic-item:focus {
    border-color: var(--tertiary);
    transform: translateY(-1px);
}

.topic-count {
    padding: 0.15rem 0.5rem;
    background: var(--code-bg);
    border-radius: 999px;
}

07#自定义分区列表页

分类入口只是入口。点进“技术”以后,还需要展示 content/tech 下的文章。

页面文件:

1
2
3
content/topics/tech/_index.md
content/topics/stock/_index.md
content/topics/essay/_index.md

以技术页为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
---
# 页面标题
title: "技术"

# 这个页面本身的访问路径
url: "/topics/tech/"

# 指定使用 layouts/category-list.html
layout: "category-list"

# 告诉模板当前列表页要展示 content/tech 这个 section
targetSection: "tech"
---

三个页面共用一个模板:

1
layouts/category-list.html

模板里只关心 targetSection

 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
{{- define "main" }}

{{- $targetSection := .Params.targetSection | default "" }}
{{- $pages := where site.RegularPages "Section" $targetSection }}
{{- $pages = where $pages "Params.hiddenInHomeList" "!=" "true" }}
{{- $paginator := .Paginate $pages }}

<header class="page-header">
  {{- partial "breadcrumbs.html" . }}
  <h1>{{ .Title }}</h1>
  {{- if .Description }}
  <div class="post-description">
    {{ .Description | markdownify }}
  </div>
  {{- end }}
</header>

{{- range $index, $page := $paginator.Pages }}
<article class="post-entry">
  <header class="entry-header">
    <h2 class="entry-hint-parent">
      {{- .Title }}
    </h2>
  </header>

  {{- if (ne (.Param "hideSummary") true) }}
  <div class="entry-content">
    <p>{{ .Summary | plainify | htmlUnescape }}{{ if .Truncated }}...{{ end }}</p>
  </div>
  {{- end }}

  {{- if not (.Param "hideMeta") }}
  <footer class="entry-footer">
    {{- partial "post_meta.html" . -}}
  </footer>
  {{- end }}

  <a class="entry-link" aria-label="post link to {{ .Title | plainify }}" href="{{ .Permalink }}"></a>
</article>
{{- end }}

{{- end }}

这个模板基本复用了 PaperMod 的列表卡片结构,只是数据源从当前 section 改成了 front matter 指定的 section。

以后想加一个 notes 分区,只需要:

  1. 新建 content/notes/
  2. params.mainSections 加上 notes
  3. permalinks 加上 notes
  4. content/topics/_index.md 加一个入口
  5. 新建 content/topics/notes/_index.md

模板不用动。

08#搜索页

PaperMod 自带搜索模板,但要让它工作,需要几个点配合。

先建页面:

1
content/search.md
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
---
# 搜索页标题
title: "搜索"

# 使用 PaperMod 的 search 布局
layout: "search"

# 页面摘要,避免列表页展示空摘要
summary: "搜索"
---

然后首页输出里必须有 JSON:

1
2
3
4
5
6
outputs:
  # PaperMod 搜索需要首页 JSON 作为索引数据
  home:
    - "HTML"
    - "RSS"
    - "JSON"

搜索参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
params:
  # Fuse.js 搜索参数,PaperMod 的前端搜索会读取这些配置
  fuseOpts:
    # 是否大小写敏感;中文站点一般关闭
    isCaseSensitive: false
    # 是否按匹配分数排序
    shouldSort: true
    # 匹配位置偏移,保持默认从开头开始
    location: 0
    # 允许匹配偏移的距离,调大一点更容易搜到正文内容
    distance: 1000
    # 匹配阈值,越小越严格,越大越宽松
    threshold: 0.4
    # 最短匹配字符数;设为 0 表示不额外限制
    minMatchCharLength: 0
    # 最多返回 10 条结果
    limit: 10
    # 搜索字段:标题、链接、摘要、正文
    keys: ["title", "permalink", "summary", "content"]

keys 决定搜索哪些字段。中文博客里我比较看重全文搜索,所以把 content 也放进去了。

09#关于页单独做模板

关于页不太适合直接套普通文章页。我希望它更像一张个人介绍页:头像居中,正文宽一点,标题风格单独调。

页面:

1
content/about.md

front matter:

 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
---
# 页面标题
title: "关于我"

# 使用自定义关于页模板 layouts/about.html
layout: "about"

# 固定关于页路径
url: "/about/"

# 页面日期
date: 2026-06-07T00:00:00+08:00

# 页面描述,显示在标题下方,也可用于元信息
description: "一个写 Java 后端,也爱折腾博客和新工具的人。"

# 关于页不显示目录
ShowToc: false

# 关于页不显示面包屑
ShowBreadCrumbs: false

# 关于页不显示日期、字数、阅读时间等元信息
hideMeta: true

# 关于页不显示上一篇/下一篇
ShowPostNavLinks: false

# 关于页标题不加锚点
disableAnchoredHeadings: true
---

模板:

1
layouts/about.html
 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
{{- define "main" }}

<article class="post-single about-page">
  <header class="post-header about-header">
    {{ partial "breadcrumbs.html" . }}
    <h1 class="post-title entry-hint-parent">
      {{ .Title }}
    </h1>
    {{- if .Description }}
    <div class="post-description">
      {{ .Description }}
    </div>
    {{- end }}
  </header>

  {{- if .Content }}
  <div class="post-content md-content">
    {{- if not (.Param "disableAnchoredHeadings") }}
    {{- partial "anchored_headings.html" .Content -}}
    {{- else }}{{ .Content }}{{ end }}
  </div>
  {{- end }}
</article>

{{- end }}

样式放在:

1
assets/css/extended/about.css
 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
.about-page {
  max-width: 760px;
}

.about-page .about-header {
  margin-bottom: 18px;
  text-align: center;
}

.about-page .post-title {
  margin-bottom: 0;
  font-size: 2.35rem;
  letter-spacing: 0;
}

.about-page .post-content {
  color: var(--content);
  font-size: 1.02rem;
  line-height: 1.9;
}

.about-page .post-content > p:first-child {
  margin: 2px 0 18px;
  text-align: center;
}

.about-page .post-content > p:first-child img {
  width: 136px;
  height: 136px;
  margin: 0 auto;
  border: 4px solid var(--entry);
  border-radius: 50%;
  box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
  object-fit: cover;
}

这里用了一个“小技巧”:关于页正文第一个段落只放头像图片,然后 CSS 用:

1
.about-page .post-content > p:first-child img

把第一张图片处理成圆形头像。Markdown 仍然保持简单:

1
![Zeyes 的头像](/avatar.jpg)

10#文章目录改成宽屏左侧固定

PaperMod 默认目录在正文里。我更喜欢宽屏时把目录放到文章左侧,滚动时固定,同时高亮当前标题。

这部分主要参考了周鑫的《在PaperMod中引入侧边目录和阅读进度显示》。我这里没有继续做阅读百分比,只保留并调整了侧边目录、宽度判断、滚动高亮这些逻辑。

配置先打开:

1
2
3
4
5
params:
  # 全局显示文章目录
  ShowToc: true
  # 目录默认展开
  TocOpen: true

单篇文章不想显示目录,可以在 front matter 写:

1
2
# 单篇文章关闭目录
ShowToc: false

覆盖模板:

1
layouts/partials/toc.html

模板开头先抓取正文里的标题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{{- $headers := findRE "<h[1-6].*?>(.|\n])+?</h[1-6]>" .Content -}}
{{- $has_headers := ge (len $headers) 1 -}}
{{- if $has_headers -}}
<aside id="toc-container" class="toc-container wide">
  <div class="toc">
    <details {{if (.Param "TocOpen") }} open{{ end }}>
      <summary accesskey="c" title="(Alt + C)">
        <span class="details">{{- i18n "toc" | default "Table of Contents" }}</span>
      </summary>
      <div class="inner">
        <!-- 根据标题层级生成 ul/li -->
      </div>
    </details>
  </div>
</aside>
{{- end }}

真正麻烦的是多级标题嵌套。我的处理方式是遍历所有 header,根据当前标题级别和上一个标题级别的差值,决定什么时候开 <ul>、什么时候闭合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{{- range $i, $header := $headers -}}
{{- $headerLevel := index (findRE "[1-6]" . 1) 0 -}}
{{- $headerLevel := len (seq $headerLevel) -}}
{{- $id := index (findRE "(id=\"(.*?)\")" $header 9) 0 }}
{{- $cleanedID := replace (replace $id "id=\"" "") "\"" "" }}
{{- $header := replaceRE "<h[1-6].*?>((.|\n])+?)</h[1-6]>" "$1" $header -}}

<li>
  <a href="#{{- $cleanedID -}}" aria-label="{{- $header | plainify -}}">
    {{- $header | safeHTML -}}
  </a>
{{- end -}}

然后加一段 JS,在滚动时找当前标题:

 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
<script>
    let activeElement;
    let elements;

    document.addEventListener('DOMContentLoaded', function () {
        checkTocPosition();

        elements = document.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]');
        if (elements.length > 0) {
            activeElement = elements[0];
            const id = encodeURI(activeElement.getAttribute('id')).toLowerCase();
            document.querySelector(`.inner ul li a[href="#${id}"]`).classList.add('active');
        }
    }, false);

    window.addEventListener('scroll', () => {
        const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;

        if (elements && elements.length > 0) {
            activeElement = Array.from(elements).find((element) => {
                if ((getOffsetTop(element) - scrollPosition) > 0 &&
                    (getOffsetTop(element) - scrollPosition) < window.innerHeight / 2) {
                    return element;
                }
            }) || activeElement;

            elements.forEach(element => {
                const id = encodeURI(element.getAttribute('id')).toLowerCase();
                const tocLink = document.querySelector(`.inner ul li a[href="#${id}"]`);
                if (element === activeElement) {
                    tocLink.classList.add('active');
                } else {
                    tocLink.classList.remove('active');
                }
            });
        }
    }, false);

    function getOffsetTop(element) {
        if (!element.getClientRects().length) {
            return 0;
        }
        let rect = element.getBoundingClientRect();
        let win = element.ownerDocument.defaultView;
        return rect.top + win.pageYOffset;
    }
</script>

宽屏判断也放在 JS 里。根据正文宽度、TOC 宽度和间距判断是否有空间放左侧目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const main = parseInt(getComputedStyle(document.body).getPropertyValue('--article-width'), 10);
const toc = parseInt(getComputedStyle(document.body).getPropertyValue('--toc-width'), 10);
const gap = parseInt(getComputedStyle(document.body).getPropertyValue('--gap'), 10);

function checkTocPosition() {
    const width = document.body.scrollWidth;

    if (width - main - (toc * 2) - (gap * 4) > 0) {
        document.getElementById("toc-container").classList.add("wide");
    } else {
        document.getElementById("toc-container").classList.remove("wide");
    }
}

CSS:

 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
:root {
    --nav-width: 1380px;
    --article-width: 650px;
    --toc-width: 300px;
}

.toc-container.wide {
    position: absolute;
    height: 100%;
    border-right: 1px solid var(--border);
    left: calc((var(--toc-width) + var(--gap)) * -1);
    top: calc(var(--gap) * 2);
    width: var(--toc-width);
}

.wide .toc {
    position: sticky;
    top: var(--gap);
    border: unset;
    background: unset;
    border-radius: unset;
    width: 100%;
    margin: 0 2px 40px 2px;
}

.toc .inner {
    margin: 0 0 0 20px;
    padding: 0 15px 15px 20px;
    font-size: 16px;
    max-height: 83vh;
    overflow-y: auto;
}

.active {
    font-size: 110%;
    font-weight: 600;
}

这一步是整个站点里比较“折腾”的地方。目录看起来只是一个侧边栏,但要兼顾标题层级、滚动高亮、宽度不足时回到正文上方,细节还挺多。

11#Notice短代码

写技术文章经常需要“注意”“提示”“警告”这种块。Markdown 原生没有很好看的提示框,于是我加了一个 notice shortcode。

文件:

1
layouts/shortcodes/notice.html
1
2
3
4
5
6
7
8
9
{{- $noticeType := .Get 0 -}}
{{- $raw := (markdownify .Inner | chomp) -}}
{{- $block := findRE "(?is)^<(?:address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h(?:1|2|3|4|5|6)|header|hgroup|hr|li|main|nav|noscript|ol|output|p|pre|section|table|tfoot|ul|video)\\b" $raw 1 -}}
{{- $icon := (replace (index hugo.Data.SVG $noticeType) "icon" "icon notice-icon") -}}

<div class="notice {{ $noticeType }}" {{ if len .Params | eq 2 }} id="{{ .Get 1 }}" {{ end }}>
  <div class="notice-title">{{ $icon | safeHTML }}</div>
  {{- if or $block (not $raw) }}{{ $raw }}{{ else }}<p>{{ $raw }}</p>{{ end -}}
</div>

这里有两个点:

第一,图标不直接写在 shortcode 里,而是放在数据文件:

1
data/SVG.toml
1
2
3
notice-warning = '<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 576 512"><path d="M570 440c18 32-5 72-42 72H48c-37 0-60-40-42-72L246 24c19-32 65-32 84 0l240 416zm-282-86a46 46 0 100 92 46 46 0 000-92zm-44-165l8 136c0 6 5 11 12 11h48c7 0 12-5 12-11l8-136c0-7-5-13-12-13h-64c-7 0-12 6-12 13z"/></svg>'

notice-info = '<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 512 512"><path d="M256 8a248 248 0 100 496 248 248 0 000-496zm0 110a42 42 0 110 84 42 42 0 010-84zm56 254c0 7-5 12-12 12h-88c-7 0-12-5-12-12v-24c0-7 5-12 12-12h12v-64h-12c-7 0-12-5-12-12v-24c0-7 5-12 12-12h64c7 0 12 5 12 12v100h12c7 0 12 5 12 12v24z"/></svg>'

第二,shortcode 里判断了一下 .Inner 生成的是块级 HTML 还是普通文本。如果只是普通文本,就补一层 <p>;如果里面已经是 <ul><pre><blockquote> 这类块级元素,就不再强行套 <p>,避免 HTML 结构乱掉。

样式:

 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
.notice {
  display: flex;
  align-items: center;
  position: relative;
  padding: 0.6em;
  margin-bottom: 1em;
  border-radius: 4px;
}

.notice p:last-child {
  margin-bottom: 0;
}

.notice .notice-title {
  margin-right: 0.5em;
  margin-top: 0.5em;
}

.notice .notice-title .notice-icon {
  width: 1.2em;
  height: 1.2em;
}

.notice.notice-warning {
  background: hsla(0, 65%, 65%, 0.15);
}

.notice.notice-warning .notice-title {
  color: hsl(0, 65%, 65%);
}

.notice.notice-tip {
  background: hsla(140, 65%, 65%, 0.15);
}

.notice.notice-tip .notice-title {
  color: hsl(140, 65%, 65%);
}

使用时这样写:

1
2
3
{{< notice notice-tip >}}
这里是一条提示。
{{< /notice >}}

id

1
2
3
{{< notice notice-warning my-warning >}}
这里是一条警告。
{{< /notice >}}

12#评论接入Giscus

评论系统我选了 Giscus。它基于 GitHub Discussions,不需要自己维护服务端。

这一步在提交信息里我一开始写成了“discus插件”,实际接入的是 Giscus。名字容易写混,功能上就是把 GitHub Discussions 作为文章评论区。

PaperMod 会调用:

1
layouts/partials/comments.html

所以直接覆盖这个 partial。具体嵌入脚本不用手写,去 Giscus 官方配置页生成即可:

1
https://giscus.app/zh-CN

在页面上填好仓库、Discussion 分类、映射方式、主题和语言以后,它会自动生成一段 <script>,把这段放进 layouts/partials/comments.html 就行。

我这里生成时选择了 pathname 映射、preferred_color_scheme 主题和 zh-CN 语言。具体仓库和分类以生成页给出的结果为准,不需要写进文章里。

全局开关:

1
2
3
params:
  # 开启 PaperMod 评论区域,随后由 layouts/partials/comments.html 注入 Giscus
  comments: true

pathname 表示按页面路径映射评论。这样文章标题改了,只要 URL 不变,评论就还能对上。

13#访问统计接入不蒜子

评论之后,我又想给博客加一点访问量统计。个人站点不想为了这点数据专门上复杂分析系统,所以先接了不蒜子。

不蒜子比较简单:页面里放好指定的 id,再加载它的脚本,脚本会异步把访问量填进去。我这里分成两类数据:

1
2
站点统计:本站总访问量 site_pv、本站访客数 site_uv
文章统计:当前文章阅读量 page_pv

站点统计放在页脚。PaperMod 会调用:

1
layouts/partials/extend_footer.html

所以我在这个扩展 partial 里往 .footer 前面插一行统计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<script>
    (() => {
        const footer = document.querySelector(".footer");
        if (!footer || footer.querySelector(".footer-busuanzi")) return;

        const stats = document.createElement("div");
        stats.className = "footer-busuanzi";
        stats.innerHTML = [
            '<span id="busuanzi_container_site_pv">本站总访问量 <span id="busuanzi_value_site_pv"></span> 次</span>',
            '<span id="busuanzi_container_site_uv">本站访客数 <span id="busuanzi_value_site_uv"></span> 人</span>'
        ].join(" · ");

        footer.prepend(stats);
    })();
</script>
<script async src="https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>

这里用 footer.querySelector(".footer-busuanzi") 做了一层保护,避免局部刷新或脚本重复执行时插入两遍。

文章阅读量则单独做了一个 partial:

1
layouts/partials/busuanzi_page_pv.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{{- if not (.Param "hidePageViews") -}}
    {{- $author := "" -}}
    {{- if not (.Param "hideAuthor") -}}
        {{- $author = partial "author.html" . -}}
    {{- end -}}
    {{- if or (not .Date.IsZero) (.Param "ShowReadingTime") (.Param "ShowWordCount") $author -}}
        {{- printf "&nbsp;·&nbsp;" | safeHTML -}}
    {{- end -}}
    <span id="busuanzi_container_page_pv">阅读量 <span id="busuanzi_value_page_pv"></span></span>
{{- end -}}

这段逻辑主要是为了跟日期、阅读时间、字数、作者这些元信息保持同一行,并且只在前面已经有元信息时补分隔符。

然后覆盖单篇文章模板:

1
layouts/single.html

在文章头部元信息位置加上阅读量:

1
2
3
4
5
6
7
<div class="post-meta">
  {{- partial "post_meta_single.html" . -}}
  {{- partial "busuanzi_page_pv.html" . -}}
  {{- partial "translation_list.html" . -}}
  {{- partial "edit_post.html" . -}}
  {{- partial "post_canonical.html" . -}}
</div>

如果某篇文章不想显示阅读量,可以在 front matter 里写:

1
hidePageViews: true

页脚多了一行统计后,原来的 footer 高度不太够,于是在 assets/css/extended/custom.css 里顺手调了一下:

1
2
3
4
5
6
7
:root {
    --footer-height: 84px;
}

.footer {
    padding: 18px var(--gap);
}

这类访问统计只能算轻量展示,不适合当严肃分析数据用。它的好处是接入快、侵入小,坏处是依赖第三方脚本,统计口径也比较粗。

14#显示文章更新时间

接着又补了文章更新时间。需求很简单:文章头部除了发布日期,还能显示“更新于某天”。

先在 hugo.yaml 里打开更新时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 是否读取 Git 提交时间作为页面更新时间;这里关闭,更新时间只来自文章 front matter
enableGitInfo: false

frontmatter:
  # Hugo 的 .Lastmod 取值顺序。去掉 :git,避免 Git 提交时间覆盖文章里手写的 lastmod
  lastmod:
    - lastmod
    - modified
    - date
    - publishdate
    - pubdate
    - published

params:
  # 显示最后更新时间
  ShowLastMod: true

这里我特意把 enableGitInfo 关掉,也没有在 frontmatter.lastmod 里放 :git。否则 Hugo 可能会拿 Git 提交时间当页面更新时间,结果就是一次全站重构、批量迁移或者改模板,都可能让文章看起来像“刚刚更新过”。

单篇文章里手工写:

1
2
date: 2026-06-06T16:00:00+08:00
lastmod: 2026-06-07T18:00:00+08:00

然后新建单篇文章专用的元信息 partial:

1
layouts/partials/post_meta_single.html

核心逻辑是先放发布时间,再判断是否显示 lastmod

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{{- if not .Date.IsZero -}}
    {{- $dateText := (.Date | time.Format $dateFormat) }}
    {{- $scratch.Add "meta" (slice (printf "<span title='%s'>%s</span>" (.Date) $dateText)) }}
    {{- if and (.Param "ShowLastMod") (isset .Params "lastmod") (not .Lastmod.IsZero) -}}
        {{- $lastmodText := (.Lastmod | time.Format $dateFormat) }}
        {{- if ne $lastmodText $dateText -}}
            {{- $scratch.Add "meta" (slice (printf "<span title='%s'>更新于%s</span>" (.Lastmod) $lastmodText)) }}
        {{- end }}
    {{- end }}
{{- end }}

这里有两个小判断:

第一,用 isset .Params "lastmod" 限制只有手工写了 lastmod 的文章才显示更新时间。这样旧文章即使 .Lastmod 回退到 date,也不会凭空多一个“更新于”。

第二,dateTextlastmodText 一样时不显示更新时间。当前日期格式是:

1
DateFormat: "2006年01月02日"

所以只要发布日和更新日是同一天,即使具体时分不同,页面上也不会额外显示“更新于”。如果以后想显示当天内的更新时间,就要把 DateFormat 改成包含时分,或者去掉这层日期文本比较。

最后在 layouts/single.html 里把 PaperMod 原来的:

1
{{- partial "post_meta.html" . -}}

换成:

1
{{- partial "post_meta_single.html" . -}}

这样列表页还继续用 PaperMod 默认元信息,只有单篇文章页显示自定义的更新时间和阅读量,改动范围比较收敛。

15#代码高亮改用Chroma

PaperMod 可以用 highlight.js,也可以用 Hugo 内置 Chroma。我最后选了 Chroma,因为它在构建阶段完成高亮,不依赖前端 JS。

代码块背景颜色和 PaperMod 主题变量的处理,参考了 hcy-asleep 的《Hugo PaperMod 改变主题配色(代码块背景颜色)》。我自己的重点则放在 Chroma 的 class 模式,以及亮色/暗色两套语法高亮 CSS 如何避免互相覆盖。

先关闭 highlight.js:

1
2
3
4
params:
  assets:
    # 禁用 highlight.js,改用 Hugo 内置 Chroma
    disableHLJS: true

然后配置 Hugo 的高亮:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
markup:
  highlight:
    # false 表示输出 CSS class;true 表示把样式写成内联 style
    noClasses: false
    # 开启 Markdown ``` 代码围栏高亮
    codeFences: true
    # 没写语言时尝试自动猜测
    guessSyntax: true
    # 显示行号
    lineNos: true
    # noClasses=false 时,颜色交给外部 CSS,这里留空即可
    style: ""

重点是:

1
2
# Chroma 输出 CSS class,方便用外部 CSS 同时维护亮色/暗色两套样式
noClasses: false

这样 Hugo 会输出 Chroma class,而不是把颜色写成内联样式。随后在 CSS 里控制亮色和暗色主题。

PaperMod 会自动加载:

1
assets/css/extended/*.css

所以我放了:

1
2
3
assets/css/extended/syntax-light.css
assets/css/extended/syntax-dark.css
assets/css/extended/custom.css

生成 Chroma 样式可以用:

1
2
hugo gen chromastyles --style=tango > assets/css/extended/syntax-light.css
hugo gen chromastyles --style=dracula > assets/css/extended/syntax-dark.css

但这还不够。两套 CSS 如果都是裸 .chroma,后加载的会覆盖先加载的。所以需要给选择器加主题作用域:

1
2
3
4
5
6
7
:root[data-theme="light"] .chroma {
  background-color: #f8f8f8;
}

:root[data-theme="dark"] .chroma {
  background-color: #282a36;
}

另外补一下纯文本和 fallback 代码块的颜色:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
:root[data-theme="light"] .chroma code.language-fallback,
:root[data-theme="light"] .chroma code.language-text,
:root[data-theme="light"] .chroma code.language-bash,
:root[data-theme="light"] .chroma code[data-lang="fallback"],
:root[data-theme="light"] .chroma code[data-lang="text"],
:root[data-theme="light"] .chroma code[data-lang="bash"] {
  color: #24292f;
}

:root[data-theme="dark"] .chroma code.language-fallback,
:root[data-theme="dark"] .chroma code.language-text,
:root[data-theme="dark"] .chroma code[data-lang="fallback"],
:root[data-theme="dark"] .chroma code[data-lang="text"] {
  color: #f8f8f2;
}

代码块背景也顺手覆盖一下:

1
2
3
4
5
6
7
8
9
:root {
    --hljs-bg: #f5f5f5;
    --code-block-bg: #f8f8f8;
}

.dark {
    --hljs-bg: #2e2e33;
    --code-block-bg: #2e2e33;
}

这里有个坑:PaperMod 的不同版本里,暗色模式可能使用 .dark,也可能使用 data-theme="dark"。所以调 CSS 时最好打开浏览器开发者工具,看 <html><body> 上到底挂了什么标识。

16#字体和全局样式

字体加载放在:

1
layouts/partials/extend_head.html

PaperMod 会在 head 里调用这个扩展 partial。

我加载了正文和代码字体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload"
    href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&family=Noto+Serif+SC:wght@400;600;700&display=swap"
    as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript>
    <link rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&family=Noto+Serif+SC:wght@400;600;700&display=swap">
</noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap">

然后在 custom.css 里应用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
body,
.post-content {
    font-family: Lora, "Noto Serif SC", "PingFang SC", "Microsoft YaHei", sans-serif;
}

code,
pre,
kbd,
samp {
    font-family: "Source Code Pro", monospace;
}

首页标题还加了一个打字机效果:

 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
.profile .profile_inner h1 {
    max-width: 100%;
    width: 14em;
    overflow: hidden;
    white-space: nowrap;
    border-right: 1px solid currentColor;
    animation: profile-title-typing 3.2s steps(14, end), profile-title-caret 0.75s step-end infinite;
}

@keyframes profile-title-typing {
    from {
        width: 0;
    }

    to {
        width: 14em;
    }
}

@keyframes profile-title-caret {
    from,
    to {
        border-color: transparent;
    }

    50% {
        border-color: currentColor;
    }
}

@media (prefers-reduced-motion: reduce) {
    .profile .profile_inner h1 {
        animation: none;
        border-right: 0;
    }
}

prefers-reduced-motion 这个判断是为了照顾系统关闭动画的用户。虽然只是个人博客,但这种小地方顺手做一下并不麻烦。

17#页脚备案信息

备案信息配置在 hugo.yaml

1
2
3
4
5
6
7
8
params:
  footer:
    # ICP 备案号;有值时会在页脚显示并链接到工信部备案查询
    icp: "xxx"
    # 公安备案文案;没有就留空
    mps: ""
    # 公安备案号里的数字编码;没有就留空,模板里也会尝试从 mps 文案提取
    mpsCode: ""

PaperMod 页脚本身没有我想要的备案位置,所以我用:

1
layouts/partials/extend_footer.html

在前端插进去:

 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
{{- $footer := site.Params.footer -}}
{{- if or $footer.icp $footer.mps }}
<script>
    (() => {
        const footer = document.querySelector(".footer");
        if (!footer || footer.querySelector(".footer-beian")) return;

        const powered = footer.querySelector("span:last-of-type");
        const beianItems = [
            {{- with $footer.icp }}
            {
                text: {{ . | jsonify | safeJS }},
                href: "https://beian.miit.gov.cn/"
            },
            {{- end }}
        ];

        beianItems.forEach((item) => {
            const beian = document.createElement("span");
            beian.className = "footer-beian";

            const link = document.createElement("a");
            link.href = item.href;
            link.target = "_blank";
            link.rel = "noopener noreferrer";
            link.textContent = item.text;
            beian.append(link);

            if (powered) {
                powered.before(beian, document.createTextNode(" · "));
            } else {
                footer.append(document.createTextNode(" · "), beian);
            }
        });
    })();
</script>
{{- end }}

这里用 jsonify | safeJS,是为了把配置里的字符串安全地塞进 JS 字符串里,避免手写引号转义。

样式很简单:

1
2
3
4
5
6
7
.footer-beian {
    display: inline;
}

.footer-beian a {
    text-decoration: none;
}

18#中文文案

PaperMod 有不少默认文案来自 i18n。中文覆盖文件:

1
i18n/zh-cn.yaml
 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
# 上一页按钮
- id: prev_page
  translation: "上一页"

# 下一页按钮
- id: next_page
  translation: "下一页"

# 阅读时间,.Count 由 Hugo/PaperMod 传入
- id: read_time
  translation:
    one: "1 分钟"
    other: "{{ .Count }} 分钟"

# 文章字数
- id: words
  translation:
    one: "1 字"
    other: "{{ .Count }} 字"

# 文章目录标题
- id: toc
  translation: "目录"

# 代码复制按钮
- id: code_copy
  translation: "复制"

# 代码复制成功后的提示
- id: code_copied
  translation: "已复制!"

配置里对应:

1
2
# 这里要和 i18n/zh-cn.yaml 的文件名对应
defaultContentLanguage: "zh-cn"

文件名和语言代码要对上,否则可能加载不到自己的翻译。

19#静态资源

static/ 目录下的文件会原样发布到站点根路径。

我放了这些常用资源:

1
2
3
4
5
6
7
8
9
static/
├── avatar.jpg
├── favicon.ico
├── favicon.svg
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon-next.png
├── alipay.jpg
└── wechatpay.png

配置 favicon:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
params:
  assets:
    # 浏览器地址栏 favicon
    favicon: "/favicon.ico"
    # 16x16 favicon
    favicon16x16: "/favicon-16x16.png"
    # 32x32 favicon
    favicon32x32: "/favicon-32x32.png"
    # iOS 添加到主屏幕时使用的图标
    apple_touch_icon: "/apple-touch-icon-next.png"

不要放到 assets/,因为 assets/ 是给 Hugo Pipes 处理的,不是原样复制目录。

20#本地开发和构建

本地开发:

1
hugo server --disableFastRender

预览草稿:

1
hugo server --disableFastRender -D

构建:

1
hugo

生产构建:

1
hugo --gc --minify

清理构建输出:

1
2
Remove-Item -Recurse -Force public
hugo

如果主题目录为空,多半是 submodule 没初始化:

1
git submodule update --init --recursive

21#部署到Vercel

Vercel 构建 Hugo 站点时,最好指定 Hugo 版本。

文件:

1
vercel.json

注意 JSON 标准不支持注释,所以下面这段要保持纯 JSON,不要把注释直接写进 vercel.json

1
2
3
4
5
6
7
{
  "build": {
    "env": {
      "HUGO_VERSION": "0.162.0"
    }
  }
}

Vercel 项目里构建命令用:

1
hugo

或者:

1
hugo --gc --minify

发布目录:

1
public

如果用的是 git submodule,部署平台还要能拉 submodule。否则本地能跑,线上会因为 themes/PaperMod 不存在而构建失败。

22#这次踩到的几个点

第一,主题尽量不要直接改。

一开始直接进 themes/PaperMod 改东西很爽,但以后主题更新会很痛苦。Hugo 的覆盖机制已经足够好用,能放 layouts/ 就放 layouts/,能放 assets/css/extended/ 就放 assets/css/extended/

第二,搜索页依赖首页 JSON。

只创建 content/search.md 不够,还要:

1
2
3
4
outputs:
  # PaperMod 搜索索引必须依赖 JSON 输出
  home:
    - "JSON"

第三,分区不是菜单里写一下就完事。

新增 section 时,要同时检查:

1
2
3
4
5
6
content/<section>/
params.mainSections
permalinks
menu.main
content/topics/_index.md
content/topics/<section>/_index.md

第四,代码高亮别让两套 CSS 互相覆盖。

亮色和暗色 Chroma 样式必须加作用域:

1
2
:root[data-theme="light"] .chroma {}
:root[data-theme="dark"] .chroma {}

第五,shortcode 示例要转义。

如果文章里要展示 shortcode 写法,最好这样写:

1
2
3
{{< notice notice-tip >}}
这里是提示内容。
{{< /notice >}}

否则 Hugo 可能会把示例当成真正的 shortcode 执行。

第六,不蒜子统计依赖固定 id

站点访问量、访客数和文章阅读量分别对应:

1
2
3
busuanzi_value_site_pv
busuanzi_value_site_uv
busuanzi_value_page_pv

这些 id 写错一个,页面结构还在,但数字不会回来。另外脚本全站只需要引一次,放在 extend_footer.html 里就够了。

第七,更新时间最好别直接交给 Git。

Git 提交时间很方便,但对博客文章不一定准确。尤其是迁移旧文、批量改模板、批量格式化 front matter 时,Git 时间会把很多并没有真正更新正文的文章也标成新更新。所以我这里关闭 enableGitInfo,只认文章 front matter 里的 lastmod

参考资料

小结

这次从 Hexo 换到 Hugo,最大的感受是:Hugo 更像一个静态站点编译器,很多能力不是靠插件堆出来,而是靠内容组织、模板覆盖、数据文件和 Hugo Pipes 组合出来。

现在这套博客基本形成了自己的结构:

1
2
3
4
5
6
内容:tech / stock / essay
入口:topics 自定义分类页
模板:layouts 覆盖 PaperMod
样式:assets/css/extended 扩展
功能:搜索、归档、评论、访问统计、更新时间、TOC、notice、代码高亮
部署:Vercel + Hugo Extended

后面如果继续折腾,大概会往这几个方向走:

  1. 给旧文章批量补 slugtags
  2. 做一个文章发布脚本,把 Obsidian 源文档同步到 Hugo
  3. 给图片加自动压缩和尺寸规范
  4. 给股票和随笔分区做更适合它们的列表样式

折腾博客这件事说起来挺矛盾:它本来是为了写东西,结果常常会先折腾出一堆写东西的工具。但也正是这些小修小补,最后会把一个通用主题慢慢拧成自己的工作台。