Meratyn Plugin API
This page is the complete API reference. For a guided walkthrough of the lifecycle, state, and UI-surface patterns behind complex plugins, see Writing Plugins.
Interface
import type { FeatureCollection } from "geojson";
import type { IControl } from "maplibre-gl";
export type MappViewMapControlPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
export type MappViewBuiltInMapControl =
| "navigation"
| "fullscreen"
| "geolocate"
| "globe"
| "terrain"
| "scale"
| "attribution"
| "logo"
| "layer-control";
export interface MappViewPlugin {
id: string;
name: string;
version: string;
activeByDefault?: boolean;
/** Plugins in the same group cannot be active together. */
exclusiveGroup?: string;
/** At least one name is required for handleUrlParameters to be called. */
urlParameterNames?: string[];
activate: (app: MappViewAppAPI) => boolean | void;
deactivate: (app: MappViewAppAPI) => void;
handleUrlParameters?: (app: MappViewAppAPI, params: URLSearchParams) => void | Promise<void>;
getMapControlPosition?: () => MappViewMapControlPosition;
setMapControlPosition?: (
app: MappViewAppAPI,
position: MappViewMapControlPosition,
) => boolean | void;
getProjectState?: () => unknown;
applyProjectState?: (app: MappViewAppAPI, state: unknown) => boolean | void;
}
// Resolved by app.getDeckGL(): Meratyn's own deck.gl modules, so a plugin
// renders on the host's single instance instead of bundling its own copy.
export interface MappViewDeckGL {
core: typeof import("@deck.gl/core");
layers: typeof import("@deck.gl/layers");
geoLayers: typeof import("@deck.gl/geo-layers");
meshLayers: typeof import("@deck.gl/mesh-layers");
mapbox: typeof import("@deck.gl/mapbox");
}
export interface MappViewAppAPI {
setBasemap: (styleUrl: string) => void;
addGeoJsonLayer: (name: string, data: FeatureCollection, sourcePath?: string) => string;
// Read-only project queries. These return document data only; use the
// documented commands above to change a project.
listLayers?: () => readonly MappViewPluginLayerSummary[];
getLayerFeatures?: (layerId: string) => FeatureCollection | null;
getSelectedFeatures?: () => readonly GeoJSON.Feature[];
getSelectedLayerId?: () => string | null;
// Native raster/tile layers (see "Raster and tile layers" below). Each
// returns the new layer's id and the layer appears in the Layers panel and
// persists with the project, like addGeoJsonLayer does for vector data.
addTileLayer?: (name: string, url: string, options?: MappViewTileLayerOptions) => string;
addWmtsLayer?: (name: string, url: string, options?: MappViewTileLayerOptions) => string;
addWmsLayer?: (name: string, options: MappViewWmsLayerOptions) => string;
// Native client-side COG (reads the GeoTIFF directly; band/rescale/colormap/
// nodata controls). Resolves with the new layer's id (see "Raster and tile
// layers" below).
addCogLayer?: (name: string, url: string, options?: MappViewCogLayerOptions) => Promise<string>;
// Zarr through the host's own @carbonplan/zarr-layer instance, with
// crs/proj4 reprojection (see "Zarr layers" below).
addZarrLayer?: (name: string, url: string, options: MappViewZarrLayerOptions) => Promise<string>;
setZarrLayerSelector?: (
layerId: string,
selector: Record<string, number | string>,
) => Promise<boolean>;
// Register a layer the plugin added to the map itself, so it appears in the
// Layers panel (see "Custom (WebGL) layers and paint ownership" below).
registerExternalNativeLayer?: (layer: MappViewExternalNativeLayerRegistration) => void;
unregisterExternalNativeLayer?: (id: string) => void;
getActiveBasemap: () => string;
onBasemapChange: (callback: (styleUrl: string) => void) => () => void;
fetchArrayBuffer?: (url: string) => Promise<ArrayBuffer>;
fitBounds?: (bounds: [number, number, number, number]) => void;
getMap?: () => import("maplibre-gl").Map | null;
addMapControl: (control: IControl, position?: MappViewMapControlPosition) => boolean;
removeMapControl: (control: IControl) => void;
// Note: showing the "terrain" control (visible: true) also switches 3D
// terrain on, mirroring the Map menu so the user doesn't have to click
// the control button as a second step. Hiding it turns terrain back off.
setBuiltInMapControlVisible: (control: MappViewBuiltInMapControl, visible: boolean) => boolean;
getBuiltInMapControlPosition: (control: MappViewBuiltInMapControl) => MappViewMapControlPosition;
setBuiltInMapControlPosition: (
control: MappViewBuiltInMapControl,
position: MappViewMapControlPosition,
) => boolean;
getDeckGL?: () => Promise<MappViewDeckGL>;
// Right-sidebar panels (see "Right sidebar panels" below).
registerRightPanel?: (panel: MappViewRightPanelRegistration) => () => void;
unregisterRightPanel?: (id: string) => void;
openRightPanel?: (id: string) => boolean;
collapseRightPanel?: (id: string) => void;
closeRightPanel?: (id: string) => void;
getActiveRightPanel?: () => string | null;
setActiveRightPanelDock?: (dock: MappViewRightPanelDock) => void;
getActiveRightPanelDock?: () => MappViewRightPanelDock | null;
/** Host language for plugin-owned plain DOM. */
getLocale?: () => string;
/** Subscribe to language changes; call the returned disposer on deactivate. */
onLocaleChange?: (listener: (locale: string) => void) => () => void;
/** Translate a plugin key, falling back to the supplied English text. */
translate?: (
key: string,
defaultValue: string,
params?: Record<string, string | number>,
) => string;
// Top toolbar menus (see "Toolbar menus" below).
registerToolbarMenu?: (menu: MappViewToolbarMenu) => () => void;
unregisterToolbarMenu?: (id: string) => void;
// Top-edge panels (see "HeaderDock panels" below).
registerHeaderPanel?: (panel: MappViewHeaderPanelRegistration) => () => void;
unregisterHeaderPanel?: (id: string) => void;
openHeaderPanel?: (id: string) => boolean;
collapseHeaderPanel?: (id: string) => void;
closeHeaderPanel?: (id: string) => void;
getActiveHeaderPanel?: () => string | null;
// Floating panels (see "Floating panels" below).
registerFloatingPanel?: (panel: MappViewFloatingPanelRegistration) => () => void;
unregisterFloatingPanel?: (id: string) => void;
openFloatingPanel?: (id: string) => boolean;
closeFloatingPanel?: (id: string) => void;
getOpenFloatingPanels?: () => string[];
}
export interface MappViewToolbarMenu {
id: string;
label: string | (() => string);
icon?: string; // URL or data: URI
items: MappViewToolbarMenuItem[];
}
export type MappViewToolbarMenuItem =
| {
type?: "action";
id: string;
label: string | (() => string);
icon?: string;
disabled?: boolean;
onSelect: () => void;
}
| {
type: "submenu";
id: string;
label: string | (() => string);
icon?: string;
items: MappViewToolbarMenuItem[];
}
| { type: "separator"; id?: string };
export interface MappViewHeaderPanelRegistration {
id: string;
title: string | (() => string);
icon?: string; // URL or data: URI
defaultHeight?: number;
showCloseButton?: boolean;
render: (container: HTMLElement) => void | (() => void);
onOpen?: () => void;
onCollapse?: () => void;
onClose?: () => void;
}
export interface MappViewFloatingPanelRegistration {
id: string;
// A getter makes the title re-localize live on language changes: it is
// re-evaluated on every getFloatingPanel() call, so it picks up the current
// language without re-registering the panel. Caveat: the registry itself
// does not subscribe to i18n events, so the getter is only re-run when a
// consumer re-reads the panel. In practice every host component that renders
// the title also calls useTranslation(), whose languageChanged re-render
// re-reads the panel as a side effect; a host that reads the title without
// that subscription would show a stale title after a language switch until
// the next registry mutation, and must re-read the panel itself on language
// change. A plain string is frozen at registration time.
title: string | (() => string);
icon?: string; // URL or data: URI
defaultWidth?: number;
render: (container: HTMLElement) => void | (() => void);
onOpen?: () => void;
onClose?: () => void;
}
export type MappViewRightPanelDock =
| "left-of-layers" // left of the Layers panel
| "right-of-layers" // between the Layers panel and the map
| "left-of-style" // between the map and the Style panel
| "right-of-style" // right of the Style panel (default)
| "replace-style" // share the Style sidebar's single rail (shared-rail mode)
| "replace-layers"; // share the Layers sidebar's single rail (shared-rail mode)
export interface MappViewRightPanelRegistration {
id: string;
// A getter makes the title re-localize live on language changes: it is
// re-evaluated on every getRightPanel() call, so it picks up the current
// language without re-registering the panel. Caveat: the registry itself
// does not subscribe to i18n events, so the getter is only re-run when a
// consumer re-reads the panel. In practice every host component that renders
// the title also calls useTranslation(), whose languageChanged re-render
// re-reads the panel as a side effect; a host that reads the title without
// that subscription would show a stale title after a language switch until
// the next registry mutation, and must re-read the panel itself on language
// change. A plain string is frozen at registration time.
title: string | (() => string);
/** Initial dock position; "right-of-style" (default). */
dock?: MappViewRightPanelDock;
/** Optional rail icon: a URL or data: URI rendered as an image. */
icon?: string;
/** Preferred expanded width in px (desktop only; host-clamped). */
defaultWidth?: number;
/** Whether the host renders the close button in the panel header (default: true). */
showCloseButton?: boolean;
/** Fill the panel body with your own DOM. May return a cleanup function. */
render: (container: HTMLElement) => void | (() => void);
onOpen?: () => void;
onCollapse?: () => void;
onClose?: () => void;
}
Mutually exclusive plugins
In the PluginDefinition (MappViewPlugin) set exclusiveGroup when plugins share a runtime or panel and only one can be
active at a time. Activating a member deactivates the active sibling in the
same group before mounting the new plugin. If mounting fails, the host attempts
to reactivate the displaced sibling. During project restore, the last member
listed for a group wins, so older project files that contain both IDs still
produce one deterministic active plugin.
The built-in STAC Catalogs and Planet Open Data browsers use the
stac-catalog-browser group. Planet Open Data is pre-connected to the public
Planet disaster STAC catalog; STAC Catalogs starts with the general catalog
browser. A plugin should use a stable group string only when its members truly
cannot coexist, and should still clean up controls, listeners, and layers in
deactivate().
Register a plugin
import { PluginManager } from "@mappview/plugins";
const manager = new PluginManager();
manager.register(myPlugin);
manager.activate("my-plugin", appApi);
Add a built-in plugin (in this repository)
Built-in plugins ship with Meratyn itself. Most plugins do not need to be built in — see External plugins to ship one without forking Meratyn. To add one to this repository:
-
Create a plugin file in
packages/plugins/src/plugins/.import type { MappViewAppAPI, MappViewPlugin } from "../types"; export const myPlugin: MappViewPlugin = { id: "my-plugin", name: "My Plugin", version: "0.1.0", activate: (app: MappViewAppAPI) => { app.setBasemap("https://example.com/style.json"); }, deactivate: () => { /* basemap remains until the user changes it */ }, };Basemap plugins deliberately leave the style they applied in place on deactivate — the built-in
osm-basemapandcarto-lightplugins do the same, since reverting the map under the user would be more surprising than keeping what they last selected. Plugins that add controls, listeners, or layers must undo them indeactivate(). -
Export it from
packages/plugins/src/index.ts.export { myPlugin } from "./plugins/my-plugin"; -
Register it in
apps/mappview/src/features/plugins/runtime/manager.ts.import { myPlugin } from "@mappview/plugins"; manager.registerAll([ maplibreLayerControlPlugin, maplibreGeoAgentPlugin, maplibreGeoEditorPlugin, myPlugin, ]);
For a MapLibre control plugin, add the package dependency, then call
app.addMapControl(control, "top-left") in activate() and
app.removeMapControl(control) in deactivate(). If the control’s npm package
ships its own stylesheet, import that stylesheet in
apps/mappview/src/main.tsx, alongside the existing
maplibre-gl-*/style.css imports. That is only for a dependency’s own CSS —
any app-specific fixes on top of it belong in index.css, as described under
Styling third-party controls below.
Built-in MapLibre controls such as Navigation, Fullscreen, Geolocate, Globe, Terrain, Scale, Attribution, and Logo are toggled from the desktop app’s Map menu, which also opens Search, a standalone place search panel backed by the Components plugin. Keep project-specific controls such as Layer Control and Components in the Plugins menu when they use the plugin API or need plugin lifecycle behavior.
The Components plugin wraps maplibre-gl-components controls and wires their
layer events into the Meratyn store. It provides Add Data shortcuts for
FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, while raster COG and
GeoTIFF layers can also be added through the standard Add Raster Layer dialog.
Styling third-party controls
If a third-party MapLibre control needs app-specific styling fixes, add scoped
overrides in apps/mappview/src/index.css instead of editing files in
node_modules. Keep selectors limited to the plugin’s control class. For
example, GeoEditor toolbar buttons need a local override because MapLibre’s
default control button CSS can override their flex centering:
.geo-editor-control .geo-editor-tool-button {
align-items: center;
display: flex !important;
justify-content: center;
line-height: 0;
padding: 0;
}
.geo-editor-control .geo-editor-tool-button svg {
display: block;
flex: 0 0 auto;
margin: 0;
}
Run bun run build after the change; build the docs app with
bun run --filter docs build if you touched pages under docs/.
Built-in plugins
mappview-hosted-layers, shown to users as Online Layers, is registered as
a default-active built-in. On a new project the plugin is activated with the
real app API and its panel is restored in the collapsed state, so the layer
catalogue is available without taking over the map. A saved project’s explicit
plugin state still wins.
| ID | Description |
|---|---|
osm-basemap | OpenFreeMap Liberty style |
carto-light | CARTO Positron GL style |
maplibre-gl-basemap-control | Adds a MapLibre basemap picker |
maplibre-gl-components | Adds the MapLibre Components control grid and panels for FlatGeobuf, COG, PMTiles, Zarr, LiDAR, and Gaussian splats |
maplibre-gl-geo-editor | Adds GeoEditor drawing controls |
maplibre-gl-geoagent | Adds GeoAgent map assistant controls |
maplibre-gl-geolens | Adds the GeoLens catalogue and feature-editing workflow |
mappview-hosted-layers | Default Online Layers panel; resolves stable hosted layer IDs and adds a suitable map representation |
maplibre-gl-usgs-lidar | Adds LiDAR controls |
maplibre-gl-streetview | Adds street view controls |
maplibre-gl-swipe | Adds map swipe controls |
Example plugin
import type { MappViewAppAPI, MappViewPlugin } from "@mappview/plugins";
export const myPlugin: MappViewPlugin = {
id: "my-plugin",
name: "My Plugin",
version: "0.1.0",
activate(app: MappViewAppAPI) {
app.setBasemap("https://example.com/style.json");
},
deactivate() {
// Clean up controls, listeners, and plugin state here.
},
};
Map control plugins can optionally expose getMapControlPosition() and setMapControlPosition() so the desktop Plugins menu can move the control between map corners. Position-aware plugins should remove and recreate or re-add their control when the position changes.
Plugins with serializable runtime settings can expose getProjectState() and applyProjectState() so Meratyn can save and restore those settings in the project file. A wrapper should use these hooks to adapt external control APIs such as getState() without requiring every external package to implement a Meratyn-specific interface.
Plugins that render with deck.gl should call app.getDeckGL() (returns a promise) to obtain Meratyn’s own deck.gl modules — core, layers, geoLayers, meshLayers, and mapbox (use mapbox.MapboxOverlay for interleaved MapLibre rendering). Render on the host’s single deck.gl instance rather than bundling a second copy: deck.gl and luma.gl throw on a version mismatch and share global singletons, so a bundled copy fails to render. Call it with optional chaining (app.getDeckGL?.()) since a host variant may not ship deck.gl.
Plugins can also declare URL query parameters and handle them when Meratyn opens. URL parameter handlers run after the map is ready, external plugins are loaded, and project plugin state has been restored. Meratyn calls handlers for plugins whose declared parameter names are present in the URL, and it suppresses repeated handling of the same URL context for the same plugin. If a matching plugin is registered (installed) but inactive, Meratyn first attempts to activate it via PluginManager.activate; the handler runs only if activation succeeds (an activate() that returns false or throws leaves the plugin inactive and skips dispatch). Parameter names are case-sensitive, as URL query parameters are: declaring exampleGeoJson will not match ?ExampleGeoJson=….
import type { MappViewAppAPI, MappViewPlugin } from "@mappview/plugins";
export const plugin: MappViewPlugin = {
id: "example-url-loader",
name: "Example URL Loader",
version: "0.1.0",
urlParameterNames: ["exampleGeoJson"],
activate() {
// Set up controls or plugin state here.
},
deactivate() {
// Clean up controls, listeners, and plugin state here.
},
async handleUrlParameters(app: MappViewAppAPI, params: URLSearchParams) {
for (const dataUrl of params.getAll("exampleGeoJson")) {
// URL parameter values are attacker-controlled: only fetch HTTPS URLs
// and verify the origin is one you trust before loading. Parsing the
// value rejects malformed URLs, and the protocol check blocks
// non-HTTPS schemes (file://, data:, http://); neither protects
// against SSRF to loopback or private-network addresses.
let parsedUrl: URL;
try {
parsedUrl = new URL(dataUrl);
} catch {
continue;
}
if (parsedUrl.protocol !== "https:") continue;
const response = await fetch(parsedUrl.href);
if (!response.ok) continue;
app.addGeoJsonLayer("Example URL layer", await response.json(), dataUrl);
}
},
};
Validate URL parameter values before acting on them. Anyone can craft a link to Meratyn, so handlers that fetch a parameter value should reject unexpected schemes (file://, data:, plain http://) and only contact origins they trust.
For example:
http://localhost:5173/?url=https://example.com/project.mappview.json&exampleGeoJson=https://example.com/data.geojson
A URL parameter activates only an already-registered (installed) plugin that owns it; it never loads a plugin from the URL. For external plugins, include the plugin manifest URL in the project plugins state (so the plugin is registered) before relying on its URL handler — the matching parameter then activates and dispatches it even if it is not in the active set.
Raster and tile layers
addGeoJsonLayer registers vector data as a native layer. For raster and tile data there are three matching helpers — addTileLayer (XYZ), addWmtsLayer (WMTS), and addWmsLayer (WMS). Each returns the new layer’s id, and the layer appears in the Layers panel with full opacity, reorder, and styling support and persists with the project, so a plugin no longer has to call getMap().addSource()/addLayer() directly (which leaves the layer invisible to Meratyn’s layer store).
export interface MappViewTileLayerOptions {
tileSize?: number; // default 256
attribution?: string;
bounds?: [number, number, number, number]; // [west, south, east, north] in WGS84
minzoom?: number;
maxzoom?: number;
scheme?: "xyz" | "tms"; // default "xyz"
visible?: boolean; // default true
opacity?: number; // default 1
beforeLayerId?: string; // insert beneath this layer
}
export interface MappViewWmsLayerOptions extends MappViewTileLayerOptions {
url: string; // WMS GetMap endpoint
layers: string; // comma-separated layer name(s)
styles?: string;
format?: string; // default "image/png"
transparent?: boolean; // default true
version?: string; // "1.1.1" (default) or "1.3.0" (sends CRS instead of SRS)
}
export interface MappViewCogLayerOptions {
/**
* Shared raster renderer. `maplibre-gl-raster` (GPU/deck.gl) requires
* Mercator; `cog-tiler-wasm` also works in globe views. `"auto"` leaves the
* host's current control-wide renderer unchanged. Omitting the field keeps
* the legacy GPU default for callers that do not opt into a renderer.
*/
engine?: "maplibre-gl-raster" | "cog-tiler-wasm" | "titiler" | "auto";
bands?: string; // "1" (single band) or "1,2,3" (RGB)
colormap?: string; // named colormap for a single-band COG, e.g. "terrain"
rescaleMin?: number;
rescaleMax?: number;
nodata?: number; // pixel value rendered transparent
opacity?: number; // default 1
beforeLayerId?: string;
}
// XYZ tiles (e.g. an imagery, topo, or DEM hillshade endpoint).
app.addTileLayer?.("LINZ Aerial Imagery", "https://tiles.example.nz/aerial/{z}/{x}/{y}.png", {
attribution: "Sourced from LINZ. CC BY 4.0",
maxzoom: 22,
bounds: [166.0, -47.5, 178.6, -34.0],
});
// WMTS tile URL template.
app.addWmtsLayer?.("LINZ Topo50", "https://tiles.example.nz/topo50/{z}/{x}/{y}.png");
// WMS — pass the request parameters; the host builds the GetMap tile URL.
app.addWmsLayer?.("LINZ Coverage", {
url: "https://wms.example.nz/wms",
layers: "coverage",
transparent: true,
});
// COG — read the GeoTIFF directly (client-side), with raster controls.
const cogId = await app.addCogLayer?.("LINZ DEM", "https://cog.example.nz/dem.tif", {
colormap: "terrain",
nodata: -9999,
});
addTileLayer/addWmtsLayer/addWmsLayer expect pre-rendered tiles (e.g. a COG already served through a tiler such as titiler as an XYZ endpoint). addCogLayer is different: it loads the GeoTIFF itself and renders it client-side, exposing band selection, rescale, colormap, and nodata in the raster panel. It is async (it fetches the file’s header), so it returns a Promise<string> and rejects if the COG cannot be read. The optional engine selects the shared raster control’s renderer: use "auto" when a catalog should respect the renderer already selected by the user, or an explicit value when the catalog intentionally opts in. Because the setting is control-wide, selecting an explicit engine can re-render COGs already on the map.
The helpers are typed optional for forward-compatibility with host variants, so call them with optional chaining (app.addTileLayer?.(...)).
Desktop (Tauri) note: The desktop app enforces a Content Security Policy that restricts which tile hosts the WebView can reach. If your plugin registers tiles from a host not already in the Meratyn CSP allowlist, the layer is created but its tiles silently fail to load. For bundled (first-party) plugins, add the host to
connect-src/img-srcinapps/mappview/src-tauri/tauri.conf.json; external plugins can only reach already-permitted hosts. The web build is unaffected.
Zarr layers
addZarrLayer renders a Zarr store (Zarr v2/v3 over HTTP) through Meratyn’s own @carbonplan/zarr-layer instance and mirrors the result into the Layers panel. It is the Zarr counterpart of addCogLayer. Stores that are not read from a URL — such as an Icechunk repository or a local folder — reach the same renderer through an internal store option that this public API does not expose, so the built-in STAC and Zarr panels open them instead.
Do not bundle @carbonplan/zarr-layer in a plugin: a second copy ships a duplicate numcodecs WASM payload, and adding the renderer’s layer yourself with getMap().addLayer() produces a MapLibre custom layer, which has no paint properties for the Style panel to drive.
export interface MappViewZarrLayerOptions {
variable: string; // array to render (required)
selector?: Record<string, number | string>; // non-spatial dims, e.g. { time: 0 }
clim?: [number, number];
colormap?: string | string[]; // named ramp ("viridis") or hex stops
opacity?: number;
zarrVersion?: 2 | 3;
crs?: string; // e.g. "EPSG:32633"
proj4?: string; // for a CRS with no built-in
bounds?: [number, number, number, number]; // [xMin, yMin, xMax, yMax] in the store's CRS
spatialDimensions?: { lat?: string; lon?: string };
headers?: Record<string, string>; // authenticated stores
beforeLayerId?: string;
}
// A projected national grid: crs/proj4 are forwarded to the renderer, which
// reprojects on the GPU. Without them the store is read as WGS84 and lands in
// the wrong place.
const layerId = await app.addZarrLayer?.("seNorge tmax", "https://example.no/senorge.zarr", {
variable: "tmax",
selector: { time: 0 },
clim: [-30, 30],
colormap: "viridis",
crs: "EPSG:32633",
});
// Drive a time slider without rebuilding the layer: the renderer keeps the
// chunks it already fetched. (`addZarrLayer` is optional, so guard the id.)
if (layerId) {
await app.setZarrLayerSelector?.(layerId, { time: 12 });
// Read the clicked pixel without changing the slice on screen. Points return
// click-to-value data; Polygon/MultiPolygon geometries return region values.
const result = await app.queryZarrLayer?.(
layerId,
{ type: "Point", coordinates: [-105.27, 40.02] },
{ time: 11 },
{ includeSpatialCoordinates: true },
);
}
addZarrLayer is headless: it does not open the Zarr panel (the user can still open it from Add Data → Zarr Layer to tweak colormap and color limits). It resolves with the new layer’s id once the layer is registered, and rejects when variable is missing or the store cannot be read. The layer supports visibility, opacity, ordering, and removal from the Layers panel like any other layer.
selector picks a slice by coordinate value, not by index: on a month axis of 1-12, December is { month: 12 }. The panel’s Selector (JSON) field means the same thing, and editing it now re-slices the layers already on the map.
The panel can also open a store from a folder on disk, via a Browse folder button next to the Zarr URL. It appears in the desktop app and in browsers with the File System Access API (Chromium), since reading a folder needs a filesystem API the plugin cannot supply; elsewhere the panel is unchanged. A local cube binds to the Time Slider like a remote one — its CF units are read out of the folder, because its recorded URL is an identifier rather than an address.
queryZarrLayer delegates to the same live renderer instance, so plugins do not
need their own zarrita reader or reprojection logic. Query geometries are WGS84;
returned coordinate arrays use the store’s source CRS. It resolves to null for
an unknown layer id, returns empty value arrays outside the grid or before the
first chunks load, and rejects an aborted request.
Driving a layer’s own time dimension from the Time Slider
The Time Slider understands three kinds of temporal layer. Two are built in: a vector layer filtered by a timestamp property, and a raster time series of dated sources the dock steps between. The third is for a layer that is one store with time as an internal dimension — a Zarr data cube, or a plugin’s own frame-based layer — where the timeline picks a slice rather than a source.
That third kind is expressed as a temporal adapter:
export interface TemporalLayerAdapter {
getTimeValues: () => ReadonlyArray<Date | number | string>; // the time coordinate, in index order
setTime: (date: Date) => void | Promise<void>; // apply a date to the layer
dimension?: string; // the axis name, default "time"
}
The slider owns the snapping: it reads getTimeValues(), finds the index nearest the current date, and calls setTime() with that slice’s own date, skipping a tick that lands on the slice already showing. So a daily cube under a month-stepping timeline costs one chunk fetch per changed index, not one per tick. Calls are throttled while the handle is dragged, with the trailing edge always applied. Accepted value forms are Dates, epoch seconds/milliseconds, bare calendar years, and date strings.
A Zarr layer added with addZarrLayer registers its own adapter for its time axis (decoding CF units such as "days since 1970-01-01" from the store’s metadata), so it needs no call here. Use registerTemporalLayer for a custom layer you render yourself:
const times = frames.map((frame) => frame.timestamp); // Date[] | number[] | string[]
const detach = app.registerTemporalLayer?.(
layerId,
{
dimension: "time",
getTimeValues: () => times,
setTime: (date) => showFrame(nearestFrame(date)),
},
{ bind: true },
);
Registering makes the layer bindable: the Layers panel’s row menu gains “Bind time dimension to Time Slider”. Passing { bind: true } binds it immediately and opens the dock, which is usually what a plugin that just loaded a cube wants. The binding is persisted on layer.metadata.timeBinding (as { kind: "selector", dimension, min, max, granularity }) so it survives a project round-trip; the time values themselves are not persisted, because they belong to the data store — re-register the adapter when the layer is recreated and the timeline picks it back up.
Call the returned function, or app.unregisterTemporalLayer?.(layerId), to drop the adapter. Removing the layer does it too.
A bound cube shares the timeline with any vector bindings and dated overlays: the track spans the union of their extents, and the widest dataset sets the stepping granularity.
Closing the dock again. A dock that a binding opened closes itself once the last temporal layer is gone, so it never lingers over a map with no timeline. Removing the bound layer is enough; if your plugin keeps the layer and only drops its binding, clear layer.metadata.timeBinding as well as unregistering the adapter. A dock the user opened from the Plugins menu is left alone, and so is one that still has raster sources of its own or a KML <TimeSpan> overlay to drive.
Activating and deactivating other plugins
await app.activatePlugin?.("maplibre-gl-time-slider");
app.deactivatePlugin?.("maplibre-gl-time-slider");
activatePlugin takes an optional second argument, a project-state patch applied once the target is active; it resolves false when the plugin is unavailable, refuses to activate, or rejects the state. deactivatePlugin is its counterpart and returns true when the plugin ended up inactive.
Neither may target the calling plugin: activatePlugin on yourself is meaningless, and deactivating yourself would unmount the code still on the stack. Both return false in that case.
Custom (WebGL) layers and paint ownership
registerExternalNativeLayer mirrors a layer the plugin added to the map itself into Meratyn’s layer store, so it appears in the Layers panel and persists with the project:
export interface MappViewExternalNativeLayerRegistration {
id: string;
name: string;
type?: MappViewLayer["type"]; // closest built-in type, e.g. "raster"
nativeLayerIds: string[]; // the MapLibre layer id(s) you added
source?: Record<string, unknown>;
sourceIds?: string[];
sourceId?: string;
geojson?: FeatureCollection; // for vector layers
beforeId?: string;
opacity?: number;
style?: Partial<LayerStyle>;
metadata?: Record<string, unknown>;
sourcePath?: string;
paintMode?: "mappview" | "plugin"; // see below
paintBridge?: {
// see below
setOpacity?: (opacity: number) => void;
setVisibility?: (visible: boolean) => void;
};
}
A plugin that renders with its own MapLibre CustomLayerInterface — a WebGL layer that draws its own pixels — registers it the same way:
const layer = new MyWebGLLayer({ id: "my-layer" });
app.getMap?.()?.addLayer(layer);
app.registerExternalNativeLayer?.({
id: "my-layer",
name: "My WebGL layer",
type: "raster",
nativeLayerIds: ["my-layer"],
// The layer has no MapLibre paint properties, so Meratyn must not offer
// paint editors that cannot reach it.
paintMode: "plugin",
// Optional: keep the Opacity sliders live by forwarding them to the layer.
paintBridge: {
setOpacity: (opacity) => layer.setOpacity(opacity),
setVisibility: (visible) => layer.setActive(visible),
},
});
paintMode: "plugin"tells the Style panel that the plugin paints the layer. It then shows only the controls that actually apply — insert-below, zoom range, and (with a bridge) opacity — instead of raster brightness/saturation/contrast/hue sliders that silently do nothing. Visibility, reordering, and removal keep working from the Layers panel; MapLibre honorsvisibilityand the zoom range on a custom layer.paintBridgesupplies the setters Meratyn calls when the user changes opacity or visibility. SupplyingsetOpacitykeeps the Opacity slider in both the Layers and Style panels; omitting it hides the slider rather than leaving an inert one. Supplying a bridge impliespaintMode: "plugin".- The setters are called only when the value changes, not on every layer sync, and they are held outside the layer record (functions cannot be serialized into a project file). Re-register the layer after a project reload to restore the bridge, and call
unregisterExternalNativeLayer(id)fromdeactivate.
For Zarr specifically, prefer addZarrLayer above: the host’s renderer already integrates with the panels, so no custom layer or bridge is needed.
Right sidebar panels
A plugin can register a native right-sidebar panel that docks beside the built-in Style panel and behaves like a first-class part of the workspace, instead of emulating one with a fixed overlay. The host renders the panel chrome (a header with collapse and close buttons, a collapsible rail, and a resize handle); the plugin owns only the body.
export const myPlugin: MappViewPlugin = {
id: "my-workbench",
name: "Workbench",
version: "0.1.0",
activate(app) {
// Register once, then open. registerRightPanel returns an unregister fn.
this._unregister = app.registerRightPanel?.({
id: "my-workbench",
title: "Workbench",
defaultWidth: 360,
render(container) {
const button = document.createElement("button");
button.textContent = "Run analysis";
container.appendChild(button);
// Optional cleanup, run when the panel closes or is unregistered.
return () => button.remove();
},
onOpen() {},
onCollapse() {},
onClose() {},
});
app.openRightPanel?.("my-workbench");
},
deactivate(app) {
app.closeRightPanel?.("my-workbench");
this._unregister?.();
},
} as MappViewPlugin & { _unregister?: () => void };
Notes:
render(container)is called once with an empty element you fill with plain DOM. An external plugin cannot share Meratyn’s React instance, so the contract is DOM, not a React node. The container stays mounted across collapse, suspension, and tool selection, so any state in your DOM persists. Inactive panels are hidden and cannot receive focus; the returned cleanup runs on close or unregister (or when replacing the render function).- Only one plugin panel is active at a time, but every opened panel remains available in its dock list until the user explicitly closes it. Switching entries preserves each panel’s dock, and the expanded title exposes a quick selector when more than one entry is available. Positional plugin panels use the same layout policy as the Dashboard: moving one beside Layers, the map, or Style does not change another panel’s expanded state. The
replace-styleandreplace-layersmodes deliberately share the corresponding sidebar surface instead of adding another column. openRightPanel(id)makes the panel active and expanded (it also expands a collapsed panel);collapseRightPanel(id)collapses it to its rail without closing;closeRightPanel(id)releases the panel even when it is not selected;getActiveRightPanel()returns the active id ornull. TheonOpenandonClosehooks follow those open/close transitions, not tool selection.- The panel is a flex sibling of the map, so opening it shrinks the map view (the map keeps filling the remaining space); no manual map padding is required.
- Dock position: a panel docks at one of four positions (left to right):
left-of-layers,right-of-layers(between Layers and the map),left-of-style(between the map and Style), orright-of-style(the default). Setdockon the registration to choose the initial position. The user steps the panel between positions at runtime with the two move buttons in the panel header (disabled at the ends), and a plugin can set it directly withapp.setActiveRightPanelDock?.(...). The position resets to the panel’s declareddockwhen it closes or another panel opens. - Shared-rail modes (
replace-style/replace-layers): two non-positional docks for workbench-style plugins that want to feel like a first-class sidebar workspace rather than a second rail beside Style (right) or Layers (left). Register withdock: "replace-style"(or"replace-layers") and the host shows a single rail on that edge listing both your panel and the built-in panel; selecting one expands it while the other stays as a rail entry. The two are mutually exclusive, so the user never sees two adjacent rails. The built-in panel starts collapsed so the workbench reads as the active workspace, and the user can expand it (which collapses the workbench) at any time. Everything else (chrome, resize, collapse, close, lifecycle hooks) is unchanged. - Switching modes at runtime: the modes are not exclusive choices baked in at registration. In a positional dock the panel header shows a merge button that joins the shared rail on its current side — a layers-side panel (
left-of-layers/right-of-layers) joins the Layers rail, a style-side panel the Style rail. In a shared rail it shows a detach button that pops the panel back out to a movable positional panel on the same side (right-of-layers/right-of-style), where the left/right move buttons return. A plugin can drive the same switch withapp.setActiveRightPanelDock?.("replace-style" | "replace-layers" | "right-of-style" | ...). The shared rails are not part of the left/right step sequence (the arrows only walk the four positional docks); merge/detach is the way in and out. - These methods are typed optional for forward-compatibility with host variants that have no right sidebar, so call them with optional chaining (
app.registerRightPanel?.(...)).
Toolbar menus
A plugin can add its own top-level menu button to Meratyn’s action toolbar, with nested submenus and action items. Register the menu in activate and unregister it in deactivate:
const unregister = app.registerToolbarMenu?.({
id: "my-plugin-menu",
label: "Workbench",
items: [
{
id: "open",
label: "Open workbench",
onSelect: () => app.openRightPanel?.("my-workbench"),
},
{
type: "submenu",
id: "tools",
label: "Tools",
items: [
{
id: "qa",
label: "Data QA",
onSelect: () => app.openFloatingPanel?.("my-qa"),
},
],
},
{ type: "separator" },
{ id: "about", label: "About", disabled: false, onSelect: () => {} },
],
});
Each item is an action (onSelect, the default when type is omitted), a submenu (nested items), or a separator. Items typically open a right panel or a floating panel, but onSelect can run anything. Re-registering the same id replaces the menu, so you can rebuild it as your plugin’s state changes.
Menus from external plugins (loaded from a zip, a manifest URL, or a bundled drop-in) render in the toolbar’s external-extension group. The host decides the group from the menu’s owning plugin, so you do not need to do anything special. Toolbar label and visibility settings do not unregister plugin menus.
HeaderDock panels
HeaderDock is a separate panel surface at the top of the viewer’s central column, between the left and right docks. registerHeaderPanel does not create a toolbar menu, and registerToolbarMenu does not create or open a HeaderDock panel. A plugin can expose both and connect them explicitly:
const unregisterHeader = app.registerHeaderPanel?.({
id: "my-header-panel",
title: "Timeline",
defaultHeight: 220,
render(container) {
const timeline = document.createElement("div");
timeline.textContent = "Timeline content";
container.append(timeline);
return () => timeline.remove();
},
});
const unregisterMenu = app.registerToolbarMenu?.({
id: "my-plugin-menu",
label: "Workbench",
items: [
{
id: "open-timeline",
label: "Open timeline",
onSelect: () => app.openHeaderPanel?.("my-header-panel"),
},
],
});
// deactivate:
unregisterMenu?.();
unregisterHeader?.();
Only one HeaderDock panel is active at a time. The host provides panel selection, vertical resize, collapse, and close controls. Collapsing retains the mounted DOM; closing or switching to another HeaderDock panel runs the renderer cleanup and the panel’s onClose callback. showCloseButton: false hides only the close action; collapse remains available. The whole surface can be enabled or disabled from Settings → Layout without changing toolbar menus.
An optional renderHeaderActions(container) renderer places panel actions in the same row as the title and host controls. It can return a cleanup function just like render; its content stays mounted but hidden while collapsed and is cleaned up when the panel switches or unmounts.
Toolbar labels may be a string or a getter. Getters are evaluated at render time and the host re-renders the menu after a language switch, so a plugin can keep one registration and still update its labels:
const title = () => app.translate?.("plugin.my-plugin.menu", "Workbench") ?? "Workbench";
app.registerToolbarMenu?.({
id: "my-plugin-menu",
label: title,
items: [{ id: "open", label: title, onSelect: () => {} }],
});
If a getter throws or returns an empty value, Meratyn keeps the item usable by falling back to its stable menu/item id and emits one diagnostic for that path.
Plugin-owned panels are plain DOM, not React, so use the optional locale bridge when their text should follow Meratyn’s language. Always retain the disposer:
const unsubscribe = app.onLocaleChange?.(() => renderPanel()) ?? (() => {});
const label = app.translate?.("plugin.my-plugin.run", "Run analysis") ?? "Run analysis";
const locale = app.getLocale?.() ?? "en";
// call unsubscribe() from deactivate()
Namespaced plugin keys (plugin.<id>.*) are intentionally not added to the
host catalog by the plugin API. A missing key returns the supplied fallback, so
external plugins remain functional without a host release.
Floating panels
A floating panel is a draggable, closeable card the host overlays on the map’s top-left corner. Unlike a dockable right panel (one active panel docked at a fixed position), several floating panels can be open at once and they do not shrink the map. Set allowMainView: true for an immersive visual surface that users should be able to alternate with the map. The host adds an icon-only title-bar switch that swaps the two surfaces: the panel fills the viewing area while the live map takes over that panel’s exact frame, and switching back reverses the swap. The same card and switch stay in place throughout, and the mounted plugin preserves its state. The render contract is the same plain-DOM render(container) as right panels.
const unregister = app.registerFloatingPanel?.({
id: "my-qa",
title: "Data QA",
defaultWidth: 300,
allowMainView: true,
render(container) {
container.textContent = "Rendered by the plugin via registerFloatingPanel().";
return () => {
// optional cleanup, run on close/unregister
};
},
});
app.openFloatingPanel?.("my-qa"); // open (or bring to front)
app.closeFloatingPanel?.("my-qa"); // close
app.getOpenFloatingPanels?.(); // -> string[] of open ids, stacking order
Use a right panel for a primary, persistent workspace and a floating panel for an ancillary tool or dashboard the user positions over the map. As with the other surfaces, call these methods with optional chaining since they are typed optional.
External plugins
Use the examples in this repository as the starting point for external plugin
development. A plugin needs a plugin.json manifest, a Meratyn plugin entry
point, and a zip matching the layout Meratyn Desktop expects.
Meratyn Desktop loads external plugins from the app data plugins/ directory at startup. External plugins are trusted code and can be installed as:
- A
.zipfile with a rootplugin.json. - An unpacked directory with a root
plugin.json. - A HTTPS
plugin.jsonmanifest URL.
The fastest way to install a .zip is Manage Plugins > Settings > Install from file: pick a packaged plugin archive and Meratyn validates it (parsing plugin.json, enforcing the manifest rules, and checking the entry/style are present and within the size limit) before installing it. The plugin loads immediately and persists; reinstalling the same id replaces the previous copy and reloads the updated version. Persistence differs by build:
- Desktop copies the archive into the app data
plugins/directory as<plugin-id>.zip, where the startup scan re-loads it. - Web unpacks the archive in the browser and stores the bundle in IndexedDB, replaying it on the next visit. Web-installed plugins are listed under Install from file with an uninstall control (the desktop copies live on disk and are managed there).
The Plugins settings section can also add local development directories outside the app data folder. Each configured directory can contain plugin zips, unpacked plugin bundle folders, or be a single unpacked plugin bundle itself. Configured development directories are scanned before the app data plugins/ directory, so a development copy can override an installed external plugin with the same ID. Built-in plugins still take precedence over all external plugins.
For the web app, use manifest URLs or Install from file (above). Manifest URLs: Meratyn fetches the manifest, resolves entry and style relative to the manifest URL, then loads the bundled ESM entry. Browser loading requires HTTPS except for localhost and depends on the host allowing CORS. Install-from-file unpacks the uploaded zip in the browser (no network or CORS) and persists it in IndexedDB. Both paths execute the bundled ESM entry the same way (a blob: import(), allowed by the web build’s script-src), so external plugins remain trusted code regardless of how they were installed.
Bundled plugins (baked into the build)
To ship an external plugin as part of Meratyn — loaded automatically, with no Settings entry and no manifest URL — drop its built bundle into the Vite public directory, one folder per plugin id:
apps/mappview/public/plugins/example-plugin/
plugin.json
dist/index.js
dist/style.css
This is the same content a manifest URL would serve. A drop-in is all that is required — no source edits per plugin. The bundledPlugins() Vite plugin (apps/mappview/vite-plugins/bundled-plugins.ts) scans public/plugins/ at build and dev-server start, exposes the discovered manifest paths through the virtual:bundled-plugins module, and apps/mappview/src/features/plugins/runtime/manager.ts loads them through the normal external-plugin path (fetch → blob import → register). Discovery happens at build time, so restart the dev server or rebuild after adding, updating, or removing a plugin folder.
The same folder serves both the web and desktop builds: the desktop app bundles the identical frontend (frontendDist in tauri.conf.json) and serves it from tauri://localhost, which is same-origin and allowed by the desktop CSP (connect-src 'self', script-src ... blob:). Bundled manifest URLs are injected at load time rather than stored in Settings, so a baked-in plugin always loads and cannot be removed by a user; they are deduplicated by plugin id against any user/project plugin of the same id.
Private plugins should be git-ignored under public/plugins/ (see that folder’s .gitignore) and copied in at build/deploy time (for example in CI before bun run build, or by a plugin repo’s own install script) so their code stays out of Meratyn’s history. The discovery code is generic and committed; only the plugin payload is excluded.
A bundled drop-in’s plugin.json may additionally set "activeByDefault": true to activate the plugin on startup, so its control appears without a trip to the Plugins menu. Saved plugin state still wins: a loaded project (or the user’s persisted plugin state) that carries activePluginIds overrides the default. The flag is honored only for bundled drop-ins, since a deployer who bakes a plugin into the build is trusted like a built-in author; it is silently ignored on manifests installed at runtime from URLs or zips.
If instead you want a plugin compiled into the main JS bundle (no plugin.json, no fetch), register it as a built-in plugin (see “Add a plugin” in the repository README).
{
"id": "example-plugin",
"name": "Example Plugin",
"version": "0.1.0",
"entry": "dist/index.js",
"description": "Optional short description",
"style": "dist/style.css"
}
The entry file must export a MappViewPlugin as either the default export or a named plugin export. The exported plugin id, name, and version must match plugin.json. The entry must be a self-contained .js or .mjs bundle because relative module imports inside the zip are not resolved by this first loader.
External plugin entries are executed with import(URL.createObjectURL(...)), which is why the desktop CSP in tauri.conf.json includes blob: in script-src. Removing blob: from script-src breaks external plugin loading. Combined with 'unsafe-eval', this means code that can create a blob URL can execute scripts, which is acceptable because external plugins are trusted local files installed by the user.
Because plugins run as trusted code in the host document, they can read window.__MAPPVIEW_RUNTIME_ENV__, the runtime environment map. On the desktop app this map includes the AI Assistant’s OS-environment keys (the allowlisted provider variables read from the user’s shell), not only the values typed into Settings → Environment Variables. Treat any credential reachable through the app’s environment as visible to installed plugins, and only install plugins you trust.
Manifest paths must be relative zip paths with forward slashes, no leading slash, no backslashes, and no .. segments. External plugins cannot set activeByDefault on the exported plugin object, and the manifest-level flag is honored only for bundled drop-ins (see “Bundled plugins” above); saved project state can still reactivate an external plugin by ID after the zip is loaded.
The optional style CSS is injected globally into the host document, not scoped to the plugin. Plugin authors are responsible for scoping their selectors (for example with a plugin-specific class prefix) so broad rules do not restyle the rest of the app. Injected CSS can also issue network requests through url() references and @import, so a plugin stylesheet can load external fonts, images, or additional sheets; treat plugin CSS with the same trust expectations as plugin code.
Keep plugin.json and the plugin entry point in sync so id, name, and
version match. Then copy the packaged zip into the desktop app data plugins/
directory, add its unpacked directory in Settings > Plugins for local
development, or host the directory and add its plugin.json URL.
Plugin marketplace
The Settings menu’s Manage Plugins entry opens a standalone dialog (modeled on QGIS’s plugin manager) with All, Installed, Not installed, Upgradeable, and Settings sections. The first four list curated registry plugins so users can install, update, and uninstall them without hand-entering manifest URLs; the Settings section installs a plugin from a local .zip and manages additional local plugin directories and manual manifest URLs. Actions apply immediately (install/uninstall/update are live; uninstall asks for confirmation). It is a thin layer over the manifest-URL loader above: installing an entry records its manifest URL in the plugin manifest URL list, and the existing loader fetches and registers it. It introduces no new trust path.
The registry is JSON, fetched from VITE_MAPPVIEW_PLUGIN_REGISTRY_URL. The
default is the deployment’s plugin origin (registry.json on
plugins.mappview.mapptech.com.co in production), generated by
scripts/build/trusted-plugins.ts. It is an
array, or an object with a plugins array, of entries:
{
"version": 1,
"plugins": [
{
"id": "example-plugin",
"name": "Example Plugin",
"version": "1.0.0",
"description": "Optional short description",
"author": "Example Author",
"homepage": "https://github.com/example/example-plugin",
"manifestUrl": "https://example.com/example-plugin/plugin.json",
"categories": ["Example"],
"minMappViewVersion": "1.0.0"
}
]
}
id, name, version, and manifestUrl are required; the rest are optional. A relative manifestUrl is resolved against the registry location, so a plugin hosted alongside the registry (e.g. sample/plugin.json) can be listed with a relative path. minMappViewVersion gates installation against the running app version. Curate the registry and host plugin bundles on infrastructure owned by Meratyn or the deployment operator.
Uninstalling prompts for confirmation, then unregisters the plugin at runtime (deactivating any active map control) so the Plugins menu updates without a reload. When a registry entry advertises a newer version than the loaded plugin, the marketplace shows an Update action that re-fetches the manifest URL and re-registers the published version in place; the new version is fetched and validated before the old one is removed, so a failed update leaves the installed plugin intact.
Deployment-trusted external plugins
VITE_MAPPVIEW_TRUSTED_PLUGIN_REGISTRY_URL points to a second, standalone
registry document. Every exact manifest URL returned by this registry is
allowed to load without a project trust prompt. The app fetches this document
at startup, so its entries and plugin versions can change without rebuilding or
redeploying Meratyn; only changing the registry URL itself requires a new app
build.
This registry is a code-distribution trust root, not a marketplace. An attacker who can modify it can direct the app to new JavaScript that will run without user consent. Host it on deployment-owned infrastructure with HTTPS and tightly restricted write access. Trusted external plugins are registered at startup but are not activated by default; saved project state still decides whether they activate.
Use immutable versioned manifest URLs. Meratyn pins each remote bundle’s
SHA-256 digest on first use and blocks unexpected content changes at the same
URL. To release an update, publish it under a new version path and change the
registry entry’s manifestUrl.
Build-time drop-ins under public/plugins/ are served by the same deployment
as the Meratyn application. They are therefore exempt from the per-user
remote pin: pinning would reject every redeploy while offering no protection
against an operator who can replace the app itself. The exemption is limited to
the manifest URLs discovered by the build; user-entered HTTPS URLs and ZIP
archives retain the normal trust-on-first-use check.
The MappStock and Cali artifacts can be generated without embedding them in the app:
bun run build:trusted-plugins
This writes output/trusted-plugins/registry.json and versioned plugin
directories. In production the plugins Compose service serves them at
plugins.mappview.mapptech.com.co; dev and desktop builds bake a copy into
public/plugins/.
Publish a new plugin to the registry
The registry document and its versioned bundles are generated from checked-in release directories; nothing is uploaded by hand. The full workflow:
-
Build a self-contained bundle. The entry must export the
MappViewPlugin(default export or a namedpluginexport) as one minified.jsfile with every dependency bundled, because the registry serves the exact file. For example, with Bun:bun build ./src/entry.ts --outfile index.js --minify -
Create an immutable release directory
scripts/build/releases/<slug>/<version>/containing:index.js— the exact bundle to serve.release.json—id,name,version, anddescriptionof the plugin, plussha256: the hex SHA-256 digest ofindex.js. The trusted build refuses to run when the digest no longer matches the bundle.
Never replace files in an existing version directory; publish a new version directory instead.
-
Register the release in the
releasesarray ofscripts/build/trusted-plugins.tswith itsslug(the URL namespace),directory,author,current: truefor the version the registry lists, and optionalaliases(such as["latest"], copied toplugins/<slug>/<alias>/). -
Generate the registry document:
bun run build:trusted-pluginsThis writes
output/trusted-plugins/registry.jsonplusoutput/trusted-plugins/plugins/<slug>/<version>/containing the bundle and a generatedplugin.json. Only releases markedcurrent: truebecome registry entries, andminMappViewVersionis stamped from the repository version at build time. -
Deploy. The
pluginsCompose service (docker/Dockerfile.plugins) re-runs the same script withMAPPVIEW_PLUGINS_BASE_URLand serves the output on the plugin origin; each registry entry’smanifestUrlis<base>/plugins/<slug>/<version>/plugin.json. Dev and desktop builds consume a copy baked intopublic/plugins/as a bundled drop-in. Commit the release directory and thetrusted-plugins.tsregistration together.
Remember the trust model from the section above: this registry is a code-distribution trust root. Version URLs are immutable and clients pin the bundle digest on first use, which is why updates are new version directories plus a registry regeneration, never in-place replacements.