scmJS docs

The plugin host

plugins.md is written for someone who has the npm package and none of this repository. This is the editor's half: where the plugin system lives and how a plugin gets from an address to a running activate.

File
src/plugins/api.ts The public types, and the only place the contract is written. PLUGIN_API_VERSION bumps for a change that is not backward compatible; additions leave it alone.
src/plugins/host.ts createPluginApi(store, info) builds one plugin's PluginApi over the store, with a Contributions bag that dispose() empties. activatePlugin / deactivatePlugin drive the lifecycle; inspectPlugin / installPlugin are the confirm-then-add pair.
src/plugins/loader.ts Spec parsing, the manifest fetch, and the fetch / transpile / rewrite-imports / blob-URL pipeline. Pure apart from the callbacks it takes, so the tests run it in Node.
src/plugins/defaults.ts The plugins a fresh editor starts with, each pinned to a tag.
src/plugins/registry.ts Browse Plugins: parsing, searching and caching the JSON indexes.
src/plugins/widgets.ts el and api.ui.widgets: plain DOM in the editor's own class names, so a plugin's dialog looks like a built-in one. The waiting kit — spinner, progressBar, statusLine, skeleton, busy, and a button's setBusy — is here too; its styles are the Waiting block at the end of src/styles/ui.css, which is also where the reduced-motion behaviour lives, so nothing here animates in JavaScript.
src/plugins/claims.ts The trigger-list claims the trigger editors read.
src/plugins/images.ts Image loading, clipboard images, and drop / paste transfers for plugin dialogs.
src/plugins/failures.ts The toast a failed activation raises.
src/plugins/builtin.ts import.meta.glob over plugins/*/, where the vendored defaults land.
src/plugins/transpile.worker.ts TypeScript in a worker, for a .ts plugin loaded from source.
src/atoms/pluginAtoms.ts The installed list, the stored code copies, the runtimes, the contribution registries, and the one-at-a-time requests the viewport serves: picks, map tools, overlays, panels, claims.
src/hooks/usePlugins.ts Activates the enabled plugins at startup and keeps the runtimes in step with the list.
src/components/dialogs/PluginDialogs.tsx Manage Plugins, Browse, the Add / Update confirmation, and the frame a plugin's dialog mounts into.
src/components/panels/PluginPanels.tsx The floating frames a plugin's panel mounts into, and DockedPluginPanels, the .panels the right dock renders for dock: "right".
src/components/ui/DialogSlots.tsx The footer row a plugin's dialogSlot mounts into; a dialog opts in by passing slot to DialogFrame, naming its id and lending its working-copy fields.

The chrome reads the registries: the menu bar merges plugin menu items into its model, the viewport and the terrain palette append the matching context items, the status bar renders the plugin status items after its message cell, the right dock renders the docked panels after Properties (and stays on screen for them), DialogFrame mounts the slots registered for its dialog, and the hotkey hook checks plugin combos first. view.flash puts boxes in map pixels on viewFlashesAtom; the viewport paints them fading at the end of its pass and repaints every frame until the last one is over. The viewport also serves the requests: a pick ahead of every layer, then a running map tool, then the overlays at their slots in the paint pass, each with a guarded finish so its promise settles exactly once however it ends.

A plugin's document.edit wraps the scenario in a transaction whose operations apply as they are called and accumulate change lists, then commits one history entry through the same path a brush stroke takes, stranded-object pass included. document.update is the settings-and-triggers equivalent, committing through the settings and triggers commits. document.sections rewrites the file and installs the re-parsed scenario, which is why it drops the history.

api.sync hooks the same places. While a map is shared, every commit, undo and redo, the settings and trigger commits, a whole-document change and a write to the archive's extra files report to the session, which turns the change into an op, applies it again through the op's own resolution (so this editor computes exactly what every other one will), and hands it to the plugin. Other people's ops queue with the server's confirmations in arrival order and are applied when no stroke, map-editing dialog or other tab is in the way: the pending local ops are taken back, the queue is applied, the pending ops go on again, and the selections, the mirror atoms and the revisions are brought up to date. The resolution itself — records found by content, cells by value, locations by slot with their names as text, dialog tables whole — is pure and has its own tests, including one that interleaves three editors' random edits across a hundred seeds and checks they end identical.

The loading pipeline#

  1. The spec is parsed into a base URL. builtin:<name> is a plugin compiled in from plugins/<name>/; a github: spec or a github.com URL resolves to raw.githubusercontent.com/owner/repo/<ref or HEAD>/<dir>/; any other URL is a manifest, an entry file (a manifest is synthesised from its name) or a directory holding plugin.json.
  2. The manifest is fetched and validated; only name is required.
  3. The file to import is the manifest's build when it has one (a committed bundle, one fetch, no transpile), else its entry.
  4. That file is fetched as text and, if it is TypeScript, transpiled in the worker. Fetching as text matters: raw.githubusercontent.com serves text/plain, which a browser refuses to import as a module.
  5. Relative imports are followed depth first, and each file becomes a blob: module URL with its specifiers rewritten. An extensionless specifier is tried as .ts, .tsx, .mts, .js, .mjs and then as that directory's index.*; ./x.js falls back to ./x.ts. Cycles and bare package names are errors naming the file.
  6. The module is imported and its default export (or a named activate) is called with the API. Whatever it returns is kept for deactivation.

Adding one#

Pressing Add in Manage Plugins installs nothing. The editor canonicalises the address, asks GitHub which commit the ref points at, and reads the plugin.json at that commit. No entry file is fetched, nothing is transpiled and nothing is imported; probing for plugin.ts would mean fetching code the user has not agreed to. The confirmation opens only once a manifest came back; an address with nothing behind it is reported under the Add field instead.

The install is the only writer past the dialog. Its three ticks are Enable it now, Pin to this version (the stored spec becomes github:owner/repo@<sha>) and Load from a copy saved here (below). The Update button on a pinned row previews the branch and, when it holds a different commit, reopens the same dialog with the old commit marked to be replaced.

A listed plugin that is not running is still described: the manifest alone is fetched to fill in name, version, description and icon, and the answers are cached in browser storage so the next visit renders from them while the refresh runs. Browse Plugins reads the registry indexes the same way and hands a chosen entry to the same Add path, so a registry decides what is listed and never what is trusted.

Loading from a copy#

Load from a copy saved here means "prefer the copy". With no copy yet, the load records every fetched file into browser storage (capped; a larger plugin stays remote with a console warning). With a copy, the load has no network path at all and errors on a file the copy lacks, so a plugin that grew a file since the copy was made says so rather than fetching it. Reload drops the copy and fetches again; turning the option off drops it too.

Defaults and vendoring#

The default plugins (scmscx.com, Repair, Walkability, Terrain from Image, Paint, TrigEdit, TrigScript, Stamp Library and scmjs.dev, of which TrigEdit and scmjs.dev are installed but start off) are ordinary plugins from their own repositories, each pinned in src/plugins/defaults.ts to a tag, never a branch, so that a push to a plugin repository cannot change every editor already in use and any release can be rebuilt as it shipped. Moving a default forward is a commit that changes the tag there. A plugin's identity is its repository, whatever version follows, which is what keeps an older editor's unpinned spec, the compiled-in copy and the pinned default from being listed and run as three plugins.

scripts/vendor-plugins.mjs writes each default's source at its tag into the gitignored plugins/, where builtin.ts compiles it into the bundle. It runs from predev, prebuild and the desktop build, fetches only what is not already there at the pinned version, and honours GITHUB_TOKEN to stay off the anonymous rate limit (CI sets it). A directory it wrote carries a vendored.json; one you put there by hand is left alone.

npm run vendor:plugins             # bring plugins/ up to date
npm run vendor:plugins -- --force  # fetch again even where the spec matches
npm run vendor:plugins -- --clean  # remove the directory
npm run vendor:plugins -- --list   # print the pinned specs
SCMJS_SKIP_VENDOR=1 npm run build  # skip it: that bundle fetches its defaults at startup

Every build compiles them in, not only the desktop, because of the cold path: one remote .ts plugin starts the transpile worker, and TypeScript is inlined into that worker, so five remote defaults put 3.4 MB (975 KB gzipped) of compiler onto a first visit. Measured on the production build, a cold visit went from 1235 KB gzipped and 20 cross-origin requests to 344 KB and none. tests/vendor-plugins.test.ts pins the script's parsing and the rule that no default may be unpinned.

scmJS 0.6.2 + main@dd79a77 · 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.