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:

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:

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:

Edit this page on GitHub — this site is generated from CONTRIBUTING.md, which is the source of truth.