Contributing
Thank you for reading this before opening a PR. The rules below are short, and every one of them is load-bearing — each exists because breaking it is how a library stops being extensible.
Getting set up
npm install # at the ROOT. Workspaces link on install.
npm run verify # scaffold:check → boundaries → typecheck (src, examples, tests) → lint
# → doc fences → test → build
npm run format:check # CI runs this too — `verify` alone is not enough for a green PR
CI runs both of those and then four more jobs: the maplibre peer-range matrix (4.7.0, ^5, ^6, type-check only), a pack-and-consume job that builds every tarball and installs it, the real-browser suite, and — nightly rather than per-PR, because it takes minutes — mutation testing.
Individually:
npm run lint:boundaries # package-dependency rules (below)
npm run scaffold:check # manifests still match the generator (first step of verify)
npm run typecheck # tsc --build, project references, incremental
npm run typecheck:examples
npm run typecheck:tests # the test files, which the build graph does not cover
npm run lint # eslint
npm run format # prettier --write
npm run format:check # what CI checks
npm run check:docs # type-checks every ts fence in the docs that has an import
npm test # vitest, all packages, headless — the node suite
npm run test:coverage # v8 coverage; reported, not thresholded
Three further suites live outside verify, because they are slow and a gate people learn to
skip is worse than no gate: npm run test:browser (real MapLibre in headless Chromium — the
renderer only), npm run bench (store hot-path benchmarks; read the ratios, not the
absolutes), and npm run test:mutation (Stryker over store/, crs/, layers/ and
commands/, with break set as a ratchet at the measured floor).
Tests and typecheck resolve @blaeu/* to source, not to dist — via the paths map in
tsconfig.base.json and the matching aliases in vitest.config.ts (and
vitest.browser.config.ts). The exports map in each package points at dist and is only for
consumers; there is deliberately no development condition, because when published it would
resolve a consumer to ./src, which files never ships. So there is no build step in the inner
loop, and a type error in the core surfaces in a plugin's test run immediately.
The boundary rules
packages/
core/ @blaeu/core ← depends on NOTHING in this repo
plugin-*/ @blaeu/plugin-* ← peer-depends on core only
preset-*/ @blaeu/preset-* ← depends on core + plugins
The arrows only ever point left. npm run lint:boundaries fails the build on:
- a
core → pluginorcore → presetimport, - a
plugin → pluginimport, - a plugin or preset listing
@blaeu/coreunderdependenciesinstead ofpeerDependencies, - a plugin or preset that does not declare
@blaeu/coreas a peer at all — npm can then silently install a second copy instead of warning about a mismatch.
The first is core invariant 1: if the core needs to know something a plugin knows, the plugin registers it and the core calls it through an interface the core owns. Wanting to import a plugin into the core always means the same thing — the core is missing an extension point. Add the extension point.
The second is the same rule one tier down. The draw plugin does not import the snap plugin; a
plugin that needs another plugin's API asks for it by id (ctx.tryPlugin('snap')) and
degrades if it is absent. If your plugin cannot degrade, declare a hard dependency
({ id: 'snap' }, no optional) — but think first about whether you have picked the wrong
extension point.
The last two look pedantic and are not. Two copies of @blaeu/core in a user's
node_modules means two event buses, two command buses, two stores. Nothing throws. The
plugin silently never receives an event, and someone loses a day to it. If you ever triage an
issue that says "my listener never fires", check for a duplicate core before anything else.
The three tests every plugin owes
Non-negotiable. A plugin without them is not reviewable, because the three properties they assert are exactly the three that cannot be established by reading the code.
Use the headless harness. It is a real kernel, a real store, real plugins, real pipelines; a fake renderer and a stub container.
import { createTestMap } from '@blaeu/core/testing'
1. Degradation — an optional dependency really is optional
{ id: 'snap', optional: true } means the plugin works without it, not that it crashes
politely. An "optional" dependency with no test proving the map works without it is a required
dependency with a bug.
it('draws without the snap plugin present', async () => {
const map = await createTestMap({ plugins: [drawPlugin({ collection: 'parcels' })] }) // no snap
map.tools.activate('draw:polygon')
map.test.click([32.85, 39.93])
map.test.click([32.851, 39.93])
map.test.click([32.851, 39.931])
map.plugin('draw').finish()
await map.test.flush() // the commit pipeline is async — see ADR 0004
expect(map.store.collection('parcels').size).toBe(1)
})
Guard with ctx.tryPlugin('snap')?.…, never with a bare ctx.plugin('snap'), which throws.
2. Teardown — removing the plugin leaks nothing
A plugin that registers a listener without putting it in ctx.disposables leaks it forever,
and worse, a re-registered plugin then runs its handler twice.
it('leaks nothing on removal', async () => {
const map = await createTestMap({ plugins: [drawPlugin()] })
const before = map.debug.snapshot()
await map.remove('draw')
const after = map.debug.snapshot()
expect(after.listeners).toBe(before.listeners) // no orphaned subscriptions
expect(after.middleware).toBe(before.middleware)
expect(after.layers).toBe(before.layers)
expect(after.plugins).toBe(before.plugins - 1)
})
map.debug.snapshot() returns { listeners, middleware, layers, plugins, features }. It
exists for this test.
3. Undo round-trip — the one that catches real bugs
it('round-trips every command', async () => {
const map = await createTestMap({
plugins: [editPlugin(), historyPlugin()],
features: { parcels: sharedEdgeParcels() },
})
const before = map.store.snapshot()
map.plugin('edit').move(['parcel-left'], [1.5, 0]) // metres in the working CRS
await map.test.flush()
expect(map.store.snapshot()).not.toEqual(before)
map.plugin('history').undo()
await map.test.flush()
expect(map.store.snapshot()).toEqual(before) // deep equality, no tolerance
})
Both flushes are load-bearing, not defensive. The commit pipeline is asynchronous by design
(ADR 0004), and history records in onDidExecute — so without the first flush the move has
not landed in the store, and without it the command is not on the undo stack when undo() is
called. Drop either one and the test fails.
If undo cannot restore deep equality, the command captured too little state. Do not
loosen the assertion — fix the command. A command that captures what it was asked to do can
undo approximately; only one that captures what the store actually did (the minted ids, the
stamped meta) can undo exactly.
Assertions on coordinates
Use a metric tolerance, never a decimal-places one:
import { expectWithinMetres } from '@blaeu/core/testing'
import type { LngLat } from '@blaeu/core'
declare const actual: LngLat
declare const expected: LngLat
expectWithinMetres(actual, expected, 0.001) // 1 mm
toBeCloseTo(lng, 6) means a different distance at 39°N than at 60°N, which makes it a
latitude-dependent flake generator.
Prefer fixtures that are nasty, because nasty is what production sends: sharedEdgeParcels()
(two parcels sharing a boundary exactly), sliverParcels() (0.4 mm apart — snapping and the
topology index must treat these as one corner), selfIntersectingRing(),
duplicateVertexRing().
Do not assert on MapLibre's internal source or layer JSON. That is testing MapLibre, and it
breaks on their minor releases. The renderer contract is the boundary: assert that we call it
correctly (spy on FakeRenderer), not what MapLibre does afterwards.
When a change needs an ADR
Any change to a contract needs an ADR in docs/adr/, in the same PR. Concretely:
- anything in
packages/core/src/types/— every plugin in every downstream product implements against those interfaces, - a new extension point, or a change to how an existing one is invoked,
- a change to the merge semantics of
composePresets, - a change to the
Commandcontract, the pipeline contracts, or theRendererinterface, - swapping a load-bearing dependency (a geometry engine, the renderer).
An ADR is four sections, and the third is the one that makes it worth writing:
## Context — what forced a decision. The constraint, not the solution.
## Decision — what we chose, stated flatly, with the code that expresses it.
## Alternatives rejected — what we turned down, and WHY.
## Consequences — good and bad. Name the bad ones; every design has them.
An ADR without rejected alternatives is just a description. The next person's real question is never "what did you do", it is "did you think about X" — and the only way to answer that a year later is to have written down that you did, and what was wrong with it.
A fifth section earns its place where a decision was forced by a bug: the test that would have caught it (ADR 0009 and 0013 both carry one).
Adding a file needs no ADR. Adding an entry point to a package's exports does: it is a
public API surface with a versioning consequence, forever.
House style
TypeScript strict, with noUncheckedIndexedAccess, exactOptionalPropertyTypes and
verbatimModuleSyntax. ESM, .js extensions on relative imports, import type for types.
Prettier: no semicolons, single quotes, 100 columns, trailing commas. Private fields use #.
Comments explain WHY, never WHAT. The code already says what it does. A comment earns its
place by recording the reason a line is the way it is — the bug it prevents, the alternative
that was tried, the number that is not arbitrary. Read almost any file in packages/core for
the register we are aiming at.
Error messages are documentation that arrives at the worst moment. Say what went wrong, why it matters, and what to do instead:
throw new Error(
`[blaeu] plugin "${plugin.id}" is already installed (or still installing). ` +
`Two instances would each register their listeners and layers, and you would see every action happen twice. ` +
`If a composed preset lists "${plugin.id}" twice, include it once.`,
)
The third sentence is the one doing the work: it names the situation the reader is almost certainly in and tells them what to change.
Releases
Changesets. Run npm run changeset on every user-visible change and describe it in the terms
a user experiences, not the terms the diff does. On merge to main, the action opens (or
updates) a "Version Packages" PR; merging that publishes.
All twelve packages move in lockstep (fixed: [["@blaeu/*"]] in .changeset/config.json).
A plugin peer-depends on @blaeu/core with a caret range and the presets depend on the plugins
the same way, so releasing the core without the plugins produces a matrix of version pairs
nobody has ever run together. One version for the whole kernel is the only claim we can stand
behind.
One trap, measured rather than assumed: with a fixed group on 0.x packages, a minor
changeset does not produce 0.2.0 — it produces 1.0.0. Changesets cannot keep a group
aligned across a 0.x minor, so it escalates. Keep every changeset patch while the API is
still moving, and take 1.0.0 deliberately rather than on the way past. The four example apps
sit in the config's ignore list: private: true stops them publishing but not being
versioned, and the @blaeu/* glob was bumping them and writing them a changelog each.
Core is versioned strictly: a change to a public interface in packages/core/src/types/ is
a major, no matter how small it looks.
Two things the release does not let you skip: npm run verify runs first, because a release
that skipped the gate is the one build nobody checked and the one that reaches other people's
machines; and every package publishes with npm provenance, so "which commit built this
tarball" has an answer.
The generator holds no copy of the version number: scripts/scaffold-packages.mjs reads it out
of packages/core/package.json and derives the plugins' caret peer range from it. So
changeset version, which rewrites all twelve manifests, cannot desync scaffold:check.
Adding a package
Package manifests are generated, not hand-written: scripts/scaffold-packages.mjs holds
the list of workspace packages and emits every package.json, tsconfig.json and
tsup.config.ts from one template. That is what keeps the exports map, the dependency
versions and — above all — the peer-dependency rule consistent across twelve packages.
So: add your package to the list in that script, run it, and then wire the three things it does
not own — the paths entry in tsconfig.base.json, without which npm run typecheck cannot
resolve your imports; the @blaeu/* alias in vitest.config.ts, without which your tests will
resolve dist instead of source; and, only if the package needs browser coverage, the smaller
alias list in vitest.browser.config.ts. The root workspaces array is already packages/*,
so nothing is needed there. Check the diff.
(There is no new-package script — npm run scaffold regenerates manifests only, and creates
neither a src/ nor any tests. A generator that scaffolds a new plugin with the three tests
above already wired and already failing would be a good first PR: a package that starts with
three failing tests gets them passing; one that starts with zero tests ships with zero tests.)
Then, before you open the PR:
@blaeu/coreis a peerDependency, never a dependency."sideEffects": false, so it tree-shakes.- Named export
xPlugin(), plus theApiandOptionstypes. - The
BlaeuPluginRegistryaugmentation, somap.plugin('your-id')needs no cast. Skipping this is the single most common way a plugin ends up feeling second-class. Do it even for tiny plugins. - A README saying what it registers, what it depends on, and what events it emits.
CONTRIBUTING.md, which is the source of truth.