🔮 Quartz 5 配置指南

一句话总结:Quartz 5 能把 Obsidian vault 直接发布成静态站点,wikilink、graph、全文搜索都是原生能力。本站的做法是把 Quartz 克隆进 vault 的 .quartz/,用 .scripts/sync-content.mjs 把要发布的内容复制到 .quartz/content/,首页动态内容用原生 Obsidian Bases。

🎯 三个核心决策

问题选择理由
Quartz 放哪vault 内的 .quartz/同一仓库一起提交,CI 不用额外拉取
content 指向哪复制到 .quartz/content/,-d content输出与 content 互不嵌套,躲开坑 8 的无限重建;顺带能把 首页.md 改名成 index.md
首页动态内容Obsidian Bases + BasesPage 插件一份 .base 在 Obsidian 和站点里共用

🛠️ 1. 安装

git clone https://github.com/jackyzha0/quartz.git .quartz
cd .quartz
npm i
rm -rf .git
  • 最后那步 rm -rf .git 必做。Quartz 自带的 .gitignore 只忽略嵌套的 .quartz/,不忽略自己的 .git。不删的话,外层仓库会把 .quartz 当成 gitlink(一个提交指针),源码全丢。实测 .quartz/node_modules 和 .quartz/public 都被它自己的 .gitignore 正常挡住了,唯独 .git 没有
  • 不用 npx quartz create。它的交互式选项里,内容策略只有 new / copy / symlink 三种,都不是我们想要的(见第 2 节)
  • npx quartz 只在装过依赖的 Quartz 仓库内存在。在仓库外跑 npx quartz@latest 会报 could not determine executable to run。所以 CI 构建命令里也必须先 cd .quartz
  • 版本 v5.0.0(2026-09-20 发布)。quartz 仓库用 v4 / v5 分支,没有 main / master,v5 是默认分支

📂 2. 内容:复制到 .quartz/content/

发布用的内容是 vault 的副本,放在 .quartz/content/。由 .scripts/sync-content.mjs 生成(目录名以点开头,Obsidian 不会在文件树里显示它):

node .scripts/sync-content.mjs              # 同步一次
node .scripts/sync-content.mjs --dry-run    # 只报告要做什么
node .scripts/sync-content.mjs --watch      # 持续监听 vault 变化并同步

什么会被发布,由脚本顶部 CONFIG 决定,是唯一真相来源:顶层目录白名单(笔记/、附件/、收件箱)+ 顶层 .md,并把 首页.md 改名成 index.md。用白名单而非黑名单,是为了让以后多出一个顶层文件夹时不会被静默发布。模板/、每日/、归档/ 不发布——因为没列进白名单,不需要额外规则。

脚本还负责三件容易出错的事:

  • 镜像语义:删掉源里已不存在 target(改名或删除笔记后不留陈旧页面),并清理空目录
  • 注入 git 日期:往副本的 frontmatter 写 created(首次进入 git 的时间)和 modified(最后一次提交的时间),仅在字段缺失时写,源文件不动。详见下面的「日期怎么来的」
  • 登记 .git/info/exclude:见下面的坑

日期怎么来的

副本的 frontmatter 会被写入两个字段,只在缺失时写,源文件不动:

字段含义来源
created该文件首次进入 git 的时间git log --diff-filter=A -1
modified最后一次提交的时间git log -1

这样 file.ctime、file.mtime、页面页脚日期三者都取自 git,可复现:同一份仓库在任何机器、任何时刻构建,日期都一样。file.mtime 在改用复制方案之前本来就是 git 档的值(源文件受 git 跟踪),所以这是在恢复原行为。

为什么必须走 frontmatter,不能靠设文件时间戳。created-modified-date 的三档里,只有 frontmatter 档会赋值 created:

} else if (source === "git" && repo) {
  modified ||= await repo.getFileLatestModifiedDateAsync(relativePath);
}   // ← created 在 git 档根本没被赋值

(created-modified-date/dist/index.js:64-70)created 只能来自 frontmatter 的 created 字段,或 filesystem 档的 st.birthtimeMs。而复制出来的文件 birthtime 是「复制发生的时刻」,Node 没有可移植的 API 去设置它(Windows 的 SetFileTime 未暴露)。

实测(手工往副本注入后构建):

注入file.ctimefile.mtime页脚日期
created: 2026-01-152026-01-15——
created + modified: 2026-06-202026-01-152026-06-202026年6月20日

代价:本地改了但还没 commit 的笔记,站点上仍显示上次的提交日期。对已发布的站点这是对的——没提交的内容本来就不该上线。

副作用:file.mtime 现在是提交日期而不是本机文件修改时间,与 Obsidian 里看到的 file.mtime(真实文件系统时间)不同。这是刻意的:.base 是两端共用的,同名列在站点上取 git 语义更可复现。想临时对照可用 --date-source=none 关掉注入。

.quartz/content/ 不能进 .gitignore(重要)

quartz/util/glob.ts:15-19 硬编码 gitignore: true,globby 会读已提交的 .gitignore。内容目录一旦被覆盖,构建就找不到任何文件:

Found 0 input files from `content`
Emitted 54 files to `public`     ← 空站

(实测:.gitignore 里有该条时 glob 返回 0 个,关掉是 28 个。)

所以它改由脚本写入 .git/info/exclude——本地生效、不进版本控制,git 认它、globby 不认它。新克隆跑一次脚本会自动补上。

本地启动(预览用)

两个进程,各管一件事:

# 终端 1:同步 vault -> content
node .scripts/sync-content.mjs --watch
 
# 终端 2:quartz 监听 content -> public,起预览服务
cd .quartz
npx quartz build --serve -d content

打开 http://localhost:8080。--serve 不能省——它起本地服务器并监听 content/ 的变化,改完笔记跑一下同步脚本就会自动重建并刷新页面。

⚠️ 现在不需要 -o 了。content 根是 .quartz/content/,输出 .quartz/public/ 与它是兄弟目录,产物写出去不在监听范围内,坑 8 的无限重建从结构上消失了。

⚠️ 必须先 cd .quartz。npx quartz 只存在于 .quartz/node_modules/.bin/,在 vault 根目录跑会报:

npm error could not determine executable to run

原因是 npm 在本地找不到这个可执行文件,转而去 registry 上找 quartz——而 npm 上那个同名包不是 Quartz 静态站生成器。跟 npx quartz create 的交互界面无关,纯粹是工作目录问题。

停服务按 Ctrl+C。若端口 8080 被占(报 EADDRINUSE),先找出占用的进程再决定是否结束它——不要无差别 Stop-Process -Name node,本机 WebStorm 的 TypeScript 语言服务和 MCP 工具链都是 node 进程,会被一起杀掉。

生产构建(部署用,不带 --serve)

cd .quartz
node ../.scripts/sync-content.mjs
npx quartz build -d content

产出 .quartz/public/,Cloudflare Pages 用的就是它。实测每次都是干净的 112 个文件(Found 15 input files)。

曾经用 -d ..,为什么换掉

-d .. 让 content 根 = vault 根,保存即最新、零同步步骤。但输出目录和 Quartz 自己的转译缓存都落在被监听的 content 树内,watcher 会监听到 Quartz 自己的输出,叠加两道失效的过滤器形成无限重建,把 .idea/ 一层层嵌进 public/——而 public/ 就是部署目录。完整复现见坑 8。

换到复制方案后,content 树里只剩该发布的东西,ignorePatterns 从 10 条清空成 [](.quartz/、openspec/ 等仓库内部目录已不在作用域内),并顺带用 index.md 让首页 URL 变成 /,不再需要配重定向。

代价:不再是实时的,改完笔记要跑一次同步。这是明知的取舍——换来的是结构上不可能再出现坑 8。

⚙️ 3. 配置

主配置是 quartz.config.yaml,不是 .ts。quartz.ts 只留给 YAML 表达不了的东西——条件 filter、自定义 transformer 这类 JS 回调。

configuration:
  pageTitle: NotebookAI
  enableSPA: true             # ← 模板默认,别改,见坑 1
  locale: zh-CN
  baseUrl: <真实域名>          # ← 当前是占位值,部署后要回填
  fontOrigin: googleFonts
  ignorePatterns: []          # ← 刻意留空,发布范围由 sync-content.mjs 决定

🧩 4. 插件

source 写 npm 包名(@quartz-community/bases-page),不是 github:quartz-community/...。

模板没有、我们自己加的

  • bases-page — 渲染 .base 文件,首页所有动态表的来源
  • note-properties — includeAll: true,页面顶部显示全部 frontmatter 字段

调整过 options 的

  • created-modified-date — 保持模板默认的 priority: [frontmatter, git, filesystem]。日期由同步脚本注入的 frontmatter 决定(created / modified),见第 2 节「日期怎么来的」。vault 自己的 verified 是内容核验时间,与「最后修改」语义不同,不合并
  • breadcrumbs — 开着(默认就是 true),只在内容页显示(condition: not-index)。主页不需要面包屑

关掉的

  • tag-page — vault 没有 tags 层级,也用不到 tag 页面
  • tag-list — 同上
  • obsidian-plugin-excalidraw — 加载失败
  • explicit-publish — vault 没有 publish 字段,remove-draft 够用
  • plausible analytics(analytics 整段删除);footer 的 GitHub 链接改成 lihuohuo1988/NotebookAI

保持模板默认的

  • folder-page — 开着,见坑 2

🏠 5. 首页:Obsidian Bases,不用 Dataview

Dataview 插件已于 2026-09-29 移除。首页 13 个 .base 嵌入全部由 BasesPage 渲染——同一份 .base 在 Obsidian 和站点里用。

能用:contains / containsAny / isEmpty / isNotEmpty / note.<字段> / date()。

⚠️ 相对日期在两端没有交集:

写法Obsidian 1.13.7BasesPage v1.0.0
now() - "180 days"✅❌ 返回 undefined,不做隐式 Duration 转换
duration("180 days")❌ 无此全局函数✅ 返回毫秒数,可参与减法
ISO 8601 P180D—❌ 不支持

因此「网页侧」的正确写法是:

- verified.isNotEmpty() && date(verified) < (now() - duration("180 days"))

三处缺一不可,各对应一个实测到的坑:

片段缺了会怎样
now() - duration(...)求值为 undefined,比较恒false,视图恒空
date(...) 包一层字符串跟 Date 对象比较走字典序而非时间戳,结果不可靠
isNotEmpty() && 守卫字段缺失时 date(undefined) 落入兜底比较、返回 true,缺字段的笔记全部误报

⚠️ 守卫不能写成 !verified.isEmpty()——开头的 ! 会被 YAML 解析成标签,整条条件被
静默丢弃,等于没写守卫。

.base 保持 Obsidian 语法,代价是网页侧要用上面那套写法。这个问题已于 2026-10-02 修复——此前网页上的「收件箱超两周」「链接待验证」两个视图是静默恒空:不报错、不显示任何内容,很容易误读成「确实没有过期项」。迁移过程和失败路径见 Dataview 迁移到 Bases 实践。

⚠️ 6. 坑

1. 尾斜杠 URL 会让整站资源 404(但和 enableSPA 无关)

⚠️ 这条曾经被误记成「enableSPA: true 导致全站破版」,并据此把 SPA 关掉了。那个因果链不成立,enableSPA 已恢复模板默认的 true。

enableSPA 实际只做一件事:plugins/emitters/componentResources.ts:261-270 里决定注入 spaRouterScript,还是退化成 window.location.assign 的兜底脚本。它不生成 URL,v5 源码里也根本没有 trailingSlash 配置项。构建产物 30 个 HTML 的内部链接中,只有根路径 / 带尾斜杠。

真正存在的 v5.0.0 缺陷是 emitPage() 用 baseDir = pathToRoot(slug)(plugins/pageTypes/dispatcher.ts:87-92),而 pathToRoot 按「段数 − 1」生成 ..(@quartz-community/utils path.js:121-127)。2 段 slug 得 1 个 ..,产物是 ../index.css。浏览器以 URL 所在目录为相对基准:

URL基准目录../index.css 解析成结果
/笔记/xxx(无尾斜杠)/笔记//index.css✅
/笔记/xxx/(有尾斜杠)/笔记/xxx//笔记/index.css❌ 404

对照 transformLink() 已经在做补偿(path.js:165 的 effectiveSrc 补 /index),emitPage() 没有,属未覆盖分支。

触发条件是「URL 带尾斜杠」,正常点链接浏览命不中,实测显式访问 /笔记/xxx/ 才有 33 个资源 404。文件夹页(slug 形如 笔记/index)天然正确;404.html 走绝对 base 路径(dispatcher.ts:88-91)也不受影响。

唯一要记住的约束:Cloudflare Pages 侧不要配置「自动补尾斜杠」的重写/重定向规则。构建产物不产生此类链接,所以只要平台不做尾斜杠规范化就不会触发。真要开的话,得先 patch Quartz 或等上游修。

2. folder-page 会造出一批没有 frontmatter 的虚拟页

folder-page 是个页面生成器,干两件事:

  1. 扫描所有笔记的 slug,把出现过的目录收进集合,给没有 index.md 的目录各造一个 slug 为 <目录>/index 的虚拟页。这些页的 data 是空的 {}——没有任何 frontmatter。
  2. 给 slug 以 /index 结尾的页面渲染「本目录下所有笔记」的列表,并给没写 title 的补上目录名。

问题出在第 1 条:BasesPage 不跳过 data: {} 的虚拟页,把它当成一篇属性全空的笔记收进索引,于是表格里多出一行——文件名列显示 index,其余列全 —。

当前处理:folder-page 保持开启(模板默认),靠 .base 的 filter 兜住。 表格 - 全部笔记.base 的 filters 里有 file.name != "index"。其余 12 个 .base 没有这条,因为它们本来就带 file.folder == "笔记" 之类的约束,虚拟页落不进去。

这条 filter 在 Obsidian 侧恒真(Obsidian 根本没有虚拟索引页这种东西),实际是零成本的保险。代价是 .base 里多了一条对 Quartz 有意义的规则——与「.base 是 Obsidian 权威源、不为 Quartz 塞补丁」的原则有冲突,但换来 /笔记/ 目录页可访问(笔记/index.html 正常打开,侧栏「笔记」是可展开的目录节点)。

曾经一度是关掉 folder-page 的。后来发现关掉后 /笔记/ 目录页消失、explorer 少一层,就改回开启 + filter 兜底。现状即最终选择,两种做法功能上都能跑,别照着旧结论去改配置。

3. URL 一律小写 + 连字符

DeepSeek-Harness桌面版.md → /deepseek-harness桌面版。新建站点没有历史 URL 问题(alias-redirects 管的是 v4 升级场景),代价只是中文 + 大小写混合的文件名可读性变差。存量文件名别改——改了就断了所有 [[wikilink]]。

4. icon 字段 Quartz 不认

首页表格里的 emoji 不是从 icon 读的。

5. contentIndex.json 是搜索索引,也是图谱数据源

图谱插件靠 fetch("../static/contentIndex.json") 拿数据。这个文件被排除或 404,搜索和图谱会同时失效。

6. LaTeX 对中文数学模式刷 warning

构建输出里会有 Unicode text character "自" used in math mode。strict mode 是 warn 不是 fail,不影响构建。

7. npx quartz sync 不是内容同步

它是 git 工作流(pull → add → commit → push)。内容同步是另一回事——由 .scripts/sync-content.mjs 负责,见第 2 节。

8. --serve 会无限重建,并把垃圾嵌进 public/ → 改用复制方案后已从结构上消除

这条曾污染部署产物:.quartz/public/ 就是 Cloudflare Pages 的输出目录,垃圾会被一起发布到公网。现在 content 根是 .quartz/content/,与输出目录互为兄弟,不再自触发。下面保留完整成因,因为它解释了一整类 Quartz watcher 的陷阱,改回 -d 或升级上游时还会遇到。

成因。-d .. 让 content 目录 = vault 根,于是有两样东西也住在 content 树里:

东西路径
输出目录.quartz/public/
Quartz 自己的转译缓存.quartz/quartz/.quartz-cache/

watcher 监听的是整个 content 树(quartz/build.ts:160,chokidar.watch(".", { cwd: argv.directory }))——Quartz 写自己的输出时,它自己监听到了自己。

三道过滤防线全漏(build.ts:143-153 的 ignored()):

防线实测结果
.git/ 前缀判断正常
gitIgnoredMatcher整条失效——isGitIgnored() 无参数调用,按 process.cwd()(= .quartz/)构建,不是 content 目录。实测它对 .quartz/public/index.html 返回 false,尽管 vault .gitignore 明确忽略了它
minimatch(ignorePatterns)对点开头路径失效——minimatch 默认 dot: false,** 不匹配以点开头的路径段

minimatch 的实测结果(true = 被忽略):

路径.quartz/**.idea
.quartz/public/index.htmltrue ✅—
.idea/workspace.xml—false ❌ 裸名只匹配自身,不匹配子文件
.quartz/public/.idea/workspace.xmlfalse ❌ .idea 是点段,** 跨不过false
.quartz/quartz/.quartz-cache/transpiled-build.mjsfalse ❌ 同理—

失控路径:

① WebStorm 保存状态 → 写 .idea/workspace.xml
② 三道防线全漏 → watcher 触发重建
③ build.ts:230 只把 .md 送去解析,".xml" 落到 build.ts:259 的兜底:
   contentMap.set(".idea/workspace.xml", { type: "other" })
④ 静态资源 emitter 把它拷进输出目录 → public/.idea/workspace.xml
⑤ 它的 content 路径 .quartz/public/.idea/workspace.xml 同样漏过过滤
   → 这次写入又是一个"变化"
⑥ 相对路径 .quartz/public/... 被原样保留 → public/.quartz/public/.idea/...
   深一层,回到 ②

实测最深嵌到 63 层、Emitted 从 20 涨到 67、Parsed 0 Markdown files 却连续重建 40 轮。

为什么完整构建没事:util/glob.ts:17 用的是 globby 的 ignore,它把裸名 .idea 当目录处理,能正确排除子文件。三道防线里只有完整构建用的那道是对的,加上每次先 Cleaned output directory,产物恒为干净的 112 个文件。

为什么 -o 能治:输出挪出 content 树后,Quartz 写自己的产物不再被监听到,第 ⑤ 步的自我触发不成立——不依赖过滤器有没有 bug。实测触发同样 1 次重建,循环消失。

为什么改 ignorePatterns 治不了:这是打地鼠。** 跨不过点段,补不完;而且除了 .idea,Quartz 自己的 .quartz-cache/transpiled-build.mjs 是第二个入口(hard rebuild 时写入,同样漏过过滤),单加 .idea/** 堵不住。

残留:-o 下 .idea/workspace.xml 仍会被拷进预览输出(实测 102 vs 101 个文件),但预览目录是一次性的、无所谓。部署构建走默认输出 + globby,产物干净。

上游可报 issue:isGitIgnored() 不传 cwd,与 -d 指定的 content 目录不一致时整条 gitignore 防线失效。

🚀 7. 部署:Cloudflare Pages

项值
构建命令cd .quartz && npm ci && node ../.scripts/sync-content.mjs && npx quartz build -d content
输出目录.quartz/public
production branchmain(NotebookAI 仓库的分支,与 quartz 的 v5 分支无关)
  • 必须 cd .quartz——见第 1 节
  • 必须 npm ci——node_modules 不进 git,CI 得自己装
  • 必须先跑同步脚本——.quartz/content/ 不进 git(见第 2 节),CI 的 checkout 里没有它
  • 必须 -d content——content 根是复制出来的目录,不是 vault 根
  • 同步脚本漏跑不会静默发布旧内容:content/ 不存在时构建得到空站(Found 0 input files),是响亮失败
  • npm run prebuild 会自动跑 install-plugins,不用显式写 npx quartz plugin install
  • 不走 GitHub Actions,也不用 Quartz 内置的 sync

部署完只剩一件事:

  1. 回填 baseUrl。当前是占位值 notebookai.example.com。不回填的话,sitemap、RSS、og:url / twitter:url 全指向错误域名

配 / → /首页 重定向 —— 已不需要。同步脚本把 首页.md 改名成 index.md,simplifySlug() 剥掉 index 后缀后 slug 为空串,URL 直接是 /(已实测 http://localhost:8080/ 打开就是首页)。

📋 8. 待办

  • 部署(需先建 Cloudflare Pages 项目)
  • baseUrl 回填真实域名
  • fontOrigin 当前是 googleFonts,国内访问会请求被墙的字体源。改 local 就不再发这个请求(回落到系统字体),但字体会变,得权衡

🔗 相关

🕘 状态记录

  • 2026-09-28:首次记录。整理 Quartz 5 安装、配置、Dataview 支持、部署流程
  • 2026-09-29:重写。按本站实际做法校正(克隆进 .quartz/、-d ..、Bases、Cloudflare Pages),删掉已失效的 Dataview / Quartz Syncer / GitHub Pages 章节,删掉 09-28 那轮尚未决策的分析原文(结论已落进正文);补 7 个实际踩到的坑
  • 2026-10-01:内容方案从 -d .. 改为复制到 .quartz/content/。起因是坑 8——-d .. 下输出目录与转译缓存都在 content 树内,--serve 会无限重建并把垃圾嵌进部署产物。新增 scripts/sync-content.mjs(镜像语义 + 注入 git 日期 + 自动登记 .git/info/exclude;该目录后于 10-02 改名为 .scripts/),ignorePatterns 清空,首页改名 index.md 使 / 直接可用、不再需要 Cloudflare 重定向。坑 8 标注为结构性消除但保留成因记录。同日补:日期改由脚本注入 frontmatter 的 created(首次进入 git)/ modified(最后提交),解决 file.ctime 只能取 birthtime、复制后不可控的问题;实测 created-modified-date 的 git 档从不赋值 created,故必须走 frontmatter
  • 2026-10-02:日期权威来源从「同步脚本注入副本 frontmatter」改为「vault 源文件 frontmatter + Obsidian Linter」。① 启用 Linter 的 yaml-timestamp 规则(键名 created / modified,格式 YYYY-MM-DD HH:mm:ss,on save),存量 13 篇用一次性脚本 .scripts/backfill-frontmatter-dates.mjs 按 git 时间回填,verified 补全到 00:00:00。② 同步脚本移除日期注入(injectGitDates / loadGitAddDates / loadGitTouchDates / --date-source),只保留复制与 mtime 对齐。③ 13 个 .base 的时间列从 file.mtime / file.ctime 改为 note.modified / note.created,「超两周」「超半年未验证」改用 date(...) < (now() - duration(...)) —— 实测裸比较 verified < (now() - "180 days") 是恒空(now() - "180 days" 求值为 undefined,因为 applyBinary 的减号分支只接受 Date - number / Date - Date / number - number,字符串三条都不匹配;字符串跟 undefined 比永远 false,连 2020 年的旧日期也返回 false)。⚠️ 但 date(verified) < (now() - duration("180 days")) 在字段缺失时返回 true(date(undefined) 落入兜底比较),所以必须加 verified.isNotEmpty() && 守卫;写成 !x.isEmpty() 会被 YAML 当成标签、整条条件丢弃。第 5 节「相对日期」一节据此重写。同日 scripts/ 改名 .scripts/,理由见下
  • 2026-09-30:四批订正。① 第 2 节拆成「本地启动」和「生产构建」,补上 --serve、8080 端口、EADDRINUSE 与误杀 node 进程的坑。② 坑 2 与第 4 节插件清单——原文说 folder-page 已关,实际一直是 enabled: true + .base filter 兜底,补上真实机制与这段反复。③ 坑 1 与第 3 节配置示例——原文把全站资产 404 归因于 enableSPA: true 并据此关掉 SPA,该因果链不成立;enableSPA 已恢复模板默认 true,坑 1 改写为「尾斜杠 URL 的真实缺陷 + 部署时不要配尾斜杠规范化重写」的约束。④ 新增坑 8——--serve 下输出目录与转译缓存都在 content 树内,watcher 监听到 Quartz 自己的输出,三道过滤防线(.git/ 判断、失效的 gitignore matcher、对点段失效的 minimatch)全部漏过,导致无限重建并把 .idea/ 等垃圾一层层嵌进 public/。本地启动命令改为加 -o ../../.quartz-preview 把输出挪出 vault,实测循环消失