docs: add design docs breaking down architecture and major decisions
Eight docs under docs/ covering the current design: the WithProps mixin and lifecycle, the PropDef/attribute model, the leaflet-register bubbling protocol, generic event forwarding and its typing, per-component special cases, the load-bearing export order in src/index.ts, and the toolchain (TS 7, oxlint, oxfmt, Vitest, the no-bundle build, dual npm/JSR publish). Also wire docs/**/*.md into the format script and point at docs/ from CLAUDE.md and README.md.main
parent
449767335b
commit
2c77784345
@ -0,0 +1,82 @@
|
|||||||
|
# 02 — Props & attributes
|
||||||
|
|
||||||
|
`src/core/props.ts` defines the model; `src/core/shared-props.ts` bundles the
|
||||||
|
fragments components reuse. `WithProps` (`src/core/with-props.ts`) is the only
|
||||||
|
consumer — components declare a table and never touch the plumbing.
|
||||||
|
|
||||||
|
## The `PropDef`
|
||||||
|
|
||||||
|
Every element property is one `PropDef`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `default` | Value when the attribute is absent. **Must equal Leaflet's own default** — an absent attribute is left out of the options object entirely, so it's Leaflet's default that actually takes effect. `default` only feeds the property getter's fallback. |
|
||||||
|
| `decode(raw)` | attribute string → value |
|
||||||
|
| `encode(value)` | value → attribute string, or `null` to _remove_ the attribute (which restores Leaflet's default) |
|
||||||
|
| `attribute?` | Override the derived kebab-case name. A function form receives the property name (used by `disabled()` to prefix `disable-`). |
|
||||||
|
| `option?: false` | Set only through `positional()`. Marks a value the Leaflet **constructor takes as an argument** (coordinates, url, bounds, GeoJSON data), so it's excluded from the options object and from `PropOptionValues`. |
|
||||||
|
| `set?(obj, value, el)` | Push a new value into the live object. Omitted ⇒ the mixin calls the naming-convention setter (`radius` → `obj.setRadius`) if it exists, else no-op. |
|
||||||
|
| `get?(obj)` | Read the live value back out. Used by the property getter and by two-way sync. |
|
||||||
|
| `event?` | Leaflet event after which `get` is re-read and written to the attribute (`move` keeps `lat`/`lng` current during a drag). Only meaningful together with `get`. |
|
||||||
|
|
||||||
|
## Codec factories
|
||||||
|
|
||||||
|
Instead of writing `decode`/`encode` by hand, components call a factory:
|
||||||
|
|
||||||
|
| Factory | Attribute semantics |
|
||||||
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `num(default?, opts?)` | `Number` ↔ `String` |
|
||||||
|
| `str(default?, opts?)` | identity both ways |
|
||||||
|
| `choice<T>(default, opts?)` | like `str`, but typed as a string union (`ControlPosition`, `CrossOrigin`, tooltip `Direction`); **no runtime validation** — the point is that the options object comes out with the type Leaflet's constructor expects |
|
||||||
|
| `bool(default?, opts?)` | present ⇒ `true`, `="false"` ⇒ `false`, absent ⇒ `default`. `encode` returns `null` when the value equals the default, so the attribute only appears when it's doing something. Use `bool(true)` for options Leaflet defaults on, so `<leaflet-popup auto-pan="false">` can turn them off. |
|
||||||
|
| `disabled(opts?)` | the inverse of `bool(true)`: attribute named `disable-<kebab>`, `<leaflet-map disable-dragging>` reads as `dragging === false` |
|
||||||
|
| `json<T>(default, opts?)` | `JSON.parse` ↔ `JSON.stringify` — bounds, icon sizes/anchors, GeoJSON data |
|
||||||
|
|
||||||
|
`positional(def)` wraps any of the above to set `option: false`.
|
||||||
|
|
||||||
|
## Shared fragments
|
||||||
|
|
||||||
|
`src/core/shared-props.ts` — spread these into a `PROPS` table:
|
||||||
|
|
||||||
|
- **`latLngProps`** (`lat`, `lng`) — the coordinate pair, shared by marker,
|
||||||
|
circle, circle-marker, popup, tooltip. Both are `positional` (passed to the
|
||||||
|
constructor). Because they travel together, each one's `set` re-issues
|
||||||
|
`obj.setLatLng([...])` using the _other_ axis read off the host element
|
||||||
|
(`el.lat` / `el.lng`). Both name `event: 'move'` for write-back, which is
|
||||||
|
what keeps the attributes live while a marker is dragged.
|
||||||
|
|
||||||
|
- **`pathProps`** — every SVG style option a Leaflet `Path` accepts. The
|
||||||
|
mutable ones (`color`, `weight`, `opacity`, `fill*`, …) use a `style(key)`
|
||||||
|
helper whose `set` calls `obj.setStyle({ [key]: value })`, because Leaflet
|
||||||
|
exposes these only through `setStyle`. The constructor-only tail
|
||||||
|
(`className`, `interactive`, `pane`, …) has no `set` and so no post-create
|
||||||
|
effect.
|
||||||
|
|
||||||
|
- **`tileLayerProps`** — the `GridLayer`/`TileLayer` options common to
|
||||||
|
`leaflet-tile-layer` and `leaflet-tile-layer-wms` (WMS options extend tile
|
||||||
|
layer options). Almost all constructor-only. `referrerPolicy` is a
|
||||||
|
hand-written `PropDef` rather than `choice()` because Leaflet's
|
||||||
|
`ReferrerPolicy` type has no "unset" member — its absent-attribute fallback
|
||||||
|
is `undefined`.
|
||||||
|
|
||||||
|
- **`urlProp`** — the positional source URL for `TileLayer` / `ImageOverlay`
|
||||||
|
/ `VideoOverlay`. Its `set` ignores a blank value so clearing the attribute
|
||||||
|
can't request an empty tile URL. No `get` — none of those classes expose
|
||||||
|
`getUrl()`.
|
||||||
|
|
||||||
|
- **`getBounds(obj)`** — a shared `get` returning `[[s,w],[n,e]]` (matching
|
||||||
|
the JSON attribute shape) rather than a `LatLngBounds` instance.
|
||||||
|
|
||||||
|
## Two-way sync and cycle prevention
|
||||||
|
|
||||||
|
1. User sets a property → setter encodes it onto the attribute.
|
||||||
|
2. `attributeChangedCallback` fires → prop's `set` (or the convention setter)
|
||||||
|
pushes it into the live object.
|
||||||
|
3. Leaflet mutates and fires an event (e.g. `move`).
|
||||||
|
4. The mixin's per-event listener reads `get(obj)` for every prop naming that
|
||||||
|
event and writes it back to the attribute — but does so with the private
|
||||||
|
`#syncing` flag set, so the `attributeChangedCallback` this write triggers
|
||||||
|
returns immediately instead of looping back to step 2.
|
||||||
|
|
||||||
|
`#syncing` is the single guard. `attributeChangedCallback` also bails when
|
||||||
|
`oldValue === newValue`, and when the element isn't connected yet.
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
# 03 — Component tree & registration
|
||||||
|
|
||||||
|
Components never read each other's properties and never query the DOM for
|
||||||
|
their relatives. They join the tree by **dispatching DOM events that bubble**,
|
||||||
|
and a parent claims a child by handling the event. This replaces the
|
||||||
|
imperative parent/child wiring Leaflet normally needs.
|
||||||
|
|
||||||
|
## The `leaflet-register` protocol
|
||||||
|
|
||||||
|
`src/core/register.ts`. On connect, unless `attach: 'none'`, a component
|
||||||
|
calls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
registerWithParent(el, leafletObject);
|
||||||
|
// dispatches CustomEvent('leaflet-register', {
|
||||||
|
// detail: { leafletObject, element: el }, bubbles: true, composed: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
The event bubbles up the DOM. The nearest ancestor component that is in
|
||||||
|
`attach: 'children'` mode has a `leaflet-register` listener; its
|
||||||
|
`#onChildRegister` handler (`with-props.ts`) inspects
|
||||||
|
`detail.leafletObject` by `instanceof` and:
|
||||||
|
|
||||||
|
- `Popup` → `obj.bindPopup(child)`, `stopPropagation()`
|
||||||
|
- `Tooltip` → `obj.bindTooltip(child)`, `stopPropagation()`
|
||||||
|
- `Layer` **and** the parent has `addLayer` → `obj.addLayer(child)`,
|
||||||
|
`stopPropagation()`
|
||||||
|
- otherwise: let it keep bubbling
|
||||||
|
|
||||||
|
Discrimination is by `instanceof`, not duck typing — which is why the
|
||||||
|
child-registration tests need real `Popup`/`Tooltip`/`Layer` instances.
|
||||||
|
|
||||||
|
If nothing claims it, the event reaches `<leaflet-map>`, whose `#onRegister`
|
||||||
|
calls `e.detail.leafletObject.addTo(this.map)` and stops it. The map is the
|
||||||
|
terminus; nothing can register before it exists, which is why it's exported
|
||||||
|
first (see [06](./06-load-order.md)).
|
||||||
|
|
||||||
|
`composed: true` lets the event cross the map's shadow boundary.
|
||||||
|
|
||||||
|
### Un-registration
|
||||||
|
|
||||||
|
When a child disconnects, the mixin's `#destroyObject()` calls
|
||||||
|
`obj.remove()`, which detaches it from whatever it was added to. When a
|
||||||
|
_parent_ disconnects, `#releaseChildren()` walks the entries it adopted and
|
||||||
|
calls `unbindPopup` / `unbindTooltip` / `removeLayer` for each.
|
||||||
|
|
||||||
|
`recreateLeafletObject()` re-runs `registerWithParent` — that call otherwise
|
||||||
|
happens only once, from `connectedCallback` — so a recreated layer (e.g. a
|
||||||
|
WMS layer that picked up a child CRS) still gets re-added to its parent.
|
||||||
|
|
||||||
|
## `attach` modes
|
||||||
|
|
||||||
|
Passed as `WithProps(PROPS, { attach })`. Default `'children'`.
|
||||||
|
|
||||||
|
| Mode | Registers with parent | Adopts registering descendants | Used by |
|
||||||
|
| ------------ | --------------------- | ------------------------------ | -------------------------------------------------- |
|
||||||
|
| `'children'` | yes | yes (layers, popups, tooltips) | every layer type, groups |
|
||||||
|
| `'self'` | yes | no | `leaflet-popup`, `leaflet-tooltip`, controls |
|
||||||
|
| `'none'` | no | no | `leaflet-map` (the root), icons (not tree members) |
|
||||||
|
|
||||||
|
## The other announcement events
|
||||||
|
|
||||||
|
The `leaflet-register` protocol only carries `Layer` subclasses. Three other
|
||||||
|
relationships use the same bubble-and-claim shape with their own event types,
|
||||||
|
so a **plain custom element** (no base class) can participate just by firing
|
||||||
|
the right event:
|
||||||
|
|
||||||
|
| Event | Fired by | Claimed by | Purpose |
|
||||||
|
| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| `icon-changed` (`{ icon: Icon \| null }`) | `leaflet-icon`, `leaflet-div-icon`, on connect / disconnect | `leaflet-marker` | `marker.setIcon(icon ?? new Icon.Default())` |
|
||||||
|
| `leaflet-line-sync` (`{ element, latlng }`) | `leaflet-line`, on connect and every `lat`/`lng` change | `leaflet-polygon`, `leaflet-polyline` | feed the `VertexTracker` |
|
||||||
|
| `leaflet-line-remove` (`{ element }`) | `leaflet-line`, on disconnect (from its _cached_ parent — see [05](./05-special-cases.md)) | same | drop the vertex |
|
||||||
|
| `leaflet-crs-changed` (`{ crs: CRS \| null }`) | any element nested in a CRS-accepting component, on connect | `leaflet-tile-layer-wms` | supply a `CRS` Leaflet doesn't ship by name; `null` reverts to the component's default |
|
||||||
|
|
||||||
|
`null` in a payload consistently means "revert to the host component's own
|
||||||
|
default", mirroring `icon-changed`.
|
||||||
|
|
||||||
|
One more internal event rides the same bubble: `leaflet-add-layer` /
|
||||||
|
`leaflet-remove-layer` (`{ layer }`), dispatched only by
|
||||||
|
`leaflet-control-layers` for an `active` entry and handled only by
|
||||||
|
`leaflet-map` (`map.addLayer` / `removeLayer`). See [05](./05-special-cases.md).
|
||||||
|
|
||||||
|
## Why events, not lookups
|
||||||
|
|
||||||
|
- No load-order coupling between a parent and the DOM query it would
|
||||||
|
otherwise run (a child may upgrade before or after its parent).
|
||||||
|
- A child at any nesting depth works without the parent knowing the shape of
|
||||||
|
the subtree.
|
||||||
|
- Third-party elements can nest into the tree without importing anything —
|
||||||
|
just dispatch the documented event.
|
||||||
|
|
||||||
|
The one cost is the load-order constraint in [06](./06-load-order.md): a
|
||||||
|
one-shot connect-time announcement is lost if the listening parent isn't
|
||||||
|
defined yet.
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
# 04 — Events
|
||||||
|
|
||||||
|
## Forwarding: every Leaflet event → `leaflet:<type>`
|
||||||
|
|
||||||
|
`#forwardEvents()` in `src/core/with-props.ts`. Leaflet has no wildcard
|
||||||
|
listener, so the mixin wraps the instance's own `fire()` method (safe — it's
|
||||||
|
an object we created and hold alone):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
target.fire = (type, data, propagate) => {
|
||||||
|
const result = originalFire.call(obj, type, data, propagate);
|
||||||
|
const sourceTarget = data?.sourceTarget ?? obj;
|
||||||
|
const detail = { ...data, type, target: obj, sourceTarget };
|
||||||
|
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail }));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties of this:
|
||||||
|
|
||||||
|
- **Generic** — no per-component or per-event-type registration. Any event
|
||||||
|
Leaflet fires on the object is re-emitted.
|
||||||
|
- **`detail` mirrors Leaflet's own `Evented#fire` merge** — original event
|
||||||
|
data plus `type` / `target` / `sourceTarget` — so a `leaflet:click`
|
||||||
|
listener's `event.detail` is exactly what a real `map.on('click', …)`
|
||||||
|
handler would receive. This also keeps Leaflet's typed event interfaces
|
||||||
|
(`LeafletMouseEvent`, etc.) honest as the `detail` type.
|
||||||
|
- **Dispatched _after_ Leaflet's own handlers** run, so any attributes the
|
||||||
|
two-way sync updated in response to the same event are already current when
|
||||||
|
the DOM event fires.
|
||||||
|
- **Not bubbling.** Leaflet already propagates layer events up to the map
|
||||||
|
internally, so a bubbling DOM event would make `<leaflet-map>` see each one
|
||||||
|
twice.
|
||||||
|
|
||||||
|
## Typing: `event-types.ts`
|
||||||
|
|
||||||
|
`src/core/event-types.ts` holds small reusable **event-name → payload-type**
|
||||||
|
fragments, mirroring how `shared-props.ts` shares prop fragments:
|
||||||
|
|
||||||
|
```
|
||||||
|
MouseEvents click/dblclick/mousedown/… → LeafletMouseEvent
|
||||||
|
MoveEvents movestart/move/moveend → LeafletEvent
|
||||||
|
DragEvents dragstart/drag → LeafletEvent, dragend → DragEndEvent
|
||||||
|
PopupBindEvents popupopen/popupclose → PopupEvent
|
||||||
|
TileEvents loading/load/tileload/tileerror/…
|
||||||
|
LayerGroupEvents layeradd/layerremove → LayerEvent
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
composed into one type per family:
|
||||||
|
|
||||||
|
| Family type | = | Applied by |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------ |
|
||||||
|
| `BaseLayerEvents` | `LayerAddRemoveEvents & PopupBindEvents & TooltipBindEvents` | (building block) |
|
||||||
|
| `PathEvents` | `BaseLayerEvents & MouseEvents` | circle, polygon, polyline, rectangle |
|
||||||
|
| `MarkerEvents` | `BaseLayerEvents & MouseEvents & MoveEvents & DragEvents` | marker |
|
||||||
|
| `TileLayerEvents` | `BaseLayerEvents & TileEvents` | tile-layer, tile-layer-wms |
|
||||||
|
| `DivOverlayLayerEvents` | `LayerAddRemoveEvents & MouseEvents & DivOverlayEvents` | popup, tooltip |
|
||||||
|
| `GroupEvents` | `LayerAddRemoveEvents & LayerGroupEvents` | layer-group, feature-group, geojson |
|
||||||
|
| `MapEvents` | move + mouse + popup/tooltip + zoom + resize + location + keyboard + … | map |
|
||||||
|
|
||||||
|
A component narrows its listener types with a `declare` field — the same
|
||||||
|
zero-runtime idiom as `declare readonly leafletObject?: Marker`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class LeafletCircle extends WithProps(PROPS) {
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Components that fire nothing meaningful (controls, icons, `leaflet-line`)
|
||||||
|
skip this and keep the default `HTMLElementEventMap` typing.
|
||||||
|
|
||||||
|
### Why no string fallback overload
|
||||||
|
|
||||||
|
`LeafletAddEventListener` / `LeafletRemoveEventListener` (defined in
|
||||||
|
`with-props.ts`) deliberately have **no** generic `(type: string, …)`
|
||||||
|
overload, unlike the real DOM API. A fallback would silently accept any
|
||||||
|
misspelled `leaflet:*` name, which defeats the purpose of typing this. The
|
||||||
|
cost: a genuinely dynamic (non-literal) event-name string needs a cast.
|
||||||
|
|
||||||
|
### Keep `detail` honest
|
||||||
|
|
||||||
|
If you change what a component fires, keep `#forwardEvents`'s merge shape in
|
||||||
|
mind — don't type an event's `detail` against a Leaflet interface it wouldn't
|
||||||
|
actually match at runtime.
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
# 06 — Load order
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
**Export order in `src/index.ts` is load-bearing.** A component that listens
|
||||||
|
for a bubbling announcement from another tag must be exported — and therefore
|
||||||
|
`customElements.define()`d — **before** that other tag.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`customElements.define(name, Class)` upgrades every matching element already
|
||||||
|
in the document **immediately and synchronously**, running
|
||||||
|
`connectedCallback` right then for elements that are already connected.
|
||||||
|
|
||||||
|
On a real static-HTML page the markup is fully parsed before the deferred
|
||||||
|
module script runs, so at `define()` time the elements already exist. The
|
||||||
|
first `define()` for a given tag wins the race:
|
||||||
|
|
||||||
|
- If a **child** tag is defined before its listening **parent** tag, every
|
||||||
|
instance of that child in the page upgrades and fires its one-shot
|
||||||
|
connect-time announcement (`leaflet-register`, `icon-changed`,
|
||||||
|
`leaflet-line-sync`, `leaflet-crs-changed`) into a parent element that has
|
||||||
|
no listener attached yet — because the parent class isn't defined, so its
|
||||||
|
`connectedCallback` hasn't run.
|
||||||
|
- That announcement is **gone for good**: `connectedCallback` does not fire
|
||||||
|
again for an element that stays connected.
|
||||||
|
|
||||||
|
So parents must be defined first.
|
||||||
|
|
||||||
|
## The ordering in `src/index.ts`
|
||||||
|
|
||||||
|
Root to leaf (this is the comment at the top of the file, kept in sync):
|
||||||
|
|
||||||
|
```
|
||||||
|
LeafletMap ← nothing can register before the root exists
|
||||||
|
LeafletControlLayers, LeafletLayerGroup,
|
||||||
|
LeafletFeatureGroup ← listen for leaflet-register from arbitrary layers
|
||||||
|
every concrete layer type (Marker, Circle,
|
||||||
|
Polygon, TileLayer, …)
|
||||||
|
LeafletLine ← child of Polygon/Polyline
|
||||||
|
LeafletPopup, LeafletTooltip ← child of any layer
|
||||||
|
LeafletIcon, LeafletDivIcon ← child of Marker
|
||||||
|
```
|
||||||
|
|
||||||
|
Standalone controls (`LeafletControlZoom`, `LeafletControlAttribution`,
|
||||||
|
`LeafletControlScale`) have no such relationship and can go anywhere.
|
||||||
|
|
||||||
|
The core re-exports (`register.ts`, `props.ts`, `with-props.ts`,
|
||||||
|
`shared-props.ts`, `event-types.ts`) come first and carry no
|
||||||
|
`customElements.define`, so their order is free.
|
||||||
|
|
||||||
|
## The `HTMLElementTagNameMap` augmentation
|
||||||
|
|
||||||
|
At the bottom of `src/index.ts`. Because `export { X } from '…'` re-exports
|
||||||
|
`X` without binding it locally, the tag map needs its **own** `import type` of
|
||||||
|
every component class alongside the re-export. Those imports are type-only,
|
||||||
|
erased at build, back no `define()` — their order doesn't matter.
|
||||||
|
|
||||||
|
## The test that guards this
|
||||||
|
|
||||||
|
`test/load-order.test.ts` is structurally unlike every other test file **on
|
||||||
|
purpose**: it never statically imports a component module, so nothing is
|
||||||
|
`customElements.define`d until it dynamically `import()`s `src/index.ts`
|
||||||
|
partway through — _after_ building a DOM tree out of plain, undefined
|
||||||
|
elements. That's the only test reproducing a real page's actual order (markup
|
||||||
|
first, module script second).
|
||||||
|
|
||||||
|
Every other test file imports components up front, so components are already
|
||||||
|
defined before any element is created — they **structurally cannot** catch an
|
||||||
|
ordering regression. If you add a component with a "parent listens for a
|
||||||
|
child's announcement" relationship, extend `load-order.test.ts`.
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
# 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' '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.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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.jsonc` → `oxfmt.config.ts` → `oxfmt.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.ts` — `environment: '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.
|
||||||
|
- **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](./06-load-order.md); 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.
|
||||||
|
|
||||||
|
- Consumers import `dist/index.js` (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.js` (+ types), plus
|
||||||
|
`./dist/*` for deep imports.
|
||||||
|
- `sideEffects: true` — the component modules call `customElements.define()`
|
||||||
|
at import time, so a bundler must not tree-shake them away.
|
||||||
|
|
||||||
|
## Publishing to two registries
|
||||||
|
|
||||||
|
| Registry | Entry | Ships |
|
||||||
|
| -------- | ------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| npm | `package.json` `main`/`module`/`types` → `dist/…` | compiled `dist/` + `README.md` (`files` field); `prepublishOnly` runs `build` |
|
||||||
|
| JSR | `jsr.json` `exports` → `./src/index.ts` | TypeScript source directly (JSR compiles per-consumer) |
|
||||||
|
|
||||||
|
Both are version `0.1.0`; keep them in step. JSR publishes `src/` with its
|
||||||
|
`.ts` import extensions intact, which JSR supports natively.
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Design docs
|
||||||
|
|
||||||
|
These describe how `leaflet-components` is built and why — the load-bearing
|
||||||
|
decisions, in their current form. They are not a usage guide (that's the
|
||||||
|
top-level [`README.md`](../README.md)) and not a working cheat-sheet (that's
|
||||||
|
[`CLAUDE.md`](../CLAUDE.md)).
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||||
|
| [01 — Architecture](./01-architecture.md) | Element-per-Leaflet-object, the `WithProps` mixin, the lifecycle it owns |
|
||||||
|
| [02 — Props & attributes](./02-props-and-attributes.md) | The `PropDef` model, the codec factories, two-way attribute↔object sync |
|
||||||
|
| [03 — Component tree & registration](./03-component-tree.md) | The `leaflet-register` bubbling protocol, `attach` modes, the other announcement events |
|
||||||
|
| [04 — Events](./04-events.md) | Re-emitting Leaflet events as `leaflet:<type>`, and how they're typed |
|
||||||
|
| [05 — Per-component special cases](./05-special-cases.md) | Where a component does something the mixin can't express generically |
|
||||||
|
| [06 — Load order](./06-load-order.md) | Why the export order in `src/index.ts` is load-bearing |
|
||||||
|
| [07 — Tooling & build](./07-tooling-and-build.md) | TypeScript 7, oxlint, oxfmt, Vitest, the no-bundle build, dual publish |
|
||||||
|
|
||||||
|
## The one-paragraph version
|
||||||
|
|
||||||
|
Each `leaflet-*` custom element wraps exactly one Leaflet object. A mixin
|
||||||
|
(`WithProps`) generates the element class from a table of property
|
||||||
|
descriptors: it derives `observedAttributes`, builds the Leaflet options
|
||||||
|
object from the current attributes, calls the component's
|
||||||
|
`createLeafletObject()`, keeps attributes and the live object in sync in both
|
||||||
|
directions, and re-fires every Leaflet event on the element. Components join
|
||||||
|
each other through a DOM-event registration protocol that bubbles up to
|
||||||
|
`<leaflet-map>` at the root — no component ever reads another component's
|
||||||
|
state or queries the DOM for its relatives. The build is `tsc` with no
|
||||||
|
bundler; Leaflet is always external.
|
||||||
Loading…
Reference in New Issue