插件开发文档
第三方开发者仅凭本页与仓库内的规范原文,即可写出一个包含工具栏按钮、面板、命令、读写编辑器内容并被应用成功加载的插件。
"1"
ZLYNOTES v1.4.1
docs/plugin-spec.md
1. 运行模型
ZLYNOTES 的核心原则是一切皆插件:编辑器工具栏按钮、右栏面板、左侧导航项、命令、设置分区、预览区浮动操作,全部由插件注册表驱动。插件分前端插件、后端插件与双端插件三种形态,共用同一套清单与权限规范。
底线:插件故障绝不拖垮主程序。插件抛错只会导致自身被停用,主程序、其它插件与笔记数据均不受影响。
前端插件运行在独立的不透明源 iframe 沙箱里(<iframe sandbox="allow-scripts">,没有 allow-same-origin):宿主读不到插件的 DOM,插件也读不到宿主的 DOM、localStorage 与 cookie;两者只通过 postMessage(结构化克隆,只传 JSON 数据)与 RPC 交互。
由此推出三条硬性约定,写插件时必须遵守:
- 函数不能跨 iframe 传递。注册命令时传给宿主的是元数据,点击后宿主用定向事件
command:invoke回来调用插件侧的run()。 - 插件不能直接操作宿主 DOM。一切读写都走
window.zly.*的方法。 - 一切能力都要声明权限。未声明的接口调用会被拒绝 —— 是默认拒绝,不是默认放行。
2. 快速开始
一个插件就是一个目录,必须包含 manifest.json 与入口 HTML(默认 index.html):
my-plugin/
├── manifest.json 必填:插件清单
├── index.html 必填:入口页面(唯一被加载的 HTML)
├── panel.css 可选:本地样式(会被自动内联进沙箱)
└── panel.js 可选:本地脚本(会被自动内联进沙箱)
| 来源 | 路径 | 默认启用 |
|---|---|---|
| 内置插件 | 应用安装目录内 src/plugins/<插件目录>/(开发态) |
是(例外:user-system 默认关闭,需用户显式启用) |
| 用户插件 | <userData>/plugins/<插件目录>/(插件管理里「打开插件目录」可直接打开) |
否(用户显式开启) |
- 目录名建议与
manifest.id一致(不一致时以id为准);用户插件与内置插件id相同时用户插件优先(本地调试用的逃生门,不是分发方式)。 - 入口 HTML 里的本地脚本 / 样式会被自动内联进沙箱(
<script src>→<script>、<link rel="stylesheet">→<style>);上限 20 个文件 / 共 2MB,超出部分会被替换为一条错误提示脚本。 - 不支持
type="module"(srcdoc 里无法解析相对导入);引用..或绝对路径会被拒绝。 - 插件脚本运行在沙箱里,宿主已注入
window.zly。启动时必须调用一次window.zly.ready(cb),否则宿主会在 5 秒后把插件标记为「加载超时」并自动禁用。
3. manifest 字段表
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 插件唯一标识;^[a-zA-Z0-9_-]{2,40}$(不含点) |
name | string | 是 | 展示名称(中文亦可) |
version | string | 是 | 语义化版本,必须形如 1.0.0(三段数字) |
apiVersion | string | 是 | 宿主接口版本;必须是数字字符串(当前宿主支持 "1") |
description | string | — | 一句话描述,展示在插件管理里(缺省空串) |
icon | string | — | 宿主内置图标名(如 puzzle / table / code-block / type);未知值回退为 puzzle |
entry | string | — | 入口 HTML 相对路径,默认 index.html;拒绝绝对路径、盘符 / 协议前缀与任何 .. |
permissions | string[] | — | 权限声明(见第 5 节);重复项与未知权限都会导致清单校验失败 |
contributes | object | — | 贡献点声明(见第 4 节),用于展示与自洽校验 |
清单校验失败时会一次列出全部问题(中文),插件在管理界面显示「加载失败」并给出原因,不会影响其它插件与主程序。缺省值:entry 为 index.html,permissions 为 [],contributes 各类为空数组。
4. 扩展点声明(contributes)
| 键 | 元素字段 | 说明 |
|---|---|---|
commands | id / title / category / shortcut | 命令声明;shortcut 仅是展示文案 |
toolbarButtons | id / title / icon / commandId | 编辑器工具栏按钮 |
panels | id / title / icon | 右栏插件面板 |
sidebarItems | id / title / icon / commandId | 左侧导航项 |
previewActions | id / title / icon / commandId / shortcut | 预览区选中文字的浮动操作;shortcut 在这里是真实按键 |
settingsSections | id / title / description | 设置面板中的分区 |
- 所有
id只允许字母、数字、下划线、连字符、点,长度 1~64,同类内不可重复。 commandId填的是插件内局部 id(如wrap-quote),宿主会自动加插件前缀(my-first-plugin.wrap-quote)避免撞车。
声明与运行时注册是两件事。contributes 只用于界面展示与冻结前的自洽校验;真正把按钮 / 面板挂到界面上的是 ready 回调里的 window.zly.register.* 调用。两者应保持一致,否则界面上看到的能力与声明不符。
5. 权限清单(默认拒绝)
未在 permissions 里声明的接口,调用时一律返回 PERMISSION_DENIED。无需权限的接口:events.on / events.off / log。
| 权限 | 覆盖的接口 |
|---|---|
editor:read | editor.getContent / editor.getSelection / editor.getCursor / preview.getSelection |
editor:write | editor.insertText / editor.replaceSelection / editor.setContent / editor.wrapSelection / editor.setSelection / editor.focus / preview.wrapSelection / preview.replaceSelection / preview.insertBlock |
notes:read | notes.getCurrent / notes.getMetadata / notes.list |
notes:write | notes.updateMetadata / notes.save |
storage | storage.get / storage.set / storage.remove / storage.keys |
ui | ui.showToast / ui.confirm / ui.openPanel / ui.getTheme |
ui:toolbar | register.toolbarButton |
ui:panel | register.panel |
ui:sidebar | register.sidebarItem |
ui:preview | register.previewAction |
commands | register.command |
settings | register.settingsSection |
account | account.status / account.login / account.register / account.captcha / account.logout / account.openLoginDialog / account.syncNow |
balance | balance.status / balance.add / balance.remove / balance.test / balance.activate / balance.deactivate |
announcements | announcements.status / announcements.list / announcements.refresh / announcements.markRead / announcements.markAllRead |
net:<host> | 放行网络访问;同时决定沙箱 CSP 的 connect-src |
net: 权限不只是声明,而是真实的网络访问控制:
| 声明 | 生成的 CSP connect-src |
|---|---|
无 net:* | 'none'(插件完全无法发起网络请求) |
net:api.example.com | https://api.example.com + 本地回环(localhost / 127.0.0.1) |
net:* | https: + http: + 本地回环 |
6. 能力域与接口
所有方法返回 Promise:成功时 resolve 返回值,失败时 reject 一个 Error,其 .code 为第 12 节的错误码、.message 为中文说明。
| 能力域 | 说明 |
|---|---|
zly.editor | 读写正文、选区、光标:getContent / getSelection / getCursor / insertText / replaceSelection / setContent / wrapSelection / setSelection / focus |
zly.notes | 当前笔记与元数据:getCurrent / getMetadata / list / updateMetadata / save |
zly.storage | 插件私有数据(写在当前笔记 pluginData[pluginId]):get / set / remove / keys |
zly.ui | showToast / confirm(异步,需 await) / openPanel / getTheme |
zly.preview | 预览区选中 → 正文源码:getSelection / wrapSelection / replaceSelection / insertBlock |
zly.register | 注册扩展点:command / toolbarButton / panel / sidebarItem / previewAction / settingsSection |
zly.account | 账号与云同步(双端门禁):status / login / register / captcha / logout / openLoginDialog / syncNow |
zly.balance | 后端节点负载均衡:status / add / remove / test / activate / deactivate |
zly.announcements | 双端插件「公告」:status / list / refresh / markRead / markAllRead |
接口速查:
window.zly = {
plugin: { id, manifest, apiVersion, raw(method, params) },
editor: {
getContent(), getSelection(), getCursor(),
insertText(text), replaceSelection(text), setContent(text),
wrapSelection(before, after), setSelection(from, to?), focus(),
},
preview: { getSelection(), wrapSelection(before, after), replaceSelection(text), insertBlock(markdown) },
notes: { getCurrent(), getMetadata(), list(), updateMetadata(patch), save() },
storage: { get(key?), set(key, value), remove(key), keys() },
ui: { showToast(message, type?), confirm(message), openPanel(panelId?), getTheme() },
account: { status(), login(username, password, captcha?), register(username, password, captcha?), captcha(), logout(), openLoginDialog(), syncNow() },
balance: { status(), add(url), remove(url), test(), activate(), deactivate() },
register: {
command(def), toolbarButton(def), panel(def),
sidebarItem(def), previewAction(def), settingsSection(def),
},
events: { on(event, handler), off(event, handler) },
log: { info(...a), warn(...a), error(...a) },
ready(callback?),
}
双端门禁:account / announcements 类能力要求「客户端插件启用」且「服务端同名插件状态为 ready」。任一端未满足则不请求、不展示,接口 reject FEATURE_DISABLED + 中文原因。
主题令牌由宿主统一以 --zly- 前缀注入沙箱 :root(如 --zly-color-text、--zly-space-3、--zly-font-sans)。插件界面应引用令牌而非硬编码颜色,以自动适配双主题;主题切换时宿主还会推送 theme:change 事件。
7. 事件清单
| 事件 | 触发时机 | 载荷 |
|---|---|---|
app:ready | 插件宿主完成一次「发现 → 校验 → 启动」 | { pluginCount, loaded } |
note:open | 当前笔记切换 | { filePath, noteId } |
note:save | 当前笔记保存成功 | { filePath, noteId } |
note:change | 当前笔记正文变化(300ms 节流) | { filePath, noteId, length } |
editor:selection | 编辑器选区变化(200ms 节流,文本截断到 200 字) | { from, to, text } |
theme:change | 主题切换 | { theme, tokens } |
command:invoke | 宿主执行了你注册的命令(定向事件,只发给你的插件) | { commandId, registeredId } |
toolbar:invoke | 点击了你注册的工具栏按钮(定向事件;仅该按钮没有 commandId 时才走) | { id } |
preview:invoke | 点击了你注册的预览区操作(定向事件;仅该操作没有 commandId 时才走) | { id } |
8. 前端插件最小示例
manifest.json:
{
"id": "my-first-plugin",
"name": "我的第一个插件",
"version": "1.0.0",
"apiVersion": "1",
"description": "演示工具栏按钮 + 面板 + 读写编辑器内容",
"icon": "puzzle",
"entry": "index.html",
"permissions": ["editor:read", "editor:write", "storage", "ui", "commands", "ui:toolbar", "ui:panel"],
"contributes": {
"commands": [{ "id": "wrap-quote", "title": "把选中文字变成引用", "category": "我的插件" }],
"toolbarButtons": [
{ "id": "quote", "title": "变成引用", "icon": "quote", "commandId": "wrap-quote" }
],
"panels": [{ "id": "main", "title": "我的插件", "icon": "puzzle" }]
}
}
index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>我的第一个插件</title>
<style>
body {
margin: 0;
padding: var(--zly-space-3, 12px); /* 主题令牌已注入,前缀是 --zly- */
color: var(--zly-color-text, #e8eefc);
background: var(--zly-color-bg-panel, #121c2f);
font-family: var(--zly-font-sans, system-ui, sans-serif);
font-size: var(--zly-font-size-sm, 13px);
}
</style>
</head>
<body>
<p id="status">正在等待宿主初始化…</p>
<button id="quote" type="button">把选中文字变成引用</button>
<script>
// 插件脚本运行在沙箱里,window.zly 已由宿主注入,可放心使用
;(function () {
'use strict'
var statusEl = document.getElementById('status')
/** 读编辑器选区 → 每行前加 "> " → 写回 */
function wrapQuote() {
return window.zly.editor
.getSelection()
.then(function (selection) {
var text = selection.text || '在这里输入引用内容'
var quoted = text
.split('\n')
.map(function (line) { return '> ' + line })
.join('\n')
return window.zly.editor.replaceSelection(quoted)
})
.then(function () {
statusEl.textContent = '已插入引用'
return window.zly.storage.set('lastAction', 'quote') // 写进当前笔记的 pluginData
})
.catch(function (error) {
// 失败时 error.code 是结构化错误码,error.message 是中文说明
statusEl.textContent = '失败 [' + error.code + '] ' + error.message
})
}
document.getElementById('quote').addEventListener('click', wrapQuote)
// ① 通知宿主「我准备好了」——这一步必须做,否则宿主 5 秒后会把插件标记为加载失败
window.zly.ready(function (init) {
statusEl.textContent = '已加载 · API v' + init.apiVersion + ' · 主题 ' + init.theme
// ② 注册命令(元数据 + run 函数;run 只能活在插件侧)
window.zly.register.command({
id: 'wrap-quote',
title: '把选中文字变成引用',
category: '我的插件',
run: wrapQuote,
})
// ③ 注册工具栏按钮:优先用 commandId,宿主点击时执行对应命令
window.zly.register.toolbarButton({
id: 'quote',
title: '变成引用',
icon: 'quote',
commandId: 'wrap-quote',
})
// ④ 注册面板:注册后右栏「插件面板」会出现可切换的标签
window.zly.register.panel({ id: 'main', title: '我的插件', icon: 'puzzle' })
})
})()
</script>
</body>
</html>
把该目录放进 <userData>/plugins/ 后,在「插件管理」里启用它:工具栏会出现「变成引用」按钮,命令面板(Ctrl+Shift+P)能搜到命令,右栏能打开插件面板。
9. 后端插件
后端插件跑在服务端子工程的 worker 软沙箱里:能力白名单 + SQL 白名单 + 超时与崩溃隔离。清单除公共字段外支持 defaultEnabled、tables 与 routes({ method, path, auth })。
宿主注入的 ctx:
| 能力 | 说明 |
|---|---|
ctx.manifest | 插件清单(只读) |
ctx.log(level, message) | 写宿主日志(中文) |
ctx.config.get(key) | 读取宿主配置(如 SMTP 配置) |
ctx.db.query(sql, params) | 受 SQL 白名单限制的数据库访问(仅本插件声明的表) |
ctx.storage.* | 插件私有键值存储 |
ctx.resources.read(name) | 只读资源:读本插件目录内 resources/<name> 的文本,返回 { ok: true, text };名字禁 .. / 绝对路径 / 分隔符,单文件上限 256KB,按 mtime 缓存 |
manifest.json:
{
"id": "hello-backend",
"name": "示例后端插件",
"version": "1.0.0",
"apiVersion": "1",
"entry": "index.cjs",
"defaultEnabled": true,
"tables": [],
"routes": [{ "method": "GET", "path": "/api/hello", "auth": false }]
}
index.cjs:
'use strict'
// 读取插件目录内 resources/greeting.txt(宿主做名字白名单 + 256KB 上限 + mtime 缓存)
async function greeting(ctx) {
try {
const read = await ctx.resources.read('greeting.txt')
return { status: 200, body: { ok: true, text: read.text } }
} catch (error) {
// 任何失败都收敛为中文原因,绝不让异常逸出(否则宿主会把插件标记 error)
return { status: 200, body: { ok: true, text: '', reason: '资源不可用:' + error.message } }
}
}
module.exports = {
async handleRequest(req, ctx) {
const method = String(req.method || '').toUpperCase()
const path = String(req.path || '').split('?')[0]
if (method === 'GET' && path === '/api/hello') return greeting(ctx)
return { status: 404, body: { ok: false, code: 'NOT_FOUND', message: '接口不存在' } }
},
}
边界:这是软沙箱的一部分,不是 OS 级隔离;只应加载可信来源的插件。
10. .zlymodel 打包与分发
.zlymodel 是与 .zly 同理念的 JSON 明文单文件包格式:可 diff、可手改、零新依赖(不需要解压库)。它与「目录形式插件」完全并存,加载链路完全统一。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
format | string | 是 | 必须严格等于 zlymodel(大小写敏感) |
schemaVersion | number | 是 | 正整数,当前宿主支持 1;更高版本直接拒绝,不猜测结构 |
manifest | object | 是 | 与 manifest.json 完全同构,复用同一套校验器(问题带 manifest. 前缀) |
files | array | 是 | 非空数组,条目数 ≤ 64;必须包含 manifest.entry 指向的文件 |
files[].path | string | 是 | 相对插件目录的路径;拒绝绝对路径、盘符 / 协议前缀与任何 ..;包内去重(Windows 语义下大小写不敏感) |
files[].content | string | 是 | 非空字符串;utf8 时是文本本身,base64 时是 base64 文本 |
files[].encoding | string | — | utf8(缺省)或 base64;二进制资源用 base64 |
meta | object | — | 仅用于展示,不参与运行;支持 author / homepage / description |
| 体积与条目限制 | 上限 |
|---|---|
files 条目数 | 64 |
| 单文件解码后字节数 | 2 MB |
| 全部文件解码后总量 | 16 MB |
| base64 校验 | 严格:先校验 → 再解码 → 后按解码后字节数判体积 |
所有安全检查都发生在写盘之前:校验失败时插件目录不会有任何残留(既无临时目录,也无半装目录)。
| 导入方式 | 操作 | 结果 |
|---|---|---|
| 拖入窗口 | 把 .zlymodel 拖到应用窗口任意位置,出现全屏高亮遮罩后松手 | 成功 toast 显示「插件名 + 版本 + 文件数」;插件列表出现该插件,状态已停用 |
| 手动添加 | 插件管理 → 顶部「添加插件」→ 文件选择框(过滤器只有 zlymodel,可多选) | 同上;多个包给出汇总提示 |
| 情形 | 结果 | 说明 |
|---|---|---|
目标 <plugins>/<id>/ 已存在 | ALREADY_EXISTS | 界面弹二次确认;确认后带 overwrite: true 重试,只替换插件目录,不触碰该插件的 pluginData |
manifest.id 与内置插件同名 | BUILTIN_ID_TAKEN | 直接拒绝、不写盘(内置插件 id:md-enhance / table-tools / code-tools / user-system / announcements / load-balancer) |
| 包内容非法(路径穿越 / 缺入口 / 超限 / 版本过高 / 清单非法) | PARSE_FAILED | 中文原因一次列全,插件目录无残留 |
11. 超时、配额与稳定性约束
| 项 | 值 | 说明 |
|---|---|---|
| 握手超时 | 5 秒 | iframe load 后 5 秒内没收到 plugin:ready → 标记「加载失败」并自动禁用 |
| 普通 RPC 超时 | 5 秒 | 超时后 reject TIMEOUT,迟到的结果被丢弃 |
register.* 超时 | 3 秒 | 注册是本地操作,不该慢 |
| 单次返回值体积 | 512KB | 超出 QUOTA_EXCEEDED;别把整篇大文档一次性取回 |
storage.set 单值 | 256KB | 超出 QUOTA_EXCEEDED |
| 资源内联 | 20 个文件 / 2MB | 超出部分替换为错误提示脚本 |
| 崩溃自愈 | 连续 3 次未捕获异常 | 自动禁用插件:销毁沙箱、注销其全部命令与扩展点并提示;主程序与其它插件、笔记数据不受影响 |
| 插件日志缓冲 | 最近 200 条 | 超出后丢弃最旧的 |
12. 错误码
12.1 RPC 错误码(error.code)
| 错误码 | 含义 | 常见原因 |
|---|---|---|
PERMISSION_DENIED | 权限不足 | 未在 permissions 声明;或调用了未知接口(未登记的方法一律拒绝) |
UNKNOWN_METHOD | 方法不存在 | 拼写错误;使用了宿主未提供的方法 |
INVALID_PARAMS | 参数不合法 | 类型错误、必填缺失、id 含非法字符等(message 会指出具体字段) |
TIMEOUT | 调用超时 | 普通 RPC 超过 5 秒;register.* 超过 3 秒 |
NOT_AVAILABLE | 依赖未就绪 | 未打开笔记、预览区未展开、预览选区无法映射回源码 |
INTERNAL_ERROR | 宿主内部异常 | 宿主实现抛错,请附日志反馈 |
MANIFEST_INVALID | 清单不可用 | 清单校验未通过,或插件未成功加载却仍在发起调用 |
API_VERSION_UNSUPPORTED | 接口版本不支持 | 插件 apiVersion 高于宿主支持值 |
PLUGIN_DISABLED | 插件已停用 | 插件被禁用,或崩溃自愈停用后仍在发起调用 |
QUOTA_EXCEEDED | 超出配额 | storage.set 单值超过 256KB;或单次 RPC 返回值超过 512KB |
FEATURE_DISABLED | 双端门禁未满足 | 客户端插件未启用,或服务端同名插件非 ready |
try {
await window.zly.editor.getContent()
} catch (error) {
if (error.code === 'NOT_AVAILABLE') {
// 优雅降级:提示用户先打开一篇笔记
await window.zly.ui.showToast('请先打开一篇笔记', 'warning')
} else {
await window.zly.log.error(error.code, error.message)
}
}
12.2 .zlymodel 导入错误码(install 结果里的 code)
| 错误码 | 含义 |
|---|---|
PARSE_FAILED | 包不是合法 JSON、format 不等于 zlymodel、schemaVersion 过高、清单非法、路径穿越、缺入口文件或超出体积上限 |
ALREADY_EXISTS | 目标 <plugins>/<id>/ 已存在 |
BUILTIN_ID_TAKEN | 包的 manifest.id 与内置插件同名 |
IO_ERROR | 读源文件失败、插件目录不可写、rename 失败 |
13. 常见坑与排查
| 现象 | 原因 | 处理 |
|---|---|---|
| 插件 5 秒后被自动禁用,提示「加载超时」 | 没有调用 zly.ready(),或脚本在 ready 之前就抛错 | 确保 ready() 在脚本加载后立刻调用 |
| 状态显示「不兼容」 | apiVersion 高于宿主,或不是数字字符串 | 改为 "1" |
| 状态显示「加载失败」并列出中文原因 | 清单校验未通过(id 非法、version 非语义化、权限未知、entry 越界…) | 按提示逐条修正,校验会一次列全 |
| 按钮 / 面板不出现 | 只写了 contributes 声明,没有在 ready 回调里真正调用 register.* | 补齐运行时注册,并保持两者一致 |
| 点击按钮没反应 | 只给了 onClick 却忘了权限;或 commandId 拼错(应为局部 id) | 检查 permissions 与 commandId |
调用报 PERMISSION_DENIED | 未声明权限,或方法名未登记(未知接口同样拒绝) | 补 permissions;用 zly.plugin.raw 复查方法名 |
调 editor.* 报 NOT_AVAILABLE | 当前没有打开笔记 | 先提示用户打开 / 新建笔记,再重试 |
| 预览浮条不出现 | 没有可映射的预览选区(未展开预览 / 选区在编辑器里 / 选区无法定位到源码) | 这是预期行为:映不到源码时宁可拒绝也不写错位置 |
| 插进正文的 HTML 没有样式 | 用了 class 或非白名单 style 属性 | 改用内联 style 的 color / background-color / font-size 等 |
<script type="module"> 不执行 | srcdoc 不支持 ES module 相对导入 | 打包成单文件,或去掉 type="module" |
| 插件改完没生效 | 沙箱已缓存 | 插件管理里点「重载插件」 |
| 数据没保存 | 只写了 storage.set 但笔记未触发保存 | 写入会标记笔记为脏,等自动保存或主动调 notes.save() |
| 插件被禁用后数据丢了吗? | 不会 | 禁用只销毁沙箱,pluginData 保留在笔记文件里 |
14. 验证脚本与探针
本项目采用三层验证口径,提交前请至少跑通与你改动相关的部分:
- 纯函数层:
scripts/verify-*.mjs直接对真实源码断言(长期保留),如node scripts/verify-plugin-kernel.mjs。 - 代码健康度门禁:
node scripts/verify-code-health.mjs必须保持 0 违规(硬门禁)。 - 端到端层:
test/probe-*.mjs用真实 Electron + CDP 驱动界面与真实鼠标 / 键盘。
约定:
- 批量运行统一用加固运行器
node scripts/run-checks.mjs(逐个限时 + 输出落盘 + 每项后清理残留进程)。 - 端到端探针必须逐个串行运行(
node test/probe-xxx.mjs),不要并行 —— 多个 Electron / 桩服务并发会互相干扰,制造假失败。 - 探针一律放
test/,长期保留、可重复执行;一次性现场诊断脚本才允许在验证后删除并在总结文档中列出。 - 探针必须自建隔离 profile(
--user-data-dir=<临时目录>或本地桩服务)并收尾自清理,绝不触碰真实%APPDATA%。 - 账号 / 云同步类探针需先启动本地后端(见
test/README.md)。 - 零新增 npm 依赖:能用 Node 内置能力实现的都自行实现。
- 中文注释与用户可见文案(面向用户的报错、提示一律中文)。
npm run build # 端到端探针前置:先构建
node scripts/run-checks.mjs # 批量跑自检与探针
node scripts/verify-open-source.mjs # 提交前的开源审查(许可 / 忽略规则 / 待入库清单)
15. 目录结构
zlynotes/
├── src/ 渲染进程(React + TypeScript)
│ ├── core/ 内核:笔记 / 设置 / 插件宿主 / 网络 / API
│ ├── plugins/ 内置插件(每个插件一个目录)
│ │ ├── md-enhance/ Markdown 增强
│ │ ├── table-tools/ 表格工具
│ │ ├── code-tools/ 代码块工具
│ │ ├── user-system/ 用户系统(默认关闭)
│ │ ├── announcements/ 双端插件「公告」的客户端侧
│ │ └── load-balancer/ 负载均衡
│ └── components/ 界面组件
├── electron/ 主进程(窗口 / 原生能力 / 插件包安装 / 自动更新)
├── server/ 后端子工程(Express + better-sqlite3)
│ ├── plugins/ 后端插件(manifest.json + index.cjs)
│ │ ├── user-system/ 账号 / 邮箱验证码 / 图片验证码
│ │ └── announcements/ 公告(含 resources/announcements.json)
│ └── src/ 宿主:路由 / 插件软沙箱 / 配置
├── scripts/ 纯函数自检(verify-*.mjs)与构建脚本
├── test/ 端到端探针(probe-*.mjs)与公共库
├── docs/ 规范 / 架构 / 每轮交付记录 / 决策
├── web/ 本地站点(不随仓库分发)
└── release/ 打包产物(不入库)
16. 贡献流程
- 阅读
docs/plugin-spec.md与仓库CONTRIBUTING.md。 - 开发插件或改动代码后,补上对应的
scripts/verify-*.mjs断言或test/probe-*.mjs探针。 - 本地跑通:
npm run build、node scripts/verify-code-health.mjs(0 违规)、相关探针逐个串行运行。 - 提交前运行
node scripts/verify-open-source.mjs,确认没有把密钥 / 数据库 / 构建产物纳入版本库。 - 提交信息用「类型前缀 + 中文简述」(如
fix: 修复左下角账号下拉被遮挡)。
17. 许可
本项目(含源码、安装包、文档与插件规范)以
PolyForm Noncommercial License 1.0.0 发布。许可全文随源码仓库分发(仓库根目录 LICENSE),
官方文本见
polyformproject.org/licenses/noncommercial/1.0.0;
许可以英文原文为准。
向本项目提交的贡献(代码、文档、示例、测试等)按同一许可分发。