动机

我很喜欢 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 订阅源 4robots.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,其基于目录的组织结构并不适用于传统的博客系统体验。为此我移除了侧边栏的 ExplorerGraph View 组件,仅依靠 TagsRecent 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 订阅源文件名。