🔮 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.ctime | file.mtime | 页脚日期 |
|---|---|---|---|
created: 2026-01-15 | 2026-01-15 | — | — |
created + modified: 2026-06-20 | 2026-01-15 | 2026-06-20 | 2026年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.7 | BasesPage 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 是个页面生成器,干两件事:
- 扫描所有笔记的 slug,把出现过的目录收进集合,给没有
index.md的目录各造一个 slug 为<目录>/index的虚拟页。这些页的data是空的{}——没有任何 frontmatter。 - 给 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.html | true ✅ | — |
.idea/workspace.xml | — | false ❌ 裸名只匹配自身,不匹配子文件 |
.quartz/public/.idea/workspace.xml | false ❌ .idea 是点段,** 跨不过 | false |
.quartz/quartz/.quartz-cache/transpiled-build.mjs | false ❌ 同理 | — |
失控路径:
① 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 branch | main(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
部署完只剩一件事:
- 回填
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就不再发这个请求(回落到系统字体),但字体会变,得权衡
🔗 相关
- Cloudflare Pages 部署指南 — 本站实际使用的部署方案,配置项与踩坑
- GitHub Pages 部署指南 — 备选部署方案,GitHub Actions 流程
- Dataview 迁移到 Bases 实践 — Dataview → Bases 的迁移过程、失败路径与对应写法
- 模板说明 — vault 字段约定与
type词表 - 首页 — 首页
- Quartz 官方文档
- Quartz GitHub
🕘 状态记录
- 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+.basefilter 兜底,补上真实机制与这段反复。③ 坑 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,实测循环消失