diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index 52aeb855..1f34a9e0 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -1,18 +1,41 @@
-# EdgeEver Plugin Development (P0 Preview)
+# EdgeEver Plugin Development Guide
-EdgeEver's P0 extension API supports trusted client plugins and no-code theme packages. Users can install extensions from the verified marketplace, a public GitHub repository, or a manifest URL. The install list follows the current workspace on Web and desktop; each browser or desktop app downloads and verifies the package itself. Android and iOS apps do not run plugins. Extensions run only while EdgeEver is open. On desktop, users can schedule a registered plugin command while EdgeEver is running. Webhooks, an always-on server background runtime, unrestricted TipTap extensions, and a hard JavaScript sandbox are not part of this preview.
+[简体中文](plugin-development.zh-CN.md)
-## Security model
+EdgeEver provides client plugin and no-code theme extension capabilities. Users can install extensions from the official marketplace, public GitHub repositories, or manifest URLs. Installed extensions sync across Web and desktop platforms with the current workspace and run while EdgeEver is open; Android and iOS native apps do not run plugins.
-Theme packages contain only a validated manifest and documented design tokens. They do not execute JavaScript.
+> [!NOTE]
+> **Security Model**: Client plugins use a trusted-code model (similar to Obsidian). Enabling a plugin grants it trust to run within the client JavaScript environment and invoke plugin APIs. Capability declarations (`permissions`) in the manifest disclose the functional scope to users and do not enforce a hard sandbox. Theme packages are purely declarative JSON and execute no JavaScript.
-Client plugins use an Obsidian-style trusted-code model. Enabling a plugin trusts it with the full EdgeEver plugin context; declared capabilities are optional descriptive metadata and do not gate API calls. The plugin module runs in the client JavaScript environment, so users must install plugins only from developers they trust.
+---
-The first time a user enables a community client plugin on a device, EdgeEver presents a community-plugin trust confirmation. Once acknowledged, it is not shown for every plugin. Official plugins (publisher EdgeEver) do not trigger the confirmation. Theme packages also do not trigger it because they cannot execute JavaScript.
+## Table of Contents
-Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bindings, or internal React state through the public API.
+- [Plugin Manifest](#plugin-manifest)
+ - [Fields](#fields)
+ - [Available Permissions](#available-permissions)
+- [Plugin Entry & Lifecycle](#plugin-entry--lifecycle)
+- [API Reference](#api-reference)
+ - [1. Notes & Notebooks](#1-notes--notebooks)
+ - [2. Resources & Attachments](#2-resources--attachments)
+ - [3. Editor & Embeds](#3-editor--embeds)
+ - [4. UI, Commands & Panels](#4-ui-commands--panels)
+ - [5. Settings](#5-settings)
+ - [6. Storage & Secrets](#6-storage--secrets)
+ - [7. Network Requests](#7-network-requests)
+ - [8. Schedules (Desktop Only)](#8-schedules-desktop-only)
+ - [9. Events](#9-events)
+- [Themes (No-Code Extensions)](#themes-no-code-extensions)
+- [Packaging, Distribution & Local Testing](#packaging-distribution--local-testing)
+ - [Packaging Rules](#packaging-rules)
+ - [GitHub Distribution](#github-distribution)
+ - [Local Testing](#local-testing)
-## Plugin manifest
+---
+
+## Plugin Manifest
+
+Every plugin must supply a `manifest.json` at its root:
```json
{
@@ -22,11 +45,11 @@ Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bind
"version": "1.0.0",
"apiVersion": "2",
"settingsUi": "host",
- "description": "Adds a command for recent notes.",
+ "description": "Shows recently updated notes.",
"locales": {
"zh-CN": {
"name": "最近笔记",
- "description": "添加一个查看最近笔记的命令。"
+ "description": "查看最近更新的笔记。"
}
},
"entry": "./main.js",
@@ -35,517 +58,449 @@ Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bind
}
```
-The manifest and JavaScript module must be served with CORS headers that permit the EdgeEver origin. Relative `entry` paths resolve against the manifest URL.
+### Fields
-The top-level `name` and optional `description` remain the fallback copy. Plugins and themes can add a `locales` object keyed by BCP 47 language tags, such as `zh-CN`, `en-US`, or `ja`. Each locale can override `name`, `description`, or both. EdgeEver first matches the current interface locale, then the same base language, and finally falls back to the top-level fields. This localizes marketplace and plugin-manager metadata. Plugin setting fields can also declare `locales` with translated `label`, `description`, and (for text inputs) `placeholder`; select fields can translate option labels with an `options` object keyed by option value. The host selects setting copy using the same locale fallback. Runtime commands, panels, and notices remain the plugin's responsibility.
+| Field | Type | Required | Description |
+| :--- | :--- | :---: | :--- |
+| `type` | `"plugin"` | Yes | Fixed value: `"plugin"`. |
+| `id` | `string` | Yes | Globally unique plugin ID. Reverse-domain format recommended (e.g. `com.example.plugin`). |
+| `name` | `string` | Yes | Default display name. |
+| `version` | `string` | Yes | SemVer-compliant version string (e.g. `1.0.0`). |
+| `apiVersion` | `"2"` | Yes | Plugin API version; currently `"2"`. |
+| `settingsUi` | `"host"` | Yes | Settings UI renderer; currently `"host"` (rendered by host). |
+| `entry` | `string` | Yes | Entry script path; fixed to `./main.js` for GitHub distribution. |
+| `description` | `string` | No | Default description. |
+| `locales` | `object` | No | Localized metadata keyed by BCP 47 language tags (e.g. `zh-CN`, `en-US`), overriding `name` and `description`. |
+| `platforms` | `string[]` | No | Supported platforms: `"web"`, `"desktop"`. |
+| `permissions` | `string[]` | No | Declared capabilities disclosed to users during install and update. |
+| `settings` | `object` | No | Declarative settings schema rendered by host (detailed below). |
-## GitHub distribution
+### Available Permissions
-Developers can share a public GitHub repository URL directly with users. The default branch must contain the latest `manifest.json` at its root, and every version is published as a GitHub Release. The Release tag may be the manifest version or its `v`-prefixed form, such as `1.2.0` or `v1.2.0`.
+Permissions are declared to transparently inform users of plugin capabilities:
+- **Notes & Content**: `notes:read`, `notes:write`, `notes:delete`, `templates:read`, `templates:write`, `metadata:read`, `metadata:write`, `resources:read`, `resources:write`
+- **Editor**: `editor:read`, `editor:write`
+- **UI & Interaction**: `ui:commands`, `ui:navigation`, `ui:notices`, `ui:panels`, `ui:embeds`
+- **Storage & System**: `storage`, `secrets`, `network`, `network:public`, `schedules`
-Attach these files to the Release:
+---
-```text
-manifest.json
-main.js
-styles.css (optional)
-```
+## Plugin Entry & Lifecycle
-GitHub plugins must use `./main.js` as `entry`, and `main.js` must be a single-file bundle without relative module imports. EdgeEver reads the default-branch manifest, locates the matching Release, downloads assets in parallel, verifies GitHub's SHA-256 digest when present, and caches the verified package in device-local IndexedDB. Marketplace GitHub metadata and Release assets are fetched through the current EdgeEver instance, so the desktop or browser client does not need direct access to `api.github.com`. `main.js` is limited to 5 MB and `styles.css` to 1 MB.
-
-EdgeEver checks for updates when the Plugin Marketplace opens, when the window regains focus, and every 30 minutes. Marketplace installs whose Registry entry declares `"publisher": "edgeever"` are official EdgeEver extensions. The bundled Registry version is the verified baseline; EdgeEver then resolves the live GitHub default-branch Release through the instance and auto-updates to that newer checksum-pinned version. Community Marketplace extensions and extensions installed directly from GitHub or a Manifest URL are never updated silently: the user must click Update and confirm. If a manually confirmed version changes declared capabilities or legacy network-host metadata, the confirmation lists those changes for review. For GitHub distribution, the Release `manifest.json` must exactly match the default-branch manifest used for the update prompt or installation is rejected.
-
-Updates use a rollback-capable switch. Old and new package versions are cached separately. If the new version cannot activate, EdgeEver restores the previous manifest, enabled state, and package instead of leaving the plugin broken or disabled.
-
-Users can freely install without marketplace admission by pasting this into the standalone Plugin Marketplace page:
-
-```text
-https://github.com/owner/edgeever-plugin
-```
-
-Only public GitHub repositories are supported for now; private-repository tokens are not exposed.
-
-## Verified plugin marketplace
-
-The official marketplace only lists free and open-source plugins. Complete human-readable source, an accepted open-source license, build information, and a traceable public source revision are required for every listed version. This requirement applies only to official marketplace admission; users remain free to install other plugins from GitHub or a Manifest URL. See the [Plugin Marketplace Submission Policy](plugin-marketplace-policy.md) for the complete requirements.
-
-The marketplace is a verified Registry and does not take ownership of plugin files. For each version, the Registry pins the plugin ID, GitHub repository, version, and SHA-256 hashes for `manifest.json`, `main.js`, and optional `styles.css`. Installation still downloads from the developer's GitHub Release or registered public URL and verifies those hashes again. The optional `"publisher": "edgeever"` marker is reserved for Registry entries maintained by the EdgeEver project; it enables automatic updates and must not be used for community submissions.
-
-Official entries ship inside the app as `extensions/registry.json`. Community entries are appended from a separate signed catalog when the instance sets `EDGE_EVER_COMMUNITY_REGISTRY_URL`. That catalog cannot change an official repository URL or mark an entry as official. A community badge and license mean the listing passed the admission check. They do not mean EdgeEver guarantees the plugin is safe. Self-hosted instances leave the variable unset and show only the built-in official plugins. To submit a plugin, follow [tianma-if/edgeever-plugins](https://github.com/tianma-if/edgeever-plugins).
-
-Registry format:
-
-```json
-{
- "registryVersion": "1",
- "updatedAt": "2026-08-16T00:00:00.000Z",
- "entries": [{
- "id": "com.example.recent-notes",
- "name": "Recent Notes",
- "description": "Shows recently updated notes.",
- "locales": {
- "zh-CN": {
- "name": "最近笔记",
- "description": "显示最近更新的笔记。"
- }
- },
- "author": "EdgeEver",
- "publisher": "edgeever",
- "category": "Productivity",
- "repositoryUrl": "https://github.com/example/edgeever-recent-notes",
- "distribution": {
- "type": "github",
- "repositoryUrl": "https://github.com/example/edgeever-recent-notes"
- },
- "verification": {
- "version": "1.0.0",
- "checksums": {
- "manifestJson": "<64-character SHA-256>",
- "mainJs": "<64-character SHA-256>"
- }
- }
- }]
-}
-```
-
-Marketplace installs display a Verified badge. GitHub and manifest sideloads clearly display their unverified source, but EdgeEver does not block them. Uninstalling also deletes all device-local cached package versions, ordinary plugin storage for the current workspace, and Secret Storage.
-
-Optional capability declarations, retained for disclosure and compatibility:
-
-- `notes:read`
-- `notes:write`
-- `notes:delete`
-- `templates:read`
-- `templates:write`
-- `metadata:read`
-- `metadata:write`
-- `resources:read`
-- `resources:write`
-- `network`
-- `storage`
-- `secrets`
-- `schedules`
-- `editor:read`
-- `editor:write`
-- `ui:commands`
-- `ui:navigation`
-- `ui:notices`
-- `ui:panels`
-- `ui:embeds`
-
-Network access through `context.network.fetch()` is not restricted by capability declarations or a static host list. `networkHosts` remains accepted as legacy descriptive metadata and is not enforced. The anonymous, read-only `network:public` transport is available when a plugin needs a cross-origin public response without credentials.
-
-## Plugin entry
+The plugin entry script (`main.js`) exports a default object containing an `activate` lifecycle function:
```js
export default {
activate(context) {
- return context.commands.register({
- id: "count-recent-notes",
- title: "Count recent notes",
- async run() {
- const result = await context.notes.query({
- sort: "updated-desc",
- limit: 10
- });
- context.ui.showNotice(`${result.notes.length} recent notes`);
+ // Register commands
+ const disposeCommand = context.commands.register({
+ id: "hello-world",
+ title: "Say Hello",
+ run() {
+ context.ui.showNotice("Hello from EdgeEver Plugin!");
}
});
+
+ // Return cleanup callback (invoked on deactivation)
+ return () => {
+ disposeCommand();
+ };
}
};
```
-For TypeScript projects, import contracts and helpers from `@edgeever/plugin-api`:
+TypeScript projects can use `@edgeever/plugin-api`:
```ts
import { definePlugin } from "@edgeever/plugin-api";
export default definePlugin({
activate(context) {
- // Register commands and event listeners here.
- }
-});
-```
-
-Every registration returns a disposer. The host also disposes registered commands and events automatically when a plugin is disabled.
-
-Commands appear on the plugin marketplace card by default. Set `listed: false` for editor-context or secondary commands that belong in the plugin toolbar menu instead of the install card:
-
-```js
-context.commands.register({
- id: "insert-task",
- title: "Insert task at cursor",
- listed: false,
- async run() {
- await context.editor.insertAtCursor("- [ ] ");
- }
-});
-```
-
-Workflow and preview panels are also omitted from that card. Open them from a command, the toolbar menu, or another panel.
-
-The plugin toolbar menu lists editor commands and dashboard or onboarding panels. It omits workflow or preview dialogs, and omits a command that only opens a dashboard already in the menu. Set `menu: false` when a command should appear on the marketplace card but not next to its dashboard panel:
-
-```js
-context.commands.register({
- id: "open-dashboard",
- title: "Open task dashboard",
- menu: false,
- run: () => context.ui.panels.open("tasks"),
-});
-```
-
-## Schedules API
-
-Desktop plugins can persistently schedule one of their own registered commands. Register the command first, then use a stable plugin-local key with `upsert()`:
-
-```js
-export default {
- async activate(context) {
- context.commands.register({
- id: "refresh-feeds",
- title: "Refresh feeds",
- async run() {
- // Long-running plugin work is allowed while the desktop app remains open.
- }
- });
-
- await context.schedules.upsert({
- key: "hourly-refresh",
- name: "Hourly feed refresh",
- commandId: "refresh-feeds",
- cronExpression: "0 * * * *",
- missedRunPolicy: "run-once"
+ return context.commands.register({
+ id: "hello-world",
+ title: "Say Hello",
+ run: () => context.ui.showNotice("Hello!")
});
}
-};
+});
```
-`upsert()` is idempotent for the pair of plugin ID and schedule key, so calling it on every activation does not create duplicates. The first desktop device that creates the schedule remains its executor; activation on another device updates the same definition without stealing execution. Omitting `isEnabled` preserves the user's enabled/disabled choice. Plugins can inspect and remove their own schedules with `context.schedules.list()` and `context.schedules.remove(key)`.
+> [!TIP]
+> Registration methods (commands, panels, events) return cleanup functions. When a plugin is deactivated, the host also automatically cleans up registered commands and event listeners.
-The execution center retains scheduled-task run history for the most recent 30 days only. Expired records are removed immediately during upgrade and continue to be physically pruned when runs execute or history is viewed.
+---
-### SDK package
+## API Reference
-`@edgeever/plugin-api` is a publish-ready ESM package with generated JavaScript and TypeScript declarations. In the EdgeEver repository, rebuild it after changing public contracts:
+### 1. Notes & Notebooks
-```sh
-bun run build:plugin-api
-```
-
-Maintainers can inspect the exact public package without publishing it:
-
-```sh
-cd packages/plugin-api
-npm pack --dry-run
-```
-
-The package build contains only `dist/index.js`, `dist/index.d.ts`, its README, and package metadata. Plugin projects should bundle SDK runtime helpers into their single-file `main.js`; they must not leave a runtime import of `@edgeever/plugin-api` in the distributed bundle.
-
-## Notes API
+#### Querying Notes
```ts
-context.notes.query({ text, notebookId, tags, sort, limit, offset });
-context.notes.queryContent({ text, notebookId, tags, sort, limit, offset });
-context.notes.get(noteId);
-context.notes.editMarkdown(noteId, { expectedRevision, expectedContentHash, edits });
-context.notes.create({ notebookId, title, contentMarkdown, tags });
-context.notes.update(noteId, { title, contentMarkdown, tags });
-context.notes.delete(noteId, { permanent: false });
-context.notes.move([noteId], notebookId);
-context.notes.pin([noteId], true);
-context.notes.restore(noteId);
-context.notes.revisions.list(noteId);
-context.notes.revisions.restore(noteId, revisionId);
-context.notebooks.list();
-context.notebooks.create({ name, parentId });
-context.notebooks.update(notebookId, { name, parentId, sortOrder });
-context.notebooks.delete(notebookId);
-context.tags.list();
-context.tags.rename("old", "new");
-context.tags.delete("unused");
+// 1. Lightweight summaries (ideal for lists and sidebars; max 200 items per page)
+const result = await context.notes.query({
+ notebookId: "optional-notebook-id",
+ text: "keyword",
+ tags: ["todo"],
+ sort: "updated-desc", // "updated-desc" | "created-desc" | "title-asc"
+ limit: 50,
+ offset: 0
+});
+// Returns { notes: PluginNoteSummary[], totalCount: number, nextOffset: number | null }
+
+// 2. Query notes with full Markdown content (for linters, indexers, etc.)
+const fullNotes = await context.notes.queryContent({
+ tags: ["task"],
+ limit: 20
+});
```
-`notes.query()` returns lightweight summaries. Use `notes.queryContent()` when a plugin must scan Markdown across many notes, such as a Tasks index, Calendar, Kanban board, or Linter. Both APIs accept at most 200 notes per page; follow `nextOffset` until it is `null`. Prefer the summary query whenever full content is unnecessary.
-
-All writes go through EdgeEver's shared repository/business layer, including offline queueing and desktop adapters. Plugins do not access a storage implementation directly.
-`notes.update()` reads the current revision and returns the complete updated note.
-`notes.editMarkdown()` performs optimistic concurrency checks with the `revision` and `contentHash` returned by `notes.get()`. It is intended for plugins such as task togglers, linters, and index maintainers that change only specific Markdown ranges:
+#### Note Operations
```ts
+// Get single note details
const note = await context.notes.get(noteId);
+
+// Create note
+const newNote = await context.notes.create({
+ notebookId: "target-notebook-id",
+ title: "New Note",
+ contentMarkdown: "# Title\nBody text",
+ tags: ["tag1"]
+});
+
+// Update entire note
+await context.notes.update(noteId, {
+ title: "Updated Title",
+ contentMarkdown: "New body",
+ tags: ["updated"]
+});
+
+// Partial optimistic edit with concurrency check (revision & contentHash)
await context.notes.editMarkdown(noteId, {
expectedRevision: note.revision,
expectedContentHash: note.contentHash,
edits: [
- { from: 2, to: 3, insert: "x" },
- { from: note.contentMarkdown.length, to: note.contentMarkdown.length, insert: "\nAppended text" }
+ { from: 0, to: 0, insert: "> Prepend quote\n\n" } // UTF-16 offsets, half-open interval [from, to)
]
});
+
+// Move, pin, and delete
+await context.notes.move([noteId], targetNotebookId);
+await context.notes.pin([noteId], true);
+await context.notes.delete(noteId, { permanent: false }); // Move to trash
+await context.notes.restore(noteId); // Restore from trash
+
+// Note revisions
+const revisions = await context.notes.revisions.list(noteId);
+await context.notes.revisions.restore(noteId, revisionId);
```
-Edit ranges use JavaScript UTF-16 string offsets and half-open ranges `[from, to)`. Ranges in one call must not overlap, exceed the note, or split a Unicode surrogate pair. The host rejects writes with an error carrying `code: "NOTE_CONFLICT"` when the note baseline changed or the active editor has unsaved changes; plugins should reload and ask the user to retry. Invalid ranges use `code: "INVALID_MARKDOWN_EDIT"`. The SDK exports `PluginApiError` and `PluginApiErrorCode` for TypeScript error narrowing.
-Enabled plugins can read and change notebooks and tags without capability gates.
-
-Attachments flow through the same Web/Desktop repository adapters:
+#### Notebooks & Tags
```ts
-context.resources.list(noteId);
+// Notebooks
+const notebooks = await context.notebooks.list();
+const nb = await context.notebooks.create({ name: "New Notebook", parentId: null });
+await context.notebooks.update(nb.id, { name: "New Name" });
+await context.notebooks.delete(nb.id);
+
+// Tags
+const tags = await context.tags.list();
+await context.tags.rename("old-tag", "new-tag");
+await context.tags.delete("unused-tag");
+```
+
+---
+
+### 2. Resources & Attachments
+
+Manage images and file attachments associated with notes:
+
+```ts
+// List note attachments
+const resources = await context.resources.list(noteId);
+
+// Read attachment Blob
const blob = await context.resources.read(resourceId);
-context.resources.upload(noteId, file);
-context.resources.update(resourceId, { file, expectedContentHash });
-context.resources.rename(resourceId, filename);
-context.resources.delete(resourceId);
+
+// Upload new attachment
+const uploaded = await context.resources.upload(noteId, file);
+
+// Concurrency-checked update (file limit: 100 MiB)
+await context.resources.update(resourceId, {
+ file: newFile,
+ expectedContentHash: currentResource.contentHash
+});
+
+// Rename and delete
+await context.resources.rename(resourceId, "document.pdf");
+await context.resources.delete(resourceId);
```
-`resources.update()` uses the `contentHash` returned by `resources.list()` as an optimistic-concurrency baseline. A stale baseline throws `PluginApiError` with `code: "RESOURCE_CONFLICT"`. Replacements are currently limited to 100 MiB and require the resource to be synchronized and online. The host stores new bytes under a new object key and switches the database pointer conditionally, so a rejected update does not damage the previous object.
+---
-Enabled plugins may subscribe to `note.*`, `tag.changed`, `template.*`, `resource.*`, and sync-queue events without capability gates.
+### 3. Editor & Embeds
-Successful note, tag, template, and resource changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events.
-
-## Templates API
-
-Templates are shared workspace data rather than plugin-local settings. Enabled plugins can read, create, update, delete, and apply them without capability gates:
+Interact with active editor sessions:
```ts
-const template = await context.templates.create({
- name: "Daily stand-up",
- contentMarkdown: "## Done\n\n## Next\n",
- tags: ["daily"]
-});
-await context.templates.update(template.id, { description: "Team check-in" });
-const note = await context.templates.use(template.id, notebookId);
-context.events.on("template.updated", ({ template }) => console.log(template.name));
+// Get and replace selection
+const selection = await context.editor.getSelection();
+if (selection && !selection.empty) {
+ await context.editor.replaceSelection(selection.text.toUpperCase());
+}
+
+// Insert at cursor
+await context.editor.insertAtCursor("- [ ] New task\n");
+
+// Live edit in-memory document without losing unsaved changes
+const doc = await context.editor.getDocument();
+if (doc) {
+ await context.editor.editMarkdown([
+ { from: 0, to: 0, insert: "\n" }
+ ]);
+}
```
-`templates.create({ noteId })` can capture an existing note. Plugins can also call `templates.list()` and `templates.delete(templateId)`.
+#### Custom Block Embeds
-## Host-rendered settings
+Register custom block-level embed renderers (serialized in Markdown as `edgeever-plugin-embed` code fences):
-Plugins can declare settings that EdgeEver renders consistently on a dedicated Plugin settings page within plugin details. Installed plugin cards and the plugin toolbar menu link directly to this page. Plugins without settings fields have no settings entry, while disabled plugins remain configurable. Settings are stored on the current device only. Put defaults and credentials in settings, and use plugin commands or functional panels for actual operations; ordinary configuration does not need a separate custom panel. Supported field types are `text`, `secret`, `number`, `boolean`, and `select`. A field may also declare a read-only `list` of titles and optional descriptions; EdgeEver shows a small entry next to the field and opens the items in a host-rendered dialog.
+```ts
+// 1. Register embed renderer
+context.editor.embeds.register({
+ type: "my-diagram",
+ async mount(container, embed) {
+ const data = embed.data;
+ container.innerHTML = `
Diagram: ${data.title}
`;
+ return () => container.replaceChildren(); // Cleanup on unmount
+ }
+});
-Plugin API v2 requires `settingsUi: "host"`. The settings Schema is deliberately declarative: EdgeEver owns field layout, controls, spacing, validation, responsive behavior, accessibility, save states, and secret presentation. Presentation properties such as HTML, components, CSS classes, inline styles, colors, typography, or custom setting-page navigation are ignored. A plugin decides what can be configured, not how the settings page looks. Custom settings pages are rejected by the host. Use commands or a clearly named functional panel for workflows such as authorization, connectivity tests, migrations, and index rebuilding; do not recreate ordinary settings in a custom panel.
+// 2. Insert embed into document
+await context.editor.insertEmbed({
+ type: "my-diagram",
+ title: "Architecture",
+ data: { mode: "preview", version: 1 } // JSON-compatible object, max 64 KiB
+});
+```
+
+---
+
+### 4. UI, Commands & Panels
+
+#### Commands
+
+```ts
+context.commands.register({
+ id: "quick-action",
+ title: "Quick Action",
+ listed: true, // Display on marketplace card (default: true; false for contextual commands)
+ menu: true, // Show in desktop shortcut menu (default: true)
+ async run() {
+ context.ui.showNotice("Action executed");
+ }
+});
+```
+
+#### Note Navigation
+
+```ts
+// Open note and optionally jump to exact search term
+await context.ui.openNote(noteId, { search: "search term" });
+```
+
+#### Custom DOM Panels
+
+Register native DOM panels presented as dialogs or full-screen views:
+
+```ts
+context.ui.panels.register({
+ id: "task-dashboard",
+ title: "Task Dashboard",
+ purpose: "dashboard", // "workflow" | "dashboard" | "preview" | "onboarding"
+ presentation: "dialog", // "dialog" | "fullscreen"
+ mount(container, { shell, requestClose, state }) {
+ // 1. Use standard host shell controls (header, toolbar, search, tabs)
+ shell.set({
+ header: {
+ title: "Tasks",
+ description: "View pending and active tasks",
+ actions: [{ id: "refresh", label: "Refresh" }]
+ },
+ toolbar: [
+ {
+ type: "tabs",
+ key: "filter",
+ value: "all",
+ options: [{ value: "all", label: "All" }, { value: "done", label: "Done" }]
+ },
+ { type: "search", key: "q", placeholder: "Search tasks" }
+ ],
+ onAction(actionId) { /* Handle action button clicks */ },
+ onChange(key, value) { /* Handle filter / search changes */ }
+ });
+
+ // 2. Render custom DOM
+ const content = document.createElement("div");
+ content.textContent = "Dashboard content";
+ container.append(content);
+
+ return () => content.remove(); // Cleanup
+ },
+ beforeClose() {
+ // Guard unsaved state: return true to close, false to keep open, or confirmation options
+ return true;
+ }
+});
+
+// Open panel
+await context.ui.panels.open("task-dashboard", { state: { initialTab: "all" } });
+```
+
+---
+
+### 5. Settings
+
+Declare settings fields in `manifest.json`; EdgeEver renders the settings UI automatically:
```json
{
"settings": {
"fields": [
- { "key": "endpoint", "type": "text", "label": "API endpoint", "required": true },
- { "key": "token", "type": "secret", "label": "API token", "required": true },
- { "key": "format", "type": "select", "label": "Format", "default": "md", "options": [
- { "value": "md", "label": "Markdown" },
- { "value": "html", "label": "HTML" }
- ] },
+ { "key": "endpoint", "type": "text", "label": "API Endpoint", "required": true },
+ { "key": "token", "type": "secret", "label": "API Token", "required": true },
+ { "key": "autoSync", "type": "boolean", "label": "Auto Sync", "default": true },
{
- "key": "topics.ai",
- "type": "boolean",
- "label": "AI",
- "default": true,
- "list": {
- "title": "AI sources",
- "actionLabel": "View sources",
- "items": [
- { "title": "OpenAI News", "description": "openai.com" }
- ]
- }
+ "key": "mode",
+ "type": "select",
+ "label": "Sync Mode",
+ "default": "fast",
+ "options": [
+ { "value": "fast", "label": "Fast" },
+ { "value": "full", "label": "Full" }
+ ]
}
]
}
}
```
-Plugins read the validated values through `context.settings`. Secret values are encrypted in the device-local Secret Storage, are never embedded as Manifest defaults, and are not filled back into the settings form:
+#### Accessing and Listening to Settings
```ts
-const endpoint = await context.settings.get("endpoint");
-const token = await context.settings.get("token");
-await context.settings.set("format", "html");
-await context.settings.remove("token");
-```
+// Read setting values
+const endpoint = await context.settings.get("endpoint");
+const token = await context.settings.get("token");
-Plugins can listen for changes to their own settings and then read the host-validated value again. The event is delivered only to the plugin that owns the setting and does not include the value, keeping secrets and other configuration out of event payloads:
-
-```ts
+// Listen for setting changes
context.events.on("settings.changed", async ({ key }) => {
- if (key !== "format") return;
- const format = await context.settings.get("format");
- // Apply the updated format.
-});
-```
-
-## Storage and network
-
-Plugin storage is namespaced by EdgeEver workspace and plugin ID:
-
-```ts
-await context.storage.set("cursor", "next-page");
-const cursor = await context.storage.get("cursor");
-```
-
-Direct requests may use HTTP or HTTPS with arbitrary destinations, methods, headers, bodies, and browser credential modes. The Web runtime still follows the browser's CORS and cookie rules:
-
-```json
-{
- "permissions": ["network"]
-}
-```
-
-```ts
-await context.network.fetch("https://api.example.com/items", {
- headers: { Authorization: `Bearer ${token}`, "X-Client": "my-plugin" },
- credentials: "include",
-});
-```
-
-Use regular `storage` for cursors and preferences. Sensitive strings such as API keys belong in `secrets`:
-
-```ts
-await context.secrets.set("api-token", token);
-const token = await context.secrets.get("api-token");
-await context.secrets.remove("api-token");
-```
-
-The web host namespaces secrets by workspace and plugin ID, encrypts them with AES-GCM using a device-local, non-exportable WebCrypto key, and stores ciphertext in IndexedDB. This prevents plaintext storage, but P0 plugins are trusted same-page code and the mechanism cannot defend against a malicious plugin reading live data.
-
-## Editor API
-
-`editor:read` reads the active editor selection or full live document. `editor:write` replaces the selection, inserts Markdown at the cursor, or applies validated UTF-16 range edits to the live document:
-
-```ts
-const selection = await context.editor.getSelection();
-if (selection && !selection.empty) {
- await context.editor.replaceSelection(selection.text.toUpperCase());
-}
-await context.editor.insertAtCursor("**Inserted by plugin**");
-
-const document = await context.editor.getDocument();
-if (document) {
- await context.editor.editMarkdown([
- { from: 0, to: 0, insert: "\n" }
- ]);
-}
-```
-
-Reading returns `null` when no editable note is open; writes throw an error. `editor.editMarkdown()` uses the same range validation as `notes.editMarkdown()`, but operates on the current in-memory document so it can safely include unsaved user changes. Plugin edits use normal editor transactions and the autosave flow.
-
-### Plugin embeds
-
-An enabled plugin can register a renderer for its own constrained, block-level embed type and insert it into the editor:
-
-```ts
-const disposeEmbed = context.editor.embeds.register({
- type: "drawing",
- async mount(container, embed) {
- const scene = await context.resources.read(embed.resourceId);
- // Render a framework-independent preview into container.
- return () => container.replaceChildren();
+ if (key === "autoSync") {
+ const autoSync = await context.settings.get("autoSync");
+ // Update logic
}
});
-
-await context.editor.insertEmbed({
- type: "drawing",
- resourceId: sceneResource.id,
- previewResourceId: previewResource.id,
- title: "Architecture",
- data: { mode: "view" }
-});
```
-The host assigns the embed ID and plugin ID, so a plugin cannot impersonate another renderer. Embed metadata is limited to JSON-compatible values and 64 KiB. EdgeEver persists the generic node as an `edgeever-plugin-embed` fenced block in Markdown. When the plugin is disabled or unavailable, Web and public-share views show a stable fallback, while native editors preserve the original node through their unsupported-content compatibility path. Plugins do not receive the raw TipTap editor or schema.
+> [!NOTE]
+> `type: "secret"` values are encrypted locally and never echoed back into form markup. Settings are stored locally per device and do not sync across devices.
-## Note navigation
+---
-An enabled plugin can open an existing note from a task, calendar, index, or search panel:
+### 6. Storage & Secrets
+
+Isolated per workspace and plugin ID on the current device:
```ts
-await context.ui.openNote(noteId, { search: "- [ ] Ship release" });
+// Ordinary key-value store (ideal for cursors and caches)
+await context.storage.set("last_sync_time", Date.now());
+const lastSync = await context.storage.get("last_sync_time");
+await context.storage.remove("last_sync_time");
+
+// Secret storage (API keys, tokens; encrypted locally)
+await context.secrets.set("api_key", "sk-xxx");
+const apiKey = await context.secrets.get("api_key");
+await context.secrets.remove("api_key");
```
-The host verifies that the note exists and is not deleted, then switches to its notebook and editor. When `search` is supplied, EdgeEver opens in-note search and reveals its first exact match. Plugins do not need and cannot access private routes or React state.
+---
-## Custom panels
-
-Plugins can register framework-independent DOM panels. Users open them from the Plugin Marketplace's installed-plugin section; the host runs the disposer when the panel closes or the plugin is disabled or uninstalled:
+### 7. Network Requests
```ts
-context.ui.panels.register({
- id: "dashboard",
- title: "Dashboard",
- purpose: "dashboard",
- presentation: "fullscreen",
- mount(container, { state, requestClose }) {
- const heading = document.createElement("h2");
- heading.textContent = "Plugin dashboard";
- container.append(heading);
- return () => heading.remove();
- },
- beforeClose() {
- return hasUnsavedDrawing
- ? { title: "Unsaved drawing", message: "Close without saving?", confirmLabel: "Close drawing" }
- : true;
- }
+// 1. Standard request (arbitrary HTTP/HTTPS; subject to host CORS policies)
+const response = await context.network.fetch("https://api.example.com/data", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ query: "example" })
+});
+const data = await response.json();
+
+// 2. Public read-only request (anonymous, read-only cross-origin GET/HEAD on port 443)
+const publicRes = await context.network.fetch("https://example.org/feed.xml", {
+ transport: "public",
+ headers: { Accept: "application/rss+xml" }
+});
+const xml = await publicRes.text();
+```
+
+---
+
+### 8. Schedules (Desktop Only)
+
+Desktop plugins can persist scheduled execution of registered commands while EdgeEver is running:
+
+```ts
+// Register command
+context.commands.register({
+ id: "sync-feed",
+ title: "Sync Feeds",
+ run: async () => { /* Long-running sync task */ }
});
-await context.ui.panels.open("dashboard", { state: { resourceId } });
+// Configure schedule (idempotent; keyed by "pluginId + key")
+await context.schedules.upsert({
+ key: "hourly-sync",
+ name: "Hourly Feed Sync",
+ commandId: "sync-feed",
+ cronExpression: "0 * * * *",
+ missedRunPolicy: "run-once" // "run-once" | "skip"
+});
+
+// List or remove
+const schedules = await context.schedules.list();
+await context.schedules.remove("hourly-sync");
```
-Every API v2 panel must declare one business purpose: `workflow`, `dashboard`, `preview`, or `onboarding`. A panel is not an alternative settings surface. Persistent booleans, text, numbers, secrets, and fixed-option selections belong in the Manifest settings Schema. Workspace-backed choices that are meaningful only while performing an operation may remain workflow controls until the host settings Schema supports them.
+---
-`presentation` accepts `dialog` (the default) or `fullscreen`. `panels.open()` can only open a panel registered by the calling plugin; its optional JSON state is limited to 64 KiB and is delivered through the mount context. `beforeClose()` may return `true` to close, `false` to stay open, or confirmation copy for a host-rendered dialog. The mount context's `requestClose()` follows the same guard.
+### 9. Events
-### Panel chrome
+Subscribe to workspace data changes:
-Use `mount` context `shell.set()` for system chrome: title, description, header actions, search, tabs, selects, and empty states. EdgeEver renders those controls with the same components as the rest of the app. The `container` argument remains the plugin body — lists, canvases, and forms stay in plugin DOM.
+```ts
+// Note events
+context.events.on("note.created", ({ note }) => console.log("Created", note.id));
+context.events.on("note.updated", ({ note }) => console.log("Updated", note.id));
+context.events.on("note.deleted", ({ noteId }) => console.log("Deleted", noteId));
-```js
-mount(container, { shell, requestClose }) {
- const list = document.createElement("div");
- container.append(list);
- const render = (query) => {
- const tasks = queryTasks(query);
- shell.set({
- header: {
- title: "Tasks",
- description: `${tasks.length} open`,
- actions: [{ id: "refresh", label: "Refresh" }],
- },
- toolbar: [
- { type: "tabs", key: "view", value: query.view, options: [
- { value: "open", label: "Open" },
- { value: "done", label: "Done" },
- ] },
- { type: "search", key: "q", placeholder: "Search tasks", value: query.q },
- { type: "select", key: "priority", label: "Priority", value: query.priority, options: [
- { value: "all", label: "All" },
- { value: "high", label: "High" },
- ] },
- ],
- empty: tasks.length ? null : { title: "No matching tasks" },
- onAction(id) { if (id === "refresh") render(query); },
- onChange(key, value) { render({ ...query, [key]: value }); },
- });
- list.replaceChildren(...tasks.map(renderRow));
- };
- render({ view: "open", q: "", priority: "all" });
-}
+// Other events
+context.events.on("tag.changed", () => { /* Tag updated */ });
+context.events.on("workspace.synced", () => { /* Workspace sync completed */ });
```
-Passing `header.description: null` hides the default “provided by a trusted plugin” line from the visible header (it remains available to assistive technology). Plugins that never call `shell.set()` keep the previous nested card layout. Chrome callbacks are in-memory only and are not stored in panel `state`.
+---
-## Desktop plugin entry
+## Themes (No-Code Extensions)
-After a plugin is enabled, a unified puzzle button appears in the desktop workspace shortcuts on the left. Its menu groups commands and panels by plugin, keeps recently used actions at the top, and links directly to extension management. Individual plugins do not each consume a toolbar icon.
-
-## Theme manifest
-
-Themes are code-free extension packages:
+Themes are declarative extensions requiring only a `manifest.json`:
```json
{
"type": "theme",
"id": "com.example.theme",
- "name": "Example Theme",
+ "name": "Nord Emerald",
"version": "1.0.0",
"themeApiVersion": "1",
"modes": ["light", "dark"],
@@ -564,75 +519,37 @@ Themes are code-free extension packages:
}
```
-Supported token names are exported as `THEME_TOKEN_NAMES` by `@edgeever/plugin-api`. Unknown tokens are rejected so themes do not depend on private DOM selectors.
-Color tokens accept only `#RRGGBB` or `#RRGGBBAA`; font and size tokens also use restricted formats. Theme values cannot contain selectors, remote assets, or CSS functions.
+Supported tokens:
+- Colors: `color.background`, `color.surface`, `color.surfaceMuted`, `color.text`, `color.textMuted`, `color.border`, `color.accent`, `color.accentForeground`, `color.success`, `color.warning`, `color.danger` (format: `#RRGGBB` or `#RRGGBBAA`)
+- Typography & Layout: `font.body`, `font.mono`, `font.size`, `lineHeight.body`, `radius.medium`, `density.scale`, `editor.contentWidth`
-## Bundled examples
+---
-When developing EdgeEver locally, install these URLs from the standalone **Plugin Marketplace** page:
+## Packaging, Distribution & Local Testing
-- `/extensions/recent-notes/manifest.json`
-- `/extensions/nord-emerald/manifest.json`
+### Packaging Rules
-The first demonstrates note queries, selection replacement, commands, and a custom panel. The second demonstrates the code-free theme token API.
+A plugin distribution bundle must be self-contained:
+- `manifest.json`: Plugin manifest
+- `main.js`: Single-file JavaScript bundle (max 5 MB)
+- `styles.css`: Optional stylesheet (max 1 MB)
-## Current limits
+If using `@edgeever/plugin-api`, inline it into `main.js` during build.
-- The install list follows the current workspace on Web and desktop. Each client re-downloads and verifies packages. Native Android and iOS apps do not run plugins.
-- Plugin settings, ordinary plugin storage, and secrets stay on the current device and are not synchronized.
-- Plugins run only while the app is open.
-- Desktop plugins can persistently schedule their own registered commands, and users can manage those schedules and inspect paginated run history from the plugin page. A schedule is synced through the workspace, bound to one desktop device, and runs only while EdgeEver is open on that device. A missed occurrence can either be skipped or coalesced into one recovery run. This is not an always-on server background runtime.
-- There is no webhook receiver, server background runtime, marketplace submission backend, or automated review pipeline.
-- Capability declarations are optional descriptive metadata, not API authorization or a sandbox.
-- Custom panels open from the unified desktop plugin menu or extension settings and cannot yet be pinned to the main navigation or editor sidebar.
+### GitHub Distribution
-## Generic AI and public network capabilities (unreleased)
+1. Host a repository containing `manifest.json` on GitHub (public repository).
+2. Create a GitHub Release with a tag matching the manifest version (e.g. `1.0.0` or `v1.0.0`).
+3. Attach `manifest.json`, `main.js`, and optional `styles.css` as Release assets.
+4. Users install it directly via the Plugin Marketplace page:
+ ```text
+ https://github.com/owner/edgeever-plugin
+ ```
-Enabled plugins may use the current workspace's default AI model. Declaring `ai:generate` is optional disclosure metadata. These calls take ordinary prompts; source parsing, business workflows and prompts belong to the plugin. Credentials stay in the host.
+### Local Testing
-```ts
-const status = await context.ai.status(); // { configured, modelName? }
-const result = await context.ai.generate({
- system: "Translate the supplied text into English.",
- prompt: "用户提供的文本",
- maxOutputTokens: 1000,
- signal: controller.signal,
-});
-const transcript = await context.ai.transcribeResource(noteId, resourceId);
-// { text, resourceId, filename }. The attachment must already belong to the note; online video URLs are not accepted.
-const ownMedia = new File([audioBytes], "recording.mp3", { type: "audio/mpeg" });
-const { text } = await context.ai.transcribeMedia(ownMedia, { signal: controller.signal });
-// A plugin can also pass a video File or Blob; the host extracts its audio locally.
-```
+While developing EdgeEver, install local extension manifests in the Plugin Marketplace:
+- `/extensions/recent-notes/manifest.json` (Official example plugin)
+- `/extensions/nord-emerald/manifest.json` (Official example theme)
-`system` is limited to 8,000 characters and `prompt` to 90,000. `maxOutputTokens` must be a positive integer and defaults to 3,000 when omitted; the host does not impose a maximum output-token value. Generation is limited to 120 seconds. The model or provider may impose its own limit or reject a request based on available credits. The backend requires an interactive user session, disables AI in public demo mode, and redacts provider errors. AI calls have a four-request per-workspace guard in each backend instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls.
-
-`transcribeResource` accepts only audio or video attachments already uploaded to a note in the current workspace. The client reads the attachment in ranges through the existing resource API, extracts its audio track, and splits it into audio segments of at most 24 MB, then sends them directly to the user's configured speech model service. The instance validates attachment ownership and gives the signed-in client the model configuration; it does not extract or convert audio or relay provider requests. Browser calls require provider CORS support; the desktop main process calls the provider locally. The plugin receives text and decides how to display or save it. This API does not resolve URLs, download third-party media, or expose speech model credentials to plugins.
-
-`transcribeMedia` accepts a plugin-supplied audio or video `Blob`/`File` without uploading it to a note. The client obtains the configured default speech model and credential through the existing authenticated settings APIs, extracts and segments the media locally with the same 1 GiB source, 24 MB segment, and 64-segment limits, and calls the speech provider directly. The API returns only `{ text }`; it does not return the credential. Enabled plugins run as trusted client JavaScript, so this is not a credential isolation boundary. The method does not fetch a URL; a plugin is responsible for obtaining authorized media bytes. Provider charges and browser CORS requirements still apply. Aborting the supplied signal or disabling the plugin stops the call.
-
-The default `network.fetch(url, init)` transport is a trusted browser request. It accepts arbitrary HTTP/HTTPS destinations, methods, bodies, request headers such as `Authorization`, and the requested browser credential mode. It remains subject to the runtime browser's CORS and cookie policy. `networkHosts` is legacy descriptive metadata and is not a security boundary. To read a cross-origin public feed or API without credentials, explicitly select `transport: "public"`; listing `network` and `network:public` remains useful disclosure but is optional.
-
-```json
-{
- "permissions": ["network", "network:public"]
-}
-```
-
-```ts
-const response = await context.network.fetch("https://example.org/feed.xml", {
- transport: "public",
- headers: { Accept: "application/rss+xml" },
- redirect: "manual",
- signal: controller.signal,
-});
-const feed = await response.text(); // Parse inside the plugin.
-```
-
-Public mode supports any public HTTPS host on port 443 with GET/HEAD, no request body or credentials, a 20-second deadline, and at most 2,000,000 decoded response bytes. It always returns redirects without following them (`redirect: "error"` rejects them), so plugins can inspect a destination before requesting it separately. Upstream 403/429 remain upstream status codes; this transport does not bypass platform restrictions. Allowed request headers: Accept, Accept-Language, If-None-Match, If-Modified-Since, Range. Only content/cache metadata, Location and Retry-After are returned; Set-Cookie is excluded. The response is buffered within the size limit, not an unlimited streaming proxy.
-
-The host selects the least expensive safe transport without changing the plugin API. Web first tries browser fetch; a readable CORS response stays entirely client-side, while a browser network/CORS `TypeError` falls back to the authenticated backend relay. Desktop uses its Electron main process and the user's own network, capped at four concurrent requests. It does not relay public content through the EdgeEver backend. Cancellation propagates to every transport.
-
-All native/server drivers share one policy package. Desktop and self-hosted Bun validate every DNS answer and pass the validated address directly to TLS; private, special-use and mixed public/private answers are rejected. Cloudflare fallback uses workerd's default public-only Internet egress and no private-service bindings. Synthetic VPN/fake-IP DNS answers in reserved ranges are rejected; do not disable this check. Nonstandard workerd deployments must preserve public-only global egress. The Web fallback returns bounded binary bytes rather than Base64 JSON, avoiding Base64's transfer expansion.
-
-Plugin capability declarations communicate intent; enabled plugins are trusted JavaScript, not a security sandbox. A plugin can read notes and transmit them over the network. The public transport intentionally grants anonymous read access to arbitrary public HTTPS hosts. Backend routes independently require user authentication and enforce public-only egress so the shared EdgeEver service cannot be used to reach private networks; they do not claim server-attested per-plugin isolation. The backend never receives a source enum, search window, evidence schema or report workflow.
+To submit to the official verified marketplace, see the [Plugin Marketplace Policy](plugin-marketplace-policy.md).
diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md
index 9db9ab84..fcef77d5 100644
--- a/docs/plugin-development.zh-CN.md
+++ b/docs/plugin-development.zh-CN.md
@@ -1,18 +1,41 @@
-# EdgeEver 插件开发(P0 预览版)
+# EdgeEver 插件开发指南
-EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展。安装清单会随当前工作区在 Web 与桌面端之间同步,每个浏览器或桌面应用会自行下载并校验插件包;Android 和 iOS 应用不运行插件。扩展只在 EdgeEver 打开期间运行。桌面端用户可以为已注册的插件命令设置定时计划,并在 EdgeEver 运行期间执行。当前预览版不包含 Webhook、服务端常驻后台运行时、不受约束的 TipTap 扩展和严格的 JavaScript 沙箱。
+[English](plugin-development.md)
-## 安全模型
+EdgeEver 提供客户端插件与无代码主题扩展能力。用户可从官方市场、公开 GitHub 仓库或 Manifest 地址安装扩展。已安装扩展随当前工作区在 Web 与桌面端之间同步,在应用打开期间运行;Android 与 iOS 原生端暂不运行插件。
-主题包只包含经过校验的 Manifest 和公开 Design Tokens,不执行 JavaScript。
+> [!NOTE]
+> **安全模型**:客户端插件采用受信任代码模型(类似 Obsidian)。启用插件即代表信任其在客户端 JavaScript 环境中运行并调用插件 API。清单中的能力声明(Permissions)用于向用户披露插件涉及的功能范围,不构成强制安全沙箱。主题包为纯声明式 JSON,不执行任何 JavaScript。
-客户端插件采用类似 Obsidian 的受信任代码模型。启用插件即代表信任它使用完整的 EdgeEver 插件上下文;能力声明只是可选的描述性元数据,不限制 API 调用。插件模块运行在客户端 JavaScript 环境中,因此用户只能安装来自可信开发者的插件。
+---
-用户在一台设备上首次启用社区客户端插件时,EdgeEver 会显示一次社区插件信任确认;确认后不会对每个插件重复提示。官方插件(发布者为 EdgeEver)不会触发该确认。主题包不能执行 JavaScript,因此也不会触发该确认。
+## 目录
-公开 API 不会向插件暴露 EdgeEver Repository、IndexedDB 数据库、Cloudflare Binding 或 React 内部状态。
+- [插件清单(Manifest)](#插件清单manifest)
+ - [字段说明](#字段说明)
+ - [可用权限标识](#可用权限标识)
+- [插件入口与生命周期](#插件入口与生命周期)
+- [API 参考](#api-参考)
+ - [1. 笔记与笔记本(Notes & Notebooks)](#1-笔记与笔记本notes--notebooks)
+ - [2. 资源附件(Resources)](#2-资源附件resources)
+ - [3. 编辑器交互(Editor & Embeds)](#3-编辑器交互editor--embeds)
+ - [4. 界面交互(UI, Commands & Panels)](#4-界面交互ui-commands--panels)
+ - [5. 统一设置(Settings)](#5-统一设置settings)
+ - [6. 数据存储与安全凭据(Storage & Secrets)](#6-数据存储与安全凭据storage--secrets)
+ - [7. 网络请求(Network)](#7-网络请求network)
+ - [8. 定时任务(Schedules,仅桌面端)](#8-定时任务schedules仅桌面端)
+ - [9. 事件监听(Events)](#9-事件监听events)
+- [主题扩展(Theme Manifest)](#主题扩展theme-manifest)
+- [打包、分发与本地开发](#打包分发与本地开发)
+ - [打包规范](#打包规范)
+ - [GitHub 分发](#github-分发)
+ - [本地测试与调试](#本地测试与调试)
-## 插件 Manifest
+---
+
+## 插件清单(Manifest)
+
+每个插件必须在根目录提供 `manifest.json`:
```json
{
@@ -22,11 +45,11 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。
"version": "1.0.0",
"apiVersion": "2",
"settingsUi": "host",
- "description": "Adds a command for recent notes.",
+ "description": "查看最近更新的笔记。",
"locales": {
"zh-CN": {
"name": "最近笔记",
- "description": "添加一个查看最近笔记的命令。"
+ "description": "查看最近更新的笔记。"
}
},
"entry": "./main.js",
@@ -35,517 +58,449 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。
}
```
-Manifest 和 JavaScript 模块必须返回允许 EdgeEver 来源访问的 CORS 响应头。相对 `entry` 地址基于 Manifest 地址解析。
+### 字段说明
-顶层必填的 `name` 与可选的 `description` 保持为回退文案。插件与主题可以增加以 BCP 47 语言标签为键的 `locales` 对象,例如 `zh-CN`、`en-US` 或 `ja`;每种语言可覆盖 `name`、`description` 或两者。EdgeEver 会先匹配当前界面语言,再匹配相同基础语言,最后回退到顶层字段。这里本地化的是插件市场与插件管理页的元数据。插件设置字段也可通过 `locales` 提供翻译后的 `label`、`description` 和(文本输入框的)`placeholder`;下拉选项可使用以选项值为键的 `options` 对象翻译标签。宿主按相同规则选择设置文案。运行时命令、面板和通知仍由插件自行负责本地化。
+| 字段 | 类型 | 必填 | 说明 |
+| :--- | :--- | :---: | :--- |
+| `type` | `"plugin"` | 是 | 固定为 `"plugin"`。 |
+| `id` | `string` | 是 | 插件全局唯一标识,推荐反向域名格式(如 `com.example.plugin`)。 |
+| `name` | `string` | 是 | 插件默认名称。 |
+| `version` | `string` | 是 | 符合 SemVer 的版本号(如 `1.0.0`)。 |
+| `apiVersion` | `"2"` | 是 | 插件 API 版本,当前固定为 `"2"`。 |
+| `settingsUi` | `"host"` | 是 | 设置界面渲染方式,当前固定为 `"host"`(由宿主统一渲染)。 |
+| `entry` | `string` | 是 | 入口脚本路径,GitHub 分发时固定为 `./main.js`。 |
+| `description` | `string` | 否 | 插件默认描述。 |
+| `locales` | `object` | 否 | 多语言本地化元数据,以 BCP 47 语言标签为键(如 `zh-CN`, `en-US`),覆盖 `name` 与 `description`。 |
+| `platforms` | `string[]` | 否 | 支持的平台,可选 `"web"`、`"desktop"`。 |
+| `permissions` | `string[]` | 否 | 能力声明列表,用于安装与更新时向用户披露。 |
+| `settings` | `object` | 否 | 宿主渲染的配置项 Schema(详见下文)。 |
-## 通过 GitHub 分发
+### 可用权限标识
-开发者可以把公开 GitHub 仓库地址直接分享给用户。仓库默认分支根目录必须包含最新的 `manifest.json`,每个版本通过 GitHub Release 发布。Release Tag 使用 Manifest 中的版本号或带 `v` 前缀的版本号,例如 `1.2.0` 或 `v1.2.0`。
+声明权限用于在安装和更新界面向用户透明披露功能:
+- **笔记与内容**:`notes:read`, `notes:write`, `notes:delete`, `templates:read`, `templates:write`, `metadata:read`, `metadata:write`, `resources:read`, `resources:write`
+- **编辑器**:`editor:read`, `editor:write`
+- **界面交互**:`ui:commands`, `ui:navigation`, `ui:notices`, `ui:panels`, `ui:embeds`
+- **存储与系统**:`storage`, `secrets`, `network`, `network:public`, `schedules`
-Release 必须上传以下资产:
+---
-```text
-manifest.json
-main.js
-styles.css(可选)
-```
+## 插件入口与生命周期
-GitHub 插件的 `entry` 固定为 `./main.js`,`main.js` 必须是无需相对模块导入的单文件 Bundle。EdgeEver 会读取默认分支 Manifest、查找相同版本的 Release、并行下载资产、验证 GitHub 提供的 SHA-256 Digest(如果存在),然后把验证后的包缓存到当前设备的 IndexedDB。插件市场的 GitHub 元数据和 Release 资产都由当前 EdgeEver 实例代理获取,因此桌面端或浏览器不必直接访问 `api.github.com`。`main.js` 上限为 5 MB,`styles.css` 上限为 1 MB。
-
-EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新。Registry 条目声明 `"publisher": "edgeever"` 的市场安装属于 EdgeEver 官方扩展。应用内 Registry 版本是已校验的基线;EdgeEver 会通过实例解析 GitHub 默认分支上的最新 Release,并自动更新到该校验和固定版本。社区市场扩展以及从 GitHub 或 Manifest 地址直接安装的扩展绝不会静默更新,用户必须点击「更新」并确认;如果手动确认的新版改变能力声明或旧版网络域名元数据,确认框会列出这些变化供用户查看。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。
-
-升级采用可回滚切换:新旧版本的包会分别缓存;如果新版无法激活,EdgeEver 会恢复原 Manifest、原启用状态和上一版本代码,而不是留下一个被破坏或被停用的插件。
-
-用户在独立的「插件市场」页面中粘贴以下地址即可自由安装,无需经过官方市场收录:
-
-```text
-https://github.com/owner/edgeever-plugin
-```
-
-目前仅支持公开 GitHub 仓库;私有仓库 Token 尚未开放。
-
-## 已验证插件市场
-
-官方插件市场仅收录自由及开源插件。每个上架版本都必须提供完整且人类可读的源码、认可的开源许可证、构建信息,以及可追溯的公开源码版本。该要求仅适用于官方市场准入;用户仍可自由通过 GitHub 或 Manifest 地址安装其他插件。完整要求参阅[官方插件市场上架政策](plugin-marketplace-policy.zh-CN.md)。
-
-插件市场是一个经过校验的 Registry,不接管插件所有权。Registry 为每个版本固定插件 ID、GitHub 仓库、版本号及 `manifest.json`/`main.js`/`styles.css` 的 SHA-256;安装时仍从开发者的 GitHub Release 或登记的公开地址下载,并再次核对校验和。可选的 `"publisher": "edgeever"` 标记仅保留给 EdgeEver 项目维护的 Registry 条目;它会启用自动更新,社区投稿不得使用。
-
-官方条目随应用内置在 `extensions/registry.json`。实例设置 `EDGE_EVER_COMMUNITY_REGISTRY_URL` 后,客户端才会把另一份验签通过的社区目录追加进来。这份目录不能改官方插件的仓库地址,也不能把条目标成官方。社区标记和许可证只说明收录检查通过,不代表 EdgeEver 保证插件安全。自建实例不设置该变量时,市场只显示内置官方插件。提交插件请按 [tianma-if/edgeever-plugins](https://github.com/tianma-if/edgeever-plugins) 的说明操作。
-
-Registry 格式:
-
-```json
-{
- "registryVersion": "1",
- "updatedAt": "2026-08-16T00:00:00.000Z",
- "entries": [{
- "id": "com.example.recent-notes",
- "name": "Recent Notes",
- "description": "Shows recently updated notes.",
- "locales": {
- "zh-CN": {
- "name": "最近笔记",
- "description": "显示最近更新的笔记。"
- }
- },
- "author": "EdgeEver",
- "publisher": "edgeever",
- "category": "Productivity",
- "repositoryUrl": "https://github.com/example/edgeever-recent-notes",
- "distribution": {
- "type": "github",
- "repositoryUrl": "https://github.com/example/edgeever-recent-notes"
- },
- "verification": {
- "version": "1.0.0",
- "checksums": {
- "manifestJson": "<64 位 SHA-256>",
- "mainJs": "<64 位 SHA-256>"
- }
- }
- }]
-}
-```
-
-市场安装显示“已验证”,GitHub 或 Manifest 自由安装会明确显示未经验证的来源,但 EdgeEver 不阻止用户安装。卸载插件时会同时删除本机缓存的全部插件版本、当前工作区的普通插件存储和 Secret Storage。
-
-以下能力声明仅用于披露和向后兼容,均为可选:
-
-- `notes:read`
-- `notes:write`
-- `notes:delete`
-- `templates:read`
-- `templates:write`
-- `metadata:read`
-- `metadata:write`
-- `resources:read`
-- `resources:write`
-- `network`
-- `storage`
-- `secrets`
-- `schedules`
-- `editor:read`
-- `editor:write`
-- `ui:commands`
-- `ui:navigation`
-- `ui:notices`
-- `ui:panels`
-- `ui:embeds`
-
-通过 `context.network.fetch()` 访问网络不受能力声明或静态域名列表限制。`networkHosts` 仅作为兼容旧版的描述性元数据保留,不参与拦截。插件需要无凭据读取跨域公开响应时,可以使用匿名只读的 `network:public` 传输。
-
-## 插件入口
+插件入口文件(`main.js`)需导出一个包含 `activate` 生命周期的默认对象:
```js
export default {
activate(context) {
- return context.commands.register({
- id: "count-recent-notes",
- title: "Count recent notes",
- async run() {
- const result = await context.notes.query({
- sort: "updated-desc",
- limit: 10
- });
- context.ui.showNotice(`${result.notes.length} recent notes`);
+ // 注册命令
+ const disposeCommand = context.commands.register({
+ id: "hello-world",
+ title: "Say Hello",
+ run() {
+ context.ui.showNotice("Hello from EdgeEver Plugin!");
}
});
+
+ // 返回清理函数(插件停用时执行)
+ return () => {
+ disposeCommand();
+ };
}
};
```
-TypeScript 项目可以从 `@edgeever/plugin-api` 导入类型与辅助函数:
+TypeScript 项目可使用 `@edgeever/plugin-api` 提供的辅助工具:
```ts
import { definePlugin } from "@edgeever/plugin-api";
export default definePlugin({
activate(context) {
- // 在这里注册命令和事件监听器。
- }
-});
-```
-
-每次注册都会返回清理函数。插件停用时,宿主也会自动清理已注册的命令和事件。
-
-命令默认会出现在插件市场卡片上。编辑器上下文或次要命令应设 `listed: false`,只保留在插件工具栏菜单中,不要放在安装卡片上:
-
-```js
-context.commands.register({
- id: "insert-task",
- title: "在光标处插入待办任务",
- listed: false,
- async run() {
- await context.editor.insertAtCursor("- [ ] ");
- }
-});
-```
-
-工作流和预览面板也不会出现在该卡片上。请从命令、工具栏菜单或另一个面板打开它们。
-
-插件工具栏菜单列出编辑器命令以及 dashboard / onboarding 面板。工作流或预览对话框不会出现在菜单里;如果某条命令只是打开菜单中已有的 dashboard,也会被省略。卡片上的启动命令如果和 dashboard 面板重复,应设 `menu: false`:
-
-```js
-context.commands.register({
- id: "open-dashboard",
- title: "打开待办任务面板",
- menu: false,
- run: () => context.ui.panels.open("tasks"),
-});
-```
-
-## 定时任务 API
-
-桌面插件可以持久化定时执行自己的已注册命令。先注册命令,再用稳定的插件内计划键调用 `upsert()`:
-
-```js
-export default {
- async activate(context) {
- context.commands.register({
- id: "refresh-feeds",
- title: "刷新订阅源",
- async run() {
- // 只要桌面端保持运行,插件命令就可以执行耗时任务。
- }
- });
-
- await context.schedules.upsert({
- key: "hourly-refresh",
- name: "每小时刷新订阅源",
- commandId: "refresh-feeds",
- cronExpression: "0 * * * *",
- missedRunPolicy: "run-once"
+ return context.commands.register({
+ id: "hello-world",
+ title: "Say Hello",
+ run: () => context.ui.showNotice("Hello!")
});
}
-};
+});
```
-`upsert()` 以“插件 ID + 计划键”为幂等标识,因此插件每次激活时调用也不会重复创建。第一台创建计划的桌面设备会保持为执行设备;同一插件在另一台电脑激活时只更新同一份计划定义,不会抢走执行权。省略 `isEnabled` 会保留用户的启停选择。插件可以通过 `context.schedules.list()` 查看自己的计划,并用 `context.schedules.remove(key)` 删除。
+> [!TIP]
+> 注册命令、面板、事件等接口均会返回清理函数;插件停用时,宿主也会自动回收其注册的命令与监听器。
-执行中心只保留最近 30 天的定时任务执行记录。过期记录会在升级时立即清理,并在后续执行或查看记录时持续物理删除。
+---
-### SDK 包
+## API 参考
-`@edgeever/plugin-api` 是可发布的 ESM 包,包含生成后的 JavaScript 和 TypeScript 声明。在 EdgeEver 仓库中修改公开契约后,需要重新构建:
+### 1. 笔记与笔记本(Notes & Notebooks)
-```sh
-bun run build:plugin-api
-```
-
-维护者可以在不发布的情况下检查最终公开包内容:
-
-```sh
-cd packages/plugin-api
-npm pack --dry-run
-```
-
-构建后的包只包含 `dist/index.js`、`dist/index.d.ts`、README 和包元数据。插件项目应把 SDK 运行时辅助函数一并打入单文件 `main.js`,发布包中不能残留对 `@edgeever/plugin-api` 的运行时导入。
-
-## 笔记 API
+#### 查询笔记
```ts
-context.notes.query({ text, notebookId, tags, sort, limit, offset });
-context.notes.queryContent({ text, notebookId, tags, sort, limit, offset });
-context.notes.get(noteId);
-context.notes.editMarkdown(noteId, { expectedRevision, expectedContentHash, edits });
-context.notes.create({ notebookId, title, contentMarkdown, tags });
-context.notes.update(noteId, { title, contentMarkdown, tags });
-context.notes.delete(noteId, { permanent: false });
-context.notes.move([noteId], notebookId);
-context.notes.pin([noteId], true);
-context.notes.restore(noteId);
-context.notes.revisions.list(noteId);
-context.notes.revisions.restore(noteId, revisionId);
-context.notebooks.list();
-context.notebooks.create({ name, parentId });
-context.notebooks.update(notebookId, { name, parentId, sortOrder });
-context.notebooks.delete(notebookId);
-context.tags.list();
-context.tags.rename("old", "new");
-context.tags.delete("unused");
+// 1. 查询轻量摘要列表(用于列表、侧边栏等,每页最多 200 条)
+const result = await context.notes.query({
+ notebookId: "optional-notebook-id",
+ text: "关键词",
+ tags: ["todo"],
+ sort: "updated-desc", // "updated-desc" | "created-desc" | "title-asc"
+ limit: 50,
+ offset: 0
+});
+// 返回 { notes: PluginNoteSummary[], totalCount: number, nextOffset: number | null }
+
+// 2. 查询包含完整 Markdown 内容的笔记(用于 Linter、索引扫描等)
+const fullNotes = await context.notes.queryContent({
+ tags: ["task"],
+ limit: 20
+});
```
-`notes.query()` 返回轻量摘要。当插件必须扫描多篇笔记的 Markdown 时,例如 Tasks 索引、日历、看板或 Linter,应使用 `notes.queryContent()`。两个接口每页最多返回 200 篇笔记;持续使用 `nextOffset` 翻页,直到它为 `null`。不需要正文时应优先使用摘要查询。
-
-所有写入都经过 EdgeEver 的共享 Repository 和业务层,包括离线队列与桌面端适配器。插件不能直接访问具体存储实现。
-`notes.update()` 会读取当前版本并返回更新后的完整笔记。
-`notes.editMarkdown()` 使用 `notes.get()` 返回的 `revision` 与 `contentHash` 做乐观并发检查。它适合任务勾选、Linter 和索引维护等只修改 Markdown 局部内容的插件:
+#### 增删改查
```ts
+// 获取单篇笔记详情
const note = await context.notes.get(noteId);
+
+// 创建笔记
+const newNote = await context.notes.create({
+ notebookId: "target-notebook-id",
+ title: "新笔记",
+ contentMarkdown: "# 标题\n正文内容",
+ tags: ["tag1"]
+});
+
+// 更新整篇笔记
+await context.notes.update(noteId, {
+ title: "更新后的标题",
+ contentMarkdown: "新的正文",
+ tags: ["updated"]
+});
+
+// 局部并发安全编辑(基于 revision 与 contentHash 进行乐观锁校验)
await context.notes.editMarkdown(noteId, {
expectedRevision: note.revision,
expectedContentHash: note.contentHash,
edits: [
- { from: 2, to: 3, insert: "x" },
- { from: note.contentMarkdown.length, to: note.contentMarkdown.length, insert: "\n追加内容" }
+ { from: 0, to: 0, insert: "> 插入顶部引用\n\n" } // UTF-16 偏移量,半开区间 [from, to)
]
});
+
+// 移动、置顶与删除
+await context.notes.move([noteId], targetNotebookId);
+await context.notes.pin([noteId], true);
+await context.notes.delete(noteId, { permanent: false }); // 移入回收站
+await context.notes.restore(noteId); // 从回收站恢复
+
+// 历史版本
+const revisions = await context.notes.revisions.list(noteId);
+await context.notes.revisions.restore(noteId, revisionId);
```
-编辑区间使用 JavaScript UTF-16 字符串偏移和半开区间 `[from, to)`。一次调用中的区间不得重叠、越界或切开 Unicode 代理对。笔记基线已经变化或当前编辑器存在未保存内容时,宿主拒绝写入并抛出带 `code: "NOTE_CONFLICT"` 的错误;插件应重新读取并要求用户重试。无效区间使用 `code: "INVALID_MARKDOWN_EDIT"`。SDK 导出 `PluginApiError` 和 `PluginApiErrorCode` 供 TypeScript 插件缩小错误类型。
-启用后的插件无需能力门禁即可读取和修改笔记本与标签。
-
-附件继续通过 Web/桌面端共用的 Repository 适配器执行:
+#### 笔记本与标签
```ts
-context.resources.list(noteId);
+// 笔记本
+const notebooks = await context.notebooks.list();
+const nb = await context.notebooks.create({ name: "新建笔记本", parentId: null });
+await context.notebooks.update(nb.id, { name: "新名称" });
+await context.notebooks.delete(nb.id);
+
+// 标签
+const tags = await context.tags.list();
+await context.tags.rename("old-tag", "new-tag");
+await context.tags.delete("unused-tag");
+```
+
+---
+
+### 2. 资源附件(Resources)
+
+操作关联到笔记的媒体或文件附件:
+
+```ts
+// 获取笔记关联的所有附件
+const resources = await context.resources.list(noteId);
+
+// 读取附件 Blob
const blob = await context.resources.read(resourceId);
-context.resources.upload(noteId, file);
-context.resources.update(resourceId, { file, expectedContentHash });
-context.resources.rename(resourceId, filename);
-context.resources.delete(resourceId);
+
+// 上传新附件
+const uploaded = await context.resources.upload(noteId, file);
+
+// 乐观并发更新附件内容(单文件上限 100 MiB)
+await context.resources.update(resourceId, {
+ file: newFile,
+ expectedContentHash: currentResource.contentHash
+});
+
+// 重命名与删除
+await context.resources.rename(resourceId, "document.pdf");
+await context.resources.delete(resourceId);
```
-`resources.update()` 使用 `resources.list()` 返回的 `contentHash` 做乐观并发检查。基线过期时会抛出 `code: "RESOURCE_CONFLICT"` 的 `PluginApiError`。当前替换上限为 100 MiB,并要求资源已同步且设备在线。宿主会把新内容写入新的对象键,再有条件地切换数据库指针,因此被拒绝的更新不会损坏旧对象。
+---
-启用后的插件无需能力门禁即可订阅 `note.*`、`tag.changed`、`template.*`、`resource.*` 和同步队列事件。
+### 3. 编辑器交互(Editor & Embeds)
-通过 EdgeEver 正常 Repository 层成功完成的笔记、标签、模板和资源变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。
-
-## 模板 API
-
-模板是工作区共享数据,而不是插件私有设置。启用后的插件无需能力门禁即可读取、创建、更新、删除和套用模板:
+用于在用户处于编辑状态时操作当前文档:
```ts
-const template = await context.templates.create({
- name: "每日站会",
- contentMarkdown: "## 已完成\n\n## 下一步\n",
- tags: ["daily"]
-});
-await context.templates.update(template.id, { description: "团队同步" });
-const note = await context.templates.use(template.id, notebookId);
-context.events.on("template.updated", ({ template }) => console.log(template.name));
+// 选区读取与替换
+const selection = await context.editor.getSelection();
+if (selection && !selection.empty) {
+ await context.editor.replaceSelection(selection.text.toUpperCase());
+}
+
+// 在当前光标处插入内容
+await context.editor.insertAtCursor("- [ ] 新建任务\n");
+
+// 实时编辑当前正在编辑的文档(不会丢失未保存的内容)
+const doc = await context.editor.getDocument();
+if (doc) {
+ await context.editor.editMarkdown([
+ { from: 0, to: 0, insert: "\n" }
+ ]);
+}
```
-`templates.create({ noteId })` 可以从已有笔记生成模板。插件也可以调用 `templates.list()` 和 `templates.delete(templateId)`。
+#### 自定义块级 Embed
-## 宿主统一渲染的设置
+插件可注册自定义块级渲染器并插入到文档中(保存为通用的 `edgeever-plugin-embed` 块):
-插件可以在 Manifest 中声明设置,由 EdgeEver 在插件详情的独立「插件设置」页面统一渲染。已安装插件卡片和插件工具菜单均可直达该页面;未声明配置项的插件不显示设置入口,停用的插件仍可配置。设置仅保存在当前设备。默认行为和凭据应放在设置中,实际操作使用插件命令或功能面板,无需为普通配置另建面板。目前支持 `text`、`secret`、`number`、`boolean` 和 `select`。字段还可以声明只读的 `list`(标题和可选说明);EdgeEver 会在字段旁显示一个小入口,并用宿主对话框以列表展示这些条目。
+```ts
+// 1. 注册 Embed 渲染器
+context.editor.embeds.register({
+ type: "my-diagram",
+ async mount(container, embed) {
+ const data = embed.data;
+ container.innerHTML = `图表预览: ${data.title}
`;
+ return () => container.replaceChildren(); // 卸载清理
+ }
+});
-插件 API v2 强制要求 `settingsUi: "host"`。设置 Schema 有意保持为声明式结构:字段布局、控件、间距、校验、响应式行为、无障碍、保存状态和密钥呈现均由 EdgeEver 管理;Manifest 中的 HTML、组件、CSS class、内联样式、颜色、字体以及自定义设置页导航等展示属性会被忽略。插件决定“配置什么”,而不是“设置页长什么样”。宿主会拒绝自定义设置页。授权、连通性测试、数据迁移、索引重建等流程应使用命令或命名清晰的功能面板,不要在自定义面板中重复实现普通设置。
+// 2. 向文档中插入 Embed
+await context.editor.insertEmbed({
+ type: "my-diagram",
+ title: "架构图",
+ data: { mode: "preview", version: 1 } // JSON 兼容对象,上限 64 KiB
+});
+```
+
+---
+
+### 4. 界面交互(UI, Commands & Panels)
+
+#### 命令注册
+
+```ts
+context.commands.register({
+ id: "quick-action",
+ title: "快速操作",
+ listed: true, // 是否在市场卡片中展示,默认为 true;上下文命令建议设为 false
+ menu: true, // 是否在桌面端快捷菜单中展示,默认为 true
+ async run() {
+ context.ui.showNotice("操作执行成功");
+ }
+});
+```
+
+#### 笔记跳转
+
+```ts
+// 打开指定笔记并可选跳转至指定搜索关键词
+await context.ui.openNote(noteId, { search: "关键定位词" });
+```
+
+#### 自定义 DOM 面板(Panels)
+
+插件可以注册全屏或对话框形态的原生 DOM 面板:
+
+```ts
+context.ui.panels.register({
+ id: "task-dashboard",
+ title: "任务仪表盘",
+ purpose: "dashboard", // "workflow" | "dashboard" | "preview" | "onboarding"
+ presentation: "dialog", // "dialog" | "fullscreen"
+ mount(container, { shell, requestClose, state }) {
+ // 1. 使用宿主标准 Shell 控件(页头、搜索栏、分段切换等)
+ shell.set({
+ header: {
+ title: "任务管理",
+ description: "查看待办与进行中的任务",
+ actions: [{ id: "refresh", label: "刷新" }]
+ },
+ toolbar: [
+ {
+ type: "tabs",
+ key: "filter",
+ value: "all",
+ options: [{ value: "all", label: "全部" }, { value: "done", label: "已完成" }]
+ },
+ { type: "search", key: "q", placeholder: "搜索任务" }
+ ],
+ onAction(actionId) { /* 处理按钮点击 */ },
+ onChange(key, value) { /* 处理筛选变更 */ }
+ });
+
+ // 2. 渲染插件自定义 DOM 内容
+ const content = document.createElement("div");
+ content.textContent = "内容区域";
+ container.append(content);
+
+ return () => content.remove(); // 卸载清理
+ },
+ beforeClose() {
+ // 关闭防丢失保护:返回 true 允许关闭,false 阻止,或返回提示配置由宿主弹窗确认
+ return true;
+ }
+});
+
+// 打开面板
+await context.ui.panels.open("task-dashboard", { state: { initialTab: "all" } });
+```
+
+---
+
+### 5. 统一设置(Settings)
+
+EdgeEver 由宿主统一渲染插件设置界面。在 `manifest.json` 中声明配置字段,无需手写设置页 UI:
```json
{
"settings": {
"fields": [
- { "key": "endpoint", "type": "text", "label": "API Endpoint", "required": true },
+ { "key": "endpoint", "type": "text", "label": "API 地址", "required": true },
{ "key": "token", "type": "secret", "label": "API Token", "required": true },
- { "key": "format", "type": "select", "label": "格式", "default": "md", "options": [
- { "value": "md", "label": "Markdown" },
- { "value": "html", "label": "HTML" }
- ] },
+ { "key": "autoSync", "type": "boolean", "label": "自动同步", "default": true },
{
- "key": "topics.ai",
- "type": "boolean",
- "label": "主题 · AI 前沿",
- "default": true,
- "list": {
- "title": "AI 前沿信源",
- "actionLabel": "查看信源",
- "items": [
- { "title": "OpenAI News", "description": "openai.com" }
- ]
- }
+ "key": "mode",
+ "type": "select",
+ "label": "同步模式",
+ "default": "fast",
+ "options": [
+ { "value": "fast", "label": "快速" },
+ { "value": "full", "label": "完整" }
+ ]
}
]
}
}
```
-插件通过 `context.settings` 读取校验后的值。Secret 会加密保存在当前设备的 Secret Storage 中,禁止在 Manifest 中声明默认明文,也不会回填到设置表单:
+#### 读取与监听设置
```ts
-const endpoint = await context.settings.get("endpoint");
-const token = await context.settings.get("token");
-await context.settings.set("format", "html");
-await context.settings.remove("token");
-```
+// 读取设置值
+const endpoint = await context.settings.get("endpoint");
+const token = await context.settings.get("token");
-插件可以监听自己设置的变化,并重新读取宿主已经校验过的值。事件只会发送给拥有该设置的插件,且不会携带设置值,避免 secret 或其他配置进入事件载荷:
-
-```ts
+// 监听当前插件设置变更
context.events.on("settings.changed", async ({ key }) => {
- if (key !== "format") return;
- const format = await context.settings.get("format");
- // 应用更新后的格式。
-});
-```
-
-## 插件存储与网络
-
-插件存储按照 EdgeEver 工作区和插件 ID 隔离:
-
-```ts
-await context.storage.set("cursor", "next-page");
-const cursor = await context.storage.get("cursor");
-```
-
-直接请求可以访问任意 HTTP 或 HTTPS 地址,并使用任意方法、请求头、正文和浏览器凭据模式。Web 运行时仍遵循浏览器的 CORS 与 Cookie 规则:
-
-```json
-{
- "permissions": ["network"]
-}
-```
-
-```ts
-await context.network.fetch("https://api.example.com/items", {
- headers: { Authorization: `Bearer ${token}`, "X-Client": "my-plugin" },
- credentials: "include",
-});
-```
-
-普通 `storage` 适合游标和偏好设置。API Key 等敏感字符串应使用 `secrets`:
-
-```ts
-await context.secrets.set("api-token", token);
-const token = await context.secrets.get("api-token");
-await context.secrets.remove("api-token");
-```
-
-Web 端按照工作区和插件 ID 隔离 Secret,并使用设备本地、不可导出的 WebCrypto 密钥进行 AES-GCM 加密,密文保存在 IndexedDB。它可以避免密钥以明文形式落盘,但由于 P0 插件是同页面受信任代码,不能防御恶意插件读取运行中的数据。
-
-## 编辑器 API
-
-`editor:read` 可以读取当前编辑器选区或完整实时文档,`editor:write` 可以替换选区、在光标处插入 Markdown,或对实时文档应用经过校验的 UTF-16 区间编辑:
-
-```ts
-const selection = await context.editor.getSelection();
-if (selection && !selection.empty) {
- await context.editor.replaceSelection(selection.text.toUpperCase());
-}
-await context.editor.insertAtCursor("**Inserted by plugin**");
-
-const document = await context.editor.getDocument();
-if (document) {
- await context.editor.editMarkdown([
- { from: 0, to: 0, insert: "\n" }
- ]);
-}
-```
-
-没有打开可编辑笔记时,读取返回 `null`,写入会抛出错误。`editor.editMarkdown()` 使用与 `notes.editMarkdown()` 相同的区间校验,但操作当前内存中的文档,因此可以安全保留用户尚未保存的修改。插件修改会进入正常的编辑器事务和自动保存流程。
-
-### 插件 Embed
-
-启用后的插件可以为自己的受约束块级 Embed 类型注册渲染器,并将其插入编辑器:
-
-```ts
-const disposeEmbed = context.editor.embeds.register({
- type: "drawing",
- async mount(container, embed) {
- const scene = await context.resources.read(embed.resourceId);
- // 在 container 中渲染与框架无关的预览。
- return () => container.replaceChildren();
+ if (key === "autoSync") {
+ const autoSync = await context.settings.get("autoSync");
+ // 更新逻辑
}
});
-
-await context.editor.insertEmbed({
- type: "drawing",
- resourceId: sceneResource.id,
- previewResourceId: previewResource.id,
- title: "架构图",
- data: { mode: "view" }
-});
```
-宿主会分配 Embed ID 和插件 ID,因此插件不能冒充其他渲染器。Embed 元数据只能使用兼容 JSON 的值,且上限为 64 KiB。EdgeEver 会在 Markdown 中将通用节点保存为 `edgeever-plugin-embed` 围栏块。插件停用或不可用时,Web 和公开分享页面会显示稳定的降级内容,原生编辑器则通过“不支持内容”兼容路径无损保留原节点。插件不会获得原始 TipTap 编辑器或 Schema。
+> [!NOTE]
+> `type: "secret"` 字段会经过本地加密保存,不会明文回显在表单中。设置仅保存在当前设备,不跨端同步。
-## 笔记导航
+---
-启用后的插件可以从任务、日历、索引或搜索面板打开一篇现有笔记:
+### 6. 数据存储与安全凭据(Storage & Secrets)
+
+用于插件自身的数据持久化(按工作区与插件 ID 隔离存储在当前设备):
```ts
-await context.ui.openNote(noteId, { search: "- [ ] 发布版本" });
+// 普通键值存储(适合游标、偏好缓存)
+await context.storage.set("last_sync_time", Date.now());
+const lastSync = await context.storage.get("last_sync_time");
+await context.storage.remove("last_sync_time");
+
+// 安全凭据存储(敏感 Token、密钥等,采用本地加密保存)
+await context.secrets.set("api_key", "sk-xxx");
+const apiKey = await context.secrets.get("api_key");
+await context.secrets.remove("api_key");
```
-宿主会先确认笔记存在且未被删除,再切换到对应笔记本和编辑器。提供 `search` 后,EdgeEver 会打开笔记内搜索并显示第一个精确匹配。插件不需要也不能操作私有路由或 React 状态。
+---
-## 自定义面板
-
-插件可以注册框架无关的 DOM 面板。用户从「插件市场」的已安装插件区域打开面板,关闭、停用或卸载插件时宿主会执行清理函数:
+### 7. 网络请求(Network)
```ts
-context.ui.panels.register({
- id: "dashboard",
- title: "Dashboard",
- purpose: "dashboard",
- presentation: "fullscreen",
- mount(container, { state, requestClose }) {
- const heading = document.createElement("h2");
- heading.textContent = "Plugin dashboard";
- container.append(heading);
- return () => heading.remove();
- },
- beforeClose() {
- return hasUnsavedDrawing
- ? { title: "绘图尚未保存", message: "仍要关闭吗?", confirmLabel: "关闭绘图" }
- : true;
- }
+// 1. 标准请求(支持任意 HTTP/HTTPS 请求,受宿主环境 CORS 策略约束)
+const response = await context.network.fetch("https://api.example.com/data", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ query: "example" })
+});
+const data = await response.json();
+
+// 2. 公开只读请求(无凭据读取跨域公开 RSS/API,仅支持 443 端口 HTTPS GET/HEAD)
+const publicRes = await context.network.fetch("https://example.org/feed.xml", {
+ transport: "public",
+ headers: { Accept: "application/rss+xml" }
+});
+const xml = await publicRes.text();
+```
+
+---
+
+### 8. 定时任务(Schedules,仅桌面端)
+
+桌面端插件可注册定时计划执行已注册的命令(在 EdgeEver 打开期间运行):
+
+```ts
+// 注册命令
+context.commands.register({
+ id: "sync-feed",
+ title: "同步订阅",
+ run: async () => { /* 耗时同步任务 */ }
});
-await context.ui.panels.open("dashboard", { state: { resourceId } });
+// 配置定时计划(以 key 为唯一标识,激活时重复调用自动幂等更新)
+await context.schedules.upsert({
+ key: "hourly-sync",
+ name: "每小时同步订阅",
+ commandId: "sync-feed",
+ cronExpression: "0 * * * *",
+ missedRunPolicy: "run-once" // 错过的执行补跑一次 ("run-once" | "skip")
+});
+
+// 管理计划
+const schedules = await context.schedules.list();
+await context.schedules.remove("hourly-sync");
```
-每个 API v2 面板必须声明一种业务用途:`workflow`、`dashboard`、`preview` 或 `onboarding`。面板不能作为另一套设置入口。持久化的布尔值、文本、数字、密钥和固定选项必须放进 Manifest 设置 Schema。只有在执行某项操作时才有意义的工作区动态选项,可在宿主设置 Schema 尚不支持时保留为工作流控件。
+---
-`presentation` 可以使用 `dialog`(默认)或 `fullscreen`。`panels.open()` 只能打开调用插件自己注册的面板;可选 JSON 状态上限为 64 KiB,并通过挂载上下文传入。`beforeClose()` 可以返回 `true` 关闭、返回 `false` 保持打开,或返回由宿主显示确认框所需的文案。挂载上下文中的 `requestClose()` 同样会经过这项保护。
+### 9. 事件监听(Events)
-### 面板系统控件
+订阅工作区的数据变更事件:
-用 `mount` 上下文里的 `shell.set()` 描述标题、说明、页头按钮、搜索、分段选项、下拉框和空状态。EdgeEver 用与应用其余部分相同的组件来渲染这些控件。`container` 仍是插件内容区——列表、画布和表单继续由插件自己的 DOM 负责。
+```ts
+// 监听笔记事件
+context.events.on("note.created", ({ note }) => console.log("新建笔记", note.id));
+context.events.on("note.updated", ({ note }) => console.log("笔记更新", note.id));
+context.events.on("note.deleted", ({ noteId }) => console.log("笔记删除", noteId));
-```js
-mount(container, { shell, requestClose }) {
- const list = document.createElement("div");
- container.append(list);
- const render = (query) => {
- const tasks = queryTasks(query);
- shell.set({
- header: {
- title: "待办任务",
- description: `${tasks.length} 项未完成`,
- actions: [{ id: "refresh", label: "刷新" }],
- },
- toolbar: [
- { type: "tabs", key: "view", value: query.view, options: [
- { value: "open", label: "未完成" },
- { value: "done", label: "已完成" },
- ] },
- { type: "search", key: "q", placeholder: "搜索任务", value: query.q },
- { type: "select", key: "priority", label: "优先级", value: query.priority, options: [
- { value: "all", label: "全部" },
- { value: "high", label: "高" },
- ] },
- ],
- empty: tasks.length ? null : { title: "没有符合条件的任务" },
- onAction(id) { if (id === "refresh") render(query); },
- onChange(key, value) { render({ ...query, [key]: value }); },
- });
- list.replaceChildren(...tasks.map(renderRow));
- };
- render({ view: "open", q: "", priority: "all" });
-}
+// 监听其他变更
+context.events.on("tag.changed", () => { /* 标签更新 */ });
+context.events.on("workspace.synced", () => { /* 工作区同步完成 */ });
```
-将 `header.description` 设为 `null` 会隐藏默认的「由受信任插件提供」说明(辅助技术仍可读取)。从不调用 `shell.set()` 的插件保持原来的内嵌卡片布局。控件回调只存在于内存中,不会写入面板 `state`。
+---
-## 桌面端插件入口
+## 主题扩展(Theme Manifest)
-启用插件后,桌面端左侧工作区快捷栏会显示统一的拼图入口。菜单按插件分组展示命令和面板,并在顶部保留最近使用的操作;“管理插件与主题”会直接打开独立插件市场页面。插件不会各自在工具栏占用一个图标。
-
-## 主题 Manifest
-
-主题是一种不包含代码的扩展包:
+主题为纯无代码扩展,仅需一个 `manifest.json`:
```json
{
"type": "theme",
"id": "com.example.theme",
- "name": "Example Theme",
+ "name": "Nord Emerald",
"version": "1.0.0",
"themeApiVersion": "1",
"modes": ["light", "dark"],
@@ -564,75 +519,37 @@ mount(container, { shell, requestClose }) {
}
```
-`@edgeever/plugin-api` 会通过 `THEME_TOKEN_NAMES` 导出所有支持的 Token。未知 Token 会被拒绝,避免主题依赖私有 DOM 选择器。
-颜色 Token 只接受 `#RRGGBB` 或 `#RRGGBBAA`,字体与尺寸 Token 同样使用受限格式。主题值不能包含选择器、远程资源或 CSS 函数。
+支持的 Token 包括:
+- 颜色:`color.background`, `color.surface`, `color.surfaceMuted`, `color.text`, `color.textMuted`, `color.border`, `color.accent`, `color.accentForeground`, `color.success`, `color.warning`, `color.danger`(格式必须为 `#RRGGBB` 或 `#RRGGBBAA`)
+- 排版与尺寸:`font.body`, `font.mono`, `font.size`, `lineHeight.body`, `radius.medium`, `density.scale`, `editor.contentWidth`
-## 仓库内示例
+---
-本地开发 EdgeEver 时,可以在独立的「插件市场」页面中安装:
+## 打包、分发与本地开发
-- `/extensions/recent-notes/manifest.json`
-- `/extensions/nord-emerald/manifest.json`
+### 打包规范
-第一个示例演示笔记查询、选区替换、命令和自定义面板,第二个示例演示无代码主题 Token API。
+插件分发产物必须为免相对依赖的单个 Bundle 文件:
+- `manifest.json`:插件清单
+- `main.js`:打包后的单文件脚本(上限 5 MB)
+- `styles.css`:可选样式表(上限 1 MB)
-## 当前限制
+若使用 `@edgeever/plugin-api`,请在构建时将其内联打入 `main.js`。
-- 安装清单会随当前工作区在 Web 与桌面端之间同步;每个客户端会重新下载并校验插件包。Android 和 iOS 原生应用不运行插件。
-- 插件设置、普通插件存储和 Secret 仍只保存在当前设备,不会同步。
-- 插件只在应用打开期间运行。
-- 桌面插件可以持久化定时执行自己的已注册命令,用户则可以在插件页面管理这些计划并分页查看执行记录。计划通过工作区同步、绑定一台桌面设备,并且只在该设备运行 EdgeEver 时执行;错过的计划可以选择跳过,或在恢复后合并补跑一次。这不是服务端常驻后台运行时。
-- 暂无 Webhook 接收端、服务端后台运行环境、市场投稿后台和自动审核流水线。
-- 能力声明只是可选的描述性元数据,不是 API 授权或安全沙箱。
-- 自定义面板可以从桌面端统一插件菜单或插件管理页打开,尚未支持固定到主导航或编辑器侧栏。
+### GitHub 分发
-## 通用 AI 与公开网络能力(尚未发布)
+1. 将包含最新 `manifest.json` 的仓库发布在 GitHub(公开仓库)。
+2. 创建 GitHub Release,Release Tag 匹配 Manifest 版本(如 `1.0.0` 或 `v1.0.0`)。
+3. 在 Release 资产中上传 `manifest.json`、`main.js` 和可选的 `styles.css`。
+4. 用户可在 EdgeEver 的「插件市场」页面直接输入仓库地址安装:
+ ```text
+ https://github.com/owner/edgeever-plugin
+ ```
-启用后的插件可以调用当前工作区的默认 AI 模型,`ai:generate` 仅是可选的披露元数据。接口只接受普通提示词;来源解析、业务流程和提示词属于插件,凭据保留在宿主。
+### 本地测试与调试
-```ts
-const status = await context.ai.status(); // { configured, modelName? }
-const result = await context.ai.generate({
- system: "把给定文本翻译成英文。",
- prompt: "用户提供的文本",
- maxOutputTokens: 1000,
- signal: controller.signal,
-});
-const transcript = await context.ai.transcribeResource(noteId, resourceId);
-// { text, resourceId, filename }。附件必须已属于该笔记;不接受在线视频 URL。
-const ownMedia = new File([audioBytes], "recording.mp3", { type: "audio/mpeg" });
-const { text } = await context.ai.transcribeMedia(ownMedia, { signal: controller.signal });
-// 也可传入视频 File 或 Blob;宿主会在客户端提取音轨。
-```
+开发 EdgeEver 时,可直接在插件市场中输入本地 Manifest 地址进行调试:
+- `/extensions/recent-notes/manifest.json`(官方示例插件)
+- `/extensions/nord-emerald/manifest.json`(官方示例主题)
-`system` 最多 8,000 字符,`prompt` 最多 90,000 字符。`maxOutputTokens` 必须是正整数,省略时默认 3,000;宿主不设置输出 token 的最大值。生成最长 120 秒。模型或供应商可能有自己的限制,也可能因可用额度不足拒绝请求。后端要求交互式用户会话,公开演示模式禁用 AI,供应商错误脱敏。每个后端实例对每工作区的 AI 调用设置四路并发保护,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
-
-`transcribeResource` 仅识别当前工作区笔记中已上传的音视频附件;客户端经原有资源接口分段读取附件、提取音轨并切成不超过 24 MB 的音频片段,直接发送到用户配置的语音模型服务。实例校验附件归属并把模型配置交给已登录客户端,不提取或转换音轨,也不转发模型请求。浏览器直连要求模型服务支持跨域请求;桌面端从本机主进程直连。插件拿到文本后自行决定如何展示或写入笔记。它不解析 URL、不下载第三方媒体,也不向插件返回语音模型密钥。
-
-`transcribeMedia` 接受插件提供的音频或视频 `Blob`/`File`,无需先上传为笔记附件。客户端经现有的已认证设置接口获取默认语音模型及凭据,在本机提取和切分媒体,沿用 1 GiB 源文件、24 MB 单段和 64 段的限制,直接调用语音服务。API 只返回 `{ text }`,不把凭据作为返回值。已启用插件以受信任的客户端 JavaScript 运行,因此这不是凭据隔离边界。此方法不抓取 URL;插件须自行取得有权处理的媒体字节。模型费用与浏览器跨域要求仍适用。传入的取消信号或停用插件都会中止调用。
-
-默认的 `network.fetch(url, init)` 是受信任的浏览器请求,可以访问任意 HTTP/HTTPS 地址,使用任意方法、正文、`Authorization` 等请求头以及调用方指定的浏览器凭据模式;它仍受所在运行时的 CORS 与 Cookie 策略约束。`networkHosts` 仅为兼容旧版保留,不是安全边界。需要无凭据读取跨域公开订阅或 API 时,显式选择 `transport: "public"` 即可;列出 `network` 和 `network:public` 仍有助于披露用途,但不是必需条件:
-
-```json
-{
- "permissions": ["network", "network:public"]
-}
-```
-
-```ts
-const response = await context.network.fetch("https://example.org/feed.xml", {
- transport: "public",
- headers: { Accept: "application/rss+xml" },
- redirect: "manual",
- signal: controller.signal,
-});
-const feed = await response.text(); // 插件自己解析。
-```
-
-公开模式允许访问任意公开域名,但仅支持 443 端口的 HTTPS GET/HEAD,不携带请求体和凭据;超时 20 秒,解码后的响应正文最多 2,000,000 字节。重定向只返回、不跟随(`redirect: "error"` 会拒绝),插件可以检查目标后再单独请求。来源的 403/429 保留为来源状态,不绕过平台访问限制。允许的请求头为 Accept、Accept-Language、If-None-Match、If-Modified-Since、Range;仅返回内容/缓存元数据、Location 和 Retry-After,不返回 Set-Cookie。响应在上限内缓冲,不是无限流式代理。
-
-宿主在不改变插件 API 的前提下选择成本最低的安全传输。Web 先尝试浏览器请求:CORS 可读的响应完全留在客户端;只有浏览器以网络/CORS `TypeError` 拒绝时,才回退到已认证的后端中继。桌面端通过 Electron 主进程和用户本机网络请求,最多四路并发,不再把公开内容转发到 EdgeEver 后端。取消信号会传递到所有传输路径。
-
-桌面端、自托管与云端驱动共用同一策略包。桌面端和 Bun 自托管会校验全部 DNS 结果,并把已校验地址直接交给 TLS;私网、特殊用途和混合公私地址全部拒绝。Cloudflare 回退使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。Web 回退返回有大小限制的二进制正文,不再使用 Base64 JSON,避免 Base64 的额外传输体积。
-
-插件能力声明用于说明意图;启用后的插件属于受信任 JavaScript,不是安全沙箱。插件可以读取笔记并通过网络发送。公开传输有意允许匿名读取任意公开 HTTPS 域名。后端仍独立要求用户认证并限制仅公开网络,避免共享的 EdgeEver 服务被用来访问私网;它不声称提供服务端证明的插件隔离。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
+提交至官方已验证市场请参阅 [官方插件市场上架政策](plugin-marketplace-policy.zh-CN.md)。