zola 建站笔记

2022 年 07 月 18 日

心路历程

从 wordpress 转到 static site generator 很长时间了。

各种 ssg 工具试用过不少。以前用过 jekyll,但觉得不太好用。react/angular/vue 都是用来做 SPA 合适,建站不是主要功能。gatsby 和 nuxt 偏重于前端 js 实现功能,而我需要的更偏重于内容管理,因此只是浅尝辄止。

后来 hexo 用了很长时间,优点是中文文档,缺点是速度慢,自定义的话需要了解的东西太多了;另外由于工作原因,常常需要在 node 8 和 12 之间来回切换,导致 hexo 使用不便。于是又有了换工具的念头。

看了一段时间,本来觉得 hugo 应该是最合适的,但 golang 我不熟悉,另外 hugo 对目录和内容的管理方式我也不太喜欢。next.js 教程写的非常好,但跟 nuxt 是一类产品。考虑到现在和未来很可能专注于 rust 的相关技术,于是转到了基于 rust 实现的 zola

这篇文章从 21 年开始就被标成 draft,然后就一直拖了下来。主要是因为,本来不想使用现成主题,打算自己从零开始设计页面风格。但看了很长时间设计相关知识、试用了 tailwindcss,并且做了第一版之后;发现总会遇到各种小问题,让人非常烦躁。究其根本原因,应该是我的设计知识和写自适应 css 的经验还是不足。现在网站终于算是基本完成,所以这篇文章终于可以写了。

需要说明的是,这份笔记不是官方文档的翻译,只是一些我认为值得记录的经验,其中还包含一些我自己的理解(不保证正确)。

目标

这次做的是一个完整的个人站,除了首页和 blog 以外,还包含文档部分。后续的页面根据需求再加。

blog 包含列表和文章两个页面,基本是所有个人站点的必备部分。

文档包括文档列表和文档本身两个页面。文档列表页面相对简单;文档页面的特点是,需要有一个文档目录,还有一个可选的页内导航,对于比较长的文章,可以快速定位到相关内容。

另外还有一个链接收藏页,这部分暂时先用 blog 文章模板填充,后续等内容多了再考虑如何整理。

其他的部分等有需求再添加。

基本概念

首先需要理清一些基本概念。一个站点其实只包含以下几个部分:

  • 内容 - 应该就是文本 + 图片,不包含任何布局 / 样式等。以 markdown 实现。
  • 配置 - 站点、模块以及页面的配置,其中页面的配置可以通过 frontmatter 写在内容的 markdown 中。站点和模块的配置相对独立,可以是 json/toml 等。
  • 模板 - 所有的布局 / 样式以及一些用来生成元素的函数等。以模板语言实现。

以我的经验,需要保持以下几个原则:

  • 内容和模板应该是彻底分开的,这样不管怎么更换 ssg 工具,内容的部分重用起来都比较方便。
  • 内容和模板都对使用的 ssg 工具有一定程度的依赖。可以考虑适当减轻这种依赖。
  • 配置的部分有多种情况:
    • 跟内容强相关的,就跟内容放在一起。比如我有部分页面需要 mermaid.js 来画流程图,就需要在 frontmatter 中增加对应选项。
    • 跟模板强相关的,就跟模板放在一起。比如 header 是否要 sticky,可以写在模板中。
    • 相对比较独立的,可以做成独立文件。比如整站的 title、description、menu 这些。

不同的工具有不同的做法,zola 对于以上三个部分的支持如下:

  • 内容:支持 markdown,其中 frontmatter 可以用来放置一些内容相关的配置,可以选择 toml(推荐) 和 yaml(hugo支持),还可以通过 shortcodes(在内容中使用的函数)来简化一些特定的 html 输出。
  • 配置:可以写在页面的 frontmatter 中,也可以写在独立的 toml 或 json 文件中,通过 load_data 函数加载。
  • 模板:使用 Tera 模板引擎。可以通过 extend、include 和 macro(在模板中使用的函数)来简化实现。

页面生成逻辑

以我的理解,zola 的页面生成逻辑非常清晰,可以按照程序的编译过程来理解,大概是按照下面这几个步骤:

  1. 将配置信息写入上下文,以便后续随时可以访问。
  2. 遍历所有内容,将内容及其配置也写入上下文。
  3. 针对每一个内容文件,找到其对应的模板文件,根据配置,将内容填充进去,组成完整的 html。

也就是说,最终生成的页面的组织方式,实际上就是内容的组织方式

内容分为section + page,对应文件系统中的目录 + 文件。其中,section的信息放在目录中的 _index.md 中;page 有两种方式,默认就是简单的单个 markdown 文件,对于带 asset 的,可以创建一个独立目录,目录名其实就是原来的文件名,asset 和对应的 index.md 都放到目录中。

内容和模板的对应关系也很简单。首页找 index.html,内容里面的 sectionsection.htmlpagepage.html。所以最简单的情况下,三个模板文件就足够了。

实践中的部分经验

首页

首页的特点就是随心所欲,怎么好看怎么来。所以可以往上堆各种自己喜欢的东西。曾经用过的一些资源如下(注意部分 class 用的是 tailwindcss,仅供参考):

实现打字机效果的 typewriter-effect

<script src="{{ typewriter_core_js_path }}"></script>
<script>
    var main_writer = new Typewriter(document.getElementById('main-line'), {
        strings: ['Hello', 'World'],
        autoStart: true,
	});
</script>

算法生成彩色三角形背景的 trianglify

<svg id="trianglify" class="absolute top-0 left-0 right-0 -z-50"></svg>
<script src="{{ trianglify_js_path }}"></script>
<script>
    let refresh = () => trianglify({ width: window.innerWidth, height: window.innerHeight }).toSVG(document.getElementById('trianglify'));
    window.addEventListener('resize', refresh, true);
    refresh();
</script>

类似其他资源也有很多,比如说 vanta.js,但我没用过。

每天变化的 Bing 壁纸背景

注意图片地址是第三方服务,无法从浏览器直接从微软获取壁纸,需要经过一个中转服务。

<div class="absolute top-0 left-0 right-0 bottom-0 -z-50">
    <img class="w-full h-full object-cover" src="https://bing.biturl.top/?resolution=1920&amp;format=image&amp;index=0&amp;mkt=zh-CN" alt="background from bing daily image"/>
</div>

粒子效果动画 particles

官网提供了多种 particles 的 json 配置文件,懂动画的话也可以自己改。

<div id="particles" class="absolute top-0 left-0 right-0 bottom-0 -z-20"></div>
<script src="{{ particles_js_path }}"></script>
<script>particlesJS.load('particles', '{{ particles_json_path }}')</script>

毛玻璃效果 glassphormism

<main class="m-auto text-center bg-white p-5 bg-opacity-30 backdrop-filter backdrop-blur-sm">
    <p class="text-2xl font-black font-hei">{{ macros::meta_title() }}</p>
    <hr class="mt-2 mb-2 border-black">
    <p class="text-lg font-bold font-fang-song">{{ macros::meta_subtitle() }}</p>
    <div class="inline-flex pt-3" role="group">
        {{ macros::index_buttons() }}
    </div>
</main>

内容组织

默认的内容组织方式已经很灵活,个人经验只有以下几点:

独立的站点配置文件

虽然 zola 默认在 config.toml 中预留了 extra 可以用来放自定义的配置,但我还是做了一个独立的 toml 放在 content 目录中,原因是,如果某天我想换掉 zola 的话,那么内容就全部在 content 目录中。这样比较好维护。

文档 Summary 配置

文档的特点是,每一个文档页,不管深度多少,都有一个基本相同的目录部分(只有 active 的 item 不太一样)。

参考 gitbook 的设计,我将这个目录的信息放在 summary 中,并把 summary 放在文档的根 section 中 _index.md 的 frontmatter 中。这样编译时就不需要写特别复杂的逻辑来查找文档目录信息,只需要从当前文档页沿着其 ancestor 向上查找,直到找到这个 summary 就可以了。

模板基本结构

所有的页面都是类似的结构,因此继承关系是这样的:

flowchart TD
    base[base.html] --> index[index.html]
    base --> section[section.html]
    base --> page[page.html]
    base --> others[...]

base.html 的参考写法

{%- import "macros.html" as macros -%}

{%- block setup %}
	{% set __body_class = "home" %}
    {% set __full_width = false %}
{% endblock setup -%}

<!DOCTYPE html>
<html lang="{{ lang | default(value="en") }}">
<head>
  <meta charset="utf-8">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  {% block seo %}{% include "include/head-seo.html" %}{% endblock seo %}
  {% block favicon %}{% include "include/head-favicon.html" %}{% endblock favicon %}
  {% block stylesheets %}{% endblock stylesheets %}
  <link rel="stylesheet" href="{{ get_url(path='app.css') | safe }}">
  {% block head_scripts %}{% endblock head_scripts %}
</head>
<body class="{{ __body_class }}">
  <!-- header -->
  {% block header %}{% include "include/header.html" %}{% endblock header %}

  <!-- content -->
  <div class="wrap {{ macros::class_name_container(full_width=__full_width) }}" role="document">
    <div class="content">
      {% block content %}{% endblock content %}
    </div>
  </div>

  <!-- sidebar-prefooter -->
  {% block sidebar_prefooter %}{% endblock sidebar_prefooter %}

  <!-- sidebar-footer -->
  {% block sidebar_footer %}{% endblock sidebar_footer %}

  <!-- footer -->
  {% block footer %}{% endblock footer %}

  <!-- scripts -->
  {% block scripts %}{% endblock scripts %}

  {% if config.mode == "Serve" %}
    <!-- script to inject _context for debug only on serve mode -->
    <script>
      console.log('check window._context for debugging');
      window._context = {{ __tera_context | safe }}
    </script>
  {% endif %}
</body>
</html>

简单说明这样写法的特点:

  1. macros 可以减少重复代码,因此需要在一开始引入。
  2. 很多 block 都是空的,留给其他继承(extend)的页面实现。如果非空,继承页面可以用 super() 引入那些非空的代码。
  3. setup 块用来设置全局变量(注意全局变量以双下划线 __ 开头,避免冲突),这样的好处有:
    1. 所有页面都可以获得这些变量。
    2. 如果要自定义,那么可以在其他子页面中增加 setup 块,并仅仅修改自己需要的变量。
    3. 子页面自定义的变量可以覆盖 base.html 中的变量。
  4. include 用来引入大块的组件,虽然用 macro 可以达到同样效果,但把组件都写在 macro 中的话,macro 文件就太大了,不好维护。
  5. 最后的 serve 部分用来将上下文加入 js 环境。这样在使用 zola serve 调试页面时,直接可以通过 DevTools 查看上下文。

子页面的参考写法,以 404.html 为例

{% extends "base.html" %}

{% block setup %}
  {{ super() }}
  {% set __title = "404" %}
{% endblock setup %}

{% block content %}
<div class="row justify-content-center text-center">
  <div class="col-md-12 col-lg-10 col-xl-8">
    <article>
      <h1 class="text-center">404 网页不存在</h1>
      <p class="text-center">可能是输入了错误的链接,或网页尚未完成</p>
      <a class="btn btn-primary btn-lg px-4 mb-2" href="/" role="button">返回主页</a>
      {%- if __mail_address -%}
        <a class="btn btn-secondary btn-lg px-4 mb-2 ms-4" id="mail_link" role="button">邮件告知</a>
        <script>
          let subject = "网站存在无效链接";
          let body = `链接地址:${encodeURIComponent(window.location.href)}`;
          document.getElementById("mail_link").href = `mailto:{{ __mail_address | safe }}?subject=${subject}&body=${body}`;
        </script>
      {%- endif -%}
    </article>
  </div>
</div>
{% endblock %}

很明显,子页面的工作基本就是:

  • 继承 base.html。
  • 继承并修改 setup 块(以及其他块)中的变量。
  • 新建 content 块(以及其他块)。

模板移植

这次我参考的是 doks,其模板语言应该是 go,不过看起来都差不太多,无非不过是变量和简单的逻辑替换。

模板的移植,本质上就是将原来的模板语言翻译为 Tera。顺便说一句,doks 的源码写的非常规范易读。

模板代码中加上参考网页作为注释,可以方便后续的维护。

mermaid.js 相关问题

mermaid.js 基本是画流程图的必备,在 zola 中添加需要注意以下几点:

可选的 js

这点比较简单,通过页面的 extra 内置变量来控制是否添加。

shortcodes 方式和缺点

官方论坛有个讨论,也有个 gist 给出了示例代码。虽然这种做法可行,但缺点是,这样改变了 markdown 文件中的常用写法,常见的写法是这样的:

```mermaid
	...some mermaid code
```

这种写法在其他 Markdown 编辑器比较常见,可以直接识别,或至少可以识别为代码块。但 shortcodes 方式的写法不同,因此跟其他 Markdown 编辑器不兼容。所以我使用了下面的 js 方法。

js 初始化方法

问题产生的原因其实是,zola 生成的代码块的 class name 是 language-mermaid,而 mermaid.js 查找的默认 class name 是 mermaid。所以修改方法很简单,在初始化 mermaid.js 时,指定所需的 class name:

<script>
  mermaid.initialize({startOnLoad:true});
  mermaid.init(undefined, document.querySelectorAll('code.language-mermaid'));
</script>

下方空白问题

mermaid.js 有个下方空白问题,具体说起来就是当生成的图片较大时,图片下方可能会产生大段空白,github 上有一个 issue,不过不确定现在是否已经有了官方修正。我的改法比较简单,在 css 中:

code.language-mermaid svg {
  height: auto;
}

与 prism.js 的整合

如果引入 prism.js 做代码高亮,需要避开 mermaid.js,否则会出错。这里要保证先引入 filterHighlightAll 插件,然后对应的初始化 js 代码如下:

Prism.plugins.filterHighlightAll.add(function (env) {
    return env.language !== 'mermaid';
});

调试

当加入 search 功能之后,每次修改文件,都需要生成 search index,耗时很长。

虽然可以修改 config.toml,关掉 build_search_index 选项;但这样每次都要小心避免提交这个修改过的文件。

我的做法是,复制一份 config_no_search.toml 文件,然后用这个复制的文件做调试。

发布

正确的做法当然是 CI/CD,比如 github action 什么的。但这样需要自己编译一份 zola binary,放到 docker 里面。对于我自己来说,有时候还需要临时修改 zola 源码做实验,这种做法不是很方便,又没有速度优势什么的。所以写了一个 publish 脚本。简单说就是将生成的最终 html 代码 force push 到 public 分支。

zola build
cd public
git init .
git add .
git commit -m "published at `date -Iseconds`"
git branch -M public
git remote add my_branch_name my_branch_url # 注意替换
git push -f my_branch_name public # 注意替换

关于服务器,试验过 netlify 和 vercel;也注册了 layer0,但好像比前两者都复杂,所以压根没试验。考虑到速度问题,最后选了 cloudflare。

域名管理问题,dnspod 现在已经不是最佳选择,同样 cloudflare 搞定。

最后是域名注册商,我的域名已经被 aliyun 丢给新加坡站了,暂时还能用;计划等域名快到期的时候也转到 cloudflare 来续期。

其他

还是有一些没做完的功能,比如分类和标签、以及使用 github issue 作为评论之类。另外,部分页面的页脚还可以考虑如何优化。

使用 zola 这一段时间,我还发现了其他一些好处:

  • 可以随时从源码编译最新版本,哪怕是作者还没正式发布的分支都可以。
  • 遇到问题可以去看源码,从而分析出解决方案。
    举例来说,有一次我发现 markdown 转译出来的 html 跟想象中不同,然后从 zola 源码追踪到了 markdown 的库 pulldown_cmark,进一步发现其使用了 CommonMark 的 spec,最后参照 spec 去修改 markdown,从而得到了正确的输出。

所以,对我来说,zola 是个不错的选择。

Top