这是一个系列博客,记录了我从零开始构建Hugo主题https://github.com/tomowang/hugo-theme-tailwind 的过程。全系列包括四篇文章,这是第三篇:
- I. 主要介绍我构建Hugo主题的背景,我对主题的功能想法,以及开发环境的搭建
- II. Hugo主题的主要目录结构,需要了解的技术,以及我创建的主题的主体框架
- III. Hugo主题的其他功能,包括黑色主题,响应式设计,多语言,代码高亮,构建管道等
- IV. 该部分描述非代码相关的内容,包括持续集成(CI),如何提交至官方主题站点,以及SEO相关的数据等
黑色主题
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-[]:invisible
及peer-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
以及peer
,tailwindcss
描述为
黑色主题花了我一天的业余时间,最终的代码在#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>
最后调整导航栏,加上语言切换的按钮和语言列表。
完整的代码参考链接#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
中使用的名称:
/layouts/shortcodes/<SHORTCODE>.html
/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
进行约束。黑白主题下的效果如下:
但是在某些场景下,chroma
的默认CSS样式与tailwindcss
的typography
插件有冲突,
例如开启代码行数配置时,会发现代码行数与代码块间有较大的空白:
这是因为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
当需要允许用户对主题进行更改时,我们需要保留原始的CSS
及JavaScript
代码。但对一般直接使用者,我们需要屏蔽这些代码编译逻辑,同时使得最终生成的静态资源是被压缩的,
这时候我们可以使用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.IsDevelopment
为true
,
在开发模式下使用带时间戳的文件名防止缓存,在生产模式下应用| minify | fingerprint
来压缩及保障文件缓存。
关于不同的hugo命令对应的环境,可以参考下表
Command | Environment |
---|---|
hugo | production |
hugo --environment staging | staging |
hugo server | development |
hugo server --environment staging | staging |
同样的,对于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
属性等。
基于lighthouse的建议,我逐个对建议项进行了调整优化,最终lighthouse达到了较高的分数: