Main application: MarkText Plus
简体中文 | 日本語 | 한국어 | Deutsch | Français | Italiano | Русский | Español | Português | العربية | Português (Brasil)
How to write a plugin for MarkText Plus, and what the editor will and will not let one do.
Pre-release. This SDK stays at 0.x while the plugin system settles, and the manifest and protocol described here can still change between versions. What that means in practice, and what it will mean later, is in Compatibility below.
A plugin runs on machines that have the editor and nothing else — no Dart SDK, no Node, no Python. That single fact decides most of what follows.
runtime |
What you ship | Runs on | Use it when |
|---|---|---|---|
lua |
one .lua file |
every platform, no build | the default: menu commands, prompts, text work |
js |
one .js file |
every platform, no build | same, if you would rather write JavaScript |
process |
one executable per platform | only the platforms you built for | you need a real toolchain, libraries, or long-running work ‡ |
data |
no code at all | everywhere | themes, snippets, dictionaries |
Start with lua or js. Such a plugin is one script file and a
manifest.json, and nothing else — no build step, no compiler, no second
language, and the same two files work on Windows, macOS and Linux at once. A
script cannot crash the editor either.
‡ — the editor does not start a compiled plugin yet. One installs, and
running a command from it says so: "has no script to run: its runtime is
process". The protocol below is what it will speak, and the host that speaks it
is written and tested in the editor; nothing dispatches a command to it. Written
here so that the evening you would spend compiling one is a decision rather than
a surprise.
Only reach for process when a script genuinely will not do.
The AI translate plugin published alongside this SDK is the whole of what a Lua plugin looks like on disk:
manifest.json
plugin.lua
README.md
CHANGELOG.md
LICENSE
One script, one manifest, and three files that are documentation. No Dart anywhere.
One directory per language, each a complete plugin you can copy:
packages/lua/ ← start here
manifest.json
plugin.lua the entrypoint
lib/marktext-plus.lua the API, loaded with require("lib.marktext-plus")
packages/js/ ← or here
manifest.json
plugin.js
lib/marktext-plus.js the API, loaded with require("lib/marktext-plus")
packages/dart/ ← only if a script will not do; any compiled language works
manifest.json
plugin.dart the entrypoint, compiled to an executable
lib/ the library it imports
pubspec.yaml
schema/manifest.schema.json
Named after the language, because that is what you choose. runtime in the
manifest names how it runs — lua, js, process — and packages/dart is a
process plugin: Dart is simply the language its example happens to be written
in.
A process plugin can be written in anything that compiles to an
executable. The editor starts a program and talks JSON-RPC to it over
stdin and stdout; it never learns what produced that program. Go, Rust, C++,
C#, a statically linked Python — all of them work, and none of them needs
anything from this repository beyond the protocol:
- one JSON object per line, on stdin and stdout;
- responses echo the numeric request
id; - exit when stdin reaches end of file;
- exit when
MARKTEXT_PLUS_PLUGIN_TOKENis not in the environment, so a double-clicked executable says what it is instead of waiting.
That is the whole contract. Here it is in Python, with nothing from this repository — it answers the editor, and refuses to run when nobody started it:
#!/usr/bin/env python3
import json, os, sys
if not os.environ.get("MARKTEXT_PLUS_PLUGIN_TOKEN"):
print("This is a MarkText Plus plugin.", file=sys.stderr)
sys.exit(1)
for line in sys.stdin: # ends at EOF: the editor went away
line = line.strip()
if not line:
continue
request = json.loads(line)
if request.get("method") == "shutdown":
break
result = {"echoed": request.get("params", {}).get("text", "")}
print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}),
flush=True)packages/dart/lib is the same four rules with the edges
handled — malformed input, an unknown method, an error inside one handler not
ending the plugin. Dart is here because the editor is written in it, so
dart compile exe was the shortest way to have a working example. It is not a
requirement and not a recommendation: writing those rules again in the language
you already know is usually easier than adding a Dart toolchain to your build.
All three entrypoints do the same thing: load the API and call it.
local sdk = require("lib.marktext-plus")
return sdk.ask(sdk.t("ask.language"), { default = ..., choices = ... })const sdk = require("lib/marktext-plus");
return sdk.ask(sdk.t("ask.language"), { default: ..., choices: [...] });import 'package:marktext_plus_plugin_sdk/marktext_plus_plugin_sdk.dart';
exit(await serve(args, { 'summarise': (request) => ... }));lua and js are deliberately the same plugin written twice — same
manifest, same permissions, same behaviour — so the two can be read against
each other. Copy one. A plugin declares one runtime and one entrypoint;
a directory holding all three is three plugins wearing one manifest.
Because it ships with your plugin. lib/marktext-plus.lua is not a dependency
you point at — it is a file you copy along with the rest, and then own. Copying
packages/lua wholesale gives you a working plugin including the API; there is
no separate place to fetch it from, and nothing to keep in step with a version
number.
For a script, it is ordinary Lua or JavaScript that ships with your plugin.
The editor injects storage, t and require as globals before your file is
read; the module wraps them under one name and adds a constructor for each
action, so a plugin reads as sdk.show(text, title) rather than as a table
literal whose spelling nothing checks. You can edit it, or not use it at all —
returning the plain table works exactly as well.
For packages/dart it is a real library, compiled into your executable, and it
carries something a script does not need: the JSON-RPC loop, the launch check
and the shutdown. A process plugin is on the other side of a pipe.
require loads one of your own files — the API module above is loaded
exactly this way, and anything else you put beside it works the same:
local helpers = require("lib.helpers") -- lib/helpers.lua, returns its tableconst helpers = require("lib/helpers"); // lib/helpers.js, sets module.exportsLoaded once however often it is required. The name is a name, not a path: it
resolves inside your plugin's directory and nowhere else, so splitting a large
plugin up — or using a library someone else wrote and you shipped alongside —
costs you no access to the rest of the disk. A name with a separator in it, a
.., or a leading dot is refused before anything is read, and the resolved
file is checked to be inside your directory afterwards as well, which is what
catches a symbolic link pointing out.
Each example directory is named after the runtime it declares, and each is a
complete plugin: a manifest and the code, nothing else needed. lua and js
are deliberately the same plugin written twice — same manifest, same
permissions, same behaviour — so the two can be read side by side to see what
changes and what does not.
Copy one. A plugin declares one runtime and one entrypoint; a directory
holding a .lua, a .js and an executable is three plugins wearing one
manifest, and only whichever the manifest names would ever run. Take the
example whose language you want and leave the others here.
A Lua or JavaScript plugin is interpreted by the editor, so the honest test is installing it — Plugins → Install from ZIP. A compiled plugin can be checked sooner:
cd packages/dart && dart compile exe plugin.dart -o bin/linux/plugin
echo | ./bin/linux/plugin # should refuse: it was not started by the editor
is the whole check for a compiled plugin: it builds, and it declines to run when nothing gave it a launch token.
A plugin whose manifest.json cannot be read is not in the list, and a plugin
missing from the list looks exactly like one that was never installed — so the
usual next move is to install it again, and again nothing appears.
The editor lists it separately, below the ones that loaded, with the reason: the key it could not read and what it expected to find there. That row carries a delete button of its own, since a plugin that will not load has no page to uninstall it from.
Most reasons are one of these:
runtimehas to belua,js,processordataluaandjsneedentrypoint;processneedsentrypoints, one path per operating system- no entry point may be a
.dartfile. The editor does not ship a Dart SDK and cannot assume the reader has one — compile it and ship the executable withruntime: "process"
manifest.json sits at the root of the plugin. The editor reads it without
running anything. See schema/manifest.schema.json.
{
"id": "com.example.my-plugin",
"name": "My Plugin",
"description": "plugin.description",
"version": "1.0.0",
"minAppVersion": "1.6.1",
"runtime": "lua",
"entrypoint": "plugin.lua",
"permissions": ["document.read", "ui.contextMenu", "ai.chat", "storage.local"],
"menus": [
{"id": "translate.selection", "title": "menu.selection", "location": "editor.contextMenu"}
],
"settings": [
{"key": "target", "title": "settings.target", "type": "text", "default": "English"}
],
"defaultLocale": "en",
"locales": {
"en": {"menu.selection": "Translate selection", "settings.target": "Target language"},
"zh": {"menu.selection": "翻译选中内容", "settings.target": "目标语言"}
}
}A process plugin does not use entrypoint. It names its executables by
operating system, and by architecture underneath where that matters:
{
"runtime": "process",
"entrypoints": {
"macos": "bin/macos/plugin",
"windows": {
"x64": "bin\\windows-x64\\plugin.exe",
"arm64": "bin\\windows-arm64\\plugin.exe"
},
"linux": {
"default": "bin/linux/plugin",
"arm64": "bin/linux-arm64/plugin"
}
}
}The system is the part you always have to answer — a Windows build is a different file from a Linux one. The architecture often is not, so it is optional:
- One path for the whole system.
macosabove is a universal binary: one file holding both architectures, which is how macOS builds normally ship. Writing the same path underx64andarm64to say so would be worse. - A table of architectures.
windowsabove ships a separate build for each and supports nothing else. - A shared
defaultplus a specialisation.linuxabove runsbin/linux/plugineverywhere except arm64, which gets its own. The specialised build wins.
Systems are windows, macos and linux; architectures are x64 and
arm64. Anything else is refused when the plugin is installed rather than
skipped — a misspelt windwos would otherwise turn into "this plugin does not
support your platform" at the moment the reader clicks, with nothing to explain
it.
A platform you did not build for is named to the reader — "no build for
linux-arm64; it ships macos-x64, macos-arm64, windows-x64" — rather
than guessed at. Declaring runtime: "process" with no entrypoints, or
naming a system with nothing under it, is rejected.
Both script runtimes are synchronous — the Lua interpreter has no coroutines, and the JS engine has no event loop of its own. Anything that takes time (asking the reader something, calling a model) would therefore block the editor. So a script never waits: it returns an action describing what it wants done, the editor does it, and the editor calls the script again with the answer.
function on_command(ctx)
-- ctx.command, ctx.selection, ctx.document, ctx.answer
if ctx.selection == "" then
return { notify = t("nothing.selected") }
end
if ctx.answer == nil then
return {
ask = t("which.language"),
default = storage.get("target") or "English",
choices = { "English", "简体中文", "日本語" },
}
end
storage.set("target", ctx.answer)
return { ai = "Translate into " .. ctx.answer .. ":\n\n" .. ctx.selection }
end
function on_result(ctx, result)
return { show = result, title = ctx.answer }
endThe same shape in JavaScript:
function on_command(ctx) {
if (!ctx.selection) return { notify: t("nothing.selected") };
if (ctx.answer == null) {
return { ask: t("which.language"), default: storage.get("target") || "English" };
}
storage.set("target", ctx.answer);
return { ai: `Translate into ${ctx.answer}:\n\n${ctx.selection}` };
}
function on_result(ctx, result) {
return { show: result, title: ctx.answer };
}A command run from a panel can be run a second time, with the reader asking for the answer to be changed rather than describing the whole thing afresh. The editor says this in the terms your script already knows:
ctx.selectionis your own last answer, because a follow-up is about the draft the reader is looking at rather than about the document.ctx.answeris what they asked for this time — the same field youraskquestion is answered in.
So a command that reads the selection as "the part to work on" needs nothing
new. One that ignores ctx.selection will keep rewriting the document from
scratch, which is not what the reader asked for.
The box is only offered when the command asked something in the first place,
since that is where a follow-up arrives; a command that never asks is run
once. What the editor would replace on apply stays what the first answer
named — a shorter second draft still replaces the paragraph the first one was
made from, not the draft it came from, which is nowhere in the document.
| Return | The editor does | Then |
|---|---|---|
{ ask = "…", default = "…", choices = {…} } |
asks the reader; choices appear as chips to press, and anything typed instead is taken as it stands |
calls on_command again with ctx.answer set |
{ ai = "…" } |
sends your prompt to the model the reader configured | calls on_result(ctx, reply) |
{ show = "…", title = "…" } |
shows one answer in a small window, with a copy button | stops; nothing is written |
{ panel = "…", title = "…" } |
shows it in a panel beside the document | stops; nothing is written |
{ pane = "…", title = "…", slot = "right"|"bottom"|"corner", apply = true, replaces = "…" } |
fills one of the panes around the document; apply offers to write it back |
stops; nothing is written until the reader accepts |
{ pane = "<html>…", title = "…", as = "web" } |
draws your own HTML page in that pane, using the system's web engine. Needs ui.webview |
stops |
{ notify = "…" } |
tells the reader | stops |
{ diff = { original = "…", result = "…" } } |
shows both side by side | stops; nothing is written |
{ replace = "…" } |
replaces the selection | stops |
{ ui = <node>, title = "…" } |
draws your own interface — see below | calls on_event(ctx, id, values) when the reader uses it |
| anything else | nothing | stops |
ui carries a tree the editor renders as its own widgets. Nodes are tables
with one key each:
return sdk.ui({ column = {
{ text = sdk.t("ask.instruction"), emphasis = true },
{ input = { id = "brief", multiline = true, placeholder = "…" } },
{ chips = { id = "idea", options = prompts.writing_ideas(sdk.t) } },
{ row = {
{ spacer = true },
{ button = { id = "go", label = sdk.t("action.write"), primary = true } },
}},
}}, sdk.t("menu.write"))| Node | Fields |
|---|---|
text |
the string itself; emphasis = true draws it as a heading |
input |
id (required), value, placeholder, multiline |
chips |
id (required), options — a list of strings |
button |
id (required), label, primary |
row / column |
a list of nodes |
select |
id (required), options, value — a dropdown, for when there are too many for chips |
checkbox |
id (required), label, value; its value arrives as "true" or "false" |
markdown |
Markdown, drawn by the editor's own renderer — your answer looks like the document it is about |
image |
source (required), height — see below |
spacer |
blank space; in a row it pushes what follows to the far end |
Pressing a button or choosing a chip calls on_event(ctx, id, values), where
id is that node's id and values holds every input in the tree by id.
You do not have to remember the form you drew a step ago — the editor has it.
It is not HTML, and that is the point: these are the editor's own widgets, so they cost nothing at startup, follow the reader's theme without your plugin knowing what the theme is, and cannot draw outside the container they were given.
Pictures. source is one of three things:
- a
data:URI — decoded in place, nothing leaves the machine; - a path relative to your plugin's directory — an absolute one is refused,
because reading files off the reader's disk is what
workspace.readis for; - an
http(s)URL — fetched by the editor, not by your plugin, so it follows the reader's system proxy and is written to your plugin's log with the host, status, size and time. Needsnetwork.request.
That last line is deliberate. You may reach the network; the reader may find out where you went. A permission nobody can check up on afterwards is a promise rather than a permission.
A misspelled node refuses the whole tree with an error you will see, rather than quietly drawing nothing. The same goes for a tree deeper than 12 levels or larger than 500 nodes — half a form is worse than no form, since the reader fills in what is there and presses a button that was never drawn.
What needs asking for. Four of these are refused unless the manifest declares the permission:
| Return | Needs |
|---|---|
ai |
ai.chat |
replace |
document.write |
notify |
ui.notifications |
pane, panel |
ui.sidebar |
pane with as = "web" |
ui.webview, which carries ui.sidebar and network.request |
ask, show and diff need nothing. Answering the reader who ran the
command is what a plugin is for, so requiring a permission would mean every
plugin declares it — and one everybody holds tells the reader nothing.
Your own page. as = "web" draws the pane's text as an HTML page instead
of as words. It is the one way to an interface the editor did not design, and
it is the expensive one: everything else you can return is described to the
editor — named nodes, or text — and drawn with the editor's own widgets, which
is why those cost nothing at startup and follow the reader's theme without you
knowing what the theme is.
The engine is the operating system's — WebView2 on Windows, WKWebView on macOS — not a browser packaged with the editor, so the download and the memory stay where they were and the reader's own proxy settings apply without anyone arranging it. Nothing is created until a plugin asks: a reader who never opens one never pays for an engine.
Where the page goes is written to the plugin log, which is what ui.webview
promises. Send HTML, not an address: what you draw is your own, and a page that
wants something from a server can ask for it itself.
Not every machine has one. A Linux box without a web engine the editor can use gets a line saying so, naming your plugin — not a blank pane, which would look like your plugin had failed. Do not make a web page the only way to use your plugin unless you mean to leave those readers out.
Panes. The editor already splits a tab between source and preview; pane
is that split offered to you. Four cells at most, and the split view's own
two halves are two of them — that is what this was made from, so a document
in split view is two cells before you fill anything.
The shape follows how many cells there are, and is even at every step:
| Cells | Layout |
|---|---|
| one | the document has the whole tab |
| two | side by side, half each |
| three | one half divided, the other whole |
| four | top left, top right, bottom left, bottom right |
With three cells and a document that is not split, you choose which half is
divided: filling right puts a pane beside the document, so the top splits
and the other pane takes the bottom row whole; filling only bottom and
corner leaves the document the top row whole and divides the bottom between
them. The reader can move it afterwards from the pane's title bar — which half
is divided is a view of the same panes, not a decision you keep making.
◆A document being read in split view is two of those cells, not one — source and preview are already a division. So beside a split document there is room for two panes and not three: fill a third slot and it is not drawn until the reader leaves split view.
The dividers drag, like the one between source and preview.
The slot names — right, bottom, corner — are also how you address a pane
again later, to append to it or to replace what it holds. One pane is still one
pane: filling only corner gives you a pane beside the document, not a cell in
a corner with two empty ones to reach it. Which way it goes is the name's to
say, though — only bottom puts the pane under the document, any other slot
beside it — because a rewrite of the paragraph you are looking at reads best
under it, at the same line width, where the eye compares by dropping down
rather than across. The reader can turn that around from the pane's title bar.
A slot name
the editor does not know is refused rather than guessed at: a pane appearing
somewhere you did not ask for, with no way to find out why, is worse than being
told.
A pane belongs to the tab it was opened in. Switching tabs takes it off screen, closing the tab closes it, and switching back brings it up again.
Offer to write it back. apply puts an Apply button on the pane, which
writes what it holds into the document, and replaces says what that replaces
— an empty one meaning the whole document. What a model returns is worth
reading before it lands in what the reader was writing, so a rewrite is shown
first and written when they say so, through the editor's history, so one press
of undo takes it back. What is replaced is fixed when your command ran, not
when the button is pressed: a selection that moved in between does not send
the text somewhere arbitrary. Needs document.write, which the editor checks
at the button rather than trusting the flag.
Say you are working before you start. A pane action is read before an ai
one, so returning both means "put this up, then go and ask" — and a pane whose
text is empty with a request behind it is what the editor draws as working. It
says so where the text will be until the first block lands, and in the title
bar after that, so the progress is never on top of what arrived. Returning the
ai alone leaves the screen unchanged for however long the model takes, which
reads as a menu item that did nothing.
Closing a pane is how the reader stops you. Appending to one they closed is refused rather than putting it back a block at a time.
show or panel. A few lines are an answer: a small window is right, and
a panel for them is more furniture than content. A document-sized result is
something the reader compares against what is on screen, and a window over the
screen is the one place it cannot go.
A run is capped at 8 steps, so a script that keeps returning ask cannot trap
the reader in a loop.
The prompt is yours. The editor holds the credentials and makes the
request; it never writes or edits your prompt, and your script never sees the
API key. What the model is asked is exactly the string you returned in ai.
Only these. There is no os, no package, no dofile, no loadfile, no file
system and no network — a script plugin comes from a stranger's repository, so
it gets what it declared and nothing else.
storage.get(key) / storage.set(key, value) |
your own settings, in your own directory. Strings only. Needs storage.local. |
t(key) |
your own string in the reader's language; returns the key itself if you have no translation for it |
require(name) |
one of your own files, resolved only inside your plugin's directory |
ctx.command |
the id of the menu entry or command that fired |
ctx.selection |
the selected text, "" when nothing is selected |
ctx.document |
the whole document |
ctx.answer |
what the reader typed last time you asked, otherwise nil/undefined |
ctx.view |
how the reader is looking at the document: source, preview or split |
The interpreter is a pure-Dart Lua, which is why a script plugin needs nothing installed — and it is not complete. These all fail silently, which is the part that costs time: a pattern that matches nothing looks exactly like a document with nothing in it.
| Instead of | Use | Because |
|---|---|---|
#someString |
string.len(s) |
raises length error. # on a table works, so the two cannot be told apart by feel |
s:match("%S"), %s |
compare characters: s:sub(i, i) == " " |
the classes match nothing, so every line looks blank |
for l in s:gmatch("(.-)\n") |
s:find("\n", pos, true) and s:sub |
returns nothing at all |
s:gmatch("[^\n]*") |
the same | never advances past an empty match |
a valueless return inside a nested function |
return nil |
it is ignored, and the lines after it run |
That last one deserves more than a row. if not ok then return end is how
every Lua programmer writes a guard, and inside a nested function it guards
nothing: the lines it was meant to skip run anyway. Your validation passes, the
empty case is handled, and the handling does not happen.
Inside while true do ... end it is worse. The loop has no way out, and what
the reader sees is an editor that has stopped answering.
The fix is one word — write return nil. It is ordinary Lua, so it keeps
working the day the interpreter is fixed.
The editor's own test suite pins these, so if the interpreter is replaced this
table is corrected rather than left to mislead. The bare-return behaviour is
pinned by a test that asserts it is broken: the day that test fails is the
day the workaround can go.
The JavaScript runtime is QuickJS and has no equivalent gaps worth listing.
Declared in the manifest, shown to the reader, and enforced. VS Code and
IntelliJ show a permission list and then trust the extension; here nothing is
reviewed by anybody, so the editor checks. Returning { ai = ... } without
ai.chat does not call the model — the reader is told the plugin did not ask
for it.
| Permission | Lets the plugin |
|---|---|
document.read |
read the open document and the selection |
document.write |
change the open document |
ui.contextMenu |
add entries to the right-click menu |
ui.menuBar |
add entries to the menu bar |
ui.toolbar |
add a toolbar button † |
ui.sidebar |
add a side-bar panel |
ui.statusBar |
add a status-bar item † |
ui.settings |
have a settings page |
ui.commandPalette |
add commands to the palette † |
ui.notifications |
tell the reader things |
ai.chat |
ask the model the reader configured (never the key) |
storage.local |
keep its own settings file in its own directory |
clipboard.read / clipboard.write |
the clipboard † |
workspace.read / workspace.write |
files under the folder the reader opened † |
network.request |
make HTTP requests of its own. The widest thing to ask for: anything it can read, it can send anywhere. |
ui.webview |
open its own web page inside the editor. Carries network.request with it and the reader is told so: a page in a web view fetches whatever it likes |
† — the permission is real and the reader is shown it, but there is nothing
behind it yet: no toolbar, status bar or command palette a plugin can add to,
and no clipboard or workspace a script can reach. Declaring one gains a plugin
nothing today. They stay listed because they are part of the manifest, and they
will be honoured when the capability arrives.
Ask for what you use. A plugin asking for network.request to add a menu entry
is one the reader should decline.
"menus": [{"id": "…", "title": "…", "location": "editor.contextMenu", "when": "selection"}],
"commands": [{"id": "…", "title": "…"}],
"panels": [{"id": "…", "title": "…", "icon": "…"}],
"toolbar": [{"id": "…", "title": "…", "icon": "…"}],
"pages": [{"id": "…", "title": "…"}]title may be a translation key. location is a slot the editor defines —
a plugin places things in named slots, never at pixel coordinates, and never
by handing the editor widgets of its own.
toolbar and pages are the last two on purpose: nothing draws them yet.
A toolbar button declared here does not appear, and pages is read and then
never looked at again — a plugin's own settings page comes from settings
below, which is drawn. Both stay in the manifest because they are part of it and
will be honoured when the capability arrives, the same as the † permissions
above. The three before them are drawn today.
panels puts an icon in the right-hand side bar; pressing it opens a drawer
filled by running your command of the same id. It needs ui.sidebar, and it
needs an icon, because the bar is a rail of icons and one with nothing to
draw would be a gap that opens something. With no plugin contributing a panel
there is no rail at all — a strip of icons with no icons in it is width taken
from the document for nothing.
A panel asks in its own drawer. A command that returns ask puts the question
there — the text, whatever choices you named, and a box already holding the
answer from last time — and the reply arrives in the same drawer. It used to be
asked in the floating card while the answer landed in the drawer, which is one
exchange in two places. Closing the drawer is how the reader declines. A
command started from a menu still asks in the card, since it has no room of its
own to ask in.
when says when a menu entry is worth offering: selection only with
something selected, noSelection only without, and absent means always.
Without it every entry is offered at once — "translate the selection" with
nothing selected, and "translate the document" while the reader is pointing at
a paragraph. A value the editor does not know is refused when the plugin is
installed rather than quietly treated as "always".
Each field in settings becomes a real control on the plugin's own settings
page, which the editor draws:
type |
Control |
|---|---|
text |
a text box |
password |
a text box that does not show what is in it |
number |
a numeric text box |
boolean |
a switch; saved as the strings "true" / "false" |
Values live in settings.json inside the plugin's own directory, so no plugin
can read or write another's. What the reader saves reaches an already-running
script on its next command, not at the next launch.
locales maps a language to your own strings; defaultLocale says which to
fall back on. A reader on zh_CN gets zh_CN if you shipped it, then zh,
then your default. Ship whichever languages you like — this is your table, not
the editor's.
The editor does not start one yet — see ‡ near the top. What follows is the
protocol it will speak, and the half of it inside the editor is written: a
compiled plugin installs, and running a command from it says it has no script to
run. Everything in this section describes the plugin's side of that protocol.
The executable is started as a child process and speaks JSON-RPC 2.0, one JSON
object per line, on stdin/stdout. Responses echo the numeric request id. The
packages/dart library in this repository implements that protocol for plugins
written in Dart and compiled with dart compile exe.
This is about compiled plugins only, and about Dart in particular because the
editor is written in it: entrypoint: "bin/plugin.dart" is rejected when the
plugin is installed. Running it would need a Dart SDK on the reader's machine,
which the editor does not install and cannot assume — and a release build has
no interpreter to hand it to. Compile it (dart compile exe) and ship the
executable.
Nothing here applies to a Lua or JavaScript plugin. Those are interpreted by the editor itself, which is the whole point of them: no Dart, no toolchain, no build.
Why a process and not a library the editor loads. Lua and JS plugins already run inside the editor, on its own thread — that is the normal case, and it holds because both are interpreted: a bad script raises an error the editor catches, so a mistake ends as a message rather than as a crash. What it does not cover is a script that never returns; that freezes the window just as surely, which is why the rule above asks you to bound your own work.
Native code removes the rest of the boundary too. A thread shares the address
space, so a segfault, a stack overflow or an abort() anywhere in a loaded
.so takes the editor down with the reader's unsaved document and no way to
report what happened; and unloading is unreliable, so disabling a plugin would
not actually stop it. A separate process gives all of it back — it can crash, hang
or be killed on a timeout, and the editor survives and says which plugin did
it. (For Dart specifically there is also no choice to make: dart compile has
exe, aot-snapshot, js, wasm and the snapshot formats. There is no
subcommand that produces a C-callable .so or .dll.)
-
Use
serve()and both of the next two points are already handled. The whole of a compiled plugin:import 'dart:io'; import 'package:marktext_plus_plugin_sdk/marktext_plus_plugin_sdk.dart'; Future<void> main(List<String> args) async { exit(await serve(args, { 'shout': (request) => (request.params['text'] as String? ?? '').toUpperCase(), }, name: 'plugin')); }
dart compile exe plugin.dart -o bin/linux/plugin -
Refuse to run unless the editor started you. The editor generates a token for each launch and hands it over in the
MARKTEXT_PLUS_PLUGIN_TOKENenvironment variable; without it,serve()prints a line saying what this is and exits 1. A token made for one launch cannot be typed by someone who was not given it, and it travels in the environment rather than in argv, which anything that can runpsmay read.What this does and does not do. No program can stop a file on the reader's own disk from being executed — double-clicking your executable will always start a process. What the token makes unforgeable is your answer to "did the editor start me", so the process starts, says what it is, and ends, instead of sitting on stdin looking like a program that has hung. A plugin that skips the check is a plugin nobody protected; the check is the SDK's, which is why it is in
serve()rather than in this paragraph.Script plugins get this for nothing: a
.luaor.jsfile is interpreted by the editor, so double-clicking it opens a text editor at worst. -
Exit when stdin reaches end of file. That is how you are told to shut down, and it is what happens on its own if the editor dies —
serve()returns when that happens. The editor also writes down the processes it started and kills any it finds still running the next time it starts — but only ones it started. Processes you spawn are yours to clean up. -
Keep stdout for protocol responses; write diagnostics to stderr.
-
Requests are bounded by a timeout, and a timeout kills only your process.
-
Treat everything the host sends as untrusted and validate it.
A plugin is a file on someone else's machine that this editor reads. Breaking it is not a build failure anybody here sees — it is a plugin that stops working for its readers. So:
minAppVersion is enforced. Say the oldest editor your plugin works with
and the editor holds to it: an editor older than that refuses to install the
plugin, and refuses to run one already installed, saying which version is
wanted and which is present. It used to be read and ignored, which was worse
than not having the field.
What the editor will not take away without notice. Every permission name,
every runtime value, every action a script can return, every field in ctx,
and storage and t and require are pinned by a test in the editor's own
source. Adding to any of those lists is free; removing or renaming one fails
that test, so it cannot happen by accident.
While this is 0.x, a deliberate breaking change is still possible, and it will be in the changelog with what to do about it. When the manifest and the protocol have been left alone for long enough to be worth trusting, this becomes 1.0 and that stops: after it, anything a plugin can write today keeps working, and things go away only after being deprecated in a release that still supports them.
Where a plugin is on its own. The editor cannot promise the behaviour of
your prompt, the model a reader configured, or a process plugin's own child
processes. Nor can it stop a reader from running your executable by hand — see
the launch token above for what it does instead.
- Never write an API key into the plugin directory or the manifest.
- Do not put anything in stdout other than protocol messages.
- Keep work bounded yourself. A script runs on the editor's own thread and nothing interrupts it: a loop with no exit freezes the window until someone kills the process. Only compiled plugins get a timeout, because only they are a separate process to time out.
- A plugin ZIP with a path-traversing entry is rejected at install time, and so is one over 64 MB, one with more than 10000 entries, or one that unpacks to more than 256 MB. An executable for three platforms fits; a checked-in build directory does not.
The SDK is MIT licensed.