Skip to content

Maintenance Notes

This page collects the repository rules that are easy to break silently — hand-written mirrors of unexported upstream constants, coverage and audit gates, publishing layouts, and generated files. CLAUDE.md links here rather than restating any of it.

If you are just getting set up, read Contributing first.

Dependency bumps that need a manual check

Several features depend on values or DOM contracts that upstream packages do not export, so GeoLibre mirrors them by hand. Drift usually produces no build error — the feature just stops working. After bumping any of the packages below (including Dependabot PRs), do the listed check and run the frontend suite.

geolibre-wasm (packages/processing/package.json)

  • Processing menu catalog. ProcessingMenu.tsx renders from a checked-in, auto-generated catalog, apps/geolibre-desktop/src/lib/whitebox-menu-catalog.ts (do not hand-edit). Run node scripts/gen-whitebox-menu-catalog.mjs and commit the result, or new/renamed WASM tools silently miss the menu. The Processing dialog lists tools dynamically, so the gap only shows in the menu. Whitebox translations are optional external packs in opengeos/geolibre-language-packs, not entries generated into GeoLibre's bundled locale JSON.
  • MAX_VECTOR_PMTILES_ZOOM (packages/processing/src/wasm-convert.ts) mirrors the deepest zoom vector_to_pmtiles accepts (18 — past it the tool exits with validation error: max_zoom must be <= 18). The cap lives inside the WASM binary and is not exported. If it drifts, the browser's Vector to PMTiles either refuses a zoom the tiler would now accept, or accepts one it will reject after the user has waited. This is not the sidecar's cap: freestiler allows 24 (MAX_PMTILES_ZOOM in ConversionDialog.tsx, mirroring backend/geolibre_server/geolibre_server/app/conversion.py), and the dialog validates against whichever engine is about to run. tests/wasm-convert.test.ts fails if the mirror drifts.
  • DISTANCE_SEGMENTS / NON_DISTANCE_NAMES (apps/geolibre-desktop/src/lib/whitebox-distance-params.ts) decide, by parameter name, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (tolerance, radius, length, resolution), so a tool can carry a matching name that is not a length — corridor_tolerance is a 0–1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. Scan the new catalog for a double matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to NON_DISTANCE_NAMES. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance.

maplibre-gl

  • GLOBE_CONTROL_TOGGLE_SELECTOR (packages/map/src/globe-control-toggle.ts) mirrors the class names MapLibre's own GlobeControl puts on its toggle button — maplibregl-ctrl-globe and maplibregl-ctrl-globe-enabled, swapped on every projection change. MapCanvas persists a projection change from a click on that button rather than from the projectiontransition event, because style initialization and project reconciliation emit that event too and a stale one overwrites the projection of a project that has just loaded. tests/globe-control-toggle.test.ts builds a real GlobeControl and fails if the mirror stops matching.
  • Per-layer blend modes (packages/map/src/layer-blend-modes.ts) wrap three unexported maplibre-gl internals, because MapLibre renders every layer into one canvas and ships no per-layer blend API (upstream draft: maplibre/maplibre-gl-js#8073). The wrappers are Painter.prototype.renderLayer (brackets one layer's draws), Painter.prototype.useProgram (tells the layer-opacity composite draw from the draws feeding it), and Context.prototype.setColorMode (the single place every draw resolves GL blend state). Fill and line layers additionally get fill-layer-opacity / line-layer-opacity pinned just under 1 by style-mapper, which elects MapLibre 6's render-to-texture composite so a layer blends as a whole rather than once per overlapping polygon. installLayerBlendModes feature-detects every seam and disables the feature (hiding the Style-panel control) rather than breaking the map, so drift fails quietly — which is why tests/layer-blend-modes.test.ts asserts the seams and e2e/blend-modes.spec.ts asserts real pixels. Run both on a bump.

See Adding a blend mode before extending the list.

@maplibre/maplibre-gl-style-spec

propertySpecFor (packages/core/src/expressions.ts) fabricates the unexported StylePropertySpecification shape that createExpression uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so run the frontend suite — the "enforces an expected result type" test in tests/expressions.test.ts fails if the shape stops being honored.

maplibre-gl-components (packages/plugins/package.json)

  • MAP_PANEL_SELECTOR (apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx) mirrors the rendered control class names — maplibre-gl-html-control, maplibre-gl-legend, maplibre-gl-colorbar — so Record Video's "Include map panels" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately not the *-gui-control authoring editors. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error.
  • The PMTiles control's layer ids (pmtilesControlLayerId / pmtilesIdsForSourceLayers / pmtilesIdNamesSourceLayer, packages/map/src/pmtiles-layer.ts, read from layer-sync.ts and packages/plugins/src/plugins/maplibre-components.ts) mirror an unexported fact about PMTilesLayerControl: it names its MapLibre layers ${sourceId}-${name}-${kind} from the raw source-layer name, where pmtilesVectorLayerId percent-encodes it. The two agree for every name needing no encoding, so a store layer carrying the control's own ids — the archive kept whole, or a split part, which keeps the ids naming its own source layer — is recognised under the encoded scheme alone until a name holds a /, a space or non-ASCII. Then layer-sync decides the source layer has no native layer and adds a second fill/line/circle trio on top of the control's: drawn twice, and only the control's copy answers the panel. Both schemes are therefore matched, and only ids naming a source layer the store actually holds are kept.

What the user ticked is deliberately not inferred from those ids: selectedSourceLayers is a documented field of the exported PMTilesLayerControlState handed to every handler, so pmtilesLayerOptions reads it and the compiler checks it — the rules for a stale selection, and for the archive ids the control reuses across a panel close, are written at that function and at addPMTilesArchive. A reused id is the one case GeoLibre cannot repair: two archives then name one MapLibre source, the first to sync wins it and the other draws nothing, so addPMTilesArchive warns rather than pretending otherwise — while an archive that takes a layer over outright is drawn correctly, keeping the name, folder and styling of the one it replaced. tests/pmtiles-control-contract.test.ts drives a real control against a real archive, through the real layeradd handler into the store, and fails if the id scheme moves or the selection stops reaching the handler.

maplibre-gl-basemap-control (packages/plugins/package.json)

BASEMAP_PANEL_SELECTOR / BASEMAP_ROW_SELECTOR / BASEMAP_ROW_ID_ATTR (packages/plugins/src/plugins/basemap-thumbnails.ts) mirror the DOM the control renders — .basemap-control-panel, .basemap-control-result, data-basemap-id — which the Basemaps panel's thumbnails hook into to find rows and join each one back to its catalog entry. That package exports only BasemapControl/BasemapDefinition, so a renamed class fails nothing at build time: the queries stop matching and thumbnails silently stop appearing. tests/basemap-thumbnails.test.ts builds a real control and asserts its rendered panel against the mirror.

The same file's hasUnresolvedPlaceholder deliberately matches the complement of the tile tokens it substitutes rather than mirroring that package's credential placeholders ({api-key}, {aws-region}), so a new provider's placeholder is skipped instead of being fetched literally. Keep it that way rather than enumerating placeholder names.

maplibre-gl-vector (packages/plugins/package.json)

MAX_VECTOR_BYTES (packages/plugins/src/plugins/remote-file-formats.ts) mirrors MAX_REMOTE_FILE_BYTES, an internal, unexported constant in that package (2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported, so re-check src/lib/utils/remote.ts in that package and update the mirror if it moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face) silently block GeoParquet the engine could now open, or offer an Add that is certain to fail. Updating the constant is enough: the limit the user is shown is rendered from it, not written into the copy.

remote-file-formats.ts is the single home for this and the other format/reader/size rules those panels share — a per-panel copy would miss this check, so add new browse panels against that module rather than duplicating it (source-coop-api.ts re-exports it under its own names for compatibility).

maplibre-gl-raster — checked by the compiler

GeoLibreCogRenderEngine (packages/plugins/src/types.ts) mirrors the RenderEngine union that package exports (maplibre-gl-raster | cog-tiler-wasm | titiler). It is hand-written rather than imported because types.ts is the public plugin-API surface and importing there would make that package's types a hard dependency of every external plugin. Unlike the mirrors above this one is checked by the compiler: CogRenderEngineMirrorIsExact in packages/plugins/src/plugins/maplibre-raster.ts asserts both directions of assignability against the real imported type, so a renamed or dropped engine identifier fails npm run typecheck. Nothing extra to do on a bump beyond letting the build run.

Adding a blend mode

Do not add a blend mode without checking it in the browser. MapLibre's blend state covers the alpha channel too, and it composites a blended layer as one viewport-filling quad, so any mode that does not reduce to "leave the destination alone" at zero source alpha repaints the whole map. That is what disqualified darken (a MIN equation erased the entire basemap to transparent black) and subtract (a reverse subtract left the canvas at dstA - srcA, showing the page through the layer). The shipped list is BLEND_MODES in @geolibre/core, and both the unit test's blend simulator and the e2e spec pin their exclusion.

Only fill and line have a *-layer-opacity in the style spec, so only they blend as a whole layer; circle and fill-extrusion blend per symbol and visibly double-darken where symbols overlap on screen (measured under Multiply: rgb(23, 77, 220) in the overlap vs rgb(76, 136, 222) on a single symbol). That is upstream's limitation, documented in Managing Layers; the test "has a layer-level composite for fill and line only" fails if a bump adds one of the missing properties, at which point extend COMPOSITE_LAYER_TYPES and style-mapper together and drop the caveat.

The Style-panel control (blendModeControl in StylePanel.tsx, rendered in each of its terminal branches) is gated on !pluginOwnsPaint && !controlRendersLayer: blending only reaches layers GeoLibre itself paints, so anything a control renders or paints (3D Tiles, Gaussian splats, LiDAR, the COG raster control, and Add Vector Layer, which sets customLayerType and controlOwnsPaint) is excluded — layer-sync never applies fillPaint/linePaint to those, so the *-layer-opacity that elects the composite never lands and a Blend menu there would silently do nothing. Keep docs/user-guide/layers.md and tests/layer-blend-modes.test.ts ("the layer kinds the Blend control is offered for") in step with that gate; build the test's mocks the way the real controls build their metadata, or they pass on shapes that never occur.

Coverage floors

The :coverage test variants run the same suites and print a coverage summary; CI runs them so every build reports coverage. They are gated on a floor: test:frontend:coverage fails below 78% lines / 78% branches / 63% functions, and test:backend:coverage fails below 55% (--cov-fail-under). The floors sit a few points under the current numbers as a ratchet — regressions fail CI, and when coverage rises comfortably above a floor, raise the floor to lock in the gain.

The frontend report only counts files a test actually imports, so a module with no test does not appear at all rather than as 0%. That is the part that bites: writing the first test for a large untested module reads as a coverage regression, because the module and everything it imports enter the denominator at once. GeoLibre#1784 added a test that imported usePlugins.ts and so pulled in the whole built-in plugin registry, 39 files, dropping function coverage 72.90% → 60.36% and reddening main. The fix is to test against a leaf module rather than to lower the floor (GeoLibre#1888 extracted lib/plugin-layer-queries.ts; geo-editor-geometry.ts in @geolibre/plugins is the same pattern). Check what a new test transitively imports before assuming a coverage drop means the code got worse.

test:frontend:coverage runs through scripts/coverage-check.mjs rather than calling node --test directly. Node still enforces all three floors; the wrapper only re-measures once when line coverage alone comes up short with every test passing. Line coverage is nondeterministic on CI (GeoLibre#1889: two runs over byte-identical sources reported 81.82% and 76.47%, 114 of 444 files differing on lines and none on branches or functions), and it is not reproducible locally on either Node 22 or 26. Branch and function shortfalls, and any test failure, fail on the spot with no retry, so a real regression still fails fast. classify() is exported and covered by tests/coverage-check.test.ts — change the retry policy there, not by loosening a floor. If the retry starts firing regularly, fix the measurement instead of widening the mitigation.

The backend coverage run (and npm run ci, which calls the :coverage variants) needs pytest-cov from the backend dev extra. Install the test extra to run the full backend suite — without the optional engines (geopandas/rasterio/sedona/httpx) the vector/raster/SQL/ML tests skip themselves and CI is green but hollow: pip install -e "backend/geolibre_server[test]".

Dependency updates and the audit allowlist

Dependencies are watched two ways: Dependabot (.github/dependabot.yml) opens grouped weekly update PRs for npm, pip (backend + python/), cargo, and Actions, and the CI audit job runs npm run audit:ci (blocking) plus a non-blocking pip-audit of the resolved backend environment.

audit:ci is scripts/audit-check.mjs, a thin wrapper over npm audit --omit=dev that still fails on every high/critical advisory except the ones listed in its ALLOWLIST. The wrapper exists because plain npm audit cannot accept a single finding, so one unpatchable transitive advisory reddens every PR until upstream ships a fix — which for an unmaintained leaf package may be never. Only allowlist an advisory when there is no patched version to upgrade to and the vulnerable code is unreachable from a GeoLibre runtime path, and say why on both counts in the entry. Anything upgradeable gets upgraded instead. Stale entries print a warning rather than failing, since the advisory database is a live service and a transient omission must not redden an unrelated PR.

Publishing @geolibre/core and @geolibre/map

Both are published to npm by .github/workflows/publish-packages.yml on each GitHub Release, alongside @geolibre/embed. Their checked-in main/types/exports point at TypeScript source, because that is how the monorepo consumes them: Vite, tsc and tsx all resolve ./src/index.ts through the package's own exports, so npm run dev and node --import tsx --test tests/<name>.test.ts need no build step.

The npm tarball ships dist instead, and npm cannot express that split on its own: unlike pnpm and Yarn it deliberately ignores entry fields nested under publishConfig (npm/cli#7586), so a manifest that only states its dist entries there publishes ./src/index.ts to consumers who never receive src. The published entries therefore live under publishConfig, and scripts/prepare-npm-package.mjs hoists them (and pins the "*" @geolibre/core dependency to the release version) just before npm publish.

Point those top-level fields at dist and every frontend test that imports a @geolibre/map subpath fails with ERR_MODULE_NOT_FOUND, because dist is gitignored and nothing builds it before the suite. tests/prepare-npm-package.test.ts guards both halves, including that each published path is one the package's own tsdown entries actually emit (--format esm --dts writes <entry>.mjs and <entry>.d.mts, not .d.ts). The release workflow does build both packages, but a green build proves only that the bundles were written, not that every path the manifest publishes names one of them, so nothing else would notice that drift.

The bundled sidecar lockfile

backend/geolibre_server/uv.lock is committed (the root .gitignore ignores uv.lock everywhere else and negates it for this one path). That project is bundled into the desktop installers and launched with uv run --frozen --project <resource dir> from src-tauri/src/lib.rs — a directory the user cannot write (C:\Program Files\…, /usr/lib/GeoLibre Desktop/…). Ship it lockless and uv resolves, then tries to write uv.lock there, fails with "Permission denied" and exits 2 — which reaches the user as "Jupyter server exited before it was ready (exit code: 2)" with the cause invisible.

So: any edit to that pyproject.toml's dependencies must land with a refreshed lock (uv lock --project backend/geolibre_server). CI's "Check the bundled sidecar lockfile is in sync" step (uv lock --check) fails if they drift.

Generated files and cross-file sync

  • Processing tool metadata. Names, descriptions, group labels, parameter labels/help and select options live in registries with no i18n access, so the dialogs resolve them through apps/geolibre-desktop/src/lib/processing-tool-i18n.ts and fall back to the registry's own English string. For the four small bundled registries, en.json's processing.toolMeta/processing.toolGroup subtrees are the generated baseline translators work from. After adding or renaming one of those tools, parameters, or select options, run npm run i18n:tools and commit the result; CI fails on drift. Whitebox's much larger metadata is deliberately absent from all bundled locales and comes from optional, validated packs at languages.geolibre.app (or local file import); do not add processing.toolMeta.whitebox, processing.whitebox.categories, menuTool, or menuSubcategory back to a bundled locale.
  • The agent skill. skills/geolibre/ is a user-facing agent skill — a SKILL.md plus references/ that teaches an external AI agent to author .geolibre.json projects through geolibre-mcp, the Python package, or hand-written JSON. It is not for contributors working on GeoLibre itself. It restates things that live elsewhere: the MCP tool surface (python/src/geolibre/mcp/server.py), the basemap/color-ramp/legend catalogs (python/src/geolibre/basemaps.py, color_ramp.py, legends.py), the project schema (Project Format), and the embed parameters (Embedding). python/tests/test_agent_skill.py guards the parts that can be checked mechanically: every registered MCP tool must appear in the tool reference, every tool and Map method the skill names must exist, and the basemap, color-ramp, legend-preset, layer-type, frontmatter, and reference-file lists must match their sources. It cannot check prose, so a changed size cap, limit, or behavioral caveat still has to be carried over by hand — update the skill in the same PR. A stale caveat sends an agent down a path that no longer works, with no failure anywhere.