动机
我很喜欢 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 标准语法  和 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""
else:
md_image = f""
# 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 如下:

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 部署的方式。
- 保险起见,我创建了一个测试仓库
quartz,按照官方文档配置好了 GitHub Actions 工作流并成功部署网站至[username].github.io/quartz。测试了一下页面浏览体验,看着没什么大问题。 - 在确定该
deploy.yml配置仅影响名为v4的分支后,使用git remote set-url origin [repo_url]将上游从quartz指向用户根仓库[username].github.io。 - 修改
[username].github.io仓库的环境设置,将branch rule设置为 Quartz 4 使用的v4分支。否则,GitHub 会阻止该分支被用作 Pages 部署。 - 执行
npx quartz sync将本地内容推送至v4分支,触发 GitHub Actions 工作流,成功部署网站。 - 确认新网站部署正常,启用 Cloudflare 的 Page Rules,实现 URL forwarding。
UX 优化
如前文所说,Quartz 设计为 Digital Gardens,其基于目录的组织结构并不适用于传统的博客系统体验。为此我移除了侧边栏的 Explorer 和 Graph View 组件,仅依靠 Tags 和 Recent Notes 引导用户访问。
对于移动端布局适配,可以灵活使用 Component.MobileOnly() 和 Component.DesktopOnly()。为了不影响正文浏览体验,我将 Recent Notes 组件放在移动端的 footer 位置。
Footnotes
-
Source Han Sans vs Source Sans Pro & Source Code Pro - Adobe CJK Type Blog ↩
-
Quartz 默认使用
index.xml作为 RSS 订阅源文件名。 ↩