这一部分主要是跟着问道凌虚的《Hugo折腾记》来做的(注:该站点目前已无法访问,链接仅作出处记录)。
下面是我自行修改的配置#
1 侧边目录及阅读进度条#
侧边目录最初参考了在PaperMod中引入侧边目录和阅读进度显示,基本没有修改。后来把目录内容换成了Hugo内置的.TableOfContents(由Hugo直接生成嵌套<ul>),模板只保留外框和滚动高亮;阅读进度条则挂在「回到顶部」按钮上。下面记录最终的做法。
1.1 配置(hugo.yaml)#
目录收录的标题层级交给Hugo内置目录控制,保持h1~h6全收(我站内有文章用#当章节标题):
1
2
3
4
5
6
7
8
| params:
showToc: true
TocOpen: true # 目录默认展开
markup:
tableOfContents:
startLevel: 1
endLevel: 6
|
ShowToc控制单篇文章是否渲染目录,TocOpen控制<details>默认是否展开。
1.2 模板(layouts/partials/toc.html)#
结构沿用PaperMod的外框,<aside> / .toc / <details> / .inner这几层是给toc.css定位和折叠用的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| {{- $toc := .TableOfContents -}}
{{- if strings.Contains $toc "<ul>" }}
<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">
{{ $toc }}
</div>
</details>
</div>
</aside>
|
坑一:没有标题的文章会渲染出一个空目录框。
.TableOfContents在没有标题时返回的不是空字符串,而是一个空壳<nav id="TableOfContents"></nav>,直接用with判断会渲染出空的「目录」框(旧模板是靠findRE数标题才挡住的)。所以这里改成判断目录里到底有没有列表:strings.Contains $toc "<ul>"。
坑二:中文标题的锚点可能是转义过的。
目录里的href可能是原始中文,也可能是%xx转义形式,两种都要能对上标题的id。所以先把#后面的部分decodeURIComponent再建索引:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| function buildTocIndex() {
const index = new Map();
document.querySelectorAll('.toc a[href^="#"]').forEach((link) => {
let id = link.getAttribute('href').slice(1);
try {
id = decodeURIComponent(id);
} catch (e) {
// 不是合法的百分号编码就按原样使用
}
index.set(id, link);
});
return index;
}
|
坑三:目录不一定收录文档里的每个标题。
tocLinks.get(element.id)有可能取到undefined,早期没判空,一次null.classList抛错就把后面「回到顶部」的绑定一起中断了。所以取值一律判空:
1
2
3
4
| const tocLink = tocLinks.get(element.id);
if (!tocLink) {
return;
}
|
滚动高亮的逻辑:取所有带id的h1~h6,滚动时把「位于视口上半部分」的标题设为当前项,给对应目录链接加.active,并把.inner内部滚动到让激活项居中:
1
2
| const scrollPosition = linkOffsetTop - (containerHeight / 2) + (linkHeight / 2);
tocContainer.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
1.3 样式(assets/css/extended/toc.css)#
定位思路是「外框absolute挂到正文左侧外面 + 内部sticky固定」,宽度用变量统一管理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| :root {
--nav-width: 1380px;
--article-width: 650px;
--toc-width: 300px;
}
.toc-container.wide {
position: absolute;
height: 100%; /* 高度覆盖整篇,sticky 才能全程生效 */
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: calc(var(--header-height) + var(--gap));
}
|
目录本身限制了高度、超出内部滚动,滚动条也顺手做了样式:
1
2
3
4
5
6
7
8
9
10
11
12
| .toc .inner {
/*目录显示高度*/
max-height: 83vh;
overflow-y: auto;
}
.toc .inner::-webkit-scrollbar-thumb {
/*滚动条*/
background: var(--border);
border: 7px solid var(--theme);
border-radius: var(--radius);
}
|
坑四:内置目录会多出一层「只含<ul>的空<li>」。
两种情况会出现这种空壳:一是正文最小标题层级高于startLevel(本站startLevel: 1,多数文章从h2起始),二是标题跳级(比如h2直接跳到h4)。它们会让内层列表多吃一次.toc li ul的缩进。用:only-child正好只命中这两层壳——真正的目录条目里必然带<a>,不会是only-child:
1
2
3
| .toc .inner li > ul:only-child {
margin-inline-start: 0;
}
|
进度条没单独做组件,直接塞进「回到顶部」按钮的<span id="read_progress">里:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| <a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<span class="topInner">
<svg class="topSvg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z"/>
</svg>
<span id="read_progress"></span>
</span>
</a>
<script>
document.addEventListener('scroll', function (e) {
const readProgress = document.getElementById("read_progress");
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
readProgress.innerText = ((scrollTop / (scrollHeight - clientHeight)).toFixed(2) * 100).toFixed(0);
})
</script>
|
百分比 = 已滚动 /(文档总高 − 视口高),取整后写进按钮。
「回到顶部」按钮的点击处理不在这个文件里,而在toc.html(跟目录高亮的脚本放在一起):接管#top-link,用scrollTo做平滑回顶。
1
2
3
4
5
6
7
| const topLink = document.getElementById('top-link');
if (topLink) {
topLink.addEventListener('click', (event) => {
event.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
|
正文里的锚点跳转则是主题footer.html自带的接管逻辑,它判断了prefers-reduced-motion:系统开启「减少动态效果」时用不带参数的scrollIntoView()直接跳转,否则才用behavior: "smooth"。
1.5 宽屏浮出的判定(layouts/single.html)#
左右两侧(左侧目录#toc-container、右侧标签栏#side-aside)必须同时进入/退出.wide,否则会出现一边浮出、一边还留在文档流里的不对称。所以判定只留一份,两个元素在同一次遍历里一起切:
1
2
3
4
5
6
7
8
9
10
| function updateAsideWide() {
const wide = (document.body.scrollWidth - main - (toc * 2) - (gap * 4)) > 0;
['toc-container', 'side-aside'].forEach((id) => {
const aside = document.getElementById(id);
if (aside) aside.classList.toggle('wide', wide);
});
}
document.addEventListener('DOMContentLoaded', updateAsideWide);
window.addEventListener('resize', updateAsideWide);
|
判断条件就是「文档宽 − 文章宽 − 2 × 侧边栏宽 − 4 × 间距 > 0」,不满足(窄屏)时两侧侧边栏一起退回文档流。
1.6 小结#
侧边目录这套本质是「absolute外框 + sticky内容」,内容交给Hugo内置目录后,模板只剩外框和几段滚动脚本。几个容易漏的点:空目录框要判<ul>、中文锚点要解码、目录链接取值要判空,以及内置目录那层空壳<li>的缩进。
2 代码高亮配置#
在配置代码高亮时,我发现如果生成syntax-light.css和syntax-dark.css两个css文件,会导致代码高亮配置失效,并且之前配置好的侧边目录也会失效,回到文章正上方。
因此我将两个css文件合并为一个syntax.css,`并且精简了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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
| /* ============================================================
Syntax Highlighting
Light theme → GitHub style
Dark theme → OneDark style
============================================================ */
/* ---- Common: Structure & layout ---- */
.chroma { -webkit-text-size-adjust: none; }
.chroma .lnlinks { outline: none; text-decoration: none; color: inherit; }
.chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
.chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
.chroma .lnt,
.chroma .ln {
white-space: pre;
-webkit-user-select: none;
user-select: none;
margin-right: 0.4em;
padding: 0 0.4em;
}
.chroma .line { display: flex; }
/* ============================================================
Light Theme — GitHub
============================================================ */
:root[data-theme="light"] .chroma { background-color: #f7f7f7; }
:root[data-theme="light"] .chroma .bg { background-color: #f7f7f7; }
:root[data-theme="light"] .chroma .lnt,
:root[data-theme="light"] .chroma .ln { color: #7f7f7f; }
:root[data-theme="light"] .chroma .hl { background-color: #dedede; }
:root[data-theme="light"] .chroma .err { color: #f6f8fa; background-color: #82071e; }
/* Light: Keywords */
:root[data-theme="light"] .chroma .k,
:root[data-theme="light"] .chroma .kc,
:root[data-theme="light"] .chroma .kd,
:root[data-theme="light"] .chroma .kn,
:root[data-theme="light"] .chroma .kp,
:root[data-theme="light"] .chroma .kr,
:root[data-theme="light"] .chroma .kt { color: #cf222e; }
/* Light: Names */
:root[data-theme="light"] .chroma .na,
:root[data-theme="light"] .chroma .nc,
:root[data-theme="light"] .chroma .nx { color: #1f2328; }
:root[data-theme="light"] .chroma .no,
:root[data-theme="light"] .chroma .nd,
:root[data-theme="light"] .chroma .nt { color: #0550ae; }
:root[data-theme="light"] .chroma .ni,
:root[data-theme="light"] .chroma .nb,
:root[data-theme="light"] .chroma .nf,
:root[data-theme="light"] .chroma .fm { color: #6639ba; }
:root[data-theme="light"] .chroma .nl { color: #900; font-weight: bold; }
:root[data-theme="light"] .chroma .nn { color: #24292e; }
:root[data-theme="light"] .chroma .bp { color: #6a737d; }
/* Light: Variables */
:root[data-theme="light"] .chroma .nv,
:root[data-theme="light"] .chroma .vc,
:root[data-theme="light"] .chroma .vg,
:root[data-theme="light"] .chroma .vi,
:root[data-theme="light"] .chroma .vm { color: #953800; }
/* Light: Strings */
:root[data-theme="light"] .chroma .s,
:root[data-theme="light"] .chroma .sa,
:root[data-theme="light"] .chroma .sb,
:root[data-theme="light"] .chroma .sc,
:root[data-theme="light"] .chroma .dl,
:root[data-theme="light"] .chroma .sd,
:root[data-theme="light"] .chroma .s2,
:root[data-theme="light"] .chroma .se,
:root[data-theme="light"] .chroma .sh,
:root[data-theme="light"] .chroma .si,
:root[data-theme="light"] .chroma .sx,
:root[data-theme="light"] .chroma .sr,
:root[data-theme="light"] .chroma .s1 { color: #0a3069; }
:root[data-theme="light"] .chroma .ss { color: #032f62; }
/* Light: Numbers */
:root[data-theme="light"] .chroma .m,
:root[data-theme="light"] .chroma .mb,
:root[data-theme="light"] .chroma .mf,
:root[data-theme="light"] .chroma .mh,
:root[data-theme="light"] .chroma .mi,
:root[data-theme="light"] .chroma .il,
:root[data-theme="light"] .chroma .mo { color: #0550ae; }
/* Light: Operators */
:root[data-theme="light"] .chroma .o,
:root[data-theme="light"] .chroma .ow,
:root[data-theme="light"] .chroma .or { color: #0550ae; }
/* Light: Punctuation */
:root[data-theme="light"] .chroma .p { color: #1f2328; }
/* Light: Comments */
:root[data-theme="light"] .chroma .c,
:root[data-theme="light"] .chroma .ch,
:root[data-theme="light"] .chroma .cm,
:root[data-theme="light"] .chroma .c1,
:root[data-theme="light"] .chroma .cs,
:root[data-theme="light"] .chroma .cp,
:root[data-theme="light"] .chroma .cpf { color: #57606a; }
/* Light: Generic */
:root[data-theme="light"] .chroma .gd { color: #82071e; background-color: #ffebe9; }
:root[data-theme="light"] .chroma .ge,
:root[data-theme="light"] .chroma .go { color: #1f2328; }
:root[data-theme="light"] .chroma .gi { color: #116329; background-color: #dafbe1; }
:root[data-theme="light"] .chroma .gl { text-decoration: underline; }
:root[data-theme="light"] .chroma .w { color: #fff; }
/* ============================================================
Dark Theme — OneDark
============================================================ */
:root[data-theme="dark"] .chroma { color: #abb2bf; background-color: #282c34; }
:root[data-theme="dark"] .chroma .bg { color: #abb2bf; background-color: #282c34; }
:root[data-theme="dark"] .chroma .lnt,
:root[data-theme="dark"] .chroma .ln { color: #55595f; }
:root[data-theme="dark"] .chroma .hl { background-color: #3d4148; }
/* Dark: Keywords */
:root[data-theme="dark"] .chroma .k,
:root[data-theme="dark"] .chroma .kd,
:root[data-theme="dark"] .chroma .kn,
:root[data-theme="dark"] .chroma .kp,
:root[data-theme="dark"] .chroma .kr { color: #c678dd; }
:root[data-theme="dark"] .chroma .kc,
:root[data-theme="dark"] .chroma .kt { color: #e5c07b; }
/* Dark: Names */
:root[data-theme="dark"] .chroma .n,
:root[data-theme="dark"] .chroma .na,
:root[data-theme="dark"] .chroma .no,
:root[data-theme="dark"] .chroma .ni,
:root[data-theme="dark"] .chroma .ne,
:root[data-theme="dark"] .chroma .nl,
:root[data-theme="dark"] .chroma .nn,
:root[data-theme="dark"] .chroma .nx,
:root[data-theme="dark"] .chroma .py,
:root[data-theme="dark"] .chroma .nt { color: #e06c75; }
:root[data-theme="dark"] .chroma .nc,
:root[data-theme="dark"] .chroma .nb,
:root[data-theme="dark"] .chroma .bp { color: #e5c07b; }
:root[data-theme="dark"] .chroma .nd { color: #61afef; }
:root[data-theme="dark"] .chroma .nf { color: #61afef; font-weight: bold; }
:root[data-theme="dark"] .chroma .fm { color: #56b6c2; font-weight: bold; }
/* Dark: Variables */
:root[data-theme="dark"] .chroma .nv,
:root[data-theme="dark"] .chroma .vc,
:root[data-theme="dark"] .chroma .vg,
:root[data-theme="dark"] .chroma .vi,
:root[data-theme="dark"] .chroma .vm { color: #e06c75; }
/* Dark: Strings */
:root[data-theme="dark"] .chroma .s,
:root[data-theme="dark"] .chroma .sa,
:root[data-theme="dark"] .chroma .sb,
:root[data-theme="dark"] .chroma .sc,
:root[data-theme="dark"] .chroma .dl,
:root[data-theme="dark"] .chroma .sd,
:root[data-theme="dark"] .chroma .s2,
:root[data-theme="dark"] .chroma .se,
:root[data-theme="dark"] .chroma .sh,
:root[data-theme="dark"] .chroma .si,
:root[data-theme="dark"] .chroma .sx,
:root[data-theme="dark"] .chroma .sr,
:root[data-theme="dark"] .chroma .s1,
:root[data-theme="dark"] .chroma .ss,
:root[data-theme="dark"] .chroma .gi { color: #98c379; }
/* Dark: Numbers */
:root[data-theme="dark"] .chroma .m,
:root[data-theme="dark"] .chroma .mb,
:root[data-theme="dark"] .chroma .mf,
:root[data-theme="dark"] .chroma .mh,
:root[data-theme="dark"] .chroma .mi,
:root[data-theme="dark"] .chroma .il,
:root[data-theme="dark"] .chroma .mo { color: #d19a66; }
/* Dark: Operators */
:root[data-theme="dark"] .chroma .o,
:root[data-theme="dark"] .chroma .ow,
:root[data-theme="dark"] .chroma .or { color: #56b6c2; }
/* Dark: Punctuation */
:root[data-theme="dark"] .chroma .p { color: #abb2bf; }
/* Dark: Comments */
:root[data-theme="dark"] .chroma .c,
:root[data-theme="dark"] .chroma .ch,
:root[data-theme="dark"] .chroma .cm,
:root[data-theme="dark"] .chroma .c1,
:root[data-theme="dark"] .chroma .cs,
:root[data-theme="dark"] .chroma .cp,
:root[data-theme="dark"] .chroma .cpf { color: #7f848e; }
/* Dark: Generic */
:root[data-theme="dark"] .chroma .gd { color: #e06c75; }
:root[data-theme="dark"] .chroma .gi { font-weight: bold; }
|
顺便说一下,我用的配色方案是github+onedark。
3 首页头像剪裁#
首页头像想做成圆形、不变形。尺寸由hugo.yaml的profileMode控制(主题默认是 150,我改成了 120):
1
2
3
4
| profileMode:
imageUrl: "/avatar.jpg"
imageWidth: 120
imageHeight: 120
|
CSS 只负责裁剪成圆形,在assets/css/extended/custom.css中添加:
1
2
3
4
5
| /* 首页头像 - 保持比例裁剪,不变形 */
.profile img {
border-radius: 50%;
object-fit: cover;
}
|
主题的index_profile.html会按imageWidth × imageHeight缩放图片,并把尺寸写到img的width / height属性上,所以尺寸交给配置即可,CSS 里不必再写死width / height。
4 导航栏图标#
在hugo.yaml的导航配置中name字段后面添加图标,例如:
1
2
3
4
| - identifier: home
name: "🏠 首页"
url: /
weight: 10
|
5 分页配置#
5.1 配置文件:hugo.yaml#
5.1.1 分页配置统一(修复"修改 paginate 不生效")#
问题:paginate: 5(已弃用)与 pagination.pagerSize: 10 同时存在,新版 Hugo 以 pagination.pagerSize 为准,导致每页 10 篇、9 篇文章只有 1 页,翻页按钮不显示。
修改:
- 删除顶部
paginate: 5 - 使用
pagination.pagerSize: 5
1
2
| pagination:
pagerSize: 5
|
5.1.2 清理重复配置键(修复"分享按钮偶尔出现")#
问题:params 下showShareButtons: true与ShowShareButtons: false是同一键(Hugo 键不区分大小写),值冲突且 Hugo 合并顺序随机,导致分享按钮有时显示有时不显示。showPageNums与ShowPageNums也是同理。
修改:删除重复定义,每项只保留一处。目前 params 下只留下:
1
2
| params:
ShowShareButtons: false
|
另外showPageNums后来也一并删掉了:自定义分页(见 5.2)用自己的页码渲染,不再依赖主题的页码开关,留着是个无效配置。
5.2 模板文件:layouts/list.html(新建,覆盖主题)#
5.2.1 数字页码 + 省略号#
用$paginator.Pagers渲染完整页码,通过$window控制当前页前后显示的页码数,超出范围用省略号替代。$window: 3表示当前页前后各保留 3 页。
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
| {{- if gt $paginator.TotalPages 1 }}
<footer class="page-footer">
<nav class="pagination">
{{- if $paginator.HasPrev }}
<a class="prev" href="{{ $paginator.Prev.URL | absURL }}">«</a>
{{- end }}
{{- $current := $paginator.PageNumber }}
{{- $total := $paginator.TotalPages }}
{{- $window := 3 }}
{{- $prevShown := 0 }}
{{- range $paginator.Pagers }}
{{- $pn := .PageNumber }}
{{- $show := or (eq $pn 1) (eq $pn $total) (and (ge $pn (sub $current $window)) (le $pn (add $current $window))) }}
{{- if $show }}
{{- if and $prevShown (gt (sub $pn $prevShown) 1) }}
<span class="page-dots">…</span>
{{- end }}
<a class="{{ if eq $pn $current }}active{{ end }}"
href="{{ .URL | absURL }}">{{ $pn }}</a>
{{- $prevShown = $pn }}
{{- end }}
{{- end }}
{{- if $paginator.HasNext }}
<a class="next" href="{{ $paginator.Next.URL | absURL }}">»</a>
{{- end }}
</nav>
</footer>
{{- end }}
|
5.2.2 修复构建报错 unexpected {{end}}#
问题:替换分页代码时多了一个 {{- end }},报错 baseof.html apply base template failed unexpected {{end}}。
修改:删除多余的 {{- end }},保证尾部结构为:
1
2
3
| {{- end }} ← 分页 if 结束
{{- end }}{{/* end profileMode */}} ← profileMode if 结束
{{- end }}{{- /* end main */ -}} ← define main 结束
|
5.3 样式文件:assets/css/extended/custom.css#
在文件末尾追加分页样式(最终版):
- 布局:
« 上一页绝对定位钉在左端、下一页 »钉在右端,页码组justify-content: center始终居中(用绝对定位解决"第一页/最后一页页码被顶到两边"的问题) - 按钮:极简扁平,浅色底 + 细边框 + 圆角,hover 加深;当前页实心反白
- 省略号:
.page-dots淡色不可点
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
| /* ===== 分页按钮:页码始终居中,上下页固定在两端 ===== */
.page-footer {
margin-top: 24px;
padding-top: 20px;
border-top: 1px dashed var(--border);
}
.pagination {
position: relative;
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
}
.pagination a {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 14px;
font-size: 14px;
color: var(--primary);
background: var(--entry);
border: 1px solid var(--border);
border-radius: 8px;
transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}
.pagination a:hover {
background: var(--tertiary);
border-color: var(--secondary);
}
.pagination a.active {
background: var(--primary);
border-color: var(--primary);
color: var(--theme);
cursor: default;
pointer-events: none;
}
/* 上一页/下一页:绝对定位钉在两端,不影响页码居中 */
.pagination .prev {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
min-width: auto;
padding: 0 18px;
}
.pagination .next {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
min-width: auto;
padding: 0 18px;
}
/* 文字(不想要就删掉这两段) */
.pagination .prev::after {
content: "上一页";
margin-inline-start: 6px;
}
.pagination .next::before {
content: "下一页";
margin-inline-end: 6px;
}
/* 分页省略号 */
.pagination .page-dots {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 36px;
font-size: 15px;
color: var(--secondary);
user-select: none;
}
|
6 字体与纸张配色#
6.1 字体加载(extend_head.html)#
正文用衬线字体(Lora + 思源宋体),代码用等宽字体(Source Code Pro)。字体走 Google Fonts,layouts/partials/extend_head.html 里用 preload 先拿下来、onload 时再切成 stylesheet,不阻塞首屏渲染:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <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>
|
再到assets/css/extended/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;
}
|
preload → stylesheet的切换要有noscript兜底:脚本被禁用时,字体样式仍然会正常加载。
6.2 纸张色体系#
主题默认浅色底是纯白。我把全站换成一套「纸张色」,同时解决首页与文章页底色不一致的问题(PaperMod 会给body.list套一层code-bg浅灰,而文章详情页是--theme纯白):
1
2
3
4
5
6
7
8
9
10
| /* 让列表页跟随 --theme,再把 --theme 改成暖纸张色 */
.list {
background: var(--theme);
}
:root {
--footer-height: 84px; /* 页脚高度,404 页算高度时要引用 */
--theme: #f7f2e9; /* 页面底:暖纸张色 */
--entry: #fdfaf3; /* 卡片底:比页面略亮一档的纸张色 */
}
|
深色模式不是单纯的黑,而是一组「昏暗米纸」暖灰:
1
2
3
4
5
6
7
8
9
10
11
| :root[data-theme="dark"] {
--theme: #262421; /* 页面底:昏暗米灰 */
--entry: #2f2c27; /* 卡片/代码块底:略亮的米灰 */
--primary: #ded5c4; /* 标题与默认文字:米白 */
--content: #d3c9b5; /* 正文文字 */
--secondary: #a89d8a; /* 次要文字(时间、标签等) */
--tertiary: #403c33; /* hover 底色/按钮底 */
--code-block-bg: #2f2c27;
--code-bg: #37332b;
--border: #3d3a31; /* 分隔线/描边 */
}
|
之后所有自定义样式(侧边栏、卡片、分页、菜单悬浮面板、404 页)都复用这几个变量,明暗两边一起生效,不用为暗色单独写一套。
6.3 顺带补一下行内代码颜色#
第 2 节的代码高亮只管.chroma里的 token,没有语言标注的行内代码(fallback / text / bash)在浅色下会偏亮、看不清,单独补一条:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| :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;
}
:root[data-theme="light"] {
--code-block-bg: #f8f8f8;
}
|
7 页脚:备案与访问统计#
备案信息配置写在hugo.yaml的params.footer下面:
1
2
3
4
5
6
7
| footer:
# ICP 备案号;有值时会在页脚显示并链接到工信部备案查询
icp: "京ICP备2025140550号-1"
# 公安备案文案;没有就留空
mps: ""
# 公安备案号里的数字编码;没有就留空,模板里也会尝试从 mps 文案提取
mpsCode: ""
|
渲染放在layouts/partials/extend_footer.html,不直接改页脚结构,而是把备案项当数据交给一小段脚本插进去。两个细节:
- 公安备案的链接需要号里的数字编码,优先取
mpsCode,没配就从mps文案里用findRE "[0-9]{6,}"抠出来; - 数据用
jsonify生成 JSON,script属于 JS 上下文,必须用safeJS而不是safeHTML(否则会被转义,或让 Go 模板语法混进 JS 触发 linter 误报)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| {{- $mpsCode := $footer.mpsCode -}}
{{- if and $footer.mps (not $mpsCode) -}}
{{- $found := findRE "[0-9]{6,}" $footer.mps -}}
{{- if $found }}{{ $mpsCode = index $found 0 }}{{ end -}}
{{- end -}}
{{- $beianItems := slice -}}
{{- with $footer.icp -}}
{{- $beianItems = $beianItems | append (dict "text" . "href" "https://beian.miit.gov.cn/") -}}
{{- end -}}
{{- with $footer.mps -}}
{{- $href := "" -}}
{{- with $mpsCode }}{{ $href = printf "https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=%s" . }}{{ end -}}
{{- $beianItems = $beianItems | append (dict "text" . "href" $href) -}}
{{- end -}}
{{- if $beianItems }}
<script type="application/json" id="beian-items">{{ $beianItems | jsonify | safeJS }}</script>
{{- end }}
|
脚本端把每一项插到页脚「Powered by」那句之前,并用·分隔:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| const powered = footer.querySelector("span:last-of-type");
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);
}
});
|
顺带说一下,--footer-height: 84px(见 6.2)就是给 404 页算min-height用的——404 页要撑满「视口 − 导航栏 − 页脚」的高度,页脚高度得有个变量可引用。
7.2 不蒜子:站点 PV/UV + 文章阅读量#
页脚统计(本站总访问量 / 访客数)同样在extend_footer.html:先把两个span插到页脚最前面,再引入不蒜子的脚本(顺序不能反,脚本要能接管这两个span):
1
2
3
4
| 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);
|
1
| <script async src="https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>
|
文章页的阅读量单独放在一个 partiallayouts/partials/busuanzi_page_pv.html里,挂在single.html的post-meta中(跟在时间后面):
1
2
3
4
5
6
| {{- if not (.Param "hidePageViews") -}}
{{- if or (not .Date.IsZero) (.Param "ShowReadingTime") (.Param "ShowWordCount") -}}
{{- printf " · " | safeHTML -}}
{{- end -}}
<span id="busuanzi_container_page_pv">阅读量 <span id="busuanzi_value_page_pv"></span> 次</span>
{{- end -}}
|
前面那个·分隔符要判断一下:只有前面确实有日期 / 字数 / 阅读时间时才补,否则标题下面会孤零零挂一个间隔符。.Param "hidePageViews"可以在单篇文章里关掉阅读量。
7.3 Umami#
站点统计用 Umami,一行 script 挂在页脚:
1
| <script defer src="https://cloud.umami.is/script.js" data-website-id="f2531744-6410-447b-8b1f-37b5bc11170a"></script>
|
8 文章列表:封面右置#
/posts/列表卡片默认是封面在上、文字在下。改成封面在右、文字在左,用 grid 把左侧文字和右侧封面分成两列:
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
| .post-entry {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, 32%);
column-gap: calc(var(--gap) * 1.5);
align-items: center;
}
.post-entry .entry-header,
.post-entry .entry-content,
.post-entry .entry-footer {
grid-column: 1;
min-width: 0;
}
.post-entry .entry-header { grid-row: 1; }
.post-entry .entry-content { grid-row: 2; }
.post-entry .entry-footer { grid-row: 3; }
.post-entry .entry-cover {
grid-column: 2;
grid-row: 1 / span 3; /* 封面跨三行,和左侧文字整体垂直居中 */
margin-bottom: 0;
align-self: center;
}
.post-entry .entry-cover img {
width: 100%;
height: auto;
}
|
小屏恢复默认竖排,避免图片把文字挤成一条:
1
2
3
4
5
6
7
8
9
| @media (max-width: 680px) {
.post-entry {
display: block;
}
.post-entry .entry-cover {
margin-bottom: var(--gap);
}
}
|
样式放在assets/css/extended/post-cover-right.css,只命中.post-entry,不影响 profileMode 首页。
9 自定义关于页#
content/about.md用layout: "about"指定自定义模板,并在 frontmatter 里关掉文章页的一堆默认元素:
1
2
3
4
5
6
7
8
9
| title: "关于我"
layout: "about"
url: "/about/"
description: "一个热衷于折腾技术与记录生活的普通人。"
ShowToc: false # 不显示目录
ShowBreadCrumbs: false # 不显示面包屑
hideMeta: true # 不显示日期、字数、阅读时间
ShowPostNavLinks: false # 不显示上一篇/下一篇
disableAnchoredHeadings: true # 标题不加锚点
|
模板layouts/about.html比文章页简单得多——只有面包屑、标题、描述和正文,不带封面、目录、侧边栏、评论区:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| {{- 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 }}
|
样式assets/css/extended/about.css做了几件事:正文收窄到 760px、标题居中放大、正文首图(头像)做成圆形描边头像:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| .about-page {
max-width: 760px;
}
.about-page .about-header {
margin-bottom: 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;
}
|
再用两张网格卡片排「内容方向」和「联系我」,小屏自动收成一列:
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
| .about-page .about-topics,
.about-page .about-links {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin: 22px 0 10px;
}
.about-page .topic-card,
.about-page .link-card {
background: var(--entry);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color 0.25s ease, transform 0.25s ease, background 0.25s ease;
}
.about-page .topic-card:hover,
.about-page .link-card:hover {
border-color: var(--secondary);
background: var(--tertiary);
transform: translateY(-2px);
}
@media (max-width: 640px) {
.about-page .about-topics,
.about-page .about-links {
grid-template-columns: 1fr;
}
}
|
卡片直接复用--entry底 + --border描边,hover 上浮 2px,和右侧侧边栏卡片是同一套观感。
10 回到顶部按钮与卡片阴影#
回到顶部按钮被改成了「三角箭头 + 阅读进度数字」的小卡片,样式在assets/css/extended/top.css:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| .topInner {
display: grid;
align-items: baseline;
justify-items: center;
margin: 7px;
font-weight: 900;
}
.topSvg {
width: 20px;
}
.top-link {
padding: unset;
background: var(--entry);
transition: box-shadow 0.4s ease, transform 0.4s ease;
box-shadow: 0px 2px 4px rgb(5 10 15 / 5%), 0px 7px 13px -3px rgb(5 10 15 / 30%);
}
|
按钮里的数字就是第 1.4 节那段脚本写进.topInner的#read_progress。
另外把文章列表卡片和侧边栏卡片的阴影统一了(放在toc.css末尾):
1
2
3
4
5
6
7
8
| .post-entry {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
}
/* 暗色模式下阴影不明显,适当加深 */
[data-theme="dark"] .post-entry {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
|
11 首页打字机:参数改为自动生成#
首页标题的打字机特效(详见《首页打字机特效手机端显示不全修复》)后来做了一次改进,和本节的字体加载同在extend_head.html里。
原来的问题是:width和steps()里的字符数要跟标题字数手动对齐,标题一改就得回去改 CSS。现在改成按profileMode.title的实际字数自动注入 CSS 变量(1 个全角字按 1em 算):
1
2
3
4
5
6
7
8
9
10
11
12
13
| {{- if .IsHome }}
{{- with site.Params.profileMode }}
{{- if .enabled }}
{{- $chars := strings.RuneCount (.title | default site.Title) }}
<style>
.profile .profile_inner h1 {
--type-chars: {{ $chars }};
--type-ease: steps({{ $chars }}, end);
}
</style>
{{- end }}
{{- end }}
{{- end }}
|
custom.css里的--type-chars: 9和steps(9, end)退化成兜底值,改标题后不用再手改:
1
2
3
4
5
6
7
8
9
10
11
| .profile .profile_inner h1 {
--type-chars: 9;
--type-ease: steps(9, end);
max-width: 100%;
width: calc(var(--type-chars) * 1em);
overflow: hidden;
white-space: nowrap;
border-right: 1px solid currentColor;
animation: profile-title-typing calc(var(--type-chars) * 0.24s) var(--type-ease),
profile-title-caret 0.75s step-end infinite;
}
|
动画时长也一并改成按字数算(calc(var(--type-chars) * 0.24s)),保持每个字约 0.24s 的节奏。
参考文献#