插件开发

Morz 有两种插件:原生 SDK 插件(推荐)和 Obsidian 兼容插件。本文只讲前者;
兼容层的目标是"能把已有的 Obsidian 插件跑起来",不是新插件的开发方式。
完整的 API 表在 插件 SDK,这里讲怎么从零做一个能装、能调、
能发出去的插件。

插件长什么样

一个文件夹,两个文件:

my-plugin/
├── manifest.json
└── main.js

manifest.json:

{
  "id": "reading-progress",
  "name": "阅读进度",
  "version": "1.0.0",
  "description": "在状态栏显示当前文档的字数与预计阅读时间",
  "author": "你的名字",
  "sdk": 1,
  "permissions": ["workspace", "settings"]
}
字段 说明
id 唯一 id,小写字母、数字、短横线。同一个 id 视为同一个插件
sdk 必须是 1,否则会被当成 Obsidian 兼容插件加载
main 入口文件名,默认 main.js
permissions 需要的能力:vault:read、vault:write、workspace、editor:insert、settings。不写等于全部放行,建议显式写

main.js 是 CommonJS/IIFE 脚本(不是 ES module,不能写 import),导出 activate(ctx),
可选导出 deactivate():

module.exports = {
  async activate(ctx) {
    // 注册命令、面板、状态栏……
  },
  deactivate() {
    // 释放自己申请的定时器、事件、DOM
  },
};

从零写一个:阅读进度

main.js:

module.exports = {
  async activate(ctx) {
    // 1) 设置:每个插件有独立的持久化空间
    const settings = { speed: 300, ...(await ctx.settings.get()) };

    // 2) 命令 + 快捷键
    ctx.ui.command({
      id: "reset-speed",
      name: "重置阅读速度",
      run: async () => {
        settings.speed = 300;
        await ctx.settings.set({ speed: 300 });
        ctx.ui.notice("阅读速度已重置为 300 字/分钟");
      },
    });

    // 3) 状态栏:text 是函数时,文档变化会自动重算
    ctx.ui.statusBar({
      text: () => {
        const text = ctx.active.text();
        if (!text.trim()) return "";
        const words = (text.match(/[\u4e00-\u9fa5]|[A-Za-z0-9]+/g) ?? []).length;
        const minutes = Math.max(1, Math.round(words / settings.speed));
        return `${words} 字 · 约 ${minutes} 分钟`;
      },
      title: "由「阅读进度」统计",
      onClick: () => ctx.ui.notice(`当前速度:${settings.speed} 字/分钟`),
    });

    // 4) 面板:按标签列出文档(meta.index 是现成的查询原语)
    ctx.ui.panel({
      id: "by-tag",
      name: "按标签浏览",
      icon: '<path d="M3 12V5a2 2 0 0 1 2-2h7l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/>',
      render: async () => {
        const wrap = document.createElement("div");
        wrap.style.padding = "8px";
        const files = await ctx.meta.index({ tag: "project" });
        if (!files.length) { wrap.textContent = "没有带 #project 标签的文档"; return wrap; }
        for (const file of files) {
          const button = document.createElement("button");
          button.textContent = file.basename;
          button.style.cssText = "display:block;width:100%;text-align:left;border:0;background:none;padding:6px;border-radius:4px;cursor:pointer;font:inherit;";
          button.addEventListener("click", () => ctx.openFile(file.path, { newTab: true }));
          wrap.append(button);
        }
        return wrap;
      },
    });
  },

  deactivate() {
    // ctx.ui.* 注册的东西由宿主统一回收;这里只处理自己额外申请的
  },
};

要点:

  • ctx.ui.statusBar({ text }) 传函数时,宿主在文档变化时重算;传字符串就只显示一次。
  • ctx.ui.panel.render() 每次打开面板都会重新调用,不要在闭包里缓存上一次的 DOM。
  • ctx.openFile(path, { newTab }):newTab 对应界面上的 Ctrl/Cmd+点击语义。
  • ctx.settings.get()/set() 返回的是纯 JSON,按插件 id 隔离。
  • 绝大多数插件用 ctx. 上的窄接口就够了;需要 CodeMirror 视图本体时走
    ctx.editor 直控(v1.2,需 editor:direct 权限),但 window.app
    和宿主内部对象仍然拿不到、也不应该用。

安装与调试

  • 桌面插件目录(客户端为主):默认 <appDataDir>/plugins/<插件>/(manifest.json + main.js)。
    应用启动时自动扫描:新插件登记进列表(默认停用),改过的插件重启后读磁盘最新内容。
    设置 → 第三方插件 → 打开「已安装」右侧的图标:刷新 / 打开目录 / 从本地文件夹安装 /
    使用其他插件目录。跟主题文件夹是同一套工作流。

  • 安装:从插件商店安装、或点「安装」图标选择本地插件文件夹;桌面端会把插件文件
    复制到你的插件目录(默认目录,或点「目录」图标指定的目录)。选到
    .obsidian/plugins 这类父目录时会批量识别。

  • 使用其他目录:点「目录」图标选一个目录即可(不使用默认插件目录时);已打开的插件
    不会自动搬家,只是后续扫描/安装走新目录。

  • 调试:F12 打开开发者工具,插件里的 console.log 与宿主共用同一个控制台。
    激活失败会抛出带插件名的错误并回滚该插件注册的全部内容,不会拖垮其他插件。

  • 打包:把 manifest.json 和 main.js 放在压缩包根目录,或者直接分发文件夹。

  • 发布:把 manifest.json + main.js 推到 GitHub 仓库即可。Morz 的插件商店只读取
    官方索引(随应用分发 + 远程镜像),安装时直接从 GitHub 仓库取文件;也可以从
    「从仓库安装…」直接输入 owner/repo。索引格式(纯静态 JSON,可托管在任意位置):

    {
      "name": "我的插件索引",
      "plugins": [
        {
          "id": "my-plugin",
          "name": "我的插件",
          "description": "一句话说明",
          "author": "your-name",
          "repo": "your-name/my-plugin",
          "version": "1.0.0",
          "tag": "v1.0.0",
          "trust": "community"
        }
      ]
    }

更新与版本

  • version 是更新的唯一依据:索引里的 version 必须大于已装 manifest.version,客户端才会
    显示“可更新”。没有 version 的条目只能安装,不能更新。
  • 固定到一个 release tag(推荐 tag,或 commit):客户端按 tag 取文件,不会因为作者
    后来改了默认分支而装到不一致的版本。没有 tag/commit 时才回退默认分支。
  • 发布新版本:改 manifest.json 的 version → 打同一个 tag → 更新索引里的 version/tag。
    插件数据(data.json)和设置不会因为更新被清掉;更新失败会自动回滚旧版本。
  • 索引可选字段:minAppVersion、permissions、sha256({ "manifest": "...", "main": "...", "styles": "..." })、
    homepage、changelog、icon(一张图,128×128 推荐,市场显示 24px)。PR 里的 trust
    必须是 community;official/verified 由维护者设置。插件不支持截图轮播。
  • 国内下载会自动走 jsDelivr / 公共 GitHub 加速线路(按延迟自动选);作者不需要自己处理加速地址。

官方索引

官方索引随应用分发,并在后台从 jsDelivr / GitHub Pages 刷新:
公开仓库 cumany/Morz 的 registry/index.json(本仓库 public/registry/index.json 是离线快照)。
收录到官方索引的插件会显示「官方」可信等级;自定义索引默认
「社区」,仓库所有权验证后可以标「已验证」。主题使用同一个索引文件的 themes 数组
(id/name/author/repo/version/tag/theme/preview/trust)。

工作区与标签页

ctx.workspace 与 ctx.tabs 覆盖"多工作区 + 多标签"的操作:

// 工作区:列出、当前、切换(可能需要授权)、打开磁盘文件夹(桌面)
const all = ctx.workspace.list();            // [{ id, name, kind }]
const now = ctx.workspace.current();
await ctx.workspace.switch(all[1].id);        // 失败返回 false
ctx.workspace.onChange(ws => refresh(ws));

// 标签页:读取、激活、新建、关闭、监听
const tabs = ctx.tabs.list();                 // [{ id, name, path, active, dirty }]
ctx.tabs.activate(tabs[0].id);
const id = ctx.tabs.create("# 新文档");        // 新建并激活,返回 id
await ctx.tabs.close(id);                     // 走"未保存"确认流程
ctx.tabs.onChange(() => rerender());

脚本查询(dataviewjs)

内置 Dataview 插件支持 ```dataviewjs 代码块:脚本里用 dv 对象查询全库,
适合 DQL 表达不了的条件。dv.el/table/list 会自动插入输出区,页面名自动变成可点链接:

const pages = await dv.pages("notes");          // front matter 已展开
const open = dv.where(pages, p => p.status === "doing");
const sorted = dv.sortBy(open, p => p.priority, "desc");
dv.table(["文件", "状态", "优先级"], sorted.map(p => [p.basename, p.status, p.priority]));

可用的 dv 方法:

  • 取数:pages(source?)("folder" / #tag / [[note]])、current()、page(path)、array(values)
  • 输出:table(headers, rows)、list(items)、taskList(tasks, groupByFile?)、header(level, text)、
    paragraph(text)、span(text)、el(tag, text, { cls })(兼容旧版 el(tag, class, text))、
    fileLink(path, embed?, display?)
  • 其他:io.load/csv/json(path)、query/queryMarkdown(source)、tryQuery/tryQueryMarkdown(source)、
    markdownTable/List/TaskList、date(value)、open(path)、format(value)、compare/equal/clone、
    where(rows, fn)、sortBy(rows, key, dir?)
  • luxon:完整的 Luxon 命名空间(DateTime / Duration / Interval),按需加载,不拖累首屏。
    官方示例库的工时求和可直接用:dv.luxon.Duration.fromISOTime(h) → 相加(Duration 的
    valueOf() 是毫秒)→ dv.luxon.Duration.fromMillis(sum);表格里的 Duration 会显示成
    「1 小时 30 分钟」而不是 PT1H30M。

taskList 的复选框在任务带 path/line 时会写回源文件(插件需声明 vault:write)。
pages() / array() 返回 DataArray,支持链式
where / map / filter / sort(key, dir) / sortBy / groupBy / keyBy / distinct / limit / slice / flatten / first / last / none / mutate / sum / min / max / mean / median / array。
页面对象的 file.tasks / file.lists 也是 DataArray(file.tasks.where(t => t.completed) 可直接用)。

mutate(fn) 原地改写每个元素并返回自身,适合给页面补派生字段
(dv.pages(...).mutate(p => p.total = ...))。

尚未对齐:fileLink 的嵌入渲染(目前是带 ! 前缀的链接)。脚本异常会显示在该代码块内,
不影响其他块。

dv.pages() 和 DQL 都只读宿主的 MetadataCache 增量索引(ctx.meta.pages()),
不读正文、不自己全量解析:文件改动后索引只重解析变化的那篇,下一次查询直接拿到新值。
同一插件实例内页面投影缓存 1 秒(meta.onChange 立即失效),多个查询块共享一次投影;
FROM "" 仍受 200 篇上限保护。设置 → 插件 → Dataview 可调整:启用/禁用 DataviewJS、
DQL 单次匹配上限、TASK/表格/dv.pages 上限(已渲染的块刷新文档后生效)。
自己写聚合类插件时也用这个 API:

const pages = await ctx.meta.pages();            // 全库;可选 { folder: "notes" }
pages[0].frontmatter;   // YAML front matter
pages[0].heading;       // 第一个一级标题(无则文件 basename)
pages[0].ctime;         // 创建时间(平台拿不到时回落为 mtime)
pages[0].ext;           // 扩展名(一般 "md")
pages[0].tags;          // 正文 + frontmatter 标签(不带 #)
pages[0].inlineFields;  // `key:: value` / `[key:: value]`
pages[0].tasks;         // [{ line, text, state, checked, scheduled, deadline, priority }]
pages[0].lists;         // 所有列表项(含任务)
pages[0].inlinks;       // 反链来源路径
pages[0].outlinks;      // 出链目标(已解析为路径,未解析为原始 linkpath)

DQL 支持的子句:FROM / WHERE / FLATTEN / SORT / GROUP BY / LIMIT(顺序不限,表头可跨行)。
表达式内建 Dataview 常用函数:date、dateformat、dur、choice、round、filter、
map、contains、default、length;date() 返回 Luxon DateTime 并补了 weekyear / week
别名。标识符允许连字符(cover-img)与 this(当前页面)。表格/列表单元格按 Markdown/HTML
渲染(![封面|50](url)、<div style=…> 进度条都生效,走 renderInline 的 sanitize)。
frontmatter 支持一层嵌套映射(wellbeing.mood)。

DQL 的 file.* 字段:path / name / basename / folder / ext / size / ctime / cday / mtime / mday / day / heading / tags / etags / aliases / frontmatter / tasks / lists / link / inlinks / outlinks。
size 是 UTF-8 字节数;tags 会展开子标签,etags 保持原样;day 只从文件名日期或
frontmatter/行内 date 推导;link / inlinks / outlinks 是可点链接对象。

DQL 的默认范围与 Dataview 一致:不写 FROM 查询当前文档,FROM "" 查询整个库。
所有 dataview / dataviewjs 块 hover 时右上角出现 </>,点击回到源码编辑
(点击块内空白处也一样;块内的链接/按钮保留自己的点击行为)。

性能兜底(大库不拖死主线程,截断一律在块内提示):

位置 上限
DQL 单次查询 匹配 200 篇;超出只计算前 200 篇并提示用 FROM 缩小
DQL TASK 300 条任务(显式 LIMIT 小于 300 时按 LIMIT)
dv.pages() 300 篇
dv.table / dv.list 500 行 / 500 项
dv.el 输出 单块 800 个顶层节点、单段文本 20000 字符
脚本返回字符串 20000 字符

dataviewjs 是普通 JS,同步死循环(while (true))无法从宿主侧中断——写脚本时
自己保证循环有终止条件;渲染量由上面的上限兜底。

数据视图(```dv,小白路径)

不想写 DQL 时用 ```dv 代码块:配置是 YAML,块本身是交互界面(排序/编辑/新建/展开)。
生成入口是 /dv 斜杠指令或命令面板「插入数据视图」的可视化构建器,
它会把配置写回 fence——所以视图是可以进 git、可以复制、AI 可以改的一段文本。

title: 项目任务
source: 笔记/项目          # "" = 全库;"#标签" = 按标签
conditions:
  - { field: 状态, op: is, value: 进行中 }
columns: [状态, 截止]
sort: 截止
dir: asc
pageSize: 20
view: table                # table | list

实现上:src/core/data-view.ts 是纯逻辑(YAML ↔ 配置、映射到 SmartFilterState、
生成等价 DQL 供教学预览),src/builtin/data-view.ts 负责 DOM 与交互,挂在 Dataview
插件里——装的是同一套 ctx.meta.pages() 索引,不另建缓存。设计边界与 Make.md 对比见
docs/DATA-VIEW.md;二期(日历/看板/公式列)在 BACKLOG B-217。

网络与 AI 类插件

fetch 直接可用(CSP 放行 https/http)。AI 类插件的惯例:

  • 走 OpenAI 兼容 /chat/completions(官方 API、各类中转、本地 Ollama 都是同一协议),
    端点/Key/模型放 settings schema(存 kv,不要写进文档)。
  • 流式输出:stream: true + ReadableStream 按 data: 行分帧解析 delta.content;
    编辑器内用 ctx.active.insert 逐段追加,面板里逐字改气泡。
  • 选区改写(润色/翻译)先取 ctx.active.selection() + text(),结果完整后
    replaceSelection,避免流式碎片直接进正文。

参考实现:src/builtin/ai-assistant.ts(内置 AI 助手)。

编辑器直控(CM6)

v1.2 起插件可以直接操作 CodeMirror 6:装饰、keymap、lint、自定义行内部件都能做了。
三步走(完整可运行的示例在 sdk-examples/editor-direct/):

1. 声明权限

{ "permissions": ["editor:direct"] }

2. 从 ctx.editor.cm 取构造器

插件环境没有 require,CodeMirror 的类从这里拿(与编辑器同一份模块实例):

const { ViewPlugin, Decoration, RangeSetBuilder } = ctx.editor.cm;

3. registerExtension 装进编辑器

// 以 !! 开头的行整行高亮
const marker = Decoration.line({ class: "my-marker" });
const build = (view) => {
  const builder = new RangeSetBuilder();
  for (let i = 1; i <= view.state.doc.lines; i++) {
    const line = view.state.doc.line(i);
    if (line.text.startsWith("!!")) builder.add(line.from, line.from, marker);
  }
  return builder.finish();
};
ctx.editor.registerExtension(ViewPlugin.fromClass(class {
  constructor(view) { this.decorations = build(view); }
  update(u) { if (u.docChanged) this.decorations = build(u.view); }
}, { decorations: p => p.decorations }));

扩展跑在独立 compartment 里:插件停用或卸载时整组自动移除。registerExtension
返回取消函数,想提前撤销就调它。

注意:宿主只能兜住"装载/重配置失败";扩展运行期回调(update、装饰构建)抛错
会中断当次编辑器更新,宿主无法隔离——每个回调里自己 try/catch 并静默降级。

ctx.editor.view() 返回当前 EditorView 实例(未打开文档时 null,每次实时取、不要缓存),
拿到后就是标准 CodeMirror 6 API:view.state、view.dispatch、view.visibleRanges…
另外 ctx.editor.runCommand("toggleFormat", "strong") 可以复用内置格式命令
(加粗、标题、表格等,一步撤销),见 PLUGIN-SDK.md 的命令表。

写 TypeScript 时 npm i -D @codemirror/view @codemirror/state 拿类型即可,
打包时把代码编译进 main.js(运行时对象由宿主提供)。

三条纪律:

  • 改文本用 ctx.active(一步撤销、与索引联动),不要拿 view.dispatch 手搓;
  • 只读高亮优先 ctx.editor.highlight(自带视口扫描与上限);
  • 用 view.dispatch 改渲染结构(块替换/隐藏行)属自负区——能力上有,但会破坏
    所见即所得映射与撤销语义,宿主不做兼容保证,发布前用真实文档自查。

权限

权限 允许做什么
vault:read ctx.vault.list/read/readBlob、ctx.meta.*
vault:write ctx.vault.write/create/rename/remove/writeBlob/setFrontmatter
workspace ctx.ui.statusBar/ribbon/panel、ctx.active
editor:insert 在光标处插入/替换文本、ctx.editor.runCommand
editor:highlight ctx.editor.highlight 受控高亮
editor:direct ctx.editor.view / registerExtension / cm(CM6 直控)
views ctx.views 主区域自定义视图
settings 读写自己的设置
http ctx.http 访问网络(还需 manifest.network 声明 + 每域名用户授权)

声明得越少,安装时用户越容易放心。没有声明 permissions 的插件会被视为要求全部能力,
在插件列表里会显示完整的权限说明。

常见坑

  • main.js 不能写 import。需要多文件就先打包成一个 IIFE/CommonJS 文件。
  • 别长期持有 DOM。面板每次打开都会重新 render(),旧的节点已经不在文档里。
  • 面板默认隐藏、状态项默认显示。ctx.ui.panel 注册的面板默认不挂载——用户在
    右栏空白处右键勾选后才出现;ctx.ui.statusBar 注册的条目默认显示,用户可在状态栏
    右键逐个隐藏。面板类功能请同时注册一个打开面板的命令,别指望用户自己发现。
  • 右键菜单项会进二级。ctx.ui.menu 的贡献一律收进「插件」子菜单;
    菜单项的 enabled(pos) 用来避免在无关位置出现空项。
  • 同步耗时会被守护盯上。单次回调 >400ms 或 10s 内累计 >600ms 会触发提醒,
    反复出现会被自动停用(第三方)——重活放 ctx.timers.debounce / 分片
    (ctx.timers.nextTick),大计算用异步任务。见 PLUGIN-SDK.md 的「性能守护」。
  • 异步错误要自己处理。命令和面板里的 await 失败不会自动弹给用户,用
    ctx.ui.notice(..., { kind: "error" }) 告诉用户。
  • 不要在 activate 顶层做重活。插件启动会拖慢编辑器首屏;把耗时的索引/网络放到
    第一次打开面板时再做。
  • 桌面端的 CSP 不允许远程脚本:依赖请打包进 main.js,不要在运行时
    createElement("script") 指向 CDN;fetch 不受影响。
  • 不能直接打开库外文件/程序:桌面端没有授予 open_path,file:// 也不在白名单。
    外链用 window.open 或 opener 的 openUrl(http/https/mailto/tel 与
    obsidian/zotero 等常用软件 scheme 已放行)。
  • 主题相关样式:插件可以插入 <style>,但请用主题变量(--text-color、--primary-color、
    --ui-bg 等)而不是写死颜色,否则换主题会花。

参考

  • API 全表与类型:插件 SDK
  • 官方真实插件(推荐先读):plugins/reading-progress(状态栏刷新 + 滚动进度 + 设置实时生效)
  • 文档内 Widget 示例:sdk-examples/heatmap-widget(```heatmap 代码块 → 53 周热力图,支持 Dataview 数据源)
  • 关键词高亮示例:sdk-examples/keyword-highlight(受控 mark 装饰 + 设置变更重绘)
  • 编辑器直控示例:sdk-examples/editor-direct(CM6 行装饰 + keymap + runCommand + 状态栏行列号)
  • 示例插件:sdk-examples/doc-overview(命令 + 状态栏 + 面板 + 设置)、
    sdk-examples/calendar(每日笔记)、sdk-examples/image-upload(图床)
  • 宿主架构:ARCHITECTURE.md(仅源码仓)