You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/docs/07-tooling-and-build.md

9.0 KiB

07 — Tooling & build

Scripts

npm run … Does
build rm -rf dist && tsc --outDir dist
typecheck tsc --noEmit, then tsc -p tsconfig.test.json
lint oxlint src test
format oxfmt '*.md' 'docs/**/*.md' 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'
test / test:watch vitest run / vitest

TypeScript 7

package.json pins typescript@^7.0.2. This choice constrains the lint setup below. tsconfig.json highlights:

  • module: ESNext, moduleResolution: bundler, target: ESNext
  • allowImportingTsExtensions + rewriteRelativeImportExtensions — source imports each other with explicit .ts extensions, and tsc rewrites them to .js in the emitted output
  • declaration + declarationMap — every module ships .d.ts + .d.ts.map
  • strict, isolatedModules, skipLibCheck
  • include: ["src/**/*"] only — test/** never reaches dist/. The root-level oxfmt.config.ts is outside this glob too, so it's never compiled. Note this glob does pull in src/index.npm.ts and src/core/globals.ts, so the whole src/ tree sees the declare global augmentations at build/typecheck time even though the JSR entry doesn't import them (see "Two registries" below).

Linting: oxlint (not ESLint)

oxlint.config.tsoxlint@^1, configured with defineConfig: plugins: ['typescript', 'unicorn', 'oxc'], categories correctness→error / suspicious+pedantic→warn, max-lines* and (in test/) max-classes-per-file turned off.

Why not @typescript-eslint: it has no released version supporting TypeScript 7 — its peer range caps at <6.1.0, and even loading @typescript-eslint/parser crashes against TS 7's package shape. oxlint has its own parser and never touches the typescript package, so it works regardless of TS version.

The tradeoff: no type-aware rules — no no-floating-promises, no no-unnecessary-condition, etc. Worth revisiting once @typescript-eslint supports TS 7.

Formatting: oxfmt

oxfmt.config.tsoxfmt (the oxc project's formatter), configured with its defineConfig default export:

export default defineConfig({
  semi: true,
  singleQuote: true,
  trailingComma: 'all',
  printWidth: 100,
  tabWidth: 2,
});
  • Same toolchain family as oxlint; single native binary, no plugin ecosystem to pin against TS 7.
  • Prettier-compatible options; the values above are Prettier's popular defaults, so running oxfmt over the existing tree is a near no-op.
  • oxfmt auto-discovers oxfmt.config.ts (search order: .oxfmtrc.json.oxfmtrc.jsoncoxfmt.config.tsoxfmt.config.mts); no --config flag needed. It reads .gitignore automatically.
  • One behavioural difference from Prettier: oxfmt formats fenced code blocks inside Markdown.
  • Pre-1.0 — expect its output to shift between releases.

Testing: Vitest + jsdom

vitest.config.tsenvironment: 'jsdom', include: test/**/*.test.ts, setupFiles: ['./test/setup.ts'].

  • test/setup.ts stubs ResizeObserver — jsdom doesn't implement it and leaflet-map.ts constructs one unconditionally, so without the stub even importing the component throws. Nothing here depends on it firing. It also imports src/core/globals.ts so the ambient HTMLElementTagNameMap / HTMLElementEventMap augmentations are in scope for tsconfig.test.json (tests import element modules directly, bypassing the npm entry that would otherwise supply them).
  • Real Leaflet objects work under jsdom for everything this library verifies: option/attribute wiring, event forwarding, child binding. No browser needed.
  • onAdd()-only state: the <img>/<video> behind an overlay, a marker's dragging handler, marker.getElement() — these exist only once the layer is added to a real map. Tests touching them append through a <leaflet-map> rather than standalone.
  • test/core/with-props.test.ts tests the mixin against a fake Leaflet-like class; the exception is child registration, which needs real Popup/Tooltip/Layer because #onChildRegister branches on instanceof.
  • test/load-order.test.ts — see 06; it's the only test reproducing real page load order.
  • test/** is typechecked separately via tsconfig.test.json (extends the main config, rootDir: '.', noEmit, adds test/**/* to include).

Build output

tsc compiles src/dist/ as individual ESM modules.js + .d.ts + .d.ts.map per source file, no bundling step. dist/elements/ and dist/components/ mirror the src/ split one-to-one.

  • Consumers import the package root (dist/index.npm.js on npm, src/index.ts on JSR — see "Publishing" below) or any single module directly.
  • No CJS, no UMD build.
  • Leaflet is always external — never bundled. It's a dependencies entry and a bare import in the output.
  • package.json exports: .dist/index.npm.js (+ types — this is the npm entry, src/index.npm.ts compiled; see below); ./elementsdist/elements/index.js (the class barrel, no define); ./elements/*.js and ./components/*.js → the matching dist/ module; ./dist/* still there for deep imports.
  • sideEffects: true — the components/* modules (and both index*.js entries) call customElements.define() at import time, so a bundler must not tree-shake them away. The elements/* modules have no side effect.

Publishing to two registries

Registry Package name Entry Ships
npm leaflet-web-components package.json ./main/module/typesdist/index.npm.* compiled dist/ + README.md + LICENSE (files field); prepublishOnly runs build
JSR @buddy/leaflet-components jsr.json exports./src/index.ts (+ ./elements) TypeScript source directly (JSR compiles per-consumer), minus jsr.json's publish.exclude

Both are pinned to the same version; keep them in step. JSR publishes src/ with its .ts import extensions intact, which JSR supports natively.

The npm/JSR entry split

The two registries resolve different entry files:

  • src/index.ts (JSR's ., and the shared base) — every element class, every helper, the load-bearing import './components/*.ts' side effects. No declare global.
  • src/index.npm.ts (npm's .) — import './core/globals.ts'; export * from './index.ts';. Compiled to dist/index.npm.js.
  • src/core/globals.ts — the two ambient declare global blocks (HTMLElementTagNameMap, HTMLElementEventMap). Imported only by src/index.npm.ts and test/setup.ts; listed in jsr.json's publish.exclude so it's not even uploaded to JSR.

Why: JSR's "no slow types" check (below) rejects declare global anywhere in the module graph reachable from jsr.json's exports. Keeping the augmentations in a module that graph never reaches lets JSR publish with full fast types while npm consumers still get querySelector('leaflet-map') / addEventListener('leaflet-register', …) typing for free through the package root. JSR (jsr:@buddy/leaflet-components) consumers don't get the ambient augmentations and supply their own if they want them.

JSR "no slow types"

JSR statically analyses the public API of what it publishes and refuses constructs it can't resolve without a full tsc run. For this package that means:

  • No declare global anywhere reachable from jsr.json's exports — handled by the entry split above.
  • No call expression as a superclass. class Foo extends WithProps({…}) is rejected; every element file uses const Base = WithProps(PROPS) then extends Base instead.
  • Explicit types on anything that leaks into the public API. const PROPS gets a written-out { key: PropDef<…>; … } annotation (or as const when every value is a bare reference); const Base gets : LeafletElementConstructor<TheLeafletClass, typeof PROPS>; the shared fragments in shared-props.ts carry their own object-type annotations; the Positional<T, Obj> alias in props.ts exists to keep those annotations short.

npx jsr publish --dry-run runs the full check offline and is the gate: it must print "Success" with zero slow-type errors before publishing. Pass --allow-dirty to run it against an uncommitted tree.