Add managed plugins with lifecycle cleanup and editor extensions
This commit is contained in:
1 parent
9fd7e2a948
commit
8ce4a11b33
25 files changed
+1731
-73
No files matched your search
@@ -96,6 +96,7 @@ func run(addr string) error {
|
|||||||
|
|
||||||
srv := api.New(v, am, st, hub, idx)
|
srv := api.New(v, am, st, hub, idx)
|
||||||
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
||||||
|
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
||||||
srv.OnSetup = func() { os.Remove(codeFile) }
|
srv.OnSetup = func() { os.Remove(codeFile) }
|
||||||
go srv.FollowMoves(ctx)
|
go srv.FollowMoves(ctx)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
|
|||||||
+10
-1
@@ -40,7 +40,9 @@ type Server struct {
|
|||||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||||
OnSetup func()
|
OnSetup func()
|
||||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||||
ThemesDir string
|
ThemesDir string
|
||||||
|
PluginsDir string
|
||||||
|
pluginsMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
|
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
|
||||||
@@ -91,6 +93,13 @@ func (s *Server) routes() []route {
|
|||||||
|
|
||||||
{"GET /api/v1/settings", authed, s.getSettings},
|
{"GET /api/v1/settings", authed, s.getSettings},
|
||||||
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
|
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
|
||||||
|
{"GET /api/v1/plugins", sessionOnly, s.listPlugins},
|
||||||
|
{"POST /api/v1/plugins", sessionOnly, s.installPlugin},
|
||||||
|
{"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin},
|
||||||
|
{"DELETE /api/v1/plugins/{id}", sessionOnly, s.uninstallPlugin},
|
||||||
|
{"GET /api/v1/plugins/{id}/assets/{revision}/{path...}", sessionOnly, s.pluginAsset},
|
||||||
|
{"GET /api/v1/plugins/{id}/data", sessionOnly, s.getPluginData},
|
||||||
|
{"PUT /api/v1/plugins/{id}/data", sessionOnly, s.putPluginData},
|
||||||
{"GET /api/v1/themes", authed, s.listThemes},
|
{"GET /api/v1/themes", authed, s.listThemes},
|
||||||
{"GET /api/v1/themes/{id}", authed, s.getTheme},
|
{"GET /api/v1/themes/{id}", authed, s.getTheme},
|
||||||
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func newFixture(t *testing.T) *fixture {
|
|||||||
t.Cleanup(func() { idx.Close() })
|
t.Cleanup(func() { idx.Close() })
|
||||||
server := New(v, am, st, events.NewHub(), idx)
|
server := New(v, am, st, events.NewHub(), idx)
|
||||||
server.ThemesDir = filepath.Join(dir, "themes")
|
server.ThemesDir = filepath.Join(dir, "themes")
|
||||||
|
server.PluginsDir = filepath.Join(dir, "plugins")
|
||||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, admin: newClient()}
|
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, admin: newClient()}
|
||||||
|
|||||||
@@ -1668,6 +1668,384 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/plugins": {
|
||||||
|
"get": {
|
||||||
|
"summary": "列出已安装插件及启用状态",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"plugins": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Plugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"summary": "安装 ZIP 插件;安装后默认停用,同 id 拒绝覆盖",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Plugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "同名插件已安装"
|
||||||
|
},
|
||||||
|
"413": {
|
||||||
|
"description": "ZIP 超过 10 MiB"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/plugins/{id}": {
|
||||||
|
"patch": {
|
||||||
|
"summary": "修改全设备插件启用偏好",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Plugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "插件或指定版本的资源不存在"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"summary": "停用并卸载插件包,保留插件私有数据",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "插件或指定版本的资源不存在"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/plugins/{id}/data": {
|
||||||
|
"get": {
|
||||||
|
"summary": "读取插件私有 JSON,无数据时为 null",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "插件或指定版本的资源不存在"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"summary": "替换插件私有 JSON,最多 1 MiB",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "插件或指定版本的资源不存在"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/plugins/{id}/assets/{revision}/{path}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "鉴权读取指定安装版本的包内资源",
|
||||||
|
"tags": [
|
||||||
|
"插件"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "需要登录会话"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "请求或插件包无效"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "不接受 API 令牌"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "插件或指定版本的资源不存在"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "revision",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1849,6 +2227,38 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Plugin": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"apiVersion",
|
||||||
|
"revision",
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"apiVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"rawmark/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxPluginZip = 10 << 20
|
||||||
|
const maxPluginExpanded = 20 << 20
|
||||||
|
const pluginEnabledKey = "plugins.enabled"
|
||||||
|
|
||||||
|
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||||
|
var pluginRevisionPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||||
|
|
||||||
|
type pluginManifest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
APIVersion int `json:"apiVersion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluginInfo struct {
|
||||||
|
pluginManifest
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPluginID(id string) bool {
|
||||||
|
return pluginIDPattern.MatchString(id) && !strings.HasPrefix(id, "rawmark-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePluginManifest(data []byte) (pluginManifest, error) {
|
||||||
|
var m pluginManifest
|
||||||
|
if len(data) > 16<<10 || json.Unmarshal(data, &m) != nil || !validPluginID(m.ID) || strings.TrimSpace(m.Name) == "" || strings.TrimSpace(m.Version) == "" || m.APIVersion != 1 {
|
||||||
|
return m, fmt.Errorf("插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) openPlugin(id string) (*os.Root, pluginInfo, error) {
|
||||||
|
var info pluginInfo
|
||||||
|
if !validPluginID(id) || s.PluginsDir == "" {
|
||||||
|
return nil, info, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(s.PluginsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, info, err
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
dir, err := root.OpenRoot(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, info, err
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
defer func() {
|
||||||
|
if !ok {
|
||||||
|
dir.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
data, err := dir.ReadFile("manifest.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, info, err
|
||||||
|
}
|
||||||
|
m, err := parsePluginManifest(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, info, err
|
||||||
|
}
|
||||||
|
if m.ID != id {
|
||||||
|
return nil, info, fmt.Errorf("插件目录与 manifest id 不一致")
|
||||||
|
}
|
||||||
|
rev, err := dir.ReadFile(".rawmark-revision")
|
||||||
|
if err != nil {
|
||||||
|
return nil, info, err
|
||||||
|
}
|
||||||
|
if !pluginRevisionPattern.Match(rev) {
|
||||||
|
return nil, info, fmt.Errorf("插件安装信息无效")
|
||||||
|
}
|
||||||
|
info.pluginManifest, info.Revision = m, string(rev)
|
||||||
|
ok = true
|
||||||
|
return dir, info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) enabledPlugins() (map[string]bool, error) {
|
||||||
|
value, ok, err := s.store.Setting(pluginEnabledKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
enabled := map[string]bool{}
|
||||||
|
if ok {
|
||||||
|
err = json.Unmarshal([]byte(value), &enabled)
|
||||||
|
}
|
||||||
|
if enabled == nil && err == nil {
|
||||||
|
err = fmt.Errorf("插件启用配置无效")
|
||||||
|
}
|
||||||
|
return enabled, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setPluginEnabled(id string, enabled bool) error {
|
||||||
|
values, err := s.enabledPlugins()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
values[id] = true
|
||||||
|
} else {
|
||||||
|
delete(values, id)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.SetSetting(pluginEnabledKey, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pluginError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
writeError(w, 404, "插件不存在或安装不完整")
|
||||||
|
} else {
|
||||||
|
internalError(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
installed := []pluginInfo{}
|
||||||
|
enabled, err := s.enabledPlugins()
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.PluginsDir != "" {
|
||||||
|
entries, err := os.ReadDir(s.PluginsDir)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || !validPluginID(entry.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir, info, err := s.openPlugin(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.Close()
|
||||||
|
info.Enabled = enabled[info.ID]
|
||||||
|
installed = append(installed, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"plugins": installed})
|
||||||
|
}
|
||||||
|
|
||||||
|
func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
|
||||||
|
var info pluginInfo
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("无法读取插件 ZIP")
|
||||||
|
}
|
||||||
|
if len(archive.File) > 128 {
|
||||||
|
return info, fmt.Errorf("插件最多包含 128 个文件或目录")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var total int64
|
||||||
|
for _, file := range archive.File {
|
||||||
|
name := strings.TrimSuffix(file.Name, "/")
|
||||||
|
if !fs.ValidPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) {
|
||||||
|
return info, fmt.Errorf("插件包含非法路径、重复路径或特殊文件")
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if file.UncompressedSize64 > maxPluginExpanded || total+int64(file.UncompressedSize64) > maxPluginExpanded {
|
||||||
|
return info, fmt.Errorf("插件解压后超过 20 MiB")
|
||||||
|
}
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("无法读取插件文件:%w", err)
|
||||||
|
}
|
||||||
|
content, readErr := io.ReadAll(io.LimitReader(src, maxPluginExpanded-total+1))
|
||||||
|
closeErr := src.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return info, readErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return info, closeErr
|
||||||
|
}
|
||||||
|
total += int64(len(content))
|
||||||
|
if total > maxPluginExpanded {
|
||||||
|
return info, fmt.Errorf("插件解压后超过 20 MiB")
|
||||||
|
}
|
||||||
|
if name == "manifest.json" {
|
||||||
|
info.pluginManifest, err = parsePluginManifest(content)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full := filepath.Join(destination, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, content, 0o600); err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.ID == "" || !seen["main.js"] {
|
||||||
|
return info, fmt.Errorf("ZIP 根目录必须包含 manifest.json 和 main.js")
|
||||||
|
}
|
||||||
|
main, err := os.Stat(filepath.Join(destination, "main.js"))
|
||||||
|
if err != nil || !main.Mode().IsRegular() {
|
||||||
|
return info, fmt.Errorf("main.js 必须为普通文件")
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
info.Revision = hex.EncodeToString(hash[:])
|
||||||
|
err = os.WriteFile(filepath.Join(destination, ".rawmark-revision"), []byte(info.Revision), 0o600)
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.PluginsDir == "" {
|
||||||
|
writeError(w, 503, "插件目录未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10))
|
||||||
|
if err := r.ParseMultipartForm(maxPluginZip); err != nil {
|
||||||
|
writeError(w, 400, "无法读取插件上传,请使用不超过 10 MiB 的 ZIP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 400, "请选择插件 ZIP 文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, maxPluginZip+1))
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) > maxPluginZip {
|
||||||
|
writeError(w, 413, "插件 ZIP 超过 10 MiB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
if err := os.MkdirAll(s.PluginsDir, 0o700); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stage, err := os.MkdirTemp(s.PluginsDir, ".install-")
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(stage)
|
||||||
|
info, err := unpackPlugin(data, stage)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := filepath.Join(s.PluginsDir, info.ID)
|
||||||
|
if _, err := os.Lstat(target); err == nil {
|
||||||
|
writeError(w, 409, "同名插件已安装,请先卸载")
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.setPluginEnabled(info.ID, false); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(stage, target); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.hub.Broadcast(events.Event{Type: "plugins"})
|
||||||
|
writeJSON(w, 201, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) togglePlugin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if !readJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Enabled == nil {
|
||||||
|
writeError(w, 400, "必须指定 enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
dir, info, err := s.openPlugin(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.Close()
|
||||||
|
if err := s.setPluginEnabled(info.ID, *body.Enabled); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info.Enabled = *body.Enabled
|
||||||
|
s.hub.Broadcast(events.Event{Type: "settings"})
|
||||||
|
writeJSON(w, 200, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) uninstallPlugin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
id := r.PathValue("id")
|
||||||
|
dir, _, err := s.openPlugin(id)
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.Close()
|
||||||
|
if err := s.setPluginEnabled(id, false); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(filepath.Join(s.PluginsDir, id)); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.hub.Broadcast(events.Event{Type: "plugins"})
|
||||||
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
dir, info, err := s.openPlugin(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
name := r.PathValue("path")
|
||||||
|
if r.PathValue("revision") != info.Revision || !fs.ValidPath(name) || strings.ContainsAny(name, "\\:") || strings.HasPrefix(name, ".") {
|
||||||
|
writeError(w, 404, "插件资源不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := dir.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !stat.Mode().IsRegular() {
|
||||||
|
writeError(w, 404, "插件资源不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
ext := path.Ext(name)
|
||||||
|
if ext == ".js" || ext == ".mjs" {
|
||||||
|
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||||
|
} else {
|
||||||
|
ct := mime.TypeByExtension(ext)
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, name, stat.ModTime(), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getPluginData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
id := r.PathValue("id")
|
||||||
|
dir, _, err := s.openPlugin(id)
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.Close()
|
||||||
|
value, ok, err := s.store.Setting("plugin.data." + id)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
value = "null"
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"data": json.RawMessage(value)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) putPluginData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if !readJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Data) == 0 {
|
||||||
|
writeError(w, 400, "必须提供 data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pluginsMu.Lock()
|
||||||
|
defer s.pluginsMu.Unlock()
|
||||||
|
id := r.PathValue("id")
|
||||||
|
dir, _, err := s.openPlugin(id)
|
||||||
|
if err != nil {
|
||||||
|
s.pluginError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.Close()
|
||||||
|
if err := s.store.SetSetting("plugin.data."+id, string(body.Data)); err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"data": body.Data})
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pluginUpload(t *testing.T, f *fixture, client *http.Client, files []struct {
|
||||||
|
name, content string
|
||||||
|
mode os.FileMode
|
||||||
|
}) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var archive bytes.Buffer
|
||||||
|
z := zip.NewWriter(&archive)
|
||||||
|
for _, file := range files {
|
||||||
|
h := &zip.FileHeader{Name: file.name, Method: zip.Deflate}
|
||||||
|
if file.mode != 0 {
|
||||||
|
h.SetMode(file.mode)
|
||||||
|
}
|
||||||
|
w, err := z.CreateHeader(h)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(w, file.content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := z.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var body bytes.Buffer
|
||||||
|
m := multipart.NewWriter(&body)
|
||||||
|
w, err := m.CreateFormFile("file", "plugin.zip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(archive.Bytes()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f.do(client, "POST", "/api/v1/plugins", body.String(), map[string]string{"Content-Type": m.FormDataContentType()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginInstallBoundariesAndData(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
files := []struct {
|
||||||
|
name, content string
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{"manifest.json", `{"id":"sample","name":"示例","version":"1","apiVersion":1}`, 0},
|
||||||
|
{"main.js", `import './lib.js'; export default function () {}`, 0},
|
||||||
|
{"lib.js", `export const value = 1`, 0},
|
||||||
|
}
|
||||||
|
guest := newClient()
|
||||||
|
if res := pluginUpload(t, f, guest, files); res.StatusCode != 401 {
|
||||||
|
t.Fatalf("匿名安装得到 %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
created := pluginUpload(t, f, f.admin, files)
|
||||||
|
if created.StatusCode != 201 {
|
||||||
|
t.Fatalf("安装失败:%d", created.StatusCode)
|
||||||
|
}
|
||||||
|
info := decode[pluginInfo](t, created)
|
||||||
|
if info.Enabled || len(info.Revision) != 64 {
|
||||||
|
t.Fatal("安装默认状态无效")
|
||||||
|
}
|
||||||
|
if res := pluginUpload(t, f, f.admin, files); res.StatusCode != 409 {
|
||||||
|
t.Fatalf("重复安装得到 %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
base := "/api/v1/plugins/sample"
|
||||||
|
asset := base + "/assets/" + info.Revision + "/main.js"
|
||||||
|
res := f.do(f.admin, "GET", asset, "", nil)
|
||||||
|
content, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode != 200 || !strings.Contains(string(content), "import") || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/javascript") {
|
||||||
|
t.Fatal("模块资源读取失败")
|
||||||
|
}
|
||||||
|
if code := f.status(guest, "GET", asset, "", nil); code != 401 {
|
||||||
|
t.Fatalf("匿名资源得到 %d", code)
|
||||||
|
}
|
||||||
|
if code := f.status(f.admin, "GET", strings.Replace(asset, info.Revision, strings.Repeat("0", 64), 1), "", nil); code != 404 {
|
||||||
|
t.Fatalf("过期资源得到 %d", code)
|
||||||
|
}
|
||||||
|
toggle := decode[pluginInfo](t, f.do(f.admin, "PATCH", base, `{"enabled":true}`, nil))
|
||||||
|
if !toggle.Enabled {
|
||||||
|
t.Fatal("插件未启用")
|
||||||
|
}
|
||||||
|
if code := f.status(f.admin, "PUT", base+"/data", `{"data":{"counter":2}}`, nil); code != 200 {
|
||||||
|
t.Fatalf("保存数据得到 %d", code)
|
||||||
|
}
|
||||||
|
if code := f.status(f.admin, "DELETE", base, "", nil); code != 200 {
|
||||||
|
t.Fatalf("卸载得到 %d", code)
|
||||||
|
}
|
||||||
|
if code := f.status(f.admin, "GET", asset, "", nil); code != 404 {
|
||||||
|
t.Fatalf("卸载后资源得到 %d", code)
|
||||||
|
}
|
||||||
|
if res := pluginUpload(t, f, f.admin, files); res.StatusCode != 201 {
|
||||||
|
t.Fatal("重新安装失败")
|
||||||
|
}
|
||||||
|
data := decode[struct{ Data map[string]int }](t, f.do(f.admin, "GET", base+"/data", "", nil))
|
||||||
|
if data.Data["counter"] != 2 {
|
||||||
|
t.Fatal("卸载丢失了插件数据")
|
||||||
|
}
|
||||||
|
for _, bad := range []struct {
|
||||||
|
name, content string
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{"../escape.js", "x", 0}, {"/absolute.js", "x", 0}, {"dir\\escape.js", "x", 0}, {"main.js", "重复", 0}, {"link.js", "main.js", os.ModeSymlink | 0o777},
|
||||||
|
} {
|
||||||
|
if res := pluginUpload(t, f, f.admin, append(files, bad)); res.StatusCode != 400 {
|
||||||
|
t.Fatalf("非法包 %q 得到 %d", bad.name, res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
large := append(files, struct {
|
||||||
|
name, content string
|
||||||
|
mode os.FileMode
|
||||||
|
}{"large.js", strings.Repeat("x", maxPluginExpanded), 0})
|
||||||
|
if res := pluginUpload(t, f, f.admin, large); res.StatusCode != 400 {
|
||||||
|
t.Fatalf("解压超限得到 %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
// 令牌即使有写权限,也不能植入或读取插件代码及私有配置。
|
||||||
|
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"测试","scope":"write"}`, nil)).Token
|
||||||
|
for _, target := range []string{"/api/v1/plugins", base + "/data", asset} {
|
||||||
|
if code := f.status(guest, "GET", target, "", map[string]string{"Authorization": "Bearer " + token}); code != 403 {
|
||||||
|
t.Fatalf("令牌访问插件得到 %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { mountContent } from '../plugins/mount'
|
||||||
|
import type { OwnedView } from '../plugins/registry'
|
||||||
|
|
||||||
|
const props = defineProps<{ view: OwnedView }>()
|
||||||
|
const container = ref<HTMLElement>()
|
||||||
|
let stop = () => {}
|
||||||
|
watch(() => [container.value, props.view] as const, ([el, view]) => {
|
||||||
|
stop()
|
||||||
|
if (el) stop = mountContent(el, view.mount, view.fail)
|
||||||
|
}, { flush: 'post', immediate: true })
|
||||||
|
onBeforeUnmount(() => stop())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template><div ref="container" class="rm-plugin-view" /></template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api, ApiError, type InstalledPlugin } from './api'
|
||||||
|
import { pluginState, refreshPlugins, retryPlugin } from '../plugins/host'
|
||||||
|
import { pluginsPaused, settingTabs } from '../plugins/registry'
|
||||||
|
import PluginMount from './PluginMount.vue'
|
||||||
|
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const fileInput = ref<HTMLInputElement>()
|
||||||
|
const selected = ref('')
|
||||||
|
const currentTab = computed(() => settingTabs.get(selected.value))
|
||||||
|
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
||||||
|
|
||||||
|
async function perform(operation: () => Promise<unknown>) {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try { await operation(); await refreshPlugins() }
|
||||||
|
catch (err) { error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试' }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
|
||||||
|
await perform(() => api.installPlugin(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(plugin: InstalledPlugin) {
|
||||||
|
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
|
||||||
|
await perform(() => api.uninstallPlugin(plugin.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(plugin: InstalledPlugin) {
|
||||||
|
void perform(() => api.togglePlugin(plugin.id, !plugin.enabled))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="pluginsPaused" class="rm-appearance-note">已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。</p>
|
||||||
|
<div class="rm-setting">
|
||||||
|
<span class="rm-setting-label">已安装插件</span>
|
||||||
|
<button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">刷新列表</button>
|
||||||
|
<button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button>
|
||||||
|
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" />
|
||||||
|
</div>
|
||||||
|
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。分享页不加载外部插件。</p>
|
||||||
|
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">{{ error || pluginState.error }}</p>
|
||||||
|
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">尚未安装插件。</p>
|
||||||
|
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
|
||||||
|
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)" />
|
||||||
|
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span>
|
||||||
|
</label>
|
||||||
|
<button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">重试</button>
|
||||||
|
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">卸载</button>
|
||||||
|
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">{{ pluginState.status[plugin.id].error }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="rm-appearance-note">若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。</p>
|
||||||
|
<template v-if="settingTabs.size">
|
||||||
|
<div class="rm-setting"><label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
|
||||||
|
<select id="rm-plugin-tab" v-model="selected"><option value="">选择插件设置页</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">{{ tab.title }}</option></select>
|
||||||
|
</div>
|
||||||
|
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -4,6 +4,7 @@ import { api, ApiError } from './api'
|
|||||||
import { allCommands, formatKeys, runCommand } from './commands'
|
import { allCommands, formatKeys, runCommand } from './commands'
|
||||||
import Icon, { type IconName } from './Icon.vue'
|
import Icon, { type IconName } from './Icon.vue'
|
||||||
import AppearanceSettings from './AppearanceSettings.vue'
|
import AppearanceSettings from './AppearanceSettings.vue'
|
||||||
|
import PluginsPanel from './PluginsPanel.vue'
|
||||||
import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
|
import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
|
||||||
import SharePanel from './SharePanel.vue'
|
import SharePanel from './SharePanel.vue'
|
||||||
import TokensPanel from './TokensPanel.vue'
|
import TokensPanel from './TokensPanel.vue'
|
||||||
@@ -12,6 +13,7 @@ import { toast, ws, type SettingsSection } from './workspace'
|
|||||||
|
|
||||||
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
||||||
{ id: 'appearance', title: '外观', icon: 'palette' },
|
{ id: 'appearance', title: '外观', icon: 'palette' },
|
||||||
|
{ id: 'plugins', title: '插件', icon: 'command' },
|
||||||
{ id: 'account', title: '账户', icon: 'user' },
|
{ id: 'account', title: '账户', icon: 'user' },
|
||||||
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
|
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
|
||||||
{ id: 'shares', title: '分享链接', icon: 'link' },
|
{ id: 'shares', title: '分享链接', icon: 'link' },
|
||||||
@@ -106,7 +108,8 @@ async function changePassword() {
|
|||||||
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button>
|
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="rm-settings-content">
|
<div class="rm-settings-content">
|
||||||
<template v-if="section === 'appearance'">
|
<PluginsPanel v-if="section === 'plugins'" />
|
||||||
|
<template v-else-if="section === 'appearance'">
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
<div class="rm-setting">
|
<div class="rm-setting">
|
||||||
<span class="rm-setting-label">字号</span>
|
<span class="rm-setting-label">字号</span>
|
||||||
|
|||||||
+12
-2
@@ -1,14 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename } from './paths'
|
import { basename } from './paths'
|
||||||
import { tree } from './tree'
|
import { tree } from './tree'
|
||||||
import TreeNode from './TreeNode.vue'
|
import TreeNode from './TreeNode.vue'
|
||||||
import SearchPanel from './SearchPanel.vue'
|
import SearchPanel from './SearchPanel.vue'
|
||||||
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
||||||
|
import { sidebarViews } from '../plugins/registry'
|
||||||
|
import PluginMount from './PluginMount.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
const rootDrop = ref(false)
|
const rootDrop = ref(false)
|
||||||
|
const selectedView = ref('')
|
||||||
|
const currentView = computed(() => sidebarViews.get(selectedView.value))
|
||||||
|
watch(() => ws.searchOpen, () => { selectedView.value = '' })
|
||||||
|
|
||||||
function onRootDragOver(e: DragEvent) {
|
function onRootDragOver(e: DragEvent) {
|
||||||
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
@@ -35,7 +40,12 @@ function onRootDrop(e: DragEvent) {
|
|||||||
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
||||||
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
||||||
</div>
|
</div>
|
||||||
<SearchPanel v-if="ws.searchOpen" />
|
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
|
||||||
|
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
|
||||||
|
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
|
||||||
|
</nav>
|
||||||
|
<PluginMount v-if="currentView" :key="selectedView" :view="currentView" />
|
||||||
|
<SearchPanel v-else-if="ws.searchOpen" />
|
||||||
<div v-else class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
<div v-else class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
||||||
<ul class="rm-tree-list">
|
<ul class="rm-tree-list">
|
||||||
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export interface Theme {
|
|||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstalledPlugin {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
apiVersion: 1
|
||||||
|
revision: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrashItem {
|
export interface TrashItem {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -105,6 +114,16 @@ export const api = {
|
|||||||
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
||||||
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
|
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
|
||||||
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
|
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
|
||||||
|
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
|
||||||
|
installPlugin: (file: File) => {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('file', file)
|
||||||
|
return json<InstalledPlugin>(request('POST', '/api/v1/plugins', { body }))
|
||||||
|
},
|
||||||
|
togglePlugin: (id: string, enabled: boolean) => json<InstalledPlugin>(request('PATCH', '/api/v1/plugins/' + encodeURIComponent(id), { json: { enabled } })),
|
||||||
|
uninstallPlugin: (id: string) => request('DELETE', '/api/v1/plugins/' + encodeURIComponent(id)),
|
||||||
|
pluginData: (id: string) => json<{ data: unknown }>(request('GET', '/api/v1/plugins/' + encodeURIComponent(id) + '/data')),
|
||||||
|
savePluginData: (id: string, data: unknown) => request('PUT', '/api/v1/plugins/' + encodeURIComponent(id) + '/data', { json: { data } }),
|
||||||
// 按顶层键合并,值为 null 的键被删除
|
// 按顶层键合并,值为 null 的键被删除
|
||||||
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
||||||
|
|
||||||
|
|||||||
@@ -524,3 +524,14 @@ button {
|
|||||||
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
||||||
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
|
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
|
||||||
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: 44px; } .rm-snippet-form input, .rm-snippet-form textarea { font-size: 16px; } }
|
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: 44px; } .rm-snippet-form input, .rm-snippet-form textarea { font-size: 16px; } }
|
||||||
|
|
||||||
|
.rm-plugin-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; border-bottom: 1px solid var(--rm-divider); }
|
||||||
|
.rm-plugin-row label { display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px; overflow-wrap: anywhere; }
|
||||||
|
.rm-plugin-row small { display: block; color: var(--rm-text-muted); font-size: 12px; }
|
||||||
|
.rm-plugin-row p { width: 100%; font-size: 13px; }
|
||||||
|
.rm-plugin-row input { accent-color: var(--rm-accent); flex: none; }
|
||||||
|
.rm-plugin-view { padding: 16px; min-width: 0; overflow: auto; overflow-wrap: anywhere; }
|
||||||
|
.rm-sidebar-inner > .rm-plugin-view { flex: 1; }
|
||||||
|
.rm-plugin-tabs { display: flex; flex: none; gap: 4px; padding: 8px; overflow-x: auto; }
|
||||||
|
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
|
||||||
|
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: 44px; } }
|
||||||
@@ -23,11 +23,18 @@ export function registerCommand(cmd: Command): () => void {
|
|||||||
|
|
||||||
/** 全部已注册的命令(含当前不可用的),用于快捷键一览 */
|
/** 全部已注册的命令(含当前不可用的),用于快捷键一览 */
|
||||||
export function allCommands(): Command[] {
|
export function allCommands(): Command[] {
|
||||||
return [...commands.values()]
|
const all = [...commands.values()]
|
||||||
|
const plugins = all.filter((command) => command.id.startsWith('plugin:'))
|
||||||
|
// 动态启停不能改变快捷键优先级;同一插件内仍保留注册顺序。
|
||||||
|
plugins.sort((a, b) => {
|
||||||
|
const left = a.id.split(':')[1], right = b.id.split(':')[1]
|
||||||
|
return left < right ? -1 : left > right ? 1 : 0
|
||||||
|
})
|
||||||
|
return [...all.filter((command) => !command.id.startsWith('plugin:')), ...plugins]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function availableCommands(): Command[] {
|
export function availableCommands(): Command[] {
|
||||||
return [...commands.values()].filter((c) => c.when?.() ?? true)
|
return allCommands().filter((c) => c.when?.() ?? true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runCommand(id: string) {
|
export function runCommand(id: string) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
|
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
|
||||||
|
|
||||||
export interface VaultEvent {
|
export interface VaultEvent {
|
||||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings'
|
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins'
|
||||||
path?: string
|
path?: string
|
||||||
from?: string
|
from?: string
|
||||||
rev?: string
|
rev?: string
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { nextTick, reactive, watch } from 'vue'
|
import { nextTick, reactive, watch } from 'vue'
|
||||||
import { createEditorState, insertImageFiles, pasteImages } from '../editor'
|
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage } from '../editor'
|
||||||
import { handleShortcut, registerCommand } from './commands'
|
import { handleShortcut, registerCommand } from './commands'
|
||||||
import { api, ApiError, type Entry } from './api'
|
import { api, ApiError, type Entry } from './api'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
@@ -12,6 +12,9 @@ import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
|||||||
import { loadSettings, settings } from './settings'
|
import { loadSettings, settings } from './settings'
|
||||||
import { trackVisualViewport } from './viewport'
|
import { trackVisualViewport } from './viewport'
|
||||||
import { appearancePaused } from '../themes/appearance'
|
import { appearancePaused } from '../themes/appearance'
|
||||||
|
import { notePath, pluginEvents, pluginRefresh, pluginsPaused, setPluginEditorHandlers } from '../plugins/registry'
|
||||||
|
import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '../plugins/host'
|
||||||
|
import type { PluginAPI } from '../plugin-api'
|
||||||
|
|
||||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ export interface Tab {
|
|||||||
conflictRev?: string
|
conflictRev?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsSection = 'appearance' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash'
|
export type SettingsSection = 'appearance' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash' | 'plugins'
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number
|
id: number
|
||||||
@@ -54,7 +57,7 @@ export const ws = reactive({
|
|||||||
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||||
switcher: null as string | null,
|
switcher: null as string | null,
|
||||||
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
||||||
settings: (appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
||||||
// 分享当前笔记的对话框(笔记路径)
|
// 分享当前笔记的对话框(笔记路径)
|
||||||
sharing: null as string | null,
|
sharing: null as string | null,
|
||||||
// 「移动到…」对话框(笔记或文件夹路径)
|
// 「移动到…」对话框(笔记或文件夹路径)
|
||||||
@@ -179,6 +182,7 @@ function buildState(path: string, doc: string): EditorState {
|
|||||||
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
|
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
|
||||||
},
|
},
|
||||||
extensions: [
|
extensions: [
|
||||||
|
notePath.of(ref),
|
||||||
EditorView.updateListener.of((u) => {
|
EditorView.updateListener.of((u) => {
|
||||||
if (u.docChanged || u.selectionSet) editorListeners.forEach((fn) => fn())
|
if (u.docChanged || u.selectionSet) editorListeners.forEach((fn) => fn())
|
||||||
}),
|
}),
|
||||||
@@ -193,6 +197,33 @@ function buildState(path: string, doc: string): EditorState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reconfigurePluginStates() {
|
||||||
|
const effects = [languageCompartment.reconfigure(pluginLanguage()), pluginCompartment.reconfigure(pluginEditorExtensions()), pluginRefresh.of(null)]
|
||||||
|
// 先计算全部新状态,失败时让注册方撤回;不让后台标签保留已停用扩展。
|
||||||
|
const updates = [...editorStates].map(([path, state]) => {
|
||||||
|
const current = path === ws.active && view ? view.state : state
|
||||||
|
return { path, transaction: current.update({ effects }) }
|
||||||
|
})
|
||||||
|
for (const { path, transaction } of updates) {
|
||||||
|
if (path === ws.active && view) view.dispatch(transaction)
|
||||||
|
editorStates.set(path, transaction.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options) => {
|
||||||
|
const tab = tabOf(path)
|
||||||
|
const saved = slots.get(path)
|
||||||
|
if ((tab && (tab.dirty || tab.status !== 'ready')) || saved?.inflight || saved?.uploads || saved?.paused || opsInFlight) throw new ApiError(409, '笔记有未保存修改或正在操作,请稍后重试')
|
||||||
|
if (!options || (!('create' in options && options.create === true) && !('ifMatch' in options && typeof options.ifMatch === 'string' && options.ifMatch.length > 0))) throw new ApiError(400, '写入必须指定非空版本号或明确新建')
|
||||||
|
const initialRevision = tab?.rev
|
||||||
|
const initialSequence = saved?.seq ?? 0
|
||||||
|
const rev = await api.writeFile(path, content, 'create' in options ? '' : options.ifMatch)
|
||||||
|
// 用户可能已完成下一次保存,或已收到更新版本;迟到响应不能退回正文与 rev。
|
||||||
|
if (tab && tabOf(path) === tab && tab.rev === initialRevision && (slots.get(path)?.seq ?? 0) === initialSequence && !tab.dirty && tab.status === 'ready' && !slots.get(path)?.inflight) { replaceDoc(path, content); tab.rev = rev }
|
||||||
|
await refreshTree()
|
||||||
|
return { rev }
|
||||||
|
}
|
||||||
|
|
||||||
// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史
|
// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史
|
||||||
function replaceDoc(path: string, content: string) {
|
function replaceDoc(path: string, content: string) {
|
||||||
const state = path === ws.active && view ? view.state : editorStates.get(path)
|
const state = path === ws.active && view ? view.state : editorStates.get(path)
|
||||||
@@ -449,6 +480,7 @@ function remapOpenNotes(from: string, to: string) {
|
|||||||
const ref = noteRefs.get(next)
|
const ref = noteRefs.get(next)
|
||||||
if (ref) ref.path = next
|
if (ref) ref.path = next
|
||||||
}
|
}
|
||||||
|
reconfigurePluginStates()
|
||||||
}
|
}
|
||||||
|
|
||||||
function remapRecent(from: string, to: string) {
|
function remapRecent(from: string, to: string) {
|
||||||
@@ -696,7 +728,11 @@ async function syncOpenTab(tab: Tab) {
|
|||||||
function handleEvents(evs: VaultEvent[]) {
|
function handleEvents(evs: VaultEvent[]) {
|
||||||
for (const ev of evs) {
|
for (const ev of evs) {
|
||||||
if (ev.type === 'tree') void refreshTree()
|
if (ev.type === 'tree') void refreshTree()
|
||||||
else if (ev.type === 'settings') void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
else if (ev.type === 'settings') {
|
||||||
|
void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
||||||
|
void refreshPlugins()
|
||||||
|
}
|
||||||
|
else if (ev.type === 'plugins') void refreshPlugins()
|
||||||
else if (ev.type === 'changed') {
|
else if (ev.type === 'changed') {
|
||||||
const tab = tabOf(ev.path!)
|
const tab = tabOf(ev.path!)
|
||||||
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
||||||
@@ -711,12 +747,16 @@ function handleEvents(evs: VaultEvent[]) {
|
|||||||
tab.conflictRev = ''
|
tab.conflictRev = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ev.type === 'changed' || ev.type === 'moved' || ev.type === 'deleted') pluginEvents.dispatchEvent(new CustomEvent(ev.type, { detail: ev.type === 'moved' ? { from: ev.from!, path: ev.path! } : ev.type === 'changed' ? { path: ev.path!, rev: ev.rev! } : { path: ev.path! } }))
|
||||||
}
|
}
|
||||||
|
if (evs.some((event) => event.type === 'tree')) void resync().catch((err) => toast('插件同步失败:' + errorText(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resync() {
|
async function resync() {
|
||||||
await refreshTree()
|
await refreshTree()
|
||||||
await Promise.all(ws.tabs.map(syncOpenTab))
|
await Promise.all(ws.tabs.map(syncOpenTab))
|
||||||
|
pluginEvents.dispatchEvent(new CustomEvent('resync'))
|
||||||
|
await refreshPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 启动 ----
|
// ---- 启动 ----
|
||||||
@@ -766,6 +806,19 @@ export async function startWorkspace() {
|
|||||||
}
|
}
|
||||||
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
|
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
|
||||||
disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() })
|
disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() })
|
||||||
|
setPluginEditorHandlers(reconfigurePluginStates, pluginEditorFailure)
|
||||||
|
startPlugins({
|
||||||
|
notice: toast,
|
||||||
|
openNote,
|
||||||
|
writeNote: pluginWrite,
|
||||||
|
getActiveNote: () => {
|
||||||
|
const tab = activeTab()
|
||||||
|
if (!tab) return null
|
||||||
|
const state = view && ws.active === tab.path ? view.state : editorStates.get(tab.path)
|
||||||
|
if (!state) return null
|
||||||
|
return { path: tab.path, content: state.doc.toString(), rev: tab.rev, dirty: tab.dirty }
|
||||||
|
},
|
||||||
|
})
|
||||||
// 重新登录后工作区会再次启动,以下全局监听只注册一次
|
// 重新登录后工作区会再次启动,以下全局监听只注册一次
|
||||||
if (listenersInstalled) return
|
if (listenersInstalled) return
|
||||||
listenersInstalled = true
|
listenersInstalled = true
|
||||||
@@ -800,6 +853,7 @@ export async function startWorkspace() {
|
|||||||
() => view?.requestMeasure(),
|
() => view?.requestMeasure(),
|
||||||
)
|
)
|
||||||
window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure()))
|
window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure()))
|
||||||
|
window.addEventListener('rawmark-plugin-notice', (event) => toast((event as CustomEvent<string>).detail))
|
||||||
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||||
@@ -873,6 +927,7 @@ export function openSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stopWorkspace() {
|
export function stopWorkspace() {
|
||||||
|
stopPlugins()
|
||||||
disconnect?.()
|
disconnect?.()
|
||||||
disconnect = undefined
|
disconnect = undefined
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,14 @@ import { Decoration, EditorView, type DecorationSet } from '@codemirror/view'
|
|||||||
import type { SyntaxNode } from '@lezer/common'
|
import type { SyntaxNode } from '@lezer/common'
|
||||||
import { codeContentRange, fenceLanguage } from './codeblocks'
|
import { codeContentRange, fenceLanguage } from './codeblocks'
|
||||||
import { editorFocused, setEditorFocus } from './focus'
|
import { editorFocused, setEditorFocus } from './focus'
|
||||||
import { MathWidget, MermaidWidget } from './widgets'
|
import { MathWidget, CodeRendererWidget } from './widgets'
|
||||||
|
import { codeRenderers, notePath, pluginRefresh } from '../plugins/registry'
|
||||||
|
|
||||||
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
|
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
|
||||||
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
|
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
|
||||||
|
|
||||||
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
|
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
|
||||||
function blockContent(state: EditorState, node: SyntaxNode): { text: string; offset: number } | null {
|
function blockContent(state: EditorState, node: SyntaxNode): { text: string; offset: number; language?: string } | null {
|
||||||
if (node.name === 'BlockMath') {
|
if (node.name === 'BlockMath') {
|
||||||
const marks = node.getChildren('BlockMathMark')
|
const marks = node.getChildren('BlockMathMark')
|
||||||
const from = state.doc.lineAt(node.from).to + 1
|
const from = state.doc.lineAt(node.from).to + 1
|
||||||
@@ -19,9 +20,11 @@ function blockContent(state: EditorState, node: SyntaxNode): { text: string; off
|
|||||||
}
|
}
|
||||||
if (node.name === 'FencedCode') {
|
if (node.name === 'FencedCode') {
|
||||||
const info = node.getChild('CodeInfo')
|
const info = node.getChild('CodeInfo')
|
||||||
if (!info || fenceLanguage(state, info.from, info.to) !== 'mermaid') return null
|
if (!info) return null
|
||||||
|
const language = fenceLanguage(state, info.from, info.to).toLowerCase()
|
||||||
|
if (!codeRenderers.has(language)) return null
|
||||||
const { from, to } = codeContentRange(state, node)
|
const { from, to } = codeContentRange(state, node)
|
||||||
return { text: state.sliceDoc(from, to), offset: from - node.from }
|
return { text: state.sliceDoc(from, to), offset: from - node.from, language }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ function buildBlocks(state: EditorState): DecorationSet {
|
|||||||
if (!content) continue
|
if (!content) continue
|
||||||
const editing = sel.some((r) => r.from <= node.to && r.to >= node.from)
|
const editing = sel.some((r) => r.from <= node.to && r.to >= node.from)
|
||||||
const widget = (editOffset: number | null) =>
|
const widget = (editOffset: number | null) =>
|
||||||
node.name === 'BlockMath' ? new MathWidget(content.text, true, editOffset) : new MermaidWidget(content.text, editOffset)
|
node.name === 'BlockMath' ? new MathWidget(content.text, true, editOffset) : new CodeRendererWidget(content.text, editOffset, content.language!, state.facet(notePath).path, codeRenderers.get(content.language!)!)
|
||||||
if (editing) out.push(Decoration.widget({ widget: widget(null), block: true, side: 1 }).range(node.to))
|
if (editing) out.push(Decoration.widget({ widget: widget(null), block: true, side: 1 }).range(node.to))
|
||||||
else out.push(Decoration.replace({ widget: widget(content.offset), block: true }).range(node.from, node.to))
|
else out.push(Decoration.replace({ widget: widget(content.offset), block: true }).range(node.from, node.to))
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,7 @@ export const renderedBlocks = StateField.define<DecorationSet>({
|
|||||||
if (
|
if (
|
||||||
tr.docChanged ||
|
tr.docChanged ||
|
||||||
tr.selection ||
|
tr.selection ||
|
||||||
tr.effects.some((e) => e.is(setEditorFocus)) ||
|
tr.effects.some((e) => e.is(setEditorFocus) || e.is(pluginRefresh)) ||
|
||||||
syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
||||||
) {
|
) {
|
||||||
return buildBlocks(tr.state)
|
return buildBlocks(tr.state)
|
||||||
|
|||||||
+17
-6
@@ -1,7 +1,7 @@
|
|||||||
import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands'
|
||||||
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
import { Language } from '@codemirror/language'
|
import { Language } from '@codemirror/language'
|
||||||
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state'
|
||||||
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
||||||
import type { MarkdownParser } from '@lezer/markdown'
|
import type { MarkdownParser } from '@lezer/markdown'
|
||||||
import { typoraExtensions } from '../markdown/extensions'
|
import { typoraExtensions } from '../markdown/extensions'
|
||||||
@@ -12,17 +12,21 @@ import { insertLink, toggleBold, toggleItalic } from './format'
|
|||||||
import { livePreview, resolveImageUrl } from './livepreview'
|
import { livePreview, resolveImageUrl } from './livepreview'
|
||||||
import { tableEditing } from './table'
|
import { tableEditing } from './table'
|
||||||
import { markdownTheme } from './theme'
|
import { markdownTheme } from './theme'
|
||||||
|
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../plugins/registry'
|
||||||
|
|
||||||
export { insertImageFiles, pasteImages } from './paste'
|
export { insertImageFiles, pasteImages } from './paste'
|
||||||
|
|
||||||
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
||||||
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
||||||
const rawmarkLanguage = new Language(
|
export const pluginCompartment = new Compartment()
|
||||||
|
export const languageCompartment = new Compartment()
|
||||||
|
export function pluginLanguage() { return new Language(
|
||||||
markdownLanguage.data,
|
markdownLanguage.data,
|
||||||
(markdownLanguage.parser as MarkdownParser).configure(typoraExtensions),
|
(markdownLanguage.parser as MarkdownParser).configure([typoraExtensions, ...[...markdownSyntax.values()].map((entry) => entry.value)]),
|
||||||
[],
|
[],
|
||||||
'markdown',
|
'markdown',
|
||||||
)
|
) }
|
||||||
|
export function pluginEditorExtensions(): Extension[] { return [...editorExtensions.values()].map((entry) => entry.value) }
|
||||||
|
|
||||||
export interface EditorStateOptions {
|
export interface EditorStateOptions {
|
||||||
doc: string
|
doc: string
|
||||||
@@ -40,7 +44,9 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
|||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
||||||
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
||||||
rawmarkLanguage,
|
languageCompartment.of(pluginLanguage()),
|
||||||
|
pluginCompartment.of(pluginEditorExtensions()),
|
||||||
|
EditorView.exceptionSink.of(reportEditorFailure),
|
||||||
Prec.high(keymap.of(markdownKeymap)),
|
Prec.high(keymap.of(markdownKeymap)),
|
||||||
// 与 Typora 一致的格式快捷键
|
// 与 Typora 一致的格式快捷键
|
||||||
Prec.high(
|
Prec.high(
|
||||||
@@ -71,5 +77,10 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createEditorView(parent: HTMLElement, state: EditorState): EditorView {
|
export function createEditorView(parent: HTMLElement, state: EditorState): EditorView {
|
||||||
return new EditorView({ parent, state })
|
const view = new EditorView({ parent, state })
|
||||||
|
const dispatch = view.dispatch.bind(view)
|
||||||
|
view.dispatch = (...args: unknown[]) => {
|
||||||
|
try { Reflect.apply(dispatch, view, args) } catch (error) { reportEditorFailure(error) }
|
||||||
|
}
|
||||||
|
return view
|
||||||
}
|
}
|
||||||
+35
-50
@@ -1,5 +1,7 @@
|
|||||||
import { EditorView, WidgetType } from '@codemirror/view'
|
import { EditorView, WidgetType } from '@codemirror/view'
|
||||||
import { emojiFor, renderMath, renderMermaid } from '../markdown/render'
|
import { emojiFor, renderMath } from '../markdown/render'
|
||||||
|
import { mountContent } from '../plugins/mount'
|
||||||
|
import type { OwnedRenderer } from '../plugins/registry'
|
||||||
|
|
||||||
export class BulletWidget extends WidgetType {
|
export class BulletWidget extends WidgetType {
|
||||||
eq() {
|
eq() {
|
||||||
@@ -222,74 +224,57 @@ export class MathWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mermaidSeqs = new WeakMap<HTMLElement, number>()
|
const rendererMounts = new WeakMap<HTMLElement, () => void>()
|
||||||
const mermaidListeners = new WeakMap<HTMLElement, () => void>()
|
|
||||||
let mermaidSeq = 0
|
|
||||||
|
|
||||||
/** Mermaid 图表,editOffset 含义同 MathWidget */
|
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
|
||||||
export class MermaidWidget extends WidgetType {
|
export class CodeRendererWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: string,
|
readonly code: string,
|
||||||
readonly editOffset: number | null,
|
readonly editOffset: number | null,
|
||||||
) {
|
readonly language: string,
|
||||||
super()
|
readonly path: string,
|
||||||
}
|
readonly renderer: OwnedRenderer,
|
||||||
eq(other: MermaidWidget) {
|
) { super() }
|
||||||
return other.code === this.code && other.editOffset === this.editOffset
|
eq(other: CodeRendererWidget) {
|
||||||
|
return other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
|
||||||
}
|
}
|
||||||
toDOM(view: EditorView) {
|
toDOM(view: EditorView) {
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.className = 'cm-rm-mermaid'
|
el.className = this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render'
|
||||||
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
||||||
else editOnMousedown(el, view, this.editOffset)
|
else editOnMousedown(el, view, this.editOffset)
|
||||||
el.textContent = '图表渲染中…'
|
this.mount(el, view)
|
||||||
this.render(el, view)
|
|
||||||
const update = () => this.render(el, view)
|
|
||||||
mermaidListeners.set(el, update)
|
|
||||||
window.addEventListener('rawmark-appearance', update)
|
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
|
|
||||||
updateDOM(dom: HTMLElement, view: EditorView) {
|
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||||
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
||||||
this.render(dom, view)
|
dom.className = (this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render') + ' cm-rm-preview'
|
||||||
const old = mermaidListeners.get(dom)
|
this.destroy(dom)
|
||||||
if (old) window.removeEventListener('rawmark-appearance', old)
|
this.mount(dom, view)
|
||||||
const update = () => this.render(dom, view)
|
|
||||||
mermaidListeners.set(dom, update)
|
|
||||||
window.addEventListener('rawmark-appearance', update)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
destroy(dom: HTMLElement) {
|
destroy(dom: HTMLElement) {
|
||||||
const update = mermaidListeners.get(dom)
|
rendererMounts.get(dom)?.()
|
||||||
if (update) window.removeEventListener('rawmark-appearance', update)
|
rendererMounts.delete(dom)
|
||||||
mermaidListeners.delete(dom)
|
|
||||||
mermaidSeqs.delete(dom)
|
|
||||||
}
|
}
|
||||||
// 渲染是异步的,连续输入时只采用最后一次的结果,并保留旧图直到新图就绪,避免闪烁
|
private mount(el: HTMLElement, view: EditorView) {
|
||||||
private render(el: HTMLElement, view: EditorView) {
|
let stop = () => {}
|
||||||
const seq = ++mermaidSeq
|
const render = () => {
|
||||||
mermaidSeqs.set(el, seq)
|
stop()
|
||||||
if (!this.code.trim()) {
|
stop = mountContent(el, (context) => Promise.resolve(this.renderer.render({
|
||||||
el.textContent = '空图表'
|
...context, source: this.code, language: this.language, path: this.path,
|
||||||
return
|
colorMode: document.documentElement.dataset.rmColorMode === 'dark' ? 'dark' : 'light',
|
||||||
|
})).then((cleanup) => { requestAnimationFrame(() => view.requestMeasure()); return cleanup }), (error) => {
|
||||||
|
showRenderError(el, error)
|
||||||
|
this.renderer.fail(error)
|
||||||
|
view.requestMeasure()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
renderMermaid(this.code).then(
|
render()
|
||||||
(svg) => {
|
window.addEventListener('rawmark-appearance', render)
|
||||||
if (mermaidSeqs.get(el) !== seq) return
|
rendererMounts.set(el, () => { window.removeEventListener('rawmark-appearance', render); stop() })
|
||||||
el.innerHTML = svg
|
|
||||||
view.requestMeasure()
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
if (mermaidSeqs.get(el) !== seq) return
|
|
||||||
showRenderError(el, err)
|
|
||||||
view.requestMeasure()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
get estimatedHeight() {
|
|
||||||
return 240
|
|
||||||
}
|
}
|
||||||
|
get estimatedHeight() { return 240 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EmojiWidget extends WidgetType {
|
export class EmojiWidget extends WidgetType {
|
||||||
|
|||||||
Vendored
+88
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Extension } from '@codemirror/state'
|
||||||
|
import type { MarkdownExtension } from '@lezer/markdown'
|
||||||
|
|
||||||
|
export type Cleanup = () => void
|
||||||
|
export type AsyncCleanup = () => void | Promise<void>
|
||||||
|
export type Awaitable<T> = T | Promise<T>
|
||||||
|
export type JSONValue = null | boolean | number | string | JSONValue[] | { [key: string]: JSONValue }
|
||||||
|
|
||||||
|
export interface PluginManifest {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
apiVersion: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostModules {
|
||||||
|
readonly '@codemirror/state': typeof import('@codemirror/state')
|
||||||
|
readonly '@codemirror/view': typeof import('@codemirror/view')
|
||||||
|
readonly '@codemirror/language': typeof import('@codemirror/language')
|
||||||
|
readonly '@codemirror/commands': typeof import('@codemirror/commands')
|
||||||
|
readonly '@lezer/common': typeof import('@lezer/common')
|
||||||
|
readonly '@lezer/markdown': typeof import('@lezer/markdown')
|
||||||
|
readonly '@lezer/highlight': typeof import('@lezer/highlight')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginCommand {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
keys?: string[]
|
||||||
|
when?: () => boolean
|
||||||
|
run(): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MountContext {
|
||||||
|
container: HTMLElement
|
||||||
|
signal: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginView {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
mount(context: MountContext): Awaitable<void | AsyncCleanup>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeBlockRenderer = (context: MountContext & {
|
||||||
|
source: string
|
||||||
|
language: string
|
||||||
|
path: string
|
||||||
|
colorMode: 'light' | 'dark'
|
||||||
|
}) => Awaitable<void | AsyncCleanup>
|
||||||
|
|
||||||
|
export interface VaultEvents {
|
||||||
|
changed: { path: string; rev: string }
|
||||||
|
moved: { from: string; path: string }
|
||||||
|
deleted: { path: string }
|
||||||
|
resync: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginVault {
|
||||||
|
list(): Promise<Array<{ path: string; dir: boolean; size: number; mtime: number }>>
|
||||||
|
read(path: string): Promise<{ content: string; rev: string }>
|
||||||
|
write(path: string, content: string, options: { ifMatch: string } | { create: true }): Promise<{ rev: string }>
|
||||||
|
on<K extends keyof VaultEvents>(event: K, callback: (value: VaultEvents[K]) => Awaitable<void>): Cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginWorkspace {
|
||||||
|
getActiveNote(): { path: string; content: string; rev: string; dirty: boolean } | null
|
||||||
|
openNote(path: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAPI {
|
||||||
|
readonly manifest: Readonly<PluginManifest>
|
||||||
|
readonly signal: AbortSignal
|
||||||
|
readonly modules: HostModules
|
||||||
|
addCommand(command: PluginCommand): Cleanup
|
||||||
|
registerEditorExtension(extension: Extension): Cleanup
|
||||||
|
registerMarkdownSyntax(extension: MarkdownExtension): Cleanup
|
||||||
|
registerCodeBlockRenderer(language: string, render: CodeBlockRenderer): Cleanup
|
||||||
|
addSidebarView(view: PluginView): Cleanup
|
||||||
|
addSettingTab(view: PluginView): Cleanup
|
||||||
|
registerCleanup(cleanup: AsyncCleanup): Cleanup
|
||||||
|
readonly vault: PluginVault
|
||||||
|
readonly workspace: PluginWorkspace
|
||||||
|
readonly data: { load(): Promise<JSONValue>; save(value: JSONValue): Promise<void> }
|
||||||
|
notice(message: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function activate(api: PluginAPI): Awaitable<void | AsyncCleanup>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { api, type InstalledPlugin } from '../app/api'
|
||||||
|
import { allCommands, registerCommand } from '../app/commands'
|
||||||
|
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '../plugin-api'
|
||||||
|
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
|
||||||
|
|
||||||
|
export const pluginState = reactive({ installed: [] as InstalledPlugin[], status: {} as Record<string, { phase: string; error: string }>, error: '' })
|
||||||
|
interface Adapter extends PluginWorkspace {
|
||||||
|
notice(message: string): void
|
||||||
|
writeNote: PluginAPI['vault']['write']
|
||||||
|
}
|
||||||
|
interface Instance {
|
||||||
|
info: InstalledPlugin
|
||||||
|
controller: AbortController
|
||||||
|
stopped: boolean
|
||||||
|
cleanups: Set<() => void>
|
||||||
|
pending: Promise<unknown>[]
|
||||||
|
}
|
||||||
|
const instances = new Map<string, Instance>()
|
||||||
|
const faults = new Map<string, string>()
|
||||||
|
let adapter: Adapter
|
||||||
|
let running = false
|
||||||
|
let session = 0
|
||||||
|
let request = 0
|
||||||
|
let loading = false
|
||||||
|
|
||||||
|
function status(id: string, phase: string, error = '') { pluginState.status[id] = { phase, error } }
|
||||||
|
function assertActive(instance: Instance) {
|
||||||
|
if (instance.stopped || !running) throw new Error('插件已停止,不能继续调用 SDK')
|
||||||
|
}
|
||||||
|
|
||||||
|
function track(instance: Instance, cleanup: AsyncCleanup): () => void {
|
||||||
|
assertActive(instance)
|
||||||
|
let done = false
|
||||||
|
const remove = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
instance.cleanups.delete(remove)
|
||||||
|
try {
|
||||||
|
const pending = Promise.resolve(cleanup()).catch(() => adapter.notice(`插件“${instance.info.name}”清理失败`))
|
||||||
|
instance.pending.push(pending)
|
||||||
|
} catch { adapter.notice(`插件“${instance.info.name}”清理失败`) }
|
||||||
|
}
|
||||||
|
instance.cleanups.add(remove)
|
||||||
|
return remove
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(instance: Instance) {
|
||||||
|
if (instance.stopped) return
|
||||||
|
instance.stopped = true
|
||||||
|
instance.controller.abort()
|
||||||
|
instances.delete(instance.info.id)
|
||||||
|
for (const remove of [...instance.cleanups].reverse()) remove()
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
const finished = await Promise.race([
|
||||||
|
Promise.all(instance.pending).then(() => true),
|
||||||
|
new Promise<false>((resolve) => { timer = setTimeout(() => resolve(false), 5000) }),
|
||||||
|
])
|
||||||
|
clearTimeout(timer!)
|
||||||
|
if (!finished) adapter.notice(`插件“${instance.info.name}”清理超过 5 秒`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(instance: Instance, phase: string) {
|
||||||
|
if (instance.stopped) return
|
||||||
|
const message = `${phase}失败,已在当前页面停用;可在插件设置中重试`
|
||||||
|
faults.set(instance.info.id, instance.info.revision)
|
||||||
|
status(instance.info.id, 'error', message)
|
||||||
|
adapter.notice(`插件“${instance.info.name}”:${message}`)
|
||||||
|
void stop(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function localID(instance: Instance, id: string) {
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(id)) throw new Error('插件注册 id 无效')
|
||||||
|
return `plugin:${instance.info.id}:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) {
|
||||||
|
assertActive(instance)
|
||||||
|
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error('插件视图必须提供标题与挂载函数')
|
||||||
|
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: () => fail(instance, '视图') }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAPI(instance: Instance): Promise<PluginAPI> {
|
||||||
|
const { modules } = await import('./modules')
|
||||||
|
const { id, name, version, apiVersion } = instance.info
|
||||||
|
const check = () => assertActive(instance)
|
||||||
|
return Object.freeze({
|
||||||
|
manifest: Object.freeze({ id, name, version, apiVersion }),
|
||||||
|
signal: instance.controller.signal,
|
||||||
|
modules,
|
||||||
|
addCommand(command) {
|
||||||
|
check()
|
||||||
|
const commandID = localID(instance, command.id)
|
||||||
|
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some((c) => c.id === commandID)) throw new Error('命令无效或重复注册')
|
||||||
|
if (command.keys?.some((key) => allCommands().some((c) => c.keys?.some((existing) => existing.toLowerCase() === key.toLowerCase())))) adapter.notice(`插件“${name}”部分快捷键与其他命令冲突,请调整`)
|
||||||
|
return track(instance, registerCommand({ ...command, id: commandID,
|
||||||
|
when: () => {
|
||||||
|
if (instance.stopped) return false
|
||||||
|
try { return command.when?.() ?? true } catch { fail(instance, '命令条件'); return false }
|
||||||
|
},
|
||||||
|
run: () => {
|
||||||
|
if (instance.stopped) return
|
||||||
|
try { void Promise.resolve(command.run()).catch(() => fail(instance, '命令')) } catch { fail(instance, '命令') }
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
registerEditorExtension(extension) {
|
||||||
|
check()
|
||||||
|
return track(instance, registerEntry(editorExtensions, Symbol(id), { owner: id, value: extension }))
|
||||||
|
},
|
||||||
|
registerMarkdownSyntax(extension) {
|
||||||
|
check()
|
||||||
|
return track(instance, registerEntry(markdownSyntax, Symbol(id), { owner: id, value: extension }))
|
||||||
|
},
|
||||||
|
registerCodeBlockRenderer(language, render) {
|
||||||
|
check()
|
||||||
|
if (typeof render !== 'function') throw new Error('代码块渲染器必须为函数')
|
||||||
|
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: () => fail(instance, '代码块渲染') }))
|
||||||
|
},
|
||||||
|
addSidebarView: (view) => addView(instance, sidebarViews, view),
|
||||||
|
addSettingTab: (view) => addView(instance, settingTabs, view),
|
||||||
|
registerCleanup: (cleanup) => {
|
||||||
|
if (typeof cleanup !== 'function') throw new Error('清理回调必须为函数')
|
||||||
|
return track(instance, cleanup)
|
||||||
|
},
|
||||||
|
vault: {
|
||||||
|
list: () => { check(); return api.tree() },
|
||||||
|
read: (path) => { check(); return api.readFile(path) },
|
||||||
|
write: (path, content, options) => { check(); return adapter.writeNote(path, content, options) },
|
||||||
|
on<K extends keyof VaultEvents>(event: K, callback: (value: VaultEvents[K]) => void | Promise<void>) {
|
||||||
|
check()
|
||||||
|
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error('仓库事件类型无效')
|
||||||
|
const listener = (e: Event) => {
|
||||||
|
if (instance.stopped) return
|
||||||
|
try { void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(() => fail(instance, '仓库事件')) } catch { fail(instance, '仓库事件') }
|
||||||
|
}
|
||||||
|
pluginEvents.addEventListener(event, listener)
|
||||||
|
return track(instance, () => pluginEvents.removeEventListener(event, listener))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
getActiveNote: () => { check(); return adapter.getActiveNote() },
|
||||||
|
openNote: (path) => { check(); return adapter.openNote(path) },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
load: async () => { check(); return (await api.pluginData(id)).data as JSONValue },
|
||||||
|
save: async (data) => { check(); await api.savePluginData(id, data) },
|
||||||
|
},
|
||||||
|
notice: (message) => { check(); adapter.notice(String(message)) },
|
||||||
|
} satisfies PluginAPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activate(instance: Instance) {
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
const signal = instance.controller.signal
|
||||||
|
let onAbort: () => void = () => {}
|
||||||
|
const interrupted = new Promise<never>((_, reject) => {
|
||||||
|
onAbort = () => reject(new Error('插件已停止'))
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
timer = setTimeout(() => reject(new Error('插件激活超过 10 秒')), 10_000)
|
||||||
|
})
|
||||||
|
const pending = (async () => {
|
||||||
|
const sdk = await createAPI(instance)
|
||||||
|
assertActive(instance)
|
||||||
|
const { id, revision } = instance.info
|
||||||
|
const url = `/api/v1/plugins/${encodeURIComponent(id)}/assets/${revision}/main.js`
|
||||||
|
const module = await import(/* @vite-ignore */ url)
|
||||||
|
assertActive(instance)
|
||||||
|
if (typeof module.default !== 'function') throw new Error('插件必须默认导出激活函数')
|
||||||
|
const cleanup: unknown = await module.default(sdk)
|
||||||
|
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error('激活返回值必须为清理函数')
|
||||||
|
if (cleanup) {
|
||||||
|
if (instance.stopped) {
|
||||||
|
try { await cleanup() } catch { adapter.notice(`插件“${instance.info.name}”迟到的清理失败`) }
|
||||||
|
} else track(instance, cleanup as AsyncCleanup)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
try {
|
||||||
|
await Promise.race([pending, interrupted])
|
||||||
|
if (!instance.stopped) status(instance.info.id, 'active')
|
||||||
|
} catch {
|
||||||
|
fail(instance, '激活')
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer!)
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pump() {
|
||||||
|
if (loading || !running || pluginsPaused || pluginState.error) return
|
||||||
|
loading = true
|
||||||
|
const currentSession = session
|
||||||
|
try {
|
||||||
|
while (running && currentSession === session && !pluginState.error) {
|
||||||
|
const next = pluginState.installed.find((p) => p.enabled && !instances.has(p.id) && faults.get(p.id) !== p.revision)
|
||||||
|
if (!next) break
|
||||||
|
const instance: Instance = { info: { ...next }, controller: new AbortController(), stopped: false, cleanups: new Set(), pending: [] }
|
||||||
|
instances.set(next.id, instance)
|
||||||
|
status(next.id, 'loading')
|
||||||
|
await activate(instance)
|
||||||
|
}
|
||||||
|
} finally { loading = false; if (running && currentSession !== session) void pump() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPlugins() {
|
||||||
|
if (!running) return
|
||||||
|
const currentSession = session
|
||||||
|
const sequence = ++request
|
||||||
|
try {
|
||||||
|
const installed = await api.plugins()
|
||||||
|
if (!running || currentSession !== session || sequence !== request) return
|
||||||
|
pluginState.installed = installed
|
||||||
|
pluginState.error = ''
|
||||||
|
for (const instance of instances.values()) {
|
||||||
|
if (!installed.some((p) => p.id === instance.info.id && p.revision === instance.info.revision && p.enabled)) void stop(instance)
|
||||||
|
}
|
||||||
|
for (const p of installed) {
|
||||||
|
if (!p.enabled) { faults.delete(p.id); status(p.id, 'disabled') }
|
||||||
|
else if (pluginsPaused) status(p.id, 'paused')
|
||||||
|
}
|
||||||
|
void pump()
|
||||||
|
} catch {
|
||||||
|
if (!running || currentSession !== session || sequence !== request) return
|
||||||
|
pluginState.error = '读取插件列表失败,请检查连接后重试'
|
||||||
|
adapter.notice(pluginState.error)
|
||||||
|
for (const instance of instances.values()) {
|
||||||
|
status(instance.info.id, 'paused')
|
||||||
|
void stop(instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPlugins(value: Adapter) {
|
||||||
|
adapter = value
|
||||||
|
running = true
|
||||||
|
session++
|
||||||
|
faults.clear()
|
||||||
|
void refreshPlugins()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPlugins() {
|
||||||
|
running = false
|
||||||
|
session++
|
||||||
|
for (const instance of instances.values()) void stop(instance)
|
||||||
|
pluginState.installed = []
|
||||||
|
pluginState.status = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryPlugin(id: string) {
|
||||||
|
faults.delete(id)
|
||||||
|
void refreshPlugins()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginEditorFailure(error: unknown) {
|
||||||
|
const owners = new Set([...editorExtensions.values(), ...markdownSyntax.values()].map((entry) => entry.owner))
|
||||||
|
if (!owners.size) throw error
|
||||||
|
// CodeMirror 的事务/解析错误无法可靠定位任意第三方扩展;先保留文档并撤回外部扩展。
|
||||||
|
queueMicrotask(() => {
|
||||||
|
for (const owner of owners) {
|
||||||
|
const instance = instances.get(owner)
|
||||||
|
if (instance) fail(instance, '编辑器扩展')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as state from '@codemirror/state'
|
||||||
|
import * as view from '@codemirror/view'
|
||||||
|
import * as language from '@codemirror/language'
|
||||||
|
import * as commands from '@codemirror/commands'
|
||||||
|
import * as common from '@lezer/common'
|
||||||
|
import * as markdown from '@lezer/markdown'
|
||||||
|
import * as highlight from '@lezer/highlight'
|
||||||
|
import type { HostModules } from '../plugin-api'
|
||||||
|
|
||||||
|
export const modules: HostModules = Object.freeze({
|
||||||
|
'@codemirror/state': state,
|
||||||
|
'@codemirror/view': view,
|
||||||
|
'@codemirror/language': language,
|
||||||
|
'@codemirror/commands': commands,
|
||||||
|
'@lezer/common': common,
|
||||||
|
'@lezer/markdown': markdown,
|
||||||
|
'@lezer/highlight': highlight,
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { AsyncCleanup, Awaitable, MountContext } from '../plugin-api'
|
||||||
|
|
||||||
|
// 每次挂载使用独立容器;超时或停用后的异步渲染只能修改已脱离页面的容器。
|
||||||
|
export function mountContent(
|
||||||
|
parent: HTMLElement,
|
||||||
|
mount: (context: MountContext) => Awaitable<void | AsyncCleanup>,
|
||||||
|
fail: (error: unknown) => void,
|
||||||
|
): () => void {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const container = document.createElement('div')
|
||||||
|
parent.replaceChildren(container)
|
||||||
|
let cleanup: void | AsyncCleanup
|
||||||
|
let stopped = false
|
||||||
|
let pending = true
|
||||||
|
const runCleanup = (fn: AsyncCleanup) => {
|
||||||
|
try { void Promise.resolve(fn()).catch(fail) } catch (error) { fail(error) }
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
if (stopped) return
|
||||||
|
stopped = true
|
||||||
|
controller.abort()
|
||||||
|
container.remove()
|
||||||
|
if (cleanup) runCleanup(cleanup)
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!pending || stopped) return
|
||||||
|
stop()
|
||||||
|
fail(new Error('插件视图渲染超过 10 秒'))
|
||||||
|
}, 10_000)
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (!stopped) return mount({ container, signal: controller.signal })
|
||||||
|
}).then((result) => {
|
||||||
|
pending = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (result !== undefined && typeof result !== 'function') throw new Error('插件挂载返回值必须为清理函数')
|
||||||
|
if (stopped) { if (result) runCleanup(result) } else cleanup = result
|
||||||
|
}).catch((error) => {
|
||||||
|
pending = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (!stopped) { stop(); fail(error) }
|
||||||
|
})
|
||||||
|
return stop
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Facet, StateEffect, type Extension } from '@codemirror/state'
|
||||||
|
import type { MarkdownExtension } from '@lezer/markdown'
|
||||||
|
import { shallowReactive } from 'vue'
|
||||||
|
import type { CodeBlockRenderer, PluginView } from '../plugin-api'
|
||||||
|
import { renderMermaid } from '../markdown/render'
|
||||||
|
|
||||||
|
export const pluginsPaused = typeof location !== 'undefined' && new URLSearchParams(location.search).has('disable-plugins')
|
||||||
|
export const pluginRefresh = StateEffect.define<null>()
|
||||||
|
export const notePath = Facet.define<{ path: string }, { path: string }>({ combine: (values) => values[0] ?? { path: '' } })
|
||||||
|
export const editorExtensions = new Map<symbol, { owner: string; value: Extension }>()
|
||||||
|
export const markdownSyntax = new Map<symbol, { owner: string; value: MarkdownExtension }>()
|
||||||
|
export interface OwnedView extends PluginView { owner: string; fail: (error: unknown) => void }
|
||||||
|
export interface OwnedRenderer { owner: string; render: CodeBlockRenderer; fail: (error: unknown) => void }
|
||||||
|
export const codeRenderers = new Map<string, OwnedRenderer>()
|
||||||
|
export const sidebarViews = shallowReactive(new Map<string, OwnedView>())
|
||||||
|
export const settingTabs = shallowReactive(new Map<string, OwnedView>())
|
||||||
|
export const pluginEvents = new EventTarget()
|
||||||
|
let reconfigure: () => void = () => {}
|
||||||
|
let editorFailure: (error: unknown) => void = (error) => { throw error }
|
||||||
|
|
||||||
|
export function setPluginEditorHandlers(change: () => void, failure: (error: unknown) => void) {
|
||||||
|
reconfigure = change
|
||||||
|
editorFailure = failure
|
||||||
|
}
|
||||||
|
export function reportEditorFailure(error: unknown) { editorFailure(error) }
|
||||||
|
export function pluginsChanged() { reconfigure() }
|
||||||
|
|
||||||
|
export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => void {
|
||||||
|
if (map.has(key)) throw new Error('插件重复注册,不能覆盖已有功能')
|
||||||
|
map.set(key, value)
|
||||||
|
try { pluginsChanged() } catch (error) { map.delete(key); pluginsChanged(); throw error }
|
||||||
|
return () => {
|
||||||
|
if (map.get(key) !== value) return
|
||||||
|
map.delete(key)
|
||||||
|
pluginsChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCodeRenderer(language: string, value: OwnedRenderer) {
|
||||||
|
const key = language.trim().toLowerCase()
|
||||||
|
if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error('代码块语言名无效')
|
||||||
|
return registerEntry(codeRenderers, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCodeRenderer('mermaid', {
|
||||||
|
owner: 'rawmark-mermaid',
|
||||||
|
async render({ source, container, signal }) {
|
||||||
|
if (!source.trim()) { container.textContent = '空图表'; return }
|
||||||
|
try {
|
||||||
|
const svg = await renderMermaid(source)
|
||||||
|
if (!signal.aborted) container.innerHTML = svg
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return
|
||||||
|
container.className = 'cm-rm-render-error'
|
||||||
|
container.textContent = error instanceof Error ? error.message : '图表语法错误'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { detail: '内置图表渲染失败,请重试' })),
|
||||||
|
})
|
||||||
@@ -287,6 +287,7 @@ body {
|
|||||||
/* 编辑时显示在源码下方的预览 */
|
/* 编辑时显示在源码下方的预览 */
|
||||||
.cm-rm-preview { margin: 4px 0 8px; padding: 8px 12px; border: 1px dashed var(--rm-divider); border-radius: 6px; cursor: default; }
|
.cm-rm-preview { margin: 4px 0 8px; padding: 8px 12px; border: 1px dashed var(--rm-divider); border-radius: 6px; cursor: default; }
|
||||||
|
|
||||||
|
.cm-rm-code-render { display: block; padding: 8px 0; overflow-x: auto; cursor: pointer; }
|
||||||
.cm-rm-mermaid { display: block; padding: 8px 0; text-align: center; overflow-x: auto; cursor: pointer; color: var(--rm-text-muted); }
|
.cm-rm-mermaid { display: block; padding: 8px 0; text-align: center; overflow-x: auto; cursor: pointer; color: var(--rm-text-muted); }
|
||||||
.cm-rm-mermaid svg { max-width: 100%; height: auto; }
|
.cm-rm-mermaid svg { max-width: 100%; height: auto; }
|
||||||
.cm-rm-render-error {
|
.cm-rm-render-error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"paths": { "@rawmark/api": ["./src/plugin-api/index.d.ts"] },
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in new issue
Block a user