距离上一次写《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,最终访问路径就是:
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,靠 hover 和 focus-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 分区,只需要:
- 新建
content/notes/
params.mainSections 加上 notes
permalinks 加上 notes
content/topics/_index.md 加一个入口
- 新建
content/topics/notes/_index.md
模板不用动。
08#搜索页#
PaperMod 自带搜索模板,但要让它工作,需要几个点配合。
先建页面:
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#关于页单独做模板#
关于页不太适合直接套普通文章页。我希望它更像一张个人介绍页:头像居中,正文宽一点,标题风格单独调。
页面:
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
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
|

|
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
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 " · " | safeHTML -}}
{{- end -}}
<span id="busuanzi_container_page_pv">阅读量 <span id="busuanzi_value_page_pv"></span> 次</span>
{{- end -}}
|
这段逻辑主要是为了跟日期、阅读时间、字数、作者这些元信息保持同一行,并且只在前面已经有元信息时补分隔符。
然后覆盖单篇文章模板:
在文章头部元信息位置加上阅读量:
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 里写:
页脚多了一行统计后,原来的 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,也不会凭空多一个“更新于”。
第二,dateText 和 lastmodText 一样时不显示更新时间。当前日期格式是:
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
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
2
|
Remove-Item -Recurse -Force public
hugo
|
如果主题目录为空,多半是 submodule 没初始化:
1
|
git submodule update --init --recursive
|
21#部署到Vercel#
Vercel 构建 Hugo 站点时,最好指定 Hugo 版本。
文件:
注意 JSON 标准不支持注释,所以下面这段要保持纯 JSON,不要把注释直接写进 vercel.json:
1
2
3
4
5
6
7
|
{
"build": {
"env": {
"HUGO_VERSION": "0.162.0"
}
}
}
|
Vercel 项目里构建命令用:
或者:
发布目录:
如果用的是 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
|
后面如果继续折腾,大概会往这几个方向走:
- 给旧文章批量补
slug 和 tags
- 做一个文章发布脚本,把 Obsidian 源文档同步到 Hugo
- 给图片加自动压缩和尺寸规范
- 给股票和随笔分区做更适合它们的列表样式
折腾博客这件事说起来挺矛盾:它本来是为了写东西,结果常常会先折腾出一堆写东西的工具。但也正是这些小修小补,最后会把一个通用主题慢慢拧成自己的工作台。