Hugo主题开发记录
前两年都在用WordPress搭建博客,中途因为换主机供应商搬了一次家,试了好几个插件才顺利完成打包下载和上传。搬完家又发现新主机的图片上传速度很慢,因为每次写月记的时候会放很多图,觉得不太方便,不过这家供应商胜在主机和域名都免费,所以老博客就在这边一直挂着了(虽然以后大概不会再更新了,但毕竟当初修改了好多东西还引入了PJAX,关了有点舍不得TT)。后来尝试用NotionNext搭了一个站点,但不太会改主题,兜兜转转最后还是决定用Hugo来搭建静态博客。
之前在WordPress用的主题是Ashe Blog,在Hugo这边没找到类似风格的主题,所以打算参照Ashe的大致样式来定制一个Hugo主题。上班的时候总是最想装修博客的时候()由于是从Hugo的默认模版开始手搓主题,需要改的地方有很多,目测是一项耗时很久的工作,决定在每天上班摸鱼的时候能改一点算一点。
另近一年来感觉到自己越来越难以专注地完成(指达到能commit的程度)一项事务,虽然以前大概也有点这样,不过上班之后表现得更明显了。不知道是思维方式变了,还是可支配的时间很少而想做的事太多,总是一件事做到一半就去做另一件事,因此越来越需要to-do list来提醒自己需要完成哪些事项。
站点配置
购买域名+Vercel部署
Vercel自定义域名配置流程(之前为了NotionNext配过一次,具体流程有点忘了,所以再总结一下……
- 购买域名,并在vercel为项目添加域名
- 在cloudflare为域名添加vercel提供的CNAME和A记录(并删除原有记录)
- 前往域名供应商管理后台,将nameserver改成cloudflare提供的地址(不过这样vercel会提示不建议使用反向代理,用起来感觉没太大影响
设置Hugo版本
应该是站点搭建期初期该完成的设置工作,但我直到开始写Hugo短代码才发现了这个问题……(淡淡
在使用短代码时,本地构建的站点可以正常渲染页面,但部署到Vercel会出现以下错误信息:
1[13:08:03.469] Running "vercel build"
2[13:08:04.458] Vercel CLI 42.2.0
3[13:08:05.581] Building sites … Total in 4 ms
4[13:08:05.581] Error: Error building site: "/vercel/path0/content/posts/daily/site-build.md:28:27": failed to extract shortcode: template for shortcode "spoiler" not found
5[13:08:05.586] Error: Command "hugo --gc" exited with 255
6[13:08:05.806]
7[13:08:08.671] Exiting build container
到处找原因,看到项目设置中的预设框架是Other
(当初导入项目的时候没有选Hugo
就用了默认的……
将项目框架更改为Hugo
之后,日志中多了一行信息。这里Hugo的版本比我本地测试环境用的落后很多,猜测短代码无法运作可能是因为Vercel使用的Hugo版本不对。
1[14:10:12.345] Running "vercel build"
2[14:10:12.794] Vercel CLI 42.2.0
3[14:10:13.503] Installing Hugo version 0.58.2
4[14:10:14.139] Building sites … Total in 2 ms
5[14:10:14.140] Error: Error building site: "/vercel/path0/content/posts/daily/site-build.md:28:27": failed to extract shortcode: template for shortcode "spoiler" not found
在项目设置界面中添加新的环境变量HUGO_VERSION
,指定Hugo版本,与本地开发环境保持一致。
重新部署并查看build log,可以看到Hugo版本更改成功,短代码的问题也解决了。
[14:15:24.492] Running "vercel build"
[14:15:24.949] Vercel CLI 42.2.0
[14:15:25.528] Installing Hugo version 0.138.0
[14:15:26.601] Start building sites …
图床配置
选用Cloudflare R2作为图床,有10GB的免费容量(不过也需要先绑定一种付款方式后才可以使用),操作步骤如下:
- 创建存储桶
- 在储存桶设置页面中添加自定义域。我的博客域名是
alanone.top
,选择了media.alanone.top
这个子域名来连接存储桶。 - 为储存桶创建API,保存该API对应的访问密钥ID(Access Key ID)和机密访问密钥(Secret Access Key)。Cloudflare还会提供一个“为S3客户端使用管辖权地特定的终结点”链接。
使用PicGo作为图片上传客户端:
- 原生的PicGo不支持Amazon S3图床,因此需要先安装S3插件。
- 在S3图床设置页面填写Cloudflare R2 API的密钥ID和访问密钥,储存桶名和上传文件路径。在“自定义节点(endpoint)”中填写Cloudflare提供的S3终结点。
- 最后在储存桶中设置“自定义输出URL模版“,例如
https://media.alanone.top/{path}
,就可以开始使用了。关于上传文件路径和自定义输出URL中的{path}
、{fileName}
等占位符使用方法,在S3插件的文档中有详细说明。
基础功能
章节目录TOC
用{{ .TableOfContents }}
生成章节目录,配合js代码实现对当前浏览内容对应的标题增加高亮显示效果。
<script>
document.addEventListener("DOMContentLoaded", function () {
const tocLinks = document.querySelectorAll(".toc a");
const headings = Array.from(tocLinks)
.map(link => document.getElementById(link.getAttribute("href").substring(1)))
.filter(Boolean);
const activateLink = (id) => {
tocLinks.forEach(link => {
if (link.getAttribute("href") === `#${id}`) {
link.classList.add("active");
} else {
link.classList.remove("active");
}
});
};
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
activateLink(entry.target.id);
}
});
}, {
rootMargin: "0px 0px -60% 0px", // 提前激活(偏移视窗)
threshold: 0.1
});
headings.forEach(h => observer.observe(h));
});
</script>
增加About页面
在content
目录下增加about.md
,存放本文内容,在frontmatter中填写type = 'about'
。创建layouts/about/single.html
,用来定义about页面的样式。目前只写了简单的控制宽度+居中显示。
{{ define "main" }}
<div class="post-content" style="width: 60%; margin: 0 auto;">
<div class="article">
{{ .Content }}
</div>
</div>
{{ end }}
还试了一下只在frontmatter中写layout = 'about'
,然后配合layouts/about.html
定义样式。不清楚为什么这样会fallback到别的模版,如果放到layouts/_default/about.html
就可以了……现在还不太了解hugo寻找模版时的优先级,之后有空的话再看一下文档吧。
站内搜索
从Hugo文档中列出了一些搜索工具,尝试使用其中的开源组件Pagefind。
运行命令:npx pagefind --site public
,对站点public
目录下的内容建立索引文件。完成后会出现以下提示。
Note: Pagefind doesn't support stemming for the language zh-cn.
Search will still work, but will not match across root words.
Pagefind的文档对此做了说明,目前无法在搜索框内对输入内容进行分词,因此检索结果的精确率和召回率会受到影响。
每次文章内容发生变化后,都需要运行一次Pagefind命令来创建新的索引文件。修改Vercel项目的构建命令可以自动完成这一步骤。最简单的方式是在Vercel项目的Settings -> Build and Deployment
菜单中修改Build Command
:在hugo
构建站点之后,接着运行Pagefind的索引命令。
hugo --gc && npx pagefind --site public
接下来为站点创建一个搜索页面,类似上文中about页面的添加方式,在frontmatter中添加layout = 'search'
。创建layouts/_default/search.html
,引入Pagefind提供的默认UI PagefindUI
。
{{ define "main" }}
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search" class="search-container"></div>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
new PagefindUI({
element: "#search",
showSubResults: true
});
});
</script>
{{ end }}
另外,我希望Pagefind只对每一篇文章的标题和正文内容进行检索,从而避免检索结果出现重复,e.g. 文章视图的标题与列表视图的标题。修改文章页面的渲染模版_default/single.html
,为需要Pagefind检索的元素加上data-pagefind-body
。(试验了一下发现如果加在更外层的div
元素上会检索不到)
{{ define "main" }}
<div class="content" style="gap: 2rem;">
<div class="post-content">
<h1 data-pagefind-body>{{ .Title }}</h1>
<div class="article" data-pagefind-body>
{{ .Content }}
</div>
</div>
<aside class="toc">
{{ .TableOfContents }}
</aside>
</div>
{{ end }}
第一次使用的时候,发现返回每条检索结果都只有十几个字符,查阅文档了解到Pagefind的UI有许多可配置的参数,修改其中的excerptLength
就可以控制显示的文本长度。基本的效果已经达成了,接下来就是根据个人喜好继续调整样式。
图片缩放
想给博客加一个单击图片放大的功能,引入了medium-zoom:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/medium-zoom.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/style.min.css" rel="stylesheet">
<script>
const images = Array.from(document.querySelectorAll(".post-content img"));
images.forEach(img => {
mediumZoom(img, {
margin: 0, /* The space outside the zoomed image */
scrollOffset: 40, /* The number of pixels to scroll to close the zoom */
container: null, /* The viewport to render the zoom in */
template: null, /* The template element to display on zoom */
background: 'rgba(0, 0, 0, 0.8)'
});
});
</script>
不过放大之后,图片内部会出现几条透明的分割线。深色图片搭配白色背景,会让这些线条看起来特别明显。猜测线条产生原因是浏览器在使用transform: scale()
对图像进行非整数倍缩放时出现了渲染误差。虽然可以通过调整背景遮罩层的颜色来暂时掩盖瑕疵,但我还是更偏好在放大图片时使用浅色背景。
2025.7.21编:
突然发现一部分图片还是会有分割线……算了反正无伤大雅就先这样凑合着了(?下面这段废话就先折一下好了
失败的问题修复尝试
我尝试换了另一个脚本zooming,它会将图片本身以及添加的边框样式一起放大,在这种情况下就没有出现奇怪的分割线了。后续我又更改图片样式进行试验,发现实际起作用的部分似乎是box-shadow
,而与border
没有关系。只要为图片添加box-shadow
,缩放时的渲染就会变得正常。(虽然目前还不清楚具体原理,可能是改变了渲染行为,或者在某种程度上弥补了像素级别的误差。
我希望图片放大时不显示边框,于是查阅了 zooming 的文档,修改具体的缩放行为。由于放大后的元素无法直接定位,我在脚本的 onBeforeOpen
接口中为图片添加了一个新类,以设定不同的样式;然后在 onBeforeClose
中移除这个类即可。
<script src="https://unpkg.com/zooming/build/zooming.min.js"></script>
<script>
// Listen to images after DOM content is fully loaded
document.addEventListener('DOMContentLoaded', function () {
new Zooming({
bgOpacity: 0.7,
bgColor: 'rgb(255, 255, 255)',
onBeforeOpen: function (target) {
target.classList.add('image-zoomed-clean');
},
onBeforeClose: function (target) {
target.classList.remove('image-zoomed-clean');
}
}).listen('.post-content img')
})
</script>
对应的新类样式:
.image-zoomed-clean {
box-shadow: 0 0 1px transparent !important; //起作用的神秘小技巧!!
border-radius: 0 !important;
border: 2px solid transparent !important; // 加这个只是为了从视觉效果上fix图片刚还原时短暂的位置偏移,因为放大前的图片有2px边框
}
之前上网查找相关问题的时候,还看到别的解决方法,不过因为我没有手动使用transform: scale()
,引入的脚本也不太好改,就没有验证这个方法的可行性,先存一下也许以后用得到。
字数统计
在Hugo配置文件中添加以下代码,以支持中文字数统计:
hasCJKLanguage = true
然后使用.WordCount
就可以获取每篇文章的字数了,我加上了除以1000的计算。
<span class="tag-text">{{ div .WordCount 1000.0 | lang.FormatNumber 2}} k</span>
获取所有文章的字数总和:这里使用了lang.FormatNumber 0
来格式化数字,每3位用逗号分隔,
{{$scratch := newScratch}}
{{ range (where .Site.RegularPages "Section" "posts")}}
{{$scratch.Add "total" .WordCount}}
{{ end }}
{{ $scratch.Get "total" | lang.FormatNumber 0 }}
分页组件
在配置文件中定义每页展示的文章数量:
paginate = 10
不过编译的时候看到提示信息:
WARN deprecated: site config key paginate was deprecated in Hugo v0.128.0 and will be removed in a future release. Use pagination.pagerSize instead.
所以换成:
pagination.pagerSize = 10
为了方便复用,我把分页组件写成了单独的模块layouts/partials/pagination.html
,并从微软开源的Fluent UI System Icons里找了两个箭头svg作为翻页按钮。在导入svg时对原有属性进行了修改,去除了width
和height
,增加fill="currentColor"
,这样就可以通过css来控制图像的大小与填充颜色。
<nav class="pagination">
{{ $svg := readFile "static/icon/chevron_left_24_filled.svg" |
replaceRE ` width="[^"]+"` "" |
replaceRE ` height="[^"]+"` "" |
replaceRE `<path ` `<path fill="currentColor" ` |
safeHTML
}}
{{ if .HasPrev }}
<a href="{{ .Prev.URL }}">
<span class="pagination-btn">
{{ $svg }}
</span>
</a>
{{ else }}
<a class="disabled">
<span class="pagination-btn">
{{ $svg }}
</span>
</a>
{{ end }}
<span class="pagination-num">{{ .PageNumber }} / {{ .TotalPages }}</span>
{{ $svg := readFile "static/icon/chevron_right_24_filled.svg" |
replaceRE ` width="[^"]+"` "" |
replaceRE ` height="[^"]+"` "" |
replaceRE `<path ` `<path fill="currentColor" ` |
safeHTML
}}
{{ if .HasNext }}
<a href="{{ .Next.URL }}">
<span class="pagination-btn">
{{ $svg }}
</span>
</a>
{{ else }}
<a class="disabled">
<span class="pagination-btn">
{{ $svg }}
</span>
</a>
{{ end }}
</nav>
最后在列表的渲染模版中导入分页组件:
{{ $paginator := .Paginate (where .Site.RegularPages "Section" "posts") }}
{{ range $paginator.Pages }}
<div class="post-block">
<h2><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></h2>
{{ .Summary }}
<div class="post-meta">
{{ partial "metadata.html" . }}
</div>
</div>
{{ end }}
{{ partial "pagination.html" $paginator }}
样式修改
字体设置
在尝试了很多字体之后,最终选择了寒蝉端黑宋作为正文字体。(真的花了好久才找到一款符合自己喜好的宋体……
为了便于修改字体,我创建了_variables.scss
用来存放全局变量。这样当需要修改字体时,就不用去其他地方逐一修改font-family
了。
$font-family-post: "Fira Code", "ChillDuanHeiSong", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei", sans-serif;
$font-family-post-heading: "ELEGANT TYPEWRITER", "KingHwa OldSong", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei", sans-serif;
在其他scss文件中使用这些变量时,需要先引入:
@use "variables" as v;
然后就可以这样访问:
font-family: v.$font-family-post-heading;
代码块样式设置
Hugo中内置了一套Chroma配色方案,官方文档中也有配色方案展示。
目前比较喜欢hrdark这个配色方案。参考Hugo文档,配色方案的设置有两种方法:
-
在配置文件中设置预设的方案(
noClasses
默认为true
)[markup] [markup.highlight] noClasses = true style = 'hrdark'
除了
style
和noClasses
之外,这里还可以设置其他参数来统一控制是否显示代码行号等行为。如果想在更细粒度上自定义单个代码块的渲染效果,可以直接在Markdown的代码块上附带这些参数,具体参考Hugo文档中的语法高亮。常用的有:显示行号
{linenos=inline}
,高亮显示指定行{hl_lines=["16-18",26]}
。 -
在配置文件中设置
noClasses = false
(该属性用于控制是否使用内联式css),然后引入css文件。使用hugo命令生成css文件:
hugo gen chromastyles --style=hrdark > syntax.css
将css文件移动到
static/css
目录下,并引入:<link rel="stylesheet" href="/css/syntax.css">
感觉hrdark配色方案中括号等字符的颜色太深了,所以在原有配色方案的基础上调整了一下。hugo命令生成的css文件自带注释,找到目标类别修改即可,例如括号这类字符属于.chroma .p
,高亮代码行属于.chroma .hl
。此外,需要注意的是在修改完syntax.css
后进行本地测试时,可能需要强制刷新浏览器页面,避免发生因存在缓存而导致设置不生效的情况。
/* LiteralNumberOct */ .chroma .mo { color:#a6be9d }
/* Operator */ .chroma .o { color:#f2747e }
/* OperatorWord */ .chroma .ow { color:#f2747e }
/* Punctuation */ .chroma .p { color: #6c6b70 }
不过不知道为什么代码有了高亮,但代码块的背景颜色没有被设置成预期的样子,也许是在哪里被覆盖了,所以暂时先手动写一下样式:
.highlight pre {
background-color: #F7F9FA;
border: 2px solid;
border-color: #ecf1f7;
border-radius: 6px;
padding: 1em;
overflow-x: auto;
}
还遇到了一些小问题:
- 在调整页面布局时,我为正文容器和右侧目录容器分别设置了
flex: 3;
与flex: 1;
,再使用外层容器的gap
属性调整这两个容器的间距。然而随着gap
增大,只有目录的宽度在减小,正文容器的宽度是固定的。 - 经过排查发现,这篇正文的代码块中有一串特别长的哈希值。虽然为代码块设置了
white-space: pre-wrap;
让长代码换行,但哈希值这样的字符串难以分割,依然会增大代码块的宽度。正文容器因为没有设置min-width
,默认的最小宽度取决于最大的内部容器,因此宽度也会随代码块增加,且不受上级容器max-width
属性值的限制。 - 解决方法:为正文容器添加
min-width: 0;
样式,使其最小宽度与内部容器无关。同时为代码块添加overflow-x: auto;
,在宽度超出正文容器时显示滚动条。
防剧透短代码
增加防剧透效果:inline效果测试
顺便测试一下为单个段落添加遮罩的效果。这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落,这是一个段落。
一开始我在shortcodes目录下的spoiler.html
中只写了以下代码:
<span class="spoiler">{{ .Inner | markdownify }}</span>
这样的写法在inline使用短代码的情况下正常,但如果在markdown里对一个单独的段落运用短代码,这部分内容生成html后依然只是一个span,没有自动加上<p>
,因此这一段的文本样式会和文章其他段落不同(在没有特意调整成相同样式的情况下)。
尝试了几种方法试图完成span到p的转换却均失败之后,发现不如直接新创建一个spoiler-block.html
短代码,然后直接在这里加上<p>
标签,<p><span class="spoiler">{{ .Inner | markdownify }}</span></p>
。感觉应该有更好的方法,总之先暂时这样达到了预期效果。
链接样式
目前只改了每篇文章中的链接,加了鼠标悬浮时从左到右出现下划线的动态效果,像这样。文章列表页面的标题链接样式还没想好。
a {
color: #6190c0;
text-decoration: none;
transition: color 0.3s ease;
position: relative;
}
a::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 100%;
background: #58a1dd;
transform: scaleX(0); // 简单的过渡动画
transform-origin: left;
transition: transform 0.2s ease-in-out;
}
a:hover {
color: #58a1dd;
}
a:hover::before {
transform: scaleX(1);
}
默认情况下点击文章中的链接时,会直接在当前标签页中跳转到新页面,感觉这样在访问外部链接时不太方便。
使用Hugo的render-link
hook,可以在渲染Markdown时自动为链接加上target="_blank"
,从而达到在新标签页中打开链接的效果。
{{- $u := urls.Parse .Destination -}}
<a href="{{ .Destination | safeURL }}"
{{- with .Title }} title="{{ . }}"{{ end -}}
{{- if $u.IsAbs }} target="_blank" rel="noopener noreferrer external"{{ end -}}
>
{{- with .Text }}{{ . }}{{ end -}}
</a>
再给链接后面加一个小箭头(这里写6-8行内容的时候因为换了行,渲染出的链接标题会与周围的文本之间有空格,加上 -
就可以去掉空格了,或者将这部分代码写成一行。
1{{- $u := urls.Parse .Destination -}}
2<a href="{{ .Destination | safeURL }}"
3 {{- with .Title }} title="{{ . }}"{{ end -}}
4 {{- if $u.IsAbs }} target="_blank" rel="noopener noreferrer external"{{ end -}}
5>
6 {{- with .Text -}}
7 {{ . }}{{ if $u.IsAbs }}<span aria-hidden="true" class="external-link-indicator">↗</span>{{ end }}
8 {{- end -}}
9</a>
.external-link-indicator { //小箭头的css
font-size: 0.75em;
margin-left: 0.25em;
vertical-align: super;
}
短代码
NeoDB卡片
参考了鹤辞老师的作业,不过我的Hugo版本不一样,产生了报错信息:
ERROR deprecated: data.GetJSON was deprecated in Hugo v0.123.0 and will be removed in Hugo 0.139.0. use resources.Get or resources.GetRemote with transform.Unmarshal.
于是修改了一下短代码,分两步来替换原来的getJSON
,先用resources.GetRemote
获取内容,再用transform.Unmarshal
解析数据。
{{ /* {{ $dbFetch := getJSON $dbApiUrl $dbType }} 将这一行改为下面的代码 */ }}
{{ $fullApiUrl := printf "%s%s" $dbApiUrl $dbType }}
{{ $dbFetch := "" }}
{{ with resources.GetRemote $fullApiUrl }}
{{ if .Content }}
{{ $dbFetch = .Content | transform.Unmarshal }}
{{ end }}
{{ end }}
然后改了一下显示评分的部分,将个人评分作为参数传入。一开始改了变量没起作用(好奇怪!),查了之后才学到HTML注释和Go模版注释不一样:Hugo在构建时会执行HTML注释里的内容,只是不渲染在页面上。
<!-- {{ $itemRating := 0 }}{{ with $dbFetch.rating }}{{ $itemRating = . }}{{ end }} 这样写不起作用 -->
{{/* {{ $itemRating := 0 }}{{ with $dbFetch.rating }}{{ $itemRating = . }}{{ end }} 用这种注释方法才可以 */}}
Troubleshooting
TOC跳转失效
偶然发现点击文章目录无法跳转到对应的章节了,不过这段时间都没动过这部分代码,而且单独把标题链接复制到新窗口打开的效果也是正常的。排查了一下发现是前两天引入的APlayer脚本导致的。问了Gemini说音乐播放器通常会与pjax技术结合使用,这个站点我自己写的部分里并没有涉及pjax,所以也许是APlayer的原因。
于是让Gemini帮我写了一段脚本来绕过pjax(暂且当是pjax吧我也没有查证过)对toc链接点击事件的监听,使用新的事件监听器来恢复窗口滚动效果:
document.addEventListener('DOMContentLoaded', function() {
// 找到页面中所有的TOC链接(或其他页面内锚点链接)
const internalLinks = document.querySelectorAll('a[href^="#"]');
internalLinks.forEach(link => {
link.addEventListener('click', function(event) {
// 阻止其他脚本(比如pjax)的干扰
event.stopPropagation();
const targetId = this.getAttribute('href');
const targetElement = document.querySelector(targetId);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'start' // 将元素的顶部与视口的顶部对齐
});
}
}, true); // 使用事件捕获阶段,尝试先于pjax执行
});
}, false);
装修待办清单
随便写一下以免自己忘了,嗯但是最近在琢磨新的页面设计之后更能要整个大改
- 主页:想加一个新的主页,跳转之后再显示文章列表,嗯需要思考一下怎么设计(目前考虑用p5.js做个简单的动画,前两天试水画了一个塞在about里了
- 分tag页面增加分页组件(不过现在一个tag里也没几篇文章,晚点再加好了
- 归档页面:增加分标签展示(顺便研究一下taxonomy,还没搞懂它的作用
- 文章meta信息:增加上一篇、下一篇跳转
- 优化字体加载速度
- 增加表情包短代码
- 给更新记录做一个单独的页面
- 碎碎念模块
- waline再加一些表情包
- 深色模式配色方案
- 移动端适配(可能就这样悄悄放弃了(
- 代码框copy按钮
- 在思考是否要把Notion和wp老博客的东西搬过来……之后有空再说吧
Tips
自定义章节id:{#year2025_2}