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.
- The spec the user typed is parsed (
parseSpec) into a base URL.builtin:<name>is a plugin compiled into the editor fromplugins/<name>/; agithub:spec or a github.com URL resolves tohttps://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 holdingplugin.json. The forms are tabulated for authors under How the editor finds your code. - The manifest is fetched and validated (
PluginManifest; onlynameis required). - The file to import is the manifest's
buildwhen it has one, else itsentry.ResolvedPlugin.built/PluginAddresses.builtcarry which happened, soConfirmPluginDialogcan name the bundle and the source it was built from. - 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.comservestext/plain, which a browser refuses toimport()as a module. - 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 afetch, so the loader supplies one (candidateUrls): a specifier naming no extension —"./convert", how TypeScript is normally written — is tried as.ts,.tsx,.mts,.js,.mjsand then as that directory'sindex.*, and"./convert.js"falls back toconvert.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. - The module is
import()ed and its default export (or a namedactivate) called with thePluginApi. 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
inspectPlugin → ConfirmPluginDialog → installPlugin 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:
- no copy yet: the ordinary deps wrapped in
recordingDeps, which keeps every fetched file.storeSnapshotwrites them topluginCodeAtom(scmjs.plugin-code, keyed by spec) when the load succeeds. A snapshot overMAX_SNAPSHOTis skipped with a console warning and the plugin stays remote. - a copy:
storedDeps, which answers out of the snapshot and has no network path at all. A URL the snapshot does not hold is an error naming it, so a plugin that grew a file since the copy was made says so instead of quietly fetching it.describePluginuses the copy too, so the plugin's address is never touched while the option is on.
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.