scmJS docs

Host side (for editor developers)

File
src/plugins/api.ts The public types. Changing them is an API change: bump PLUGIN_API_VERSION for anything not backward compatible.
src/plugins/host.ts createPluginApi(store, info) builds one plugin's PluginApi over the Jotai store and a Contributions bag that dispose() empties; activatePlugin / deactivatePlugin drive the lifecycle and write pluginRuntimesAtom; inspectPlugin / installPlugin are the confirm-then-add pair, and rememberManifest is the manifest cache both it and describePlugin write.
src/plugins/loader.ts Spec parsing, manifest fetch, the fetch-as-text / transpile / rewrite-imports / blob-URL pipeline, and previewPlugin (canonicalSpec + the manifest, no code) behind the Add Plugin confirmation. Pure apart from the fetch, transpile and createModuleUrl callbacks it takes, so tests/plugins.test.ts runs it in Node.
src/plugins/claims.ts locateClaims(claims, list) asks every api.triggers.claim where its run is in a given list (a locate that throws is a skipped claim, answers are clamped), plus claimAt, claimBadge and claimDescription — what the Trigger Editor, the Text Trigger Editor and Import Triggers read.
src/plugins/images.ts loadImage / readClipboardImage behind api.ui, and transferOf (a DataTransfer{ files, text }) that PluginDialog uses for onPaste / onDrop.
src/plugins/builtin.ts import.meta.glob over plugins/*/plugin.{ts,json} — empty, since nothing ships in the bundle.
src/plugins/defaults.ts The plugins a fresh editor starts with (DEFAULT_REMOTE_PLUGINS, each with whether it starts on, plus any built-in), merged over the stored list by effectiveInstalls.
src/atoms/pluginAtoms.ts installedPluginsAtom (persisted, with local per plugin), pluginCodeAtom (the stored copies), pluginRuntimesAtom, the contribution registries pluginMenuItemsAtom, pluginContextItemsAtom, pluginHotkeysAtom, mapPickAtom — the pickArea / pickTile request the viewport is serving (cancelMapPickAtom is what Esc and a right-click write) — and its siblings mapToolAtom (the running ui.mapTool, with cancelMapToolAtom and mapToolRevisionAtom for redraw), pluginOverlaysAtom (the registered ui.overlays with their visibility — setOverlayVisibleAtom is the one writer, pluginOverlayRevisionAtom their redraw, overlayVisibilityMemory what the user last chose per plugin and name) pluginPanelsAtom (the open ui.panels) and pluginTriggerClaimsAtom (the live api.triggers.claims, each with a revision the handle's refresh bumps).
src/hooks/usePlugins.ts Activates the enabled plugins at startup and keeps runtime in step with the installed list.
src/components/dialogs/PluginDialogs.tsx Manage Plugins, ConfirmPluginDialog (the Add Plugin confirmation), and PluginDialog — the frame a plugin's ui.dialog mounts into.
src/components/panels/PluginPanels.tsx The floating frames ui.panel mounts into, rendered inside the viewport: a draggable title strip, a close button, positions remembered per plugin and title for the session.

Contribution points read the registries: MenuBar merges pluginMenuItemsAtom into its menu model (withPluginItems), MapViewport and TerrainPalette append the matching pluginContextItemsAtom entries to their context menus, useHotkeys checks pluginHotkeysAtom first. A Plugins menu (Manage Plugins… plus anything registered under "Plugins") sits between Tools and Help.

api.ui.pickArea / pickTile are pickOnMap in host.ts: one MapPickRequest at a time goes into mapPickAtom, and MapViewport serves it ahead of every layer — crosshair cursor, a teal marquee with its size, a HUD chip with the prompt — calling the request's finish on mouse-up; finish clears the atom itself and is guarded against running twice. The host also finishes it with null when the scenario atom changes, when the plugin's Contributions are disposed, or when a newer pick starts; useHotkeys (Esc) and the viewport's right-click write cancelMapPickAtom.

api.ui.mapTool is startMapTool there: one MapToolRequest in mapToolAtom, which MapViewport serves after a pick and ahead of every layer — its onDown captures the pointer and forwards the gesture as MapPointers, onLeave sends one inMap: false move, the layer's hover ghost and "placing" chips stay hidden, the surface takes the tool's cursor, and the tool's draw runs at the end of the paint pass with a MapView built from the current scroll and zoom. finish(reason) is guarded like a pick's and clears the atom; cancelMapToolAtom (Esc, right-click) asks the spec's onCancel first and only finishes when it does not keep the tool.

api.ui.overlay is registerOverlay there: one PluginOverlayEntry in pluginOverlaysAtom. MapViewport runs each visible entry's draw at its slot — after the grid, after the locations and start locations, or after the hover ghost and before a map tool's own drawing — inside a save / restore, and its onMove / onLeave forward a MapPointer (or null) to every visible entry with an onHover before doing anything else, so overlays hear the pointer on every layer and during a tool. The View menu and the Layers panel list the entries and write setOverlayVisibleAtom, which the handle's show / hide / toggle also go through, so the spec's onToggle fires once per change however it came; the atom also records the choice in overlayVisibilityMemory, which registerOverlay consults before the spec's visible. remove() (and the plugin's Contributions disposal) takes the entry out of the list.

api.document.edit is runTransaction in host.ts: it wraps the scenario in an EditTransaction whose operations apply immediately and accumulate change lists in applyEntry order, then hands the entry to commitTerrainAtom (the stranded-doodad / stranded-unit pass that used to live only inside useTerrainTools) so a plugin edit behaves exactly like a stroke. tx.rebuildIsom is rebuildIsomFromTiles from editor/isom.ts: over an existing lattice of the right size it diffs into the entry's isom list, otherwise it sets scenario.isom and records the section as the entry's createdIsom (undo puts null back; commitEditAtom bumps isomRevisionAtom for it, so the palette re-measures). The editor has no rebuild button of its own any more — the Repair plugin is where the user reaches this.

The "document" event's payload is documentEvent in host.ts, read off documentChangeAtom (atoms/documentAtoms.ts): loadDocumentAtom records the reason the caller passed ("open" by default, "new" from File ▸ New, "replace" from replaceScenarioAtom) together with the scenario object it applies to, and closeDocumentAtom records "close"; a scenario installed some other way (a test setting the atom directly) is reported as an open or a close by what is there. The sections calls Repair relies on live in editor/sections.ts: defaultSectionBytes (a fresh createScenario on the map's size, tileset and revision, one section marked dirty, encoded and picked out — the raw created sections for IVE2 / VCOD / UPRP / UPUS), rebuildSections (the given names added to a copy's dirty set, serializeScenario, parseScenario) and requiredSectionNames (requiredSections with STRx substituted on an extended-strings file).

The loading pipeline#

loader.ts is pure apart from the fetch, transpile and createModuleUrl callbacks it takes, so tests/plugins.test.ts runs the whole of this in Node.

  1. The spec the user typed is parsed (parseSpec) into a base URL. builtin:<name> is a plugin compiled into the editor from plugins/<name>/; a github: spec or a github.com URL resolves to https://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. The forms are tabulated for authors under How the editor finds your code.
  2. The manifest is fetched and validated (PluginManifest; only name is required).
  3. The file to import is the manifest's build when it has one, else its entry. ResolvedPlugin.built / PluginAddresses.built carry which happened, so ConfirmPluginDialog can name the bundle and the source it was built from.
  4. That file is fetched as text and, if it is TypeScript, transpiled in the transpile worker (ts.transpileModule; TypeScript is in the editor's bundle for this alone). Fetching as text matters: raw.githubusercontent.com serves text/plain, which a browser refuses to import() as a module.
  5. Relative imports are followed the same way, depth first, and each file becomes a blob: module URL with the specifiers rewritten to those URLs. There is no resolver behind a fetch, so the loader supplies one (candidateUrls): a specifier naming no extension — "./convert", how TypeScript is normally written — is tried as .ts, .tsx, .mts, .js, .mjs and then as that directory's index.*, and "./convert.js" falls back to convert.ts. The bundled built-in never needed this because Vite resolved for it, and the first remote load of Terrain from Image 404ed on ./convert. Circular imports and bare package names are errors naming the file.
  6. The module is import()ed and its default export (or a named activate) called with the PluginApi. Whatever it returns is kept for deactivation.

An activation that fails is not silent: plugins/failures.ts turns whatever the pass left in pluginRuntimesAtom into one toast naming what did not load, with a button to Manage Plugins (ttl: 0, so it outlives the splash). For that to work activatePlugin returns the load in flight for a spec that is already loading rather than a resolved promise — React's double mount otherwise had the second pass reading the runtimes before a single fetch had finished.

Defaults, pinning and vendoring#

Installed plugins live in localStorage (scmjs.plugins: spec + enabled flag) and are activated at startup by usePlugins. The defaults (src/plugins/defaults.ts) are merged over that list by effectiveInstalls, so they are always shown and can be turned on or off but not removed; each says whether it starts on. Being a default buys a plugin nothing else — it is fetched and loaded by the steps above like any other.

Each default names a tag, not a branch: github:scm-js/plugin-repair@v1.0.1. A moving spec meant a push to a plugin repository changed every editor already in use and no released version could be rebuilt as it shipped, so moving a default forward is now a commit in defaults.ts that goes out with the next release. isPinned therefore counts any explicit ref that is not a branch name (MOVING_REFS), and identity moved off the spec string: pluginKey(spec) is the repository whatever version follows it, with a bundled copy answering for the spec it was built from, which is what keeps effectiveInstalls from listing — and running — the same plugin twice across those forms.

Every build runs scripts/vendor-plugins.mjs first (prebuild, and scripts/build-desktop.mjs for its own bundle), which writes each default's own source at that tag into plugins/ for builtin.ts to glob, so the defaults are compiled in rather than fetched — the same code, since the version is fixed. It is worth more than it sounds: a .ts plugin has to be transpiled before the browser will import it, one transpile starts the compile worker, and TypeScript is inlined into that worker, so five remote .ts defaults put 3.4 MB (975 KB gzipped) of compiler on the cold path. Measured on the production build, a first visit went from 1235 KB gzipped and 20 cross-origin requests to 344 KB and none. It is all or nothing — one remote .ts default starts the worker and costs the lot — and the fetching path is still there for a build that skips the vendoring (SCMJS_SKIP_VENDOR=1) and for every plugin the user adds.

That costs the one thing worth naming: the remote loading path used to be exercised by simply opening the editor, on every machine, every day. tests/plugin-network.test.ts is the deliberate replacement — a real plugin fetched, transpiled and imported over the network — off unless SCMJS_NETWORK_TESTS=1, and run by CI on the job that vendors and by the release pre-flight.

The registry, on the host side#

plugins/registry.ts is the whole host side of Browse Plugins and is pure apart from the fetching. What a registry is, and how a plugin gets listed, is under Getting yours listed.

parseRegistry(raw, url) Checks the file's shape, canonicalises each entry's spec (canonicalSpec(parseSpec(...)), so rows match the installed list), drops entries it cannot use and counts them in skipped. One bad row never empties a list.
entryIcon(entry) resolveIcon against the plugin's base, so a manifest's icon: "icon.svg" can be copied into the index verbatim.
searchRegistry(entries, query) Every word has to match something; name beats tag beats description beats author beats spec.
groupByInstall(entries, stateOf) Splits the results into what the editor does not have and what it already lists (turned off counts as installed), each group keeping its order — the Browse pane's grouping and its filter counts.
mergeRegistries(list) Entries of every registry, the first to list a spec winning.
loadRegistry(store, url, opts) Fetch into registryCacheAtom unless the cached copy is younger than REGISTRY_MAX_AGE (an hour) or force was asked for. A failure records registryStateAtom and keeps the cached list — the browser shows the last list it had rather than emptying itself because the network blinked.
addRegistry / removeRegistry The user's list (userRegistriesAtom); a default cannot be removed.

Almost everything a registry lists is a plugin the editor already has — the defaults are published from the same repositories — so a flat list of rows reads as a copy of the Installed tab. The pane splits it instead: groupByInstall over the search results, the group that can be installed first under its own heading, and a filter (All / Not installed / Installed) carrying the count of each. A row says which it is by an accent down its left edge, by the one action that fits it (Install, Turn on, or Manage, which switches to the Installed tab and flashes the row) and by a line naming the state in words.

An entry is not a way in. Install hands the entry's spec to the same inspectPluginConfirmPluginDialoginstallPlugin path a pasted address takes, so the manifest is read from the plugin itself and the commit resolved and pinned at install time rather than taken from the index.

Adding one#

Pressing Add in Manage Plugins does not install anything. previewPlugin canonicalises the spec, asks GitHub which commit the spec's ref points at (resolveCommit, the public commits API, one request and no token), and reads the plugin.json at that commit through steps 1–2 of the pipeline and no further (resolvePlugin(..., { entry: false })). No entry file is fetched, nothing is transpiled and nothing is imported.

The confirmation opens only if that found a manifest. An address that answers with no plugin behind it is reported under the Add field — a details screen with no details on it reads as a broken dialog rather than a wrong address — and the preview travels to the dialog in its payload rather than being fetched again.

ConfirmPluginDialog shows what came back: the manifest's name, version, author, description and icon, links to the repository (PluginSource.webUrl, which parseSpec derives for a GitHub spec) and homepage, the addresses for the version being installed (addressesOf), and the warning that a plugin has the editor's own access and no sandbox. The entry is named only when the manifest names one; probing for plugin.ts / plugin.js would mean fetching code, which has not been agreed to yet.

Three ticks are read straight into installPlugin, which is the only writer past this point — it seeds the manifest through rememberManifest, then setInstalled and activatePlugin:

Tick Default Effect
Enable it now on activatePlugin after the install; off just lists it.
Pin to this version on, when a commit resolved Stores github:owner/repo@<sha> (PluginPreview.pin) instead of the moving spec. isPinned recognises one.
Load from a copy saved here off Stores PluginInstall.local; see below.

The addresses on screen follow the pin tick, since pinning changes which commit every one of them names. A spec that carries a ref already (@v1.2) is resolved the same way: the pin names the commit that tag points at today. The label and the explanation under the third tick read the same whether it is on or off — one that swapped between describing the copy and describing the fetch read as two different options — so the only state-dependent part is the size of the copy, shown next to it.

Reload re-fetches whatever the spec names, so for a pinned plugin it re-fetches the same commit. Moving to a newer one is the Update button on the row: it previews unpin(spec), and when the branch now holds a different commit it opens this same dialog with replaces set. The install goes through installPlugin again with the old spec named, which deactivates it, drops it from the list and drops its stored copy, because the two commits are different specs as far as everything else here is concerned. The ticks start from the old install's own settings.

A manifest that could not be fetched or parsed (PluginPreview.problem) stops the add: the Manage Plugins field says so, with the address that refused underneath, and if the dialog is reached with one anyway it says the same and disables Add. An unusable spec fails earlier still, before anything is fetched. A manifest asking for a newer api than the host provides is flagged on the dialog (needsApi) rather than only failing on load, and pinProblem says why there is no pin (not a GitHub plugin, or GitHub did not answer).

Loading from a copy in the browser#

PluginInstall.local means "prefer the copy". loadDepsFor in host.ts decides what one activation uses:

PluginRuntime.loadedFrom records which of the two happened, and the Manage Plugins row badges it. reloadPlugin drops the copy first, so Reload is how both a pinned plugin and a stored one are moved forward. Turning the option off (setInstalled, the row's disk button) drops the copy as well: turning it on again fetches the plugin rather than reviving something months old.

A plugin that is listed but not running — one you turned off, a default included — is still described in Manage Plugins: describePlugin does steps 1–2 only (resolvePlugin(..., { entry: false })), so the name, version, description and icon come out of one plugin.json fetch with no code fetched and nothing executed. One attempt per spec per store (forgetDescription, which reloadPlugin calls, asks again); the dialog triggers it for every row with no manifest, and the answer is kept in scmjs.plugin-manifests (built-ins excluded — nothing to fetch, and their icon URLs are build-hashed), so the next visit renders from storage while the refresh runs behind it. PluginRuntime.describing and status: "loading" both spin the row's badge, since a row that silently rewrites itself when a fetch lands reads as a glitch. A description that cannot be fetched changes nothing: the plugin is off, not failed.

scmJS 0.1.0 · Generated from the repository. StarCraft and Brood War are trademarks of Blizzard Entertainment; this project ships none of their data.