Featured image of post 用 Python 写一个简单的单页面静态网站生成器

用 Python 写一个简单的单页面静态网站生成器

描述了我用 Python 自己实现并逐步完善一个简单的单页面静态网站生成器的过程。

我最初的任务是基于已有的模板给我之前参与的论文做一个 project page,选用的模板是 eliahuhorwitz/Academic-project-page-template。然而,这个模板是一个 html 文件,在涉及到列表(例如作者列表)时需要手动复制粘贴并逐一修改内容,这不仅繁琐,也不便于后续的修改(例如改展示格式,或是迁移到别的项目)。我偷懒的天性不允许我这样做(好麻烦啊😫),于是我萌生了一个自然的想法:将 html 修改为 jinja 模板,使用程序渲染模板得到最终的静态页面。

第一版:基础功能

这时,我的想法比较简单:使用 Python 标准库里的 tomllib (python>=3.11) 读取 toml 文件作为 context,并使用 jinja 读取模板进行渲染,最后将渲染结果输出到 html 文件里。代码实现也非常简洁,仅包含几行代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import tomllib, os
from jinja2 import Environment, FileSystemLoader

target_folder = "./dist"
env = Environment(loader=FileSystemLoader("./template"), autoescape=False)
with open("./config.toml", 'rb') as config_file:
    config = tomllib.load(config_file)
page = env.get_template('index.html').render(config)
with open(os.path.join(target_folder, "index.html"), 'w') as f:
    f.write(page)

作为输入的toml文件类似于这样:

1
2
3
4
5
6
7
8
[paper]
title = "..."
conference = "..."
abstract = "..."

[[paper.authors]]
name = "..."
......

通过结合 TOML 配置文件与 Jinja 模板中的循环和判断语句,我们可以将编辑 html 的工作转换为编辑配置文件。相较于直接编辑 html,后者显然更为简便。

给以上的代码加上一些处理静态文件的逻辑,例如:

  1. 构建前清除目标文件夹
  2. 构建时从指定文件夹复制文件到目标文件夹

那怎么预览构建结果呢?我当时的做法是在 ./dist 中使用以下指令启动一个 Python 的简易文件服务器:

1
$ python3 -m http.server

然后,再编写一点 bash 脚本实现一键部署到 GitHub Pages,即可形成一个简易的单页面静态网站生成器,这便是第一版

第二版:监测文件更改并自动构建

在开发过程中,我很快发现第一版存在一个显著问题:每次修改模板或配置后,都需要手动执行构建命令并刷新页面。 我针对前者的解决方案是使用 watch 指令每隔一秒执行一次构建脚本,例如:

1
$ watch -d -n1 python3 build.py

但是,我认为这样的解决方法不是很优雅,于是就想着,能不能由构建脚本持续监测文件更改,并在有需要的时候自动构建呢?答案当然是可以的。互联网对此给出了四种方案:

  1. 使用 watchdog 库
  2. 使用 inotify 库 (仅限 Linux)
  3. 计算文件散列值,判断是否修改
  4. 使用 os 库获取文件修改时间,判断是否修改

前两种方法最为便捷,可以直接利用已有的库;第三种方法通过计算文件散列值来判断文件是否修改,虽然,能准确获知文件内容是否变化(其余三种只是监测更改文件的操作),但是,资源消耗较大;第四种方法通过获取文件修改时间来判断文件是否修改,易于理解且实现简单。最终,我选择了第四种方法:

首先,定义 all_filepaths 函数(generator)获取给定所有目录下的所有路径,这里需要返回文件夹是因为文件夹的最后修改时间可以反映该文件夹内是否有文件被移动或删除。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def all_filepaths(*paths): # 获取指定目录下的所有路径,包括文件和文件夹
    for path in paths:
        if not os.path.exists(path): # 如果路径不存在,跳过
            pass
        elif os.path.isfile(path):   # 如果是文件,直接返回文件路径
            yield path
        else:                        # 如果是文件夹,遍历文件夹内的所有文件和子文件夹
            for root, _, files in os.walk(path):
                yield root
                yield from (os.path.join(root, file) for file in files)

接下来,定义一个函数 build_on_change,循环获取文件的最后更改时间,并判断文件是否被更改。如果检测到文件在上次构建之后有更改,则执行 build 函数重新构建页面。此处将 last_update 初值设为零,可以确保程序启动后会立刻触发一次构建。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def build_on_change(*paths, **build_kwargs): # 监听文件更改并自动构建页面
    last_update = 0.0 # 初始化最后更新时间为0,确保程序启动后立即触发一次构建
    while True:
        for filepath in all_filepaths(*paths):
            last_modified_at = os.stat(filepath).st_mtime
            if last_modified_at >= last_update:
                build(**build_kwargs)
                last_update = time.time()
                break
        time.sleep(1) # 每隔1秒检查一次文件更改

通过以上代码,仅需 20 行即可实现 Python 原生的自动监测文件更改的功能(当然,调库更快,但是需要安装依赖)。 最后,只需再编写少量代码,利用 sys.argv 获取命令行参数,并在程序入口处区分单次构建自动更新(预览)两种模式,这便是第二版。

第三版:集成静态文件服务器+自动刷新页面

尽管第二版解决了“手动执行构建命令并刷新页面”的部分问题,但保存后仍需手动刷新页面,这一痛点依然存在。第三版的目标便是彻底解决这一问题。

先把每次需要手动启动的 http.server 集成到脚本里来:直接修改http.server 官方文档里的示例代码,然后加上 Threading 标准库使其以守护线程的方式运行(主程序退出时自动结束,无需手动终止)。这仅需 10 行代码(不包括空行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def start_server_daemon(ip="127.0.0.1", port=8123, directory="./dist"):
    from threading import Thread
    import http.server, socketserver

    def start_server(): # 创建一个简单的HTTP服务器,绑定到指定的IP和端口
        with socketserver.TCPServer((ip, port), http.server.SimpleHTTPRequestHandler) as httpd:
            print(f"Serving live preview at http://{ip}:{port}/")
            httpd.serve_forever() # 启动服务器并保持运行

    # 创建一个守护线程,当主线程退出时,守护线程也会自动退出
    t = Thread(target=start_server, daemon=True)
    t.start()

不过这样的实现有一个问题:在主程序终止导致守护线程退出后,占用的端口并不会正常释放。按理来说第六行的 with 语句会在退出时自动处理端口的释放,可能在守护线程模式下就不行了?

接下来,我们探讨如何实现页面的自动刷新功能。换言之,服务器如何通知浏览器内容已更新呢? http.server 的官方文档内同样提供了答案:在其提供文件时,会在 response header 中加入Last-Modified header,这个header的时间戳来源于文件系统记录的最后更改时间。由于我的程序只会全量构建,所以 dist 目录内的所有文件的最后修改时间必定与最后一次构建的时间相同。

基于此,我们只需在浏览器中用 JavaScript 轮询服务器上的某个文件(我这里是在自动更新模式下额外创建一个空文件),获取其 header 内的 Last-Modified,判断页面是否更新,如果更新了就调用 window.location.reload() 刷新页面即可。

Python 部分只需要创建上述的空文件,以及在模板的 context 中加入是否启用自动刷新的参数即可。模板内需要加入如下 javascript 代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(function(){ // 防止命名冲突
  let last_updated = new Date(); // 初始化为当前时间
  const dummy_file_path = "/{{preview_mode.dummy_file_path}}"; // 模板渲染时填充空文件名称
  const refresh_on_change = () => { // 定义函数
    fetch(dummy_file_path).then(res => {
      const last_modified = new Date(res.headers.get("Last-Modified"));
      if(last_updated < last_modified){
        window.location.reload(); // 刷新页面
      }
    })
  }
  setInterval(refresh_on_change, 1000); // 每一秒检查一次是否更新
})();

当然,这段 JavaScript 代码需要放在 <script> 标签内,同时在模板中确保只有自动更新启用时才会渲染这个标签。这便是第三版。

第四版:最小化生成的文件

通过模板引擎渲染生成的 html 文件,常常会包含大量的空白字符、引号和注释等冗余元素。这些元素虽然在开发阶段有助于提升代码的可读性与维护性,但在实际网页加载过程中,它们却显著增加了文件的体积,进而拖慢了网页的加载速度。为了优化页面加载性能,我们有必要在构建完成后,对这些冗余字符进行精简处理,以缩减文件大小。

这一优化策略同样适用于 css 文件,不同的是,css 文件中的冗余字符往往遵循特定的模式,可以直接运用正则表达式进行匹配与去除,我的代码如下(这段代码并不全面,但是它已经能显著缩小文件的体积,于我而言,这样就够了):

1
2
3
4
5
6
7
8
import re
def minify_css(filepath):
    content = open(filepath,'r').read()
    minimized = re.sub(r' *\n *|/\*.*?\*/', '', content)
    minimized = re.sub(r'; *(?=})|(?<=:) +| +(?={)', '', minimized)
    with open(filepath, 'w') as f:
        f.write(minimized)
    print(f'{filepath}: reduced from {len(content)} Bytes to {len(minimized)} Bytes')

而 html 的规则复杂,有的空格和换行符的移除会影响页面的呈现效果(例如 <pre> 标签的内容)。因此,在处理 html 文件时,相比于自己实现,我决定使用 htmlmin 库的实现。代码如下,注意其中的 remove_all_empty_space=True 也会在一定情况下影响页面的呈现,例如,两个 <span> 标签之间的空格也会被移除,对于这种情况需要去掉这个参数,或是在 css 或 style 属性中加入 margin-right 创造空隙。

1
2
3
4
5
6
7
from htmlmin.main import minify
def minify_html(filepath):
    content = open(filepath,'r').read()
    minimized = minify(content, remove_comments=True, remove_all_empty_space=True)
    with open(filepath, 'w') as f:
        f.write(minimized)
    print(f'{filepath}: reduced from {len(content)} Bytes to {len(minimized)} Bytes')

至于 JavaScript 文件,由于在这个项目中我自己写的 js 代码很少,且都已经嵌入 html 文件,而外部库都是通过 CDN 引入的,加载的大多是优化过的文件,因此我并没有考虑对 js 文件做最小化。

最后,在构建后利用之前写的 all_filepaths 函数遍历 dist 文件夹内的所有文件,根据扩展名判断对应的最小化方法,然后调用相应的函数最小化即可。因为这里的最小化是为了部署服务的,所以在预览模式下不会执行这段代码。

1
2
3
4
5
6
7
8
for path in all_filepaths(target_folder):
    if not os.path.isfile(path):
        continue
    extension = os.path.basename(path).rsplit(".",1)[-1].lower() # 获取文件扩展名
    if extension == "css":
        minify_css(path)
    elif extension in {'html', 'svg', 'xml'}:
        minify_html(path)

综合上述所有功能,便得到了第四版,也是目前的最终版

总结

回顾这个流程,最初我只是想偷个懒,没想到最终付出的时间比原本不偷懒还要多。然而,考虑到在这个过程中我的收获,我觉得这个时间花的还是很值得的。例如,我之前在用现成的 live server 时,从未想过这个功能的实现可以如此简单(当然我的实现也比较糙就是了)。同时,这次的经验、编写的脚本和模板,到以后做单页面静态网站或者新的论文的 project page 时也都可以派上用场。

当然,我也知道市面上有很多现成的静态网站生成工具,例如基于 Python 的 Pelican,基于 Ruby 的 Jekyll 和我构建这个博客所使用的基于 Golang 的 hugo,问题是它们都是针对多页面的内容向网站,它们的项目结构也比较复杂,对于我的单页面、仅有两张图片的 project page 而言,确实显得有些大材小用。因此,我选择自己动手造轮子,过程虽然曲折,但收获颇丰。

使用 Hugo 构建
主题 StackJimmy 设计