scmJS docs

Writing a plugin

A plugin is one TypeScript or JavaScript file exporting an activate(api) function, next to a plugin.json, in a public repository. There is no build step to start with: the editor fetches the source, transpiles it in the browser and calls activate.

Your first plugin walks through writing one. This part is the reference for each piece.

Trying the API first#

The API Playground plugin lets you call the API before setting anything up. Install it from Plugins ▸ Browse Plugins… and open Tools ▸ API Playground. Its panel has a code editor where api is already defined. Write a few lines, press Ctrl+Enter, and they run against the open map.

Type api. for completion. Hovering over a name shows its documentation, the same text as the API reference.

Hovering over api.document.edit in the playground shows its documentation

On the documentation site, an example that runs as it is written has a Try it link. It opens the editor with the example in the playground, and offers to install the playground if you do not have it. Nothing runs until you press Run. Copy Link, at the foot of the panel, makes the same kind of link for your own snippet.

Examples without a link are fragments: they need something only your plugin has, such as a server to talk to.

Starting a plugin#

Start from Hello World, or from a snippet exported from the playground. Hello World's plugin.ts is about sixty lines, mostly comments, and the typings, type-check, build and CI described below are already set up in it.

The two files#

plugin.json:

{
  "name": "Hello",
  "version": "1.0.0",
  "description": "Says hello from the Tools menu.",
  "entry": "plugin.ts",
  "icon": "icon.svg",
  "api": 1
}
Field Meaning
name The only required field.
id Used for storage keys and log lines. Derived from name when absent.
version, description, author, homepage Shown on the Add screen and in Manage Plugins.
entry The source file. Defaults to plugin.ts, then plugin.js.
api The API version the plugin needs. An older editor refuses to load it.
icon See The icon.
build A pre-built bundle to load instead of the source. See Building.
requires Other plugins this one needs. See Requiring another plugin.

plugin.ts:

import type { PluginApi } from "@scm-js/plugin-api";

export default function activate(api: PluginApi) {
  api.menu.add("Tools", {
    label: "Say Hello",
    enabled: () => api.document.isOpen(),
    run: () => api.ui.status(`Hello, ${api.document.info()?.name}!`),
  });
}
Try it

Everything add and on return is a Disposable. Keep the ones you want to remove early and ignore the rest: turning the plugin off disposes all of them. A function returned from activate also runs when the plugin is turned off, for things the API does not know about, such as timers or sockets:

import type { PluginApi } from "@scm-js/plugin-api";

export default function activate(api: PluginApi) {
  const timer = setInterval(() => api.log("still running"), 60_000);
  return () => clearInterval(timer);
}
Try it

activate may be async.

Developing locally#

npx serve --cors .

Paste http://localhost:3000/ into Plugins ▸ Manage Plugins…, and press Reload on its row after each change.

The typings#

npm i -D @scm-js/plugin-api

The package is types only: one index.d.ts. The import type line is removed before the file runs, so the package matters only for editing and type-checking. The same files are tagged at scm-js/plugin-api if you prefer a git dependency.

The package's major version is the API version, so ^1 in package.json is right. New calls do not change the version, since they break nothing that does not use them; the version is kept for a change that would.

What the editor does for you#

Imports and dependencies#

The icon#

icon is shown in Manage Plugins and in the title bar of your plugin's dialogs.

icon Meaning
"icon.svg", "art/mark.png" An image beside the manifest (.png .svg .jpg .gif .webp .avif .ico).
"https://…/mark.png" An image anywhere.
"data:image/svg+xml,…" An image inline in the manifest.
"🗺️" Up to four characters, drawn as text.

Anything else, or an image that fails to load, shows the default plugin mark. Draw for a 30 px square (it is also shown at 14 px) with no frame: an icon that is a bordered square looks like a second checkbox next to the row's own. Terrain from Image's icon.svg is an example.

Building#

A plugin can ship a bundle and name it in the manifest:

"build": "dist/plugin.js"

The editor then imports that one file instead of compiling your source file by file. It is faster for anything bigger than one file, and the only way to use an npm dependency. Keep entry in the manifest too: it is what people read, and what loads when no bundle is published.

The project's plugins all build with one esbuild command:

"build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js",
"dev": "npm run build -- --watch"

Commit dist/plugin.js, because the editor loads it straight from the repository at the version the address names. The shared workflow in scm-js/.github does the rest:

name: CI
on:
  push: { branches: [main], tags: ["v*"] }
  pull_request:
  schedule: [{ cron: "0 6 * * 1" }]
permissions: { contents: write }
jobs:
  ci:
    uses: scm-js/.github/.github/workflows/plugin-ci.yml@main

Ship the bundle unminified. Users judge a plugin by reading its repository.

When something goes wrong#

The debug console

The mistakes that come up most:

What you see Why
A change does not appear after you edit the file. The editor is running the copy it fetched. Press Reload on the plugin's row.
fetch of your local folder fails. The folder is served without cross-origin headers. Use npx serve --cors ..
"a transaction builder must be synchronous". The function given to document.edit or document.update is async. Await before the call.
A line that writes to scenario() does not compile. The map is read-only by design. Make the change in a transaction.
A constant imported from @scm-js/plugin-api is undefined. The package is types only. Take values from api.consts.
import x from "some-package" is refused. The editor loads source without a module resolver. Ship a bundle; see Building.
A terrain call changes nothing and returns 0, false or null. The tileset graphics are not loaded or not installed. await api.tileset.load() first, and handle false.
A unit lands in the top-left corner. Units are placed in pixels, not tiles. Multiply by api.consts.tile.
Writes do nothing after the plugin was turned off. A timer or request outlived the plugin. The Debug Console logs each refused write; clear timers in the function activate returns.
In the playground, a command or service of yours replaces the playground's. A snippet runs as the playground, so its ids are prefixed with the playground's id. Use a dotted id of your own ("my-plugin.open").

Testing#

The project's plugins keep their logic in files that take plain data and never touch api, and test those files alone. plugin.ts is then a thin layer that reads from the API, calls the logic and writes the result back. Paint's geometry, Terrain from Image's conversion and Stamp Library's storage format are all tested this way.

For Base Check that means moving the counting into a file of its own:

// census.ts
import type { UnitRecord } from "@scm-js/plugin-api";

export interface Start { owner: number; x: number; y: number }

/** Mineral fields and geysers within `radius` pixels of each start. */
export function census(units: readonly UnitRecord[], starts: readonly Start[], radius: number) {
  return starts.map((start) => {
    const near = units.filter((u) => Math.hypot(u.x - start.x, u.y - start.y) <= radius);
    return {
      owner: start.owner,
      minerals: near.filter((u) => u.unitId >= 176 && u.unitId <= 178).length,
      geysers: near.filter((u) => u.unitId === 188).length,
    };
  });
}

and testing it with Vitest, with no editor involved:

// tests/census.test.ts
import { expect, it } from "vitest";
import type { UnitRecord } from "@scm-js/plugin-api";
import { census } from "../census";

const unit = (unitId: number, x: number, y: number) => ({ unitId, x, y }) as UnitRecord;

it("counts only what is inside the radius", () => {
  const units = [unit(176, 100, 100), unit(177, 140, 100), unit(188, 100, 180), unit(176, 900, 900)];
  expect(census(units, [{ owner: 0, x: 100, y: 100 }], 200)).toEqual([{ owner: 0, minerals: 2, geysers: 1 }]);
});

When the logic needs a few calls of the API, give it a parameter typed as just those calls, and pass the real api.text in the plugin and a stand-in in the test. Repair's string checks are written this way:

import type { PluginApi } from "@scm-js/plugin-api";

type TextHelpers = Pick<PluginApi["text"], "bleedingLines" | "fixBleeding">;

export function repairs(strings: readonly (string | null)[], text: TextHelpers) {
  return strings.flatMap((s, index) => (s && text.bleedingLines(s).length > 0 ? [{ index, fixed: text.fixBleeding(s) }] : []));
}
Try it

The whole plugin is tested by running it: load it from your machine, or paste it into the playground. The shared CI workflow in Building runs the type-check and the tests on every push.

What your users see#

Requiring another plugin#

A plugin can depend on another, such as a trigger editor that needs a compiler. Name it in the manifest with the same address you would paste into Manage Plugins:

{ "name": "Magenta", "requires": ["github:scm-js/plugin-eudplib"] }

The editor then treats the two as a pair:

The manifest does not say which version is enough. The two meet through api.services: the required plugin provides a service with a version, and yours checks it and explains in its own UI when it is too old. If the required plugin is missing, yours still starts and sees no service.

Talking to a server#

You call fetch yourself. Two things only show up in the desktop app:

Getting listed#

scm-js/registry is generated from the scm-js organisation: every repository named plugin-…, or with both the scmjs and plugin topics, is listed with the plugin.json at its newest version tag. It refreshes hourly.

For a plugin anywhere else, fill in the submission form with your repository's address and the search words you want. The listing reads the rest from your plugin.json. A bot checks the repository right away and replies with what it found, and then someone reads the code and decides whether to list it.

Any URL serving a file in the same format is a registry, and users can add one under Sources.

scmJS 0.7.3 + main@e2d8ba5 · Generated from the repository. The source is under the MIT license, which does not cover what ATTRIBUTION.md lists. StarCraft and Brood War are trademarks of Blizzard Entertainment; this project ships none of their data.