插件开发
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
渲染(、<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(仅源码仓)