How a plugin works
A plugin is one TypeScript (or JavaScript) file with an activate(api) export, sitting in
a public repository next to a plugin.json. Nothing has to be installed and there is no
build step to start with: the editor fetches your source, transpiles it in the browser and
calls activate with the whole API. Writing a plugin below is the file itself; this is
what the editor does around it, and what it promises while your code runs.
What the editor does for you#
- Every edit is one undo entry. You never touch scenario internals.
api.document.edittakes a label and a builder, applies your operations as you call them, and commits the lot as a singleHistoryEntry— the same path a brush stroke takes, so the right CHK sections are marked dirty, the canvas repaints, and doodads or units your terrain edit stranded are lifted in the same entry.api.document.updateis the same shape for the tables that live outside the undo model (triggers, strings, the settings dialogs' data), andapi.document.sectionsfor raw bytes. See The three kinds of write. - Everything you add is taken back for you. A menu item, hotkey, context-menu entry,
dialog, panel, overlay, map tool or event listener each hand you a
Disposable, and the editor keeps its own list of them besides. Turning your plugin off, reloading it or removing it sweeps the lot whether or not you cleaned up. Whatactivatereturns — nothing, a cleanup function, or aDisposable— is for the things the editor cannot see: your timers, yourAbortController, your worker. - Reading the map is always safe. Every method that reads answers
null,[]orfalsewhen no map is open, rather than throwing. You do not have to guard the empty editor. - The graphics may not be there. The user may not have installed Blizzard's data, and the editor works without it. Anything that needs the tileset degrades — a terrain operation writes nothing and leaves a note on the result rather than failing — so check what you get back rather than assuming.
The contract#
npm i -D @scm-js/plugin-api is the whole toolchain — one generated index.d.ts, types
only, nothing to configure (Writing a plugin has the file that uses it).
The package's major is the API version and its minor moves when the declarations do, so
^1 is a range you can write and mean. Your manifest's "api": N is the version you
need; a host offering an older one refuses to load you rather than failing halfway
through activate. It is 1 today, and additions do not move it — a new call appearing on
api never breaks a plugin that does not use it, so PLUGIN_API_VERSION is reserved for
a change that would.
What you write in#
- The DOM, not React.
api.ui.dialogandapi.ui.panelhand you an element to fill (mount(el)).api.ui.elandapi.ui.widgetsbuild that content in the editor's own classes, so a plain-DOM dialog looks like a built-in one without you copying any CSS. If you would rather have a framework, bundle one into that element — it is your element. - Relative imports, with or without the extension.
./convert,./convert.js(resolving toconvert.tsthe way a TypeScript project means it), or a directory with anindex.ts— the loader tries all of those. A cycle is an error naming the file. - No bare package names on the source path.
import x from "some-package"is refused when the editor loads your source: there is no module resolver behind afetch. Animport typefrom@scm-js/plugin-apiis fine, because the compiler erases it before the loader ever sees a specifier. If you need a real dependency, ship a bundle and name it in the manifest'sbuild— see Building.
What your users are told about you#
A plugin runs in the page with the page's privileges. It can read and change the open map, read and write the files stored in the map archive, read and write the editor's own browser storage, and make network requests. There is no sandbox, the install screen says so in those words, and it is the same trust a browser extension asks for. Three things follow that are worth writing for:
- They see your manifest before any of your code is fetched. The Add screen shows the
name, version, author, description and icon out of
plugin.json, links to the repository and homepage, and the addresses your code will come from — and that is all it fetches. It is the only thing a user has to judge you by, so fill it in. - They are pinned to a commit, and never auto-updated. Installing stores the exact commit your ref pointed at, so a push of yours does not reach anyone already running the plugin. They move forward with the Update button, which shows them the new version's manifest before anything changes. Tag your releases: a tag is what the registry lists and what a considered version looks like from the outside.
- They may be running a copy saved in their browser, if they ticked Load from a copy saved here. That copy is only replaced when they press Reload.
For the same reason, ship your dist/plugin.js unminified. What the confirmation dialog
offers a user is your repository, and a plugin they cannot read is a plugin they cannot
judge.
How the editor finds your code#
The spec is the address a user pastes into Manage Plugins, or that a registry lists:
| Spec | |
|---|---|
github:owner/repo |
The default branch — fine for a private experiment, but see the pinning note above. |
github:owner/repo@v1.2 |
A tag, a branch or a commit. This is what a registry lists and what a default names. |
github:owner/repo@v1.2/plugins/mine |
A folder inside a repository, for several plugins in one. |
https://github.com/owner/repo/tree/v1.2/plugins/mine |
The same, as the URL a user copies out of their address bar. |
https://…/plugin.json |
A manifest anywhere — GitLab, your own host, a gist. |
https://…/plugin.ts |
An entry file with no manifest; one is synthesised from the file name. |
http://localhost:3000/ |
A directory holding plugin.json. This is how you develop one: serve your working copy and add it. |
What loads is the manifest's build when it has one, and its entry otherwise. A
build ends the story in one fetch — no compiler, no walking your imports — and is the only
way to use an npm dependency; the source path is the shortest way to start. entry stays
in the manifest either way, because it is what a person reads and what loads for a
repository that publishes no build.
Getting yours listed#
Plugins ▸ Browse Plugins… reads registries: one JSON file holding an entry per
plugin — the spec to install, plus the fields that plugin's own plugin.json carries, so a
whole list arrives in one request. The project's own is
scm-js/registry, generated from the organisation
itself: every repository named plugin-… or carrying both the scmjs and plugin
topics, described by the plugin.json at its newest version tag (an untagged repository
falls back to its default branch), refreshed hourly and within about a minute of a plugin
repository saying it changed. Listing there is opt-out.
For a plugin outside the organisation, open a pull request against that repository's
plugins.json; its README has the shape of an entry. Nothing about being listed is
privileged — any URL serving a file of that shape is a registry, and a user can add one
under Sources. A registry decides what is offered, never what is trusted: installing
from a Browse row goes through exactly the same confirmation, the same manifest fetch and
the same pinning as an address pasted by hand.
Five plugins are defaults — the editor lists them from the start and they are compiled into the build, so a fresh install has them with no network at all. They are ordinary plugins from their own repositories, each pinned to a tag; being a default buys nothing else. Which five, and how that works, is under Host side.