| layout | default |
|---|---|
| title | Chapter 5: Plugin Architecture |
| parent | SiYuan Tutorial |
| nav_order | 5 |
Welcome to Chapter 5: Plugin Architecture. In this part of SiYuan Tutorial: Privacy-First Knowledge Management, you will build an intuitive mental model first, then move into concrete implementation details and practical production tradeoffs.
In Chapter 4, we explored SiYuan's powerful query system. Now let's look at how to extend SiYuan's functionality through its plugin architecture. The plugin system allows developers to add new features, integrate with external services, and customize the user experience without modifying SiYuan's core codebase.
SiYuan's plugin system is built on a sandboxed JavaScript runtime that communicates with the Go backend through a well-defined API. Plugins can modify the UI, react to events, store data, and interact with the block system.
flowchart TD
subgraph "SiYuan Core"
KERNEL["Go Kernel<br/>(Backend API)"]
PROTYLE["Protyle Editor<br/>(Frontend)"]
BUS["Event Bus"]
end
subgraph "Plugin Runtime"
LOADER["Plugin Loader"]
SANDBOX["Plugin Sandbox"]
P1["Plugin A"]
P2["Plugin B"]
P3["Plugin C"]
end
subgraph "Plugin Capabilities"
UI["Custom UI<br/>(Panels, Dialogs)"]
CMD["Commands &<br/>Shortcuts"]
HOOK["Event Hooks"]
STORE["Plugin Storage"]
SLOT["Top Bar &<br/>Dock Slots"]
end
KERNEL <--> BUS
PROTYLE <--> BUS
BUS <--> LOADER
LOADER --> SANDBOX
SANDBOX --> P1
SANDBOX --> P2
SANDBOX --> P3
P1 --> UI
P1 --> CMD
P2 --> HOOK
P2 --> STORE
P3 --> SLOT
classDef core fill:#e3f2fd,stroke:#1565c0
classDef runtime fill:#fff3e0,stroke:#e65100
classDef capability fill:#e8f5e9,stroke:#2e7d32
class KERNEL,PROTYLE,BUS core
class LOADER,SANDBOX,P1,P2,P3 runtime
class UI,CMD,HOOK,STORE,SLOT capability
Every SiYuan plugin follows a standard directory layout:
my-plugin/
├── plugin.json # Plugin manifest
├── index.js # Main entry point (compiled)
├── index.css # Plugin styles (optional)
├── src/
│ ├── index.ts # TypeScript source
│ ├── components/ # UI components
│ └── utils/ # Helper utilities
├── i18n/
│ ├── en_US.json # English translations
│ └── zh_CN.json # Chinese translations
├── icon.png # Plugin icon (160x160)
├── preview.png # Marketplace preview
├── README.md # Plugin documentation
├── package.json # Node.js package config
├── tsconfig.json # TypeScript config
└── webpack.config.js # Build config
The plugin.json file defines your plugin's metadata and capabilities:
{
"name": "my-awesome-plugin",
"author": "Your Name",
"url": "https://github.com/yourname/siyuan-plugin-awesome",
"version": "1.0.0",
"minAppVersion": "2.10.0",
"displayName": {
"default": "My Awesome Plugin",
"zh_CN": "My Awesome Plugin"
},
"description": {
"default": "A plugin that does awesome things",
"zh_CN": "A plugin that does awesome things"
},
"readme": {
"default": "README.md",
"zh_CN": "README_zh_CN.md"
},
"funding": {
"openCollective": "",
"patreon": "",
"github": "",
"custom": []
},
"keywords": ["productivity", "automation"],
"backends": ["windows", "linux", "darwin", "docker", "ios", "android"],
"frontends": ["desktop", "desktop-window", "mobile", "browser-desktop", "browser-mobile"]
}Let's build a complete plugin step by step -- a "Reading Time Estimator" that shows estimated reading time for documents.
// src/index.ts
import {
Plugin,
showMessage,
Dialog,
Menu,
getFrontend,
IModel,
} from "siyuan";
const STORAGE_KEY = "reading-time-config";
export default class ReadingTimePlugin extends Plugin {
private wordsPerMinute: number = 200;
private topBarElement: HTMLElement;
private currentDocID: string | null = null;
async onload(): Promise<void> {
console.log("Reading Time plugin loaded");
// Load saved configuration
const config = await this.loadData(STORAGE_KEY);
if (config) {
this.wordsPerMinute = config.wordsPerMinute || 200;
}
// Add top bar button
this.topBarElement = this.addTopBar({
icon: "iconClock",
title: this.i18n.readingTime,
position: "right",
callback: () => this.showReadingTimeDialog(),
});
// Register slash command
this.addCommand({
langKey: "showReadingTime",
langText: "Show Reading Time",
hotkey: "⌥⇧T",
callback: () => this.showReadingTimeDialog(),
});
// Listen for document switches
this.eventBus.on("switch-protyle", this.onDocumentSwitch.bind(this));
this.eventBus.on("loaded-protyle-static", this.onDocumentLoad.bind(this));
// Add context menu item
this.eventBus.on("click-blockicon", this.onBlockIconClick.bind(this));
}
async onunload(): Promise<void> {
console.log("Reading Time plugin unloaded");
// Save configuration
await this.saveData(STORAGE_KEY, {
wordsPerMinute: this.wordsPerMinute,
});
}
onLayoutReady(): void {
// Called when the layout is fully loaded
this.updateReadingTime();
}
// Handle document switch events
private onDocumentSwitch(event: CustomEvent): void {
const protyle = event.detail.protyle;
if (protyle && protyle.block) {
this.currentDocID = protyle.block.rootID;
this.updateReadingTime();
}
}
// Handle document load events
private onDocumentLoad(event: CustomEvent): void {
this.updateReadingTime();
}
// Calculate and display reading time
private async updateReadingTime(): Promise<void> {
if (!this.currentDocID) return;
try {
const result = await this.querySQL(`
SELECT SUM(length) AS total_chars
FROM blocks
WHERE root_id = '${this.currentDocID}'
AND type IN ('p', 'h', 'i', 'c')
`);
if (result && result.length > 0) {
const totalChars = result[0].total_chars as number || 0;
const words = Math.ceil(totalChars / 5); // Rough word estimate
const minutes = Math.ceil(words / this.wordsPerMinute);
this.topBarElement.textContent = `${minutes} min read`;
}
} catch (err) {
console.error("Failed to calculate reading time:", err);
}
}
// Execute a SQL query through the API
private async querySQL(sql: string): Promise<any[]> {
const response = await fetch("/api/query/sql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stmt: sql }),
});
const data = await response.json();
return data.data;
}
// Show settings dialog
private showReadingTimeDialog(): void {
const dialog = new Dialog({
title: "Reading Time Settings",
content: `
<div class="b3-dialog__content">
<div class="fn__flex b3-label">
<div class="fn__flex-1">
Words per minute
<div class="b3-label__text">
Average reading speed for time estimates
</div>
</div>
<input class="b3-text-field fn__flex-center fn__size200"
id="wpmInput" type="number"
value="${this.wordsPerMinute}" />
</div>
</div>
`,
width: "520px",
});
const input = dialog.element.querySelector("#wpmInput") as HTMLInputElement;
input.addEventListener("change", () => {
this.wordsPerMinute = parseInt(input.value, 10) || 200;
this.saveData(STORAGE_KEY, { wordsPerMinute: this.wordsPerMinute });
this.updateReadingTime();
});
}
// Add reading time to block context menu
private onBlockIconClick(event: CustomEvent): void {
const detail = event.detail;
const menu = detail.menu as Menu;
menu.addItem({
icon: "iconClock",
label: "Show Block Reading Time",
click: async () => {
const blockID = detail.blockElements[0]?.getAttribute("data-node-id");
if (blockID) {
const result = await this.querySQL(`
SELECT content, length FROM blocks WHERE id = '${blockID}'
`);
if (result.length > 0) {
const words = Math.ceil((result[0].length as number) / 5);
const seconds = Math.ceil((words / this.wordsPerMinute) * 60);
showMessage(`Reading time: ~${seconds} seconds`);
}
}
},
});
}
}/* index.css */
/* Top bar reading time display */
.reading-time-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--b3-theme-on-surface);
background: var(--b3-theme-surface-lighter);
}
/* Settings dialog styling */
.reading-time-settings .b3-label {
margin-bottom: 12px;
}
.reading-time-settings .b3-text-field {
width: 80px;
text-align: center;
}
/* Dark mode support */
.theme--dark .reading-time-badge {
background: var(--b3-theme-surface);
}// webpack.config.js
const path = require("path");
module.exports = {
mode: "production",
entry: "./src/index.ts",
output: {
filename: "index.js",
path: path.resolve(__dirname),
libraryTarget: "commonjs2",
library: {
type: "commonjs2",
},
},
resolve: {
extensions: [".ts", ".js"],
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
externals: {
siyuan: "siyuan",
},
};The Plugin base class provides a rich set of methods for interacting with SiYuan:
// Plugin lifecycle and core methods
abstract class Plugin {
// --- Lifecycle ---
abstract onload(): Promise<void>; // Called when plugin loads
abstract onunload(): Promise<void>; // Called when plugin unloads
onLayoutReady(): void; // Called when UI is fully ready
// --- Data Storage ---
async loadData(key: string): Promise<any>;
async saveData(key: string, data: any): Promise<void>;
async removeData(key: string): Promise<void>;
// --- UI Elements ---
addTopBar(options: {
icon: string;
title: string;
position: "left" | "right";
callback: () => void;
}): HTMLElement;
addDock(options: {
config: {
position: "LeftTop" | "LeftBottom" | "RightTop" |
"RightBottom" | "BottomLeft" | "BottomRight";
size: { width: number; height: number };
icon: string;
title: string;
};
data: any;
type: string;
init: () => void;
}): void;
addTab(options: {
type: string;
init: () => void;
beforeDestroy?: () => void;
resize?: () => void;
update?: () => void;
}): () => IModel;
addStatusBar(options: {
element: HTMLElement;
}): void;
// --- Commands ---
addCommand(options: {
langKey: string;
langText: string;
hotkey: string;
callback: () => void;
globalCallback?: () => void;
}): void;
// --- Internationalization ---
i18n: Record<string, string>;
}The event bus allows plugins to react to user actions and system state changes:
// Available events for plugin subscription
type SiYuanEvent =
| "switch-protyle" // Document switched
| "loaded-protyle-static" // Document loaded (static render)
| "loaded-protyle-dynamic" // Document loaded (dynamic render)
| "destroy-protyle" // Document closed
| "click-blockicon" // Block icon clicked (context menu)
| "click-editorcontent" // Editor content clicked
| "click-editortitleicon" // Document title icon clicked
| "open-menu-doctree" // Document tree context menu
| "open-menu-blockref" // Block reference context menu
| "open-menu-fileannotationref" // File annotation context menu
| "open-menu-tag" // Tag context menu
| "open-menu-link" // Link context menu
| "open-menu-image" // Image context menu
| "open-menu-av" // Attribute view context menu
| "open-menu-content" // Content area context menu
| "open-menu-breadcrumbmore" // Breadcrumb overflow menu
| "input-search" // Search input changed
| "paste" // Content pasted
| "open-siyuan-url-plugin" // Plugin URL scheme triggered
| "open-siyuan-url-block" // Block URL scheme triggered
| "ws-main"; // WebSocket message from kernel
// Subscribe to events
class MyPlugin extends Plugin {
async onload() {
// Listen for document switches
this.eventBus.on("switch-protyle", (event: CustomEvent) => {
const protyle = event.detail.protyle;
console.log("Switched to:", protyle.block.rootID);
});
// Listen for WebSocket messages
this.eventBus.on("ws-main", (event: CustomEvent) => {
const msg = event.detail;
if (msg.cmd === "transactions") {
console.log("Blocks changed:", msg.data);
}
});
// Listen for paste events
this.eventBus.on("paste", (event: CustomEvent) => {
const detail = event.detail;
// Modify pasted content before insertion
if (detail.textPlain.includes("http")) {
detail.resolve({
textPlain: detail.textPlain,
// Auto-convert URLs to links
textHTML: `<a href="${detail.textPlain}">${detail.textPlain}</a>`,
});
}
});
}
}Plugins can call any of SiYuan's backend API endpoints:
// Helper class for kernel API calls
class KernelAPI {
// Block operations
static async insertBlock(params: {
dataType: "markdown" | "dom";
data: string;
parentID?: string;
previousID?: string;
nextID?: string;
}): Promise<{ id: string }> {
return this.call("/api/block/insertBlock", params);
}
static async updateBlock(params: {
id: string;
dataType: "markdown" | "dom";
data: string;
}): Promise<void> {
return this.call("/api/block/updateBlock", params);
}
static async deleteBlock(params: { id: string }): Promise<void> {
return this.call("/api/block/deleteBlock", params);
}
static async getBlockByID(id: string): Promise<Block> {
return this.call("/api/block/getBlockKramdown", { id });
}
// Attribute operations
static async setBlockAttrs(params: {
id: string;
attrs: Record<string, string>;
}): Promise<void> {
return this.call("/api/attr/setBlockAttrs", params);
}
static async getBlockAttrs(id: string): Promise<Record<string, string>> {
return this.call("/api/attr/getBlockAttrs", { id });
}
// Document operations
static async createDocWithMd(params: {
notebook: string;
path: string;
markdown: string;
}): Promise<string> {
return this.call("/api/filetree/createDocWithMd", params);
}
// SQL query
static async sql(stmt: string): Promise<any[]> {
const result = await this.call("/api/query/sql", { stmt });
return result;
}
// Notification
static async pushMsg(msg: string, timeout?: number): Promise<void> {
return this.call("/api/notification/pushMsg", { msg, timeout });
}
// Generic API caller
private static async call(endpoint: string, params: any): Promise<any> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Token ${window.siyuan?.config?.api?.token || ""}`,
},
body: JSON.stringify(params),
});
const result = await response.json();
if (result.code !== 0) {
throw new Error(`API error: ${result.msg}`);
}
return result.data;
}
}Plugins have access to a persistent storage system for saving configuration and data:
flowchart LR
PLUGIN["Plugin Code"]
SAVE["saveData()"]
LOAD["loadData()"]
FS["data/storage/petal/<br/>plugin-name/<br/>key.json"]
PLUGIN --> SAVE --> FS
FS --> LOAD --> PLUGIN
classDef code fill:#e3f2fd,stroke:#1565c0
classDef storage fill:#fff3e0,stroke:#e65100
class PLUGIN code
class SAVE,LOAD code
class FS storage
// kernel/model/petal.go
package model
import (
"encoding/json"
"os"
"path/filepath"
)
const petalDir = "data/storage/petal"
// SavePluginData persists plugin data to the workspace
func SavePluginData(workspacePath, pluginName, key string, data interface{}) error {
dir := filepath.Join(workspacePath, petalDir, pluginName)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
bytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, key+".json"), bytes, 0644)
}
// LoadPluginData reads persisted plugin data
func LoadPluginData(workspacePath, pluginName, key string) (interface{}, error) {
path := filepath.Join(workspacePath, petalDir, pluginName, key+".json")
bytes, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No data saved yet
}
return nil, err
}
var data interface{}
if err := json.Unmarshal(bytes, &data); err != nil {
return nil, err
}
return data, nil
}Dock panels let plugins add persistent UI elements to SiYuan's sidebar:
// A custom dock panel showing document statistics
class StatsPlugin extends Plugin {
async onload() {
this.addDock({
config: {
position: "RightTop",
size: { width: 300, height: 400 },
icon: "iconGraph",
title: "Knowledge Stats",
},
data: {},
type: "stats-dock",
init: () => {
// Build the dock panel UI
const container = document.createElement("div");
container.className = "stats-dock-container";
container.innerHTML = `
<div class="stats-header">
<h3>Knowledge Base Statistics</h3>
<button id="refreshStats" class="b3-button b3-button--outline">
Refresh
</button>
</div>
<div id="statsContent" class="stats-content">
<div class="stats-loading">Loading...</div>
</div>
`;
// Attach to dock element
const dockElement = document.querySelector(
'[data-type="stats-dock"]'
);
if (dockElement) {
dockElement.appendChild(container);
}
// Load and display stats
this.loadStats(container);
// Refresh button
container.querySelector("#refreshStats")?.addEventListener(
"click",
() => this.loadStats(container)
);
},
});
}
private async loadStats(container: HTMLElement): Promise<void> {
const content = container.querySelector("#statsContent");
if (!content) return;
try {
const [blocks, docs, refs, recent] = await Promise.all([
this.querySQL("SELECT COUNT(*) AS c FROM blocks"),
this.querySQL("SELECT COUNT(*) AS c FROM blocks WHERE type = 'd'"),
this.querySQL("SELECT COUNT(*) AS c FROM refs"),
this.querySQL(`
SELECT content, updated FROM blocks
WHERE type = 'd'
ORDER BY updated DESC LIMIT 5
`),
]);
content.innerHTML = `
<div class="stat-card">
<span class="stat-value">${blocks[0].c}</span>
<span class="stat-label">Total Blocks</span>
</div>
<div class="stat-card">
<span class="stat-value">${docs[0].c}</span>
<span class="stat-label">Documents</span>
</div>
<div class="stat-card">
<span class="stat-value">${refs[0].c}</span>
<span class="stat-label">References</span>
</div>
<h4>Recently Updated</h4>
<ul class="recent-list">
${recent.map((d: any) => `
<li>
<span class="doc-title">${d.content}</span>
<span class="doc-date">${d.updated}</span>
</li>
`).join("")}
</ul>
`;
} catch (err) {
content.innerHTML = `<div class="error">Failed to load stats</div>`;
}
}
private async querySQL(sql: string): Promise<any[]> {
const res = await fetch("/api/query/sql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stmt: sql }),
});
return (await res.json()).data;
}
}SiYuan has a built-in marketplace for discovering and installing community plugins:
flowchart LR
DEV["Develop<br/>Plugin"] --> REPO["Create<br/>GitHub Repo"]
REPO --> RELEASE["Create<br/>GitHub Release"]
RELEASE --> PR["Submit PR to<br/>bazaar index"]
PR --> REVIEW["Community<br/>Review"]
REVIEW --> PUBLISH["Published in<br/>Marketplace"]
classDef dev fill:#e3f2fd,stroke:#1565c0
classDef publish fill:#e8f5e9,stroke:#2e7d32
class DEV,REPO,RELEASE dev
class PR,REVIEW,PUBLISH publish
To publish your plugin:
- Create a GitHub repository with the standard plugin structure
- Create a release with
package.zipcontaining compiled plugin files - Submit a pull request to the SiYuan bazaar repository
- Add your plugin to the
plugins.jsonindex file
// Entry in bazaar/stage/plugins.json
{
"repos": [
"yourname/siyuan-plugin-awesome"
]
}// kernel/model/bazaar.go
package model
import (
"archive/zip"
"io"
"net/http"
"os"
"path/filepath"
)
// InstallPlugin downloads and installs a plugin from the marketplace
func InstallPlugin(workspacePath, repoURL, version string) error {
// 1. Download the plugin package
downloadURL := fmt.Sprintf(
"https://github.com/%s/releases/download/%s/package.zip",
repoURL, version,
)
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
// 2. Save to temp directory
tmpFile, err := os.CreateTemp("", "plugin-*.zip")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return err
}
tmpFile.Close()
// 3. Extract to plugins directory
pluginName := filepath.Base(repoURL)
pluginDir := filepath.Join(workspacePath, "data", "plugins", pluginName)
if err := os.MkdirAll(pluginDir, 0755); err != nil {
return err
}
return unzip(tmpFile.Name(), pluginDir)
}
func unzip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
path := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(path, f.Mode())
continue
}
os.MkdirAll(filepath.Dir(path), 0755)
outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
io.Copy(outFile, rc)
outFile.Close()
rc.Close()
}
return nil
}SiYuan plugins run with certain security constraints to protect user data:
| Capability | Allowed | Notes |
|---|---|---|
| Read blocks via API | Yes | All blocks accessible |
| Write blocks via API | Yes | Full CRUD operations |
| File system access | Limited | Only through SiYuan APIs |
| Network access | Yes | Fetch API available |
| Local storage | Yes | Plugin-specific directory |
| Execute system commands | No | Sandboxed environment |
| Access other plugins | No | Isolated namespaces |
| Modify SiYuan core | No | Core code is protected |
| Access API token | Yes | For authenticated API calls |
| WebSocket | Yes | For real-time updates |
SiYuan's plugin system provides a powerful framework for extending the application while maintaining security and stability.
| Component | Purpose | Implementation |
|---|---|---|
| Plugin Class | Base class for all plugins | Lifecycle methods, API access |
| Event Bus | Inter-component communication | Subscribe/publish pattern |
| Kernel API | Backend access | REST endpoints over HTTP |
| Plugin Storage | Persistent data | JSON files in petal/ directory |
| Dock Panels | Custom sidebar UI | DOM manipulation in slots |
| Commands | Keyboard shortcuts | Hotkey registration |
| Marketplace | Distribution | GitHub-based package registry |
- Plugins are TypeScript/JavaScript -- build with familiar web technologies and standard tooling.
- The event bus is central -- subscribe to lifecycle events, user actions, and data changes.
- Full API access -- plugins can read, write, and query blocks just like the built-in editor.
- Persistent storage -- save configuration and plugin data across sessions.
- UI integration points -- top bar, dock panels, context menus, tabs, and status bar.
- Marketplace distribution -- publish through GitHub and the bazaar index.
Now that you can extend SiYuan with plugins, let's explore the synchronization system in depth. In Chapter 6: Synchronization & Backup, we'll cover multi-device sync, backup strategies, and the S3-compatible cloud protocol.
Built with insights from the SiYuan project.
Most teams struggle here because the hard part is not writing more code, but deciding clear boundaries for void, menu, Plugin so behavior stays predictable as complexity grows.
In practical terms, this chapter helps you avoid three common failures:
- coupling core logic too tightly to one implementation path
- missing the handoff boundaries between setup, execution, and validation
- shipping changes without clear rollback or observability strategy
After working through this chapter, you should be able to reason about Chapter 5: Plugin Architecture as an operating subsystem inside SiYuan Tutorial: Privacy-First Knowledge Management, with explicit contracts for inputs, state transitions, and outputs.
Use the implementation notes around Promise, plugin, json as your checklist when adapting these patterns to your own repository.
Under the hood, Chapter 5: Plugin Architecture usually follows a repeatable control path:
- Context bootstrap: initialize runtime config and prerequisites for
void. - Input normalization: shape incoming data so
menureceives stable contracts. - Core execution: run the main logic branch and propagate intermediate state through
Plugin. - Policy and safety checks: enforce limits, auth scopes, and failure boundaries.
- Output composition: return canonical result payloads for downstream consumers.
- Operational telemetry: emit logs/metrics needed for debugging and performance tuning.
When debugging, walk this sequence in order and confirm each stage has explicit success/failure conditions.
Use the following upstream sources to verify implementation details while reading this chapter:
- View Repo
Why it matters: authoritative reference on
View Repo(github.com).
Suggested trace strategy:
- search upstream code for
voidandmenuto map concrete implementation paths - compare docs claims against actual runtime/config code before reusing patterns in production