🔌 Quartz 页面按钮与交互扩展点
一句话总结:Quartz 5 的正式扩展点只有插件。
quartz/目录可以一行不改,UI 通过quartz.config.yaml的layout挂到 6 个槽位;交互靠组件的afterDOMLoaded注入脚本,注意 SPA 导航要重绑。本文梳理五条路径的成本边界,以及四个会让插件「装了不生效」的坑。
🎯 路径选择
| 方式 | 改核心 | 需要构建 | 适合 |
|---|---|---|---|
| A. 本地插件包 | 否 | 需 tsup 一次 | 真·按钮 + JS 交互 |
| B. 纯 YAML 排版 | 否 | 否 | 复用已装组件、调位置/分组/条件 |
C. custom.scss | 是(官方预留扩展点) | 否 | 纯 CSS 交互 |
D. quartz.ts | 否 | 否 | 改 options、改 CSS 变量 |
| E. 笔记里写 HTML | 否 | 否 | 无状态的展示型按钮 |
判断依据是按钮有没有状态:纯展示/跳转走 E 或 B,几分钟搞定;有状态(勾选、折叠、筛选、调 API)只能走 A。
🧩 1. 方案 A:本地插件包
source 字段支持本地路径,isLocalSource()(quartz/plugins/loader/gitLoader.ts:47-59)认 ./ ../ / 三种前缀,quartz-plugins.schema.json:195 也明写了这条。命中后被 symlink 进 .quartz/.quartz/plugins/<目录名>,核心代码零改动。
目录位置
.quartz/local-plugins/page-buttons/
⚠️ 不能放 .quartz/plugins/——根 .gitignore:26 已排除该路径(实测 git check-ignore 命中)。.quartz/local-plugins/ 不在任何 ignore 规则里。
目录结构
.quartz/local-plugins/page-buttons/
├── package.json
├── tsup.config.ts
└── src/
├── index.ts # init(),注册 condition
└── components/
├── index.ts # 组件本体
└── buttons.inline.ts # 浏览器端脚本
package.json
quartz 字段是关键,readManifestFromPackageJson()(config-loader.ts:205-241)靠它识别类别和组件:
{
"name": "page-buttons",
"version": "1.0.0",
"type": "module",
"exports": {
".": { "import": "./dist/index.js" },
"./components": { "import": "./dist/components/index.js" }
},
"main": "./dist/index.js",
"quartz": {
"name": "page-buttons",
"displayName": "Page Buttons",
"category": "component",
"components": {
"PageButtons": {
"displayName": "Page Buttons",
"defaultPosition": "beforeBody",
"defaultPriority": 25
}
}
},
"scripts": { "build": "tsup" },
"peerDependencies": { "preact": "^10.0.0" }
}category: "component" 走的是副作用导入分支(config-loader.ts:351-374),不走工厂函数。要让 YAML 的 options 传进来,入口需导出 init(options)——config-loader 会用 {...defaultOptions, ...用户 options} 调它。
组件从 ./components 子路径加载(componentLoader.ts:13-20),所以 exports 里那行是必须的,否则回退到 dist/components/index.js 的路径猜测。
tsup.config.ts
照抄官方模板,只把单例留在 external:
import { defineConfig } from "tsup"
const SINGLETON_EXTERNALS = [
"preact", "preact/hooks", "preact/jsx-runtime",
"@jackyzha0/quartz", "vfile", "unified",
]
export default defineConfig({
entry: { index: "src/index.ts", "components/index": "src/components/index.ts" },
format: ["esm"],
dts: true,
clean: true,
noExternal: [/.*/], // 其余全部打包,安装时零依赖
external: SINGLETON_EXTERNALS,
})单例清单来自 gitLoader.ts:806,这份必须保持一致:多个 preact 实例会导致 instanceof 和共享注册表失效。
组件与交互
交互的关键在 afterDOMLoaded(types.ts:21-26),它被 renderPage.tsx:369-371 塞进每个页面:
// src/components/index.ts
import script from "./buttons.inline.ts"
import type { QuartzComponent, QuartzComponentConstructor } from "@quartz-community/types"
const PageButtons: QuartzComponentConstructor = () => {
const Component: QuartzComponent = ({ fileData }) => {
const links: string[] = fileData.frontmatter?.buttons ?? []
if (!links.length) return null
return (
<div class="page-buttons">
{links.map((l) => <a href={l} class="page-button">{l}</a>)}
</div>
)
}
Component.css = pageButtonsCss
Component.afterDOMLoaded = script
return Component
}
export default PageButtons.inline.ts 里的脚本由 esbuild 的 inline-script-loader(quartz/cli/handlers.js:364-395)在构建期转译,必须处理 SPA:
function setup() {
document.querySelectorAll(".page-button").forEach((el) => {
el.addEventListener("click", onClick)
window.addCleanup(() => el.removeEventListener("click", onClick))
})
}
document.addEventListener("nav", setup) // SPA 导航后
document.addEventListener("render", setup) // 就地重渲染后(内容解密等)挂到 quartz.config.yaml
- source: "./local-plugins/page-buttons" # 相对 .quartz/ 目录
enabled: true
layout:
position: beforeBody
priority: 25
condition: not-index组件名解析链:extractPluginName("./local-plugins/page-buttons") → page-buttons;componentLoader.ts:54-64 对单组件插件会额外注册到插件名本身,所以目录名即注册键。多组件插件则只能靠导出名(PascalCase)匹配。
📐 2. 布局槽位与条件
6 个位置(quartz/plugins/loader/types.ts:11):header / left / right / beforeBody / afterBody / footer。每项可配 priority(小的先渲染)、display(all / mobile-only / desktop-only)、condition、group。
group 把多个组件包进一个 Flex 容器,方向和间距在顶层 layout.groups 里定义——本站的 toolbar 就是这么把 search / darkmode / reader-mode 排成一行的(config-loader.ts:871-949)。
内置 condition 只有 4 个(conditions.ts:5-19):
| condition | 效果 |
|---|---|
not-index | 首页隐藏,其余页面显示 |
has-tags | 仅 frontmatter 有 tags 时显示 |
has-backlinks | 仅被其他页面链接过时显示 |
has-toc | 仅页面有目录时显示 |
自定义 condition 要在插件的 init() 里调 registerCondition(name, predicate)(conditions.ts:23),它注册进模块级 Map,applyConditionWrapper 在构建布局时查表。这个时机可行,因为 loadQuartzConfig() 先导入组件插件(config-loader.ts:351-374),再调 loadQuartzLayout()(line 512)。
🎨 3. 方案 C:custom.scss
quartz/styles/custom.scss 官方就写着 “put your custom CSS here!”,且不带 @layer 包裹,在 componentResources.ts:347 里直接拼在 @layer quartz-base 之后,优先级最高。适合做纯 CSS 的展开/收起、:target 锚点、checkbox hack。
代价:它在 quartz/ 里(upstream checkout),npx quartz pull 会冲突。
🔌 4. 方案 D:quartz.ts 的边界
本站 quartz.ts 在用 componentRegistry.setOptionOverrides(),这个有效——loadQuartzConfig() 内部调 loadQuartzLayout()(config-loader.ts:512)时会读 overrides。
⚠️ 但文档里说的「用 quartz.ts 覆盖 layout」在 v5 是失效的:
export const layout = await loadQuartzLayout({ ... }) // ← 死代码build.ts:11 和 worker.ts:3 都只 import cfg from "../quartz"(默认导出),全仓没有任何地方读 layout 具名导出。而且 dispatcher 拿到的布局来自 loadQuartzConfig() 内部那次无参调用,loadQuartzLayout(layoutOverrides) 的 layoutOverrides 参数从 quartz.ts 走不到。
结论:quartz.ts 里可用的只有 setOptionOverrides()、registerCondition(),以及任何纯 JS 副作用。改布局只能改 YAML。
📝 5. 方案 E:笔记里写 HTML
rehype-raw 打包在 obsidian-flavored-markdown 里(dist/index.js:26235),裸 HTML 会正常渲染:
<button onclick="alert(1)">点我</button>但 <script> 只在整页加载时执行一次,SPA 导航后不跑,且每次导航重复绑定。只适合无状态的展示型按钮。配置里的 enableInHtmlEmbed 管的是 Obsidian 的 ![[...]] 嵌入语法在 HTML 块内的解析,跟裸 HTML 能否渲染无关。
⚠️ 6. 坑
1. 本地插件不会被自动构建
installPlugin() 对 spec.local 走 symlink 分支后就 return 了(gitLoader.ts:476-482),根本到不了 buildInstalledPlugin()——那才是跑 npm install && npm run build 的地方(line 574)。必须自己 npm run build,且 dist/ 不能进 .gitignore。
2. 改完组件要重启构建
--serve 的 watcher 用 chokidar.watch(".", { cwd: argv.directory })(build.ts:160-165),只监听 content 目录。插件源码改动不触发重编,得重跑 npx quartz build。
3. 本地插件拿不到 peer 依赖 symlink
linkPeerDependencies() 只在 buildInstalledPlugin() 里调,本地插件跳过这步。preact 靠 Node 从真实路径(symlink 解析后的)向上找到 .quartz/node_modules——能用,但前提是插件目录确实在 .quartz/ 树内。放到仓库别处就会解析失败。
4. YAML schema 的 position 枚举不全
quartz-plugins.schema.json:244 只列了 left / right / beforeBody / afterBody / body,而 types.ts:11 的实际类型还有 header 和 footer。代码支持,schema 过期——写 header 时 YAML 语言服务器会报红,但构建正常。
🔗 相关
- Quartz 5 配置指南 — 本站 Quartz 部署结构、content 同步方案、8 个已踩的坑
- Cloudflare Pages 部署指南 — 构建命令与输出目录
- 模板说明 — vault 字段约定与
tags词表 - 首页 — 首页
- Quartz 官方文档
- quartz-community/plugin-template — 官方插件脚手架,本地插件的 tsup 配置来源
🕘 状态记录
- 2026-10-11:首次记录。起因是「给笔记页面加按钮和交互、尽量不改 Quartz 代码」的调研。梳理 5 条路径(本地插件包 / 纯 YAML /
custom.scss/quartz.ts/ 笔记内 HTML)的成本边界,补出 3 个此前没记录的机制性发现:①quartz.ts的export const layout在 v5 是死代码,TS 覆盖布局无效;② 本地插件走 symlink 分支不触发构建,dist/得自己产出;③ YAML schema 的position枚举落后于实际类型,header/footer会误报红。方案 A 尚未实施,本篇是选型依据