动机

我很喜欢 hexo-theme-oranges 的 minimal 设计理念,可惜作者已经不再维护了。尽管不升级也能用,Hexo 和 Node.js 依赖问题总是变得很棘手。某天摸鱼时看到了 Quartz,一套为 Obsidian 用户设计的静态网站生成器,默认主题就很简洁实用,页面布局和插件都是基于 TypeScript 的模块化 + pipeline 设计,扩展性很强。

于是,就这么决定了。

内容迁移

已经忘了上次何时从 WordPress 迁移至 Hexo 了,肯定不怎么优雅。直觉上,从 Hexo 迁移至 Quartz 似乎简单得多——两者的内容目录都基于 Markdown,并且遵从类似的 frontmatter 语法1 2。但具体操作起来仍然有很多细节需要注意。

内部链接

Hexo 本身被设计为博客引擎,所以存在每条 post 独立的资源目录。而 Quartz 则是基于 Obsidian 的文件夹结构设计的,附件与 Markdown 文件之间不构成附属关系。我选择了将所有附件放在 /assets/[post-slug]/ 目录下,并在 Markdown 中使用相对路径引用。

标签分类

传统的博客系统使用标签(tags)和分类(categories)两级结构来组织内容,而 Quartz 只提供了标签系统。因此,我 vibe coding 了一段脚本,来去除 Hexo frontmatter 中的非法字段,并分配默认标签。

import os
from pprint import pprint
import yaml
path = "./content/posts"
 
posts = os.listdir(path)
posts = [post for post in posts if post.endswith(".md")]
 
for p in posts:
    print(f"Processing {p}...")
    # extract front matter
    with open(os.path.join(path, p), "r", encoding="utf-8") as f:
        lines = f.readlines()
    
    front_matter = []
    in_front_matter = False
    for line in lines:
        if line.strip() == "---":
            if not in_front_matter:
                in_front_matter = True
            else:
                break
        elif in_front_matter:
            front_matter.append(line)
    
    front_matter_dict = yaml.safe_load("".join(front_matter))
    pprint(front_matter_dict)
    
    allowed_frontmatter_keys = ['title', 'description', 'permalink', 'alias', 'tags', 'draft', 'date', 'updated']
    front_matter_dict = {k: v for k, v in front_matter_dict.items() if k in allowed_frontmatter_keys}
    
    # remove tags not in allowed_tags
    allowed_tages = ["生活", "评测", "教程"]
    if "tags" in front_matter_dict and isinstance(front_matter_dict["tags"], list):
        front_matter_dict["tags"] = list(filter(lambda tag: tag in allowed_tages, front_matter_dict["tags"]))
        pprint(front_matter_dict["tags"])
    else:
        front_matter_dict["tags"] = ["教程"]
        
    # read the content after front matter
    content = []
    in_front_matter = False
    for line in lines:
        if line.strip() == "---":
            if not in_front_matter:
                in_front_matter = True
            else:
                in_front_matter = False
                continue
        elif not in_front_matter:
            content.append(line)
    content_str = "".join(content)
    
    # write back to file
    with open(os.path.join(path, p), "w", encoding="utf-8") as f:
        f.write("---\n")
        yaml.dump(front_matter_dict, f, allow_unicode=True)
        f.write("---\n")
        f.write(content_str)

图片引用

Hexo 提供了两种插图语法,分别是 Markdown 标准语法 ![alt](path) 和 Hexo 自定义的 {% asset_img [filename] [alt] %}。Quartz 只支持前者,所以需要替换所有 Hexo 语法。

import os
import re
from pprint import pprint   
 
path_posts = "./content/posts"
 
posts = os.listdir(path_posts)
posts = [post for post in posts if post.endswith(".md")]
 
r = r"{% asset_img\s+?(.*?)\s+(?:|(.*?)\s+)%}"
 
for p in posts:
    with open(os.path.join(path_posts, p), "r", encoding="utf-8") as f:
        lines = f.readlines()
    for line in lines:
        match = re.match(r, line)
        if match:
            image_path = match[1].strip().strip('"').strip("'")
            if match[2]:
                alt_text = match[2].strip().strip('"').strip("'")
            else:
                alt_text = ""
            
            # generate markdown image syntax
            file_path = f"/assets/{p[:-3]}/{image_path}"
            if alt_text:
                md_image = f"![{alt_text}]({file_path})"
            else:
                md_image = f"![]({file_path})"
 
            # replace the line with md_image in the file
            with open(os.path.join(path_posts, p), "r", encoding="utf-8") as f:
                content = f.read()
            content = content.replace(line.strip(), md_image)
            with open(os.path.join(path_posts, p), "w", encoding="utf-8") as f:
                f.write(content)

URL 结构

常见的 URL scheme 有两种:

  • /year/month/day/slug
  • /categories/slug

前者的 URL 更适合传统博客,即按时间序记录的日志;后者更简洁,也是目前较为提倡的一种方式。过去的 Hexo 博客使用了前者,而 Quartz 是为了 digital gardens 而设计,采用了后者。所以我决定借助 Cloudflare 的 Page Rules 来实现 URL 重定向,去除日期部分。

  • Request URL: https://iecho.cc/*/*/*/*/
  • Target URL: https://iecho.cc/posts/${4}
  • Status Code: 301 - Permanent Redirect

ChatGPT 的回答:Digital Garden 是一种以知识节点为中心、可持续迭代更新的个人在线笔记空间,强调笔记之间的互联和成长性;与传统博客按时间顺序发布完整文章不同,它更像“可生长的知识花园”,内容碎片化、非线性、持续优化。

Open Graph

Open Graph 基于已经淘汰的 <meta> 标记,设计用于在分享外链至社交媒体平台时,展示网页摘要。常见用途如推特上的外链的 card view 预览,或是类似 Telegram 的链接预览。本文的 OG Image 如下:

本文的 OG Image

Quartz 默认使用 frontmatter 中的 description 字段作为 Open Graph 描述文本,否则截断正文的一部分作为描述。张大妈用户 @Minja 的标题和导语风格挺不错的,值得学习。

Quartz 正文默认字体为 Source Sans Pro,即无衬线(Sans-Serif)版本的思源字体,仅支持拉丁字母设计而不支持 CJK 字符集,所以生成的 OG Image 会出现乱码。最理想的替代字体应该是 Source Han Sans,以提供中英文一致的视觉体验。

…the Latin and Latin-like glyphs—to include those for Greek and Cyrillic—in Source Han Sans are based on Source Sans Pro.3

但 Google Fonts 并没有提供 Source Han Sans,所以最终选择 Noto Sans SC,即思源黑体 Noto Sans CJK,作为替代字体。

自定义 Open Graph 图片有点复杂了,暂时先搁置。

静态资源

最后别忘了 favicon、RSS 订阅源 4、robots.txt 等静态资源文件的迁移。

部署

过去我使用了 Hexo 的 一键部署 功能,将编译后的静态文件推送至 [username].github.io 仓库的 main 分支,直接暴露给 GitHub Pages 托管。而 Quartz 默认只提供了基于 actions 部署的方式。

  1. 保险起见,我创建了一个测试仓库 quartz,按照官方文档配置好了 GitHub Actions 工作流并成功部署网站至 [username].github.io/quartz。测试了一下页面浏览体验,看着没什么大问题。
  2. 在确定该 deploy.yml 配置仅影响名为 v4 的分支后,使用 git remote set-url origin [repo_url] 将上游从 quartz 指向用户根仓库 [username].github.io。
  3. 修改 [username].github.io 仓库的环境设置,将 branch rule 设置为 Quartz 4 使用的 v4 分支。否则,GitHub 会阻止该分支被用作 Pages 部署。
  4. 执行 npx quartz sync 将本地内容推送至 v4 分支,触发 GitHub Actions 工作流,成功部署网站。
  5. 确认新网站部署正常,启用 Cloudflare 的 Page Rules,实现 URL forwarding。

UX 优化

如前文所说,Quartz 设计为 Digital Gardens,其基于目录的组织结构并不适用于传统的博客系统体验。为此我移除了侧边栏的 Explorer 和 Graph View 组件,仅依靠 Tags 和 Recent Notes 引导用户访问。

对于移动端布局适配,可以灵活使用 Component.MobileOnly() 和 Component.DesktopOnly()。为了不影响正文浏览体验,我将 Recent Notes 组件放在移动端的 footer 位置。

Footnotes

  1. Hexo Frontmatter ↩

  2. Quartz Frontmatter ↩

  3. Source Han Sans vs Source Sans Pro & Source Code Pro - Adobe CJK Type Blog ↩

  4. Quartz 默认使用 index.xml 作为 RSS 订阅源文件名。 ↩