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>

后来又找到了zooming

参阅文档

样式修改

字体设置

在尝试了很多字体之后,最终选择了寒蝉端黑宋。

代码块样式设置

Hugo中内置了一套Chroma配色方案,官方文档中也有配色方案展示。

目前比较喜欢hrdark这个配色方案。参考Hugo文档,配色方案的设置有两种方法:

  • 在配置文件中设置预设的方案(noClasses默认为true

    [markup]
    [markup.highlight]
      noClasses = true
      style = 'hrdark'
    

    除了stylenoClasses之外,这里还可以设置其他参数来统一控制是否显示代码行号等行为。如果想在更细粒度上自定义单个代码块的渲染效果,可以直接在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;
}

防剧透短代码

增加防剧透效果: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行内容的时候换行了,要注意使用-,如果不加的话渲染出的链接标题会与周围的文本之间有空格。

{{- $u := urls.Parse .Destination -}}
<a href="{{ .Destination | safeURL }}"
  {{- with .Title }} title="{{ . }}"{{ end -}}
  {{- if $u.IsAbs }} target="_blank" rel="noopener noreferrer external"{{ end -}}
>
  {{- with .Text -}}
    {{ . }}{{ if $u.IsAbs }}<span aria-hidden="true" class="external-link-indicator"></span>{{ end }}
  {{- end -}}
</a>
.external-link-indicator { //小箭头的css
  font-size: 0.75em;
  margin-left: 0.25em;
  vertical-align: super;
}

以下是待办事项

随便写一下以免自己忘了

主页

想加一个新的主页,跳转之后再显示文章列表,嗯需要思考一下怎么设计

分页

做分页组件,或者用列表展示一部分,剩下的点击链接跳转

归档页面

  • 放在侧边栏?单独页面?
  • 获取年份列表 ok
  • 获取当年的文章列表 + url跳转 ok
    • 组织方式:在content目录下创建archive
    • 关于config中的permalinks参数:例如archive = "/archive/:year/",将从markdown文件的front matter中读取date属性中的年份,然后形成url,于是当前page的url与实际文件名无关。
    • 自动生成脚本
  • 样式设计
    • 每年列表展示
    • 所有年份列表展示
    • 组件样式

文章meta data

  • tag展示(研究一下taxonomy,还没搞懂是干嘛的)
  • 上一篇、下一篇
  • 字数计算

其他

  • 友链
  • 添加sns icon
  • iframe嵌套 链接notion页面
  • 碎碎念随记模块
  • 展柜
  • 深色模式配色方案
  • 移动端适配:这个以后再说吧

内容迁移

  • NotionNext

写作tips

自定义章节id:{#year2025_2}