11 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 scripts |
format |
oxfmt over *.md, docs/, src/, test/, scripts/ |
test / test:watch |
vitest run / vitest |
sync-version |
copy package.json version → jsr.json (also the version lifecycle hook) |
TypeScript 7
package.json pins typescript@^7.0.2. This choice constrains the lint
setup below. tsconfig.json highlights:
module: ESNext,moduleResolution: bundler,target: ESNextallowImportingTsExtensions+rewriteRelativeImportExtensions— source imports each other with explicit.tsextensions, andtscrewrites them to.jsin the emitted outputdeclaration+declarationMap— every module ships.d.ts+.d.ts.mapstrict,isolatedModules,skipLibCheckinclude: ["src/**/*"]only —test/**never reachesdist/. The root-leveloxfmt.config.tsis outside this glob too, so it's never compiled. Note this glob does pull insrc/index.npm.tsandsrc/core/globals.ts, so the wholesrc/tree sees thedeclare globalaugmentations at build/typecheck time even though the JSR entry doesn't import them (see "Two registries" below).
Linting: oxlint (not ESLint)
oxlint.config.ts — oxlint@^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.ts — oxfmt (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
oxfmtover the existing tree is a near no-op. - oxfmt auto-discovers
oxfmt.config.ts(search order:.oxfmtrc.json→.oxfmtrc.jsonc→oxfmt.config.ts→oxfmt.config.mts); no--configflag needed. It reads.gitignoreautomatically. - 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.ts — environment: 'jsdom', include: test/**/*.test.ts,
setupFiles: ['./test/setup.ts'].
test/setup.tsstubsResizeObserver— jsdom doesn't implement it andleaflet-map.tsconstructs one unconditionally, so without the stub even importing the component throws. Nothing here depends on it firing. It alsoimportssrc/core/globals.tsso the ambientHTMLElementTagNameMap/HTMLElementEventMapaugmentations are in scope fortsconfig.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'sdragginghandler,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.tstests the mixin against a fake Leaflet-like class; the exception is child registration, which needs realPopup/Tooltip/Layerbecause#onChildRegisterbranches oninstanceof.test/load-order.test.ts— see 06; it's the only test reproducing real page load order.test/**is typechecked separately viatsconfig.test.json(extendsthe main config,rootDir: '.',noEmit, addstest/**/*toinclude).
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.json npm,src/index.tson JSR — see "Publishing" below) or any single module directly. - No CJS, no UMD build.
- The npm tarball ships
src/as well asdist/(filesfield) — the.d.ts.mapfiles reference../../src/*.ts, so shipping the source is what makes "go to definition" land on real code, and it matches what JSR publishes. - Leaflet is always external — never bundled, a bare
importin the output. It's apeerDependency(^1.9.4), not a regular dependency: the nesting protocol keys offinstanceofagainst Leaflet's ownLayer/Popup/Tooltip, so a second copy under our ownnode_moduleswould silently break it. Kept indevDependenciestoo, for local dev and tests. The Leaflet type packages (@types/leaflet,@types/geojson) are regulardependencies— the emitted.d.tsreference them directly, andleafletships no types of its own. package.jsonexports:.→dist/index.npm.js(+ types — this is the npm entry,src/index.npm.tscompiled; see below);./elements→dist/elements/index.js(the class barrel, nodefine);./elements/*.jsand./components/*.js→ the matchingdist/module. There is deliberately no./dist/*wildcard —core/*is not a stable contract, and the root export already surfaces the toolkit. The per-file./elements/*.js/./components/*.jspatterns are npm-only: JSR has no subpath-pattern support, sojsr.jsonexportsis just.+./elements.sideEffectsis an explicit allowlist —dist/index.js,dist/index.npm.js,dist/components/*.js— the only modules that runcustomElements.define()at import. Everything else (elements/*,core/*, andcore/globals.js, whose augmentation is type-only) is side-effect-free, so a bundler may drop it when unused.
Publishing to two registries
| Registry | Package name | Entry | Ships |
|---|---|---|---|
| npm | leaflet-web-components |
package.json ./main/module/types → dist/index.npm.* |
dist/ + src/ + README.md + LICENSE (files field); prepublishOnly runs sync-version + typecheck + test + 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 carry a version and must stay in step. scripts/sync-version.mjs
copies package.json's version into jsr.json; it's wired into the version
npm lifecycle script (so npm version <bump> updates and stages both) and
run again from prepublishOnly. 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-bearingimport './components/*.ts'side effects. Nodeclare global.src/index.npm.ts(npm's.) —import './core/globals.ts'; export * from './index.ts';. Compiled todist/index.npm.js.src/core/globals.ts— the two ambientdeclare globalblocks (HTMLElementTagNameMap,HTMLElementEventMap). Imported only bysrc/index.npm.tsandtest/setup.ts; listed injsr.json'spublish.excludeso 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 globalanywhere reachable fromjsr.json'sexports— handled by the entry split above. - No call expression as a superclass.
class Foo extends WithProps({…})is rejected; every element file usesconst Base = WithProps(PROPS)thenextends Baseinstead. - Explicit types on anything that leaks into the public API.
const PROPSgets a written-out{ key: PropDef<…>; … }annotation (oras constwhen every value is a bare reference);const Basegets: LeafletElementConstructor<TheLeafletClass, typeof PROPS>; the shared fragments inshared-props.tscarry their own object-type annotations; thePositional<T, Obj>alias inprops.tsexists 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.