这是一个系列博客,记录了我从零开始构建Hugo主题https://github.com/tomowang/hugo-theme-tailwind 的过程。全系列包括四篇文章,这是第三篇:

黑色主题

tailwindcss在处理黑色主题时非常方便,在原始白色主题的基础上,我们只需要通过dark:前缀设定黑色主题下的展示样式即可。

tailwind.config.js添加darkMode: 'class'配置,接下来就是对主题中的样式进行改造了。关于黑色模式的描述可以参考官方文档:https://tailwindcss.com/docs/dark-mode

值得一提的是黑色主题模式的切换按钮,为了使主题尽可能简单,不引用额外的前端框架, 我研究了一些tailwindcss常见的主题按钮切换示例,发现通过input + peer + group能够很好地实现我想要的效果。代码如下:

<div class="darkmode-toggle flex flex-none ml-2">
  <label class="flex items-center px-3 cursor-pointer rounded-full bg-gray-100 dark:bg-gray-600">
    <input type="checkbox" class="sr-only peer">
    <div class="group flex flex-row gap-1 justify-center h-8 px-1 rounded-full bg-white dark:bg-gray-700">
      <i class="h-6 w-6 flex-none rounded-full bg-yellow-400 place-self-center peer-checked:group-[]:invisible">
        {{ partial "icon" "brightness-down" }}
      </i>
      <i class="h-6 w-6 flex-none rounded-full place-self-center invisible peer-checked:group-[]:visible">
        {{ partial "icon" "moon-stars" }}
      </i>
    </div>
  </label>
</div>

其中我使用<input type="checkbox">来控制主题变量,然后通过<div class="group">定义了一组图标, 每个图标使用peer-checked:group-[]:invisiblepeer-checked:group-[]:visible来控制展示与隐藏。这样我只需要关注checkbox的值,而按钮的图标切换则完全交给tailwindcss来处理。

const themeToggle = document.querySelector('.darkmode-toggle input');

themeToggle.addEventListener('change', function () {
  if (this.checked) {
    localStorage.theme = dark;
    document.documentElement.classList.add(dark);
  } else {
    localStorage.theme = light;
    document.documentElement.classList.remove(dark);
  }
});

关于group以及peertailwindcss描述为

黑色主题花了我一天的业余时间,最终的代码在#75c6b41

响应式设计

tailwindcss的响应式设计使用起来也很简单,我的目标是支持手机及PC,tailwindcss在处理手机端优先设计时, 使用无前缀的组件优先设计手机端的样式,然后使用有前缀的组件(如md:)在其他屏幕大小时对样式进行覆盖,我的改造如下:

index ad2e93f..e5b7d76 100644
--- a/layouts/_default/list.html
+++ b/layouts/_default/list.html
@@ -15,9 +15,9 @@

   <div class="flex flex-col w-full max-w-4xl lg:max-w-5xl relative">
     <div class="flex flex-row">
-      <section class="flex flex-col w-2/3">
+      <section class="flex flex-col w-full md:w-2/3">
         {{ range (.Paginate $pages).Pages }}
-          <article class="flex flex-col gap-y-3 p-6 mt-6 rounded-lg shadow-md bg-white dark:bg-gray-700">
+          <article class="flex flex-col gap-y-3 p-6 mt-6 mx-2 md:mx-0 rounded-lg shadow-md bg-white dark:bg-gray-700">
             <h2 class="text-4xl font-semibold text-slate-800 dark:text-slate-200">
               <a href="{{ .RelPermalink }}">{{ .Title | markdownify }}</a>
             </h2>
@@ -40,7 +40,7 @@
           </article>
         {{ end }}
       </section>
-      <aside class="flex flex-col w-1/3 mx-3 top-0 sticky self-start">
+      <aside class="hidden md:flex flex-col md:w-1/3 mx-3 top-0 sticky self-start">
         {{ partial "sidebar.html" . }}
       </aside>
     </div>

如代码所示,我将section标签默认改为w-full,然后在大屏幕时使用md:w-2/3展示2/3宽度。

最终的代码在#279de84

多语言

hugo本身支持多语言,首先我们需要在配置文件中引入多语言的配置,如我们的示例站点,配置文件为exampleSite/config/_default/languages.toml,内容为

[en]
  languageCode = 'en-US'
  languageDirection = 'ltr'
  languageName = 'English'
  weight = 1
  [en.menu]
  [[en.menu.main]]
    identifier = "post"
    name = "Post"
    pageRef = "post"
    weight = 10

  [[en.menu.main]]
    identifier = "about"
    name = "About"
    pageRef = "about"
    weight = 20

[zh-cn]
  languageCode = 'zh-CN'
  languageDirection = 'ltr'
  languageName = '中文'
  weight = 2
  [zh-cn.menu]
  [[zh-cn.menu.main]]
    identifier = "post"
    name = "文章"
    pageRef = "post"
    weight = 10

  [[zh-cn.menu.main]]
    identifier = "about"
    name = "关于"
    pageRef = "about"
    weight = 20

然后我们需要将主题中之前硬编码的字符串整理放到i18n文件夹中,修改主题中的字符串,使用i18n函数进行替换, 如layouts/404.html文件中的部分字符串:

diff --git a/layouts/404.html b/layouts/404.html
index 77d8885..32ec853 100644
--- a/layouts/404.html
+++ b/layouts/404.html
@@ -2,11 +2,11 @@
   <main class="grid place-items-center w-full min-h-full max-w-4xl lg:max-w-5xl px-6 py-24">
     <div class="text-center w-full">
       <p class="text-base font-semibold text-indigo-600 dark:text-indigo-300">404</p>
-      <h1 class="mt-4 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-5xl">Page not found</h1>
-      <p class="mt-6 text-base leading-7 text-slate-400">Sorry, we couldn't find the page you're looking for.</p>
+      <h1 class="mt-4 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-5xl">{{ T "404.page_not_found" }}</h1>
+      <p class="mt-6 text-base leading-7 text-slate-400">{{ T "404.sorry" }}</p>
       <div class="mt-10 flex items-center justify-center gap-x-6">
         <a href="/" class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
-          Go back home
+          {{ T "404.go_back_home" }}
         </a>
       </div>
     </div>

最后调整导航栏,加上语言切换的按钮和语言列表。

hugo multilingual language switch

完整的代码参考链接#0244464

额外收获

我的母语是中文,英文阅读也还不错,所以我最初只做了中文和英文两种语言。但是如何给主题添加更多的语言呢?2023年是大语言模型蓬勃发展的一年, 使用大语言模型,我通过提示词将语言文件翻译到更多的语言,如俄语、日语等。

这里我使用gemini的接口,对i18n文件进行处理,

import google.generativeai as genai

# get key from https://makersuite.google.com/app/apikey
GOOGLE_API_KEY = 'YOUR_GOOGLE_API_KEY'
genai.configure(api_key=GOOGLE_API_KEY)
model = genai.GenerativeModel('gemini-pro')

prompt_template = '''You are a translator and your task is to translate l10n file to different languages.
The l10n file is provided in TOML format. The file contains {{ KEY }} for variables and use
`one` for singular and `other` for plural.

The TOML file is quoted in triple backtick. Please translate the content to {lang}
and keep the original content structure, also remove triple backtick in output:

```toml
{en_file_content}
```
'''

en_file_content = ''
with open('./en.toml', 'r') as f:
    en_file_content = f.read()

prompt = prompt_template.format(lang='Chinese', en_file_content=en_file_content)

response = model.generate_content(prompt)
print(response.text)

我在提示词中写了一些hugo以及代码相关的指令,比如文件格式是TOML{{ KEY }}作为变量, 以及单数、复数的处理 `one` for singular and `other` for plural, 同时通过指令使大语言模型输出的格式保持不变。我使用我熟悉的中文进行测试,上面的代码输出了如下的内容:

table_of_contents = "目录"
open_main_menu = "打开主菜单"
open_lang_switcher = "打开语言切换器"
[reading_time]
  one = "阅读时长一分钟"
  other = "阅读时长{{ .Count }}分钟"

[header]
darkmode_toggle = "切换深色模式"

[404]
go_back_home = "返回主页"
sorry = "抱歉,找不到您要查找的页面。"
page_not_found = "页面未找到"

[footer]
powered_by = "由{{ .Generator }} {{ .Heart }} {{ .Theme }}提供支持"
copyright_with_since ="{{ .CopyrightSign }} {{ .SinceYear }} - {{ .CurrentYear}} {{ .Copyright }}"
copyright_wo_since ="{{ .CopyrightSign }} {{ .CurrentYear}} {{ .Copyright }}"

[paginator]
newer_posts = "较新的文章"
older_posts = "较早的文章"

[taxonomies]
categories = "分类"
tags = "标签"
series = "系列"

最终呈现的效果还不错,文件的结构没有问题,其中的变量也保留了。我通过类似的方式生成了其他语言的本地化翻译文件,当然我实际不会俄语、日语等,所以真实翻译的准确性没法很好的评判。

其他

shortcodes

hugo提供shortcodes来扩展markdown渲染能力,shortcodes是一段可以重复利用的代码片段, 可以使编辑markdown时更高效。hugo内置了一些常用的shortcodes,如figure, gist等, 可以参考https://gohugo.io/content-management/shortcodes/

我的一些博客使用asciinema来记录终端的交互,我使用shortcodes来方便嵌入asciinema。 hugo基于一定的顺序来寻找shortcodes代码,文件名为最终在markdown中使用的名称:

  1. /layouts/shortcodes/<SHORTCODE>.html
  2. /themes/<THEME>/layouts/shortcodes/<SHORTCODE>.html

我创建layouts/shortcodes/asciinema.html文件,参考官方的嵌入文档:https://docs.asciinema.org/manual/server/embedding/#inline-player , 我在html中写入下面的内容:

{{ $id := .Get 0 }}

<script async id="asciicast-{{ $id }}" src="https://asciinema.org/a/{{ $id }}.js"></script>

其中.Get 0表示获取第一个参数。当然也可以使用命名参数。使用方式为:

{{< asciinema 239367 >}}

效果如下

render hooks

hugo使用goldmark 来渲染markdown,而render hooks允许开发者针对部分组件的渲染进行覆盖, 目前支持的有

  • image
  • link
  • heading
  • codeblock

简单的,我们可以对链接渲染的a标签增加额外的属性来保障安全性,如增加文件layouts/_default/_markup/render-link.html,内容为

<a href="{{ .Destination | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}{{ if strings.HasPrefix .Destination "http" }} target="_blank" rel="noopener"{{ end }}>{{ .Text | safeHTML }}</a>

这段代码使得在渲染指向外部的链接时,添加rel="noopener"属性。

当然更复杂的我们可以对图片的渲染增加额外的操作,比如增加懒加载,对图片进行缩放以适应不同分辨率, 对图片进行格式转换以压缩大小等。可以参考layouts/_default/_markup/render-image.html 中的处理。

代码块语法高亮及复制

hugo使用chroma 进行代码高亮。默认配置下,它运转的很好,当然我想做得更进一步, 比如在黑白主题切换的时候代码块的主题也能跟着切换,以及提供代码块的复制功能。

为了支持自定义的样式,我们需要在文件config/_default/hugo.toml中调整代码高亮的配置, 以使用CSS类来应用高亮样式:

[markup.highlight]
  noClasses = false

然后使用命令生成我们的黑白主题的代码高亮样式:

hugo gen chromastyles --style=solarized-light
hugo gen chromastyles --style=solarized-dark

把样式写入到主样式文件中,针对黑色主题,可以使用IDE的列编辑模式在每行样式之前加上.dark进行约束。黑白主题下的效果如下:

hugo syntex highlight light
hugo syntex highlight dark

但是在某些场景下,chroma的默认CSS样式与tailwindcsstypography插件有冲突, 例如开启代码行数配置时,会发现代码行数与代码块间有较大的空白:

hugo syntex highlight lineno error

这是因为chroma在渲染行号时使用了表格元素,同时typography会对pre元素添加额外的边距。这时候,我们需要找到其中的冲突点,然后对部分样式进行覆盖。下面这段CSS样式, 将去掉带行号的代码块中pre元素的边距和边框圆角,并更改表格的display属性:

/* fix highlight with line number */
.highlight .chroma .lntable {
  margin: 1.7142857em 0;
  padding: 0.8571429em 1.1428571em;
  border-radius: 0.375rem;
  overflow: hidden;
}

.highlight .chroma .lntable pre {
  margin: 0;
  padding: 0.8571429em 0;
  border-radius: 0;
}

/* https://caniuse.com/css-has */
.highlight .chroma:has(table) {
  background-color: transparent;
}

.highlight .lntable tr {
  display: flex;
}

.highlight .lntable tr .lntd:last-child {
  flex-grow: 1;
}

代码块的复制则通过检查页面中是否有pre元素,然后查找页面中的.highlight元素,并对应的添加复制按钮来达成。寻找元素我在baseof.html中使用{{- if (findRE "<pre" .Content 1) }}语句, 这样可以减少JavaScript的加载。另外需要注意的是带行号的代码块,会渲染出两个code元素,我们需要取最后一个。相关代码可以参考#4d62bba#df6ac6d

pipes

当需要允许用户对主题进行更改时,我们需要保留原始的CSSJavaScript代码。但对一般直接使用者,我们需要屏蔽这些代码编译逻辑,同时使得最终生成的静态资源是被压缩的, 这时候我们可以使用hugo提供的pipes功能。对于tailwindcss,最初我将原始的CSS样式放在assets/css/main.css,将编译后的代码放在静态文件夹static/main.css中,并在head.html中直接对静态文件进行引用:

<!-- Theme CSS (/static/main.css) -->
<link rel="stylesheet" href="{{ "main.css" | relURL }}" />

我期望在开发的时候展示原始的CSS样式,但是在部署时使用压缩并对文件名进行哈希命名处理。最终我将CSS文件位置做了调整,将编译前后的CSS代码均放在assets/css中 (这样hugo才能对资源进行处理),并对CSS文件的引入做了调整:

{{- $styles := resources.Get "css/index.css" -}}
{{- $env := getenv "HUGO_THEME_DEVELOPMENT" -}}

{{- if eq $env "true" }}
  {{- $styles = resources.Get "css/main.css" | postCSS (dict "config" "./postcss.config.js") -}}
{{ end -}}

{{- if hugo.IsDevelopment }}
  {{- $styles = $styles | resources.ExecuteAsTemplate (printf "css/main.dev.%v.css" now.UnixMilli) . -}}
{{ else }}
  {{- $styles = $styles | minify | fingerprint -}}
{{ end -}}
<link rel="stylesheet" href="{{ $styles.RelPermalink }}">

其中我使用HUGO_THEME_DEVELOPMENT来标识主题开发模式,主题开发模式下使用PostCSS处理CSS样式。当使用hugo server命令时,hugo处于开发模式,hugo.IsDevelopmenttrue, 在开发模式下使用带时间戳的文件名防止缓存,在生产模式下应用| minify | fingerprint来压缩及保障文件缓存。

关于不同的hugo命令对应的环境,可以参考下表

CommandEnvironment
hugoproduction
hugo --environment stagingstaging
hugo serverdevelopment
hugo server --environment stagingstaging

同样的,对于JavaScript文件我们也可以类似地处理,如代码块复制:

{{- $jsCopy := resources.Get "js/code-copy.js" | js.Build "code-copy.js" -}}
{{- if not hugo.IsDevelopment }}
  {{- $jsCopy = $jsCopy | minify | fingerprint -}}
{{ end -}}
<script src="{{ $jsCopy.RelPermalink }}"></script>

lighthouse

我完成了大部分的代码编写工作,但是我还需要检查下我的主题渲染出页面是否符合一些规范和最佳实践。我使用chrome自带的lighthouse开发者工具对站点进行检查。

可以看到站点的性能评分不错,但是无障碍环境以及SEO还有改进空间。比如主题中一些颜色的对比度,图片的alt属性等。

hugo theme lighthouse original report

基于lighthouse的建议,我逐个对建议项进行了调整优化,最终lighthouse达到了较高的分数:

hugo theme lighthouse report after modification