A template for creating GeoLibre Desktop plugins backed by MapLibre GL JS controls. It still includes the standalone MapLibre control and React wrapper so plugin authors can develop and test the control outside GeoLibre.
- GeoLibre Bundle Output - Builds a zip with root
plugin.json, bundled ESM, and CSS for GeoLibre Desktop - GeoLibre Host Contract - Typed
GeoLibreAppAPI/GeoLibrePlugincontract, URL deep-linking, native-layer registration, and a one-stepinstall:geolibre - TypeScript Support - Full TypeScript support with type definitions
- React Integration - React wrapper component and custom hooks
- IControl Implementation - Implements MapLibre's IControl interface
- Modern Build Setup - Vite-based library and GeoLibre bundle builds
- Testing - Vitest setup with React Testing Library
- CI/CD Ready - GitHub Actions for npm publishing and GitHub Pages
npm install geolibre-plugin-templateGeoLibre Desktop loads external plugins from an app data plugins/ directory. The zip must contain plugin.json at the root, plus a bundled ESM entry and optional CSS file.
npm install
npm run package:geolibreThis creates:
geolibre-plugin/geolibre-plugin-template-0.1.0.zip
The generated zip contains:
plugin.json
dist/index.js
dist/style.css
Copy the zip into GeoLibre Desktop's app data plugins/ directory and restart GeoLibre. On Linux with the default app identifier, that directory is usually:
~/.local/share/org.geolibre.desktop/plugins/
Customize the GeoLibre wrapper in src/geolibre.ts and the manifest in geolibre-plugin/plugin.json. The manifest id, name, and version must match the exported plugin in src/geolibre.ts.
For the GeoLibre web app, serve the unpacked plugin with CORS enabled:
npm run package:geolibre
npm run serve:geolibre -- 8000Then add this manifest URL in GeoLibre Settings > Plugins:
http://localhost:8000/plugin.json
Using python -m http.server for this cross-origin web app case is not enough
because it does not send Access-Control-Allow-Origin.
Instead of copying the zip by hand, install:geolibre builds the bundle and
drops it straight into a place GeoLibre loads from:
# GeoLibre Desktop's app-data plugins directory (auto-scanned on startup)
npm run install:geolibre
# A GeoLibre repo's bundled drop-in folder (apps/geolibre-desktop/public/plugins)
npm run install:geolibre -- --web /path/to/geolibre
# A custom directory
GEOLIBRE_PLUGINS_DIR=/path/to/plugins npm run install:geolibreRestart GeoLibre Desktop (or rebuild/restart the GeoLibre dev server for --web)
to load the plugin. The script reads geolibre-plugin/plugin.json, so it works
for any plugin built from this template with no edits.
src/geolibre.ts is the entry point GeoLibre loads. It exports a plugin object
that GeoLibre calls across the plugin lifecycle. The full contract between the
plugin and the host lives in src/lib/geolibre/host-api.ts and is re-exported
from the package, so you import the types instead of redeclaring them:
import type {
GeoLibreAppAPI,
GeoLibrePlugin,
GeoLibreNativeLayerRegistration,
} from "geolibre-plugin-template";The host passes this object to activate, deactivate, and the other hooks.
Only the first two members are guaranteed; the rest are optional capabilities,
so call them with optional chaining and degrade gracefully when a host build
does not provide them.
| Member | Required | Description |
|---|---|---|
addMapControl |
yes | Add the plugin's control to the map |
removeMapControl |
yes | Remove the control from the map |
pickLocalDirectoryFiles |
no | Open the host's directory picker (e.g. GeoLibre Desktop) |
resolvePluginAssetUrl |
no | Resolve a fetchable URL for an asset bundled in the plugin |
registerExternalNativeLayer |
no | Hand the host a dataset to render as a native layer |
unregisterExternalNativeLayer |
no | Remove a previously registered native layer |
| Hook | Description |
|---|---|
activate |
Create and add the control; return false if it cannot be added |
deactivate |
Capture state to restore, then remove the control |
urlParameterNames |
Query parameters this plugin owns (drives auto-activation) |
handleUrlParameters |
Receive deep-link query parameters (see below) |
getMapControlPosition / setMapControlPosition |
Report and change the control's dock position |
getProjectState / applyProjectState |
Serialize and restore state with the GeoLibre project |
Declare the query parameters your plugin owns in urlParameterNames. When
GeoLibre opens a URL carrying one of them, it auto-activates the plugin and
dispatches the parsed parameters to handleUrlParameters. The template wires
this to the DOM-free helpers in src/lib/utils/deep-link.ts:
https://geolibre.app/?plugin-data=https://example.com/dataset.zip
Rename PLUGIN_DATA_PARAM and adapt the DeepLinkConsumer interface (which
PluginControl.loadFromUrl implements) to whatever your plugin needs to receive.
When the host exposes registerExternalNativeLayer, a plugin can hand it a
dataset and let GeoLibre own the MapLibre sources and layers, so they appear in
the host's layer panel and follow its theme. The GeoLibre wrapper binds the
host callbacks into the control's registerNativeLayer / unregisterNativeLayer
options; PluginControl.loadFromUrl shows the end-to-end pattern, and the
control unregisters its layers automatically when removed. Outside GeoLibre the
callbacks default to no-ops, so the control still works as a standalone MapLibre
control.
If your plugin ships static assets it loads over HTTP at runtime (sample
datasets, icons, JSON, etc.), copy them into the built bundle so a baked-in or
URL-served GeoLibre install can fetch them next to the plugin entry, then resolve
their URL at runtime with app.resolvePluginAssetUrl(pluginId, relativePath):
// In createControl(app), enable a feature only when the asset is reachable:
const sampleDataBaseUrl =
app.resolvePluginAssetUrl?.("your-plugin-id", "dist/sample-data") ?? undefined;resolvePluginAssetUrl returns null when the plugin was not loaded from a URL
base (for example, a desktop filesystem install), so treat both undefined
(host lacks the method) and null (asset not resolvable) as "unavailable" and
hide any UI that depends on the asset.
vite.geolibre.config.ts ships a commented bundlePluginAssets() recipe that
copies a source directory into geolibre-plugin/dist/ on closeBundle and sets
publicDir: false so unrelated public/ files are not pulled into the bundle.
Uncomment it and point it at your asset directory to enable it.
import maplibregl from "maplibre-gl";
import { PluginControl } from "geolibre-plugin-template";
import "geolibre-plugin-template/style.css";
const map = new maplibregl.Map({
container: "map",
style: "https://tiles.openfreemap.org/styles/positron",
center: [0, 0],
zoom: 2,
});
map.on("load", () => {
const control = new PluginControl({
title: "My Plugin",
collapsed: false,
panelWidth: 300,
});
map.addControl(control, "top-right");
});import { useEffect, useRef, useState } from "react";
import maplibregl, { Map } from "maplibre-gl";
import {
PluginControlReact,
usePluginState,
} from "geolibre-plugin-template/react";
import "geolibre-plugin-template/style.css";
function App() {
const mapContainer = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<Map | null>(null);
const { state, toggle } = usePluginState();
useEffect(() => {
if (!mapContainer.current) return;
const mapInstance = new maplibregl.Map({
container: mapContainer.current,
style: "https://tiles.openfreemap.org/styles/positron",
center: [0, 0],
zoom: 2,
});
mapInstance.on("load", () => setMap(mapInstance));
return () => mapInstance.remove();
}, []);
return (
<div style={{ width: "100%", height: "100vh" }}>
<div ref={mapContainer} style={{ width: "100%", height: "100%" }} />
{map && (
<PluginControlReact
map={map}
title="My Plugin"
collapsed={state.collapsed}
onStateChange={(newState) => console.log(newState)}
/>
)}
</div>
);
}The main control class implementing MapLibre's IControl interface.
| Option | Type | Default | Description |
|---|---|---|---|
collapsed |
boolean |
true |
Whether the panel starts collapsed (showing only the 29x29 toggle button) |
position |
string |
'top-right' |
Control position on the map |
title |
string |
'Plugin Control' |
Title displayed in the header |
panelWidth |
number |
300 |
Width of the dropdown panel in pixels |
className |
string |
'' |
Custom CSS class name |
pickFiles |
function |
no-op (null) |
Host directory picker; the GeoLibre wrapper binds it to pickLocalDirectoryFiles |
registerNativeLayer |
function |
no-op | Host callback to register a native layer; bound to registerExternalNativeLayer |
unregisterNativeLayer |
function |
no-op | Host callback to remove a native layer; bound to unregisterExternalNativeLayer |
toggle()- Toggle the collapsed stateexpand()- Expand the panelcollapse()- Collapse the panelgetState()- Get the current statesetState(state)- Update the stateloadFromUrl(value)- Handle a deep-link value (implementsDeepLinkConsumer)openFiles()- Open the host directory picker viapickFileson(event, handler)- Register an event handleroff(event, handler)- Remove an event handlergetMap()- Get the map instancegetContainer()- Get the container element
collapse- Fired when the panel is collapsedexpand- Fired when the panel is expandedstatechange- Fired when the state changes
React wrapper component for PluginControl.
All PluginControl options plus:
| Prop | Type | Description |
|---|---|---|
map |
Map |
MapLibre GL map instance (required) |
onStateChange |
function |
Callback fired when state changes |
Custom React hook for managing plugin state.
const {
state, // Current state
setState, // Update entire state
setCollapsed, // Set collapsed state
setPanelWidth, // Set panel width
setData, // Set custom data
reset, // Reset to initial state
toggle, // Toggle collapsed state
} = usePluginState(initialState);The package exports several utility functions:
clamp(value, min, max)- Clamp a value between min and maxformatNumericValue(value, step)- Format a number with appropriate decimalsgenerateId(prefix?)- Generate a unique IDdebounce(fn, delay)- Debounce a functionthrottle(fn, limit)- Throttle a functionclassNames(classes)- Build a class string from an object
# Clone the repository
git clone https://github.com/your-username/geolibre-plugin-template.git
cd geolibre-plugin-template
# Install dependencies
npm install
# Start development server
npm run dev| Script | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Build the library and GeoLibre bundle |
npm run build:lib |
Build the standalone MapLibre library |
npm run build:geolibre |
Build the GeoLibre ESM and CSS bundle |
npm run package:geolibre |
Build and zip the GeoLibre plugin bundle |
npm run install:geolibre |
Build and install the bundle into GeoLibre |
npm run serve:geolibre |
Serve the unpacked bundle with CORS |
npm run build:examples |
Build examples for deployment |
npm run test |
Run tests |
npm run test:ui |
Run tests with UI |
npm run test:coverage |
Run tests with coverage |
npm run lint |
Lint the code |
npm run format |
Format the code |
geolibre-plugin-template/
├── geolibre-plugin/
│ └── plugin.json # GeoLibre external plugin manifest
├── scripts/
│ ├── package-geolibre-plugin.mjs
│ ├── install-geolibre-plugin.mjs # Install the bundle into GeoLibre
│ └── serve-geolibre-plugin.mjs
├── src/
│ ├── index.ts # Main entry point
│ ├── geolibre.ts # GeoLibre plugin wrapper entry point
│ ├── react.ts # React entry point
│ ├── index.css # Root styles
│ └── lib/
│ ├── core/ # Core classes and types
│ ├── geolibre/ # GeoLibre host-plugin contract (host-api.ts)
│ ├── hooks/ # React hooks
│ ├── utils/ # Utility functions (incl. deep-link.ts)
│ └── styles/ # Component styles
├── tests/ # Test files
├── examples/ # Example applications
│ ├── basic/ # Vanilla JS example
│ └── react/ # React example
└── .github/workflows/ # CI/CD workflows
The examples can be run using Docker. The image is automatically built and published to GitHub Container Registry.
# Pull the latest image
docker pull ghcr.io/opengeos/geolibre-plugin-template:latest
# Run the container
docker run -p 8080:80 ghcr.io/opengeos/geolibre-plugin-template:latestThen open http://localhost:8080/geolibre-plugin-template/ in your browser to view the examples.
# Build the image
docker build -t geolibre-plugin-template .
# Run the container
docker run -p 8080:80 geolibre-plugin-template| Tag | Description |
|---|---|
latest |
Latest release |
x.y.z |
Specific version (e.g., 1.0.0) |
x.y |
Minor version (e.g., 1.0) |
npm login
npm whoami
npm publish --access publicSet up Trusted Publisher on npmjs.com
To use this template for your own plugin:
- Clone or fork this repository
- Update
package.jsonwith your plugin name and details - Modify
src/lib/core/PluginControl.tsto implement your plugin logic - Update the styles in
src/lib/styles/plugin-control.css - Add custom utilities, hooks, or components as needed
- Update the README with your plugin's documentation
MIT License - see LICENSE for details.