🔌 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 语言服务器会报红,但构建正常。

🔗 相关

🕘 状态记录

  • 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 尚未实施,本篇是选型依据