honua-maplibre migration target
The honua-maplibre codemod target rewrites a curated subset of
@arcgis/core constructors directly into @honua/sdk-js/map helpers
plus MapLibre GL source/layer definitions, instead of routing them
through the Esri-shaped compat shims. It is the codemod option that
produces MapLibre-native output for apps that want to leave the
ArcGIS JS API behind end-to-end.
This page documents the slice that shipped in PR #208 (commit
af1ebee). It does not close issue #205 — open acceptance items are
called out under Manual gaps.
Canonical migration workbench
examples/migration-workbench
is the qualified arcgis-migration golden journey for the complete migration
experience. The repository build invokes the real honua-migrate CLI against
the Honua-authored arcgis-source-app fixture and commits a deterministic
report, patch, widget guidance, MapLibre assessment, generated compat target,
and SHA-256 manifest. The browser only projects and revalidates those
artifacts; it does not contain a second transform, accept uploads, read
credentials, or perform cloud import.
Use the artifact and browser gates directly, or exercise the same reviewed journey through the sample kit in source and packed SDK modes:
npm run demo:migration-workbench:artifacts:check
npm run test:playwright:migration-workbench
npm run samples:run -- verify --sample migration-workbench --sdk-mode source
npm run samples:run -- verify --sample migration-workbench --sdk-mode packed
The catalog carries current packed-build, browser, accessibility, console,
responsive, screenshot, performance, fixture, and live receipts for this
journey (#549); its live-evidence lane proves liveness by re-running the real
honua-migrate CLI right now, since the workbench itself never makes a
non-loopback network request. Gallery projection is tracked by #550.
Widget kit registration
This is a required step, not a footnote. A migrated app that constructs
LegendCompat or LayerListCompat must register the Honua web-component kit
once, or those two widgets render nothing.
Be precise about the scope, because it cuts both ways:
| Shim | With a registered kit | Without one |
|---|---|---|
LegendCompat (<honua-legend>) |
renders the delegated component | state-model-only, container stays empty, diagnostic fires |
LayerListCompat (<honua-layer-list>) |
renders the delegated component | state-model-only, container stays empty, diagnostic fires |
every other container-bearing shim (SearchCompat, MeasurementCompat, ExpandCompat, BasemapGalleryCompat, SketchCompat, …) |
still state-model-only | state-model-only, no diagnostic |
Registration is necessary for the first two and not sufficient for the
rest: about two dozen shims accept a container option, but only LegendCompat
and LayerListCompat construct a HonuaWidgetHost today. The others carry the
ArcGIS state model — properties, watch, events, methods — and expect the
application to render their state itself. Registering the kit does not give
them UI, and they never emit the missing-kit diagnostic, so do not read a
silent SearchCompat as a registration problem.
The two delegating shims draw UI through the Honua web components. The compat
entry point never imports that kit — not even dynamically: /esri-compat is
bundle-budgeted, and any intra-package import would pull the whole component
set plus its geometry closure into every compat bundle. The application injects
it instead, as early as the entry module runs:
import { registerHonuaWidgetKit } from "@honua/sdk-esri-compat";
// Eager (module object) or lazy (loader) — both work.
registerHonuaWidgetKit(() => import("@honua/sdk-js/web-components"));
Register before you construct widgets. The call is idempotent, and passing
undefined unregisters: the delegating shims return to state-model-only mode
and any component they had already mounted is removed from its container on the
next refresh, so unregistering never strands stale UI.
Without a kit those two shims degrade to state-model-only: legend.items is
populated, layerList.toggle() mutates layers, events still fire — and the
container you passed stays empty. Since a migrated app in that state simply
looks broken, the first mount without a registered kit emits a one-time
diagnostic (honua-io/honua-sdk-js#957):
- a
console.warnnamingregisterHonuaWidgetKit, quoting the exact call, and linking back to this section; and - a
widget-kit.missingevent on the shim'sCompatEventBus, carrying{ tagName, api, docs, message }for telemetry or a first-run banner.
legend.eventBus.on("widget-kit.missing", (event) => {
reportFirstRunProblem(event.payload.message);
});
The diagnostic fires once per runtime, on the first mount that finds no
kit — not once per widget instance — so subscribe before constructing widgets.
It re-arms whenever registerHonuaWidgetKit is called again, which keeps
register-then-unregister cycles honest.
Three consequences worth planning for:
- The standalone
@honua/sdk-esri-compatsplit package does not ship the kit. An app on that package installs@honua/sdk-js(or@honua/app-platform) as well to have something to register. - The codemod does not insert this call for you yet — it is tracked on
honua-io/honua-sdk-js#957 against the
honua-migrateengine. Until it lands, add the registration to every migrated entry point by hand. - Widgets outside the delegation set need a rendering plan of their own. Budget for it during migration rather than discovering it after: read the shim's state model and render it with your own components, or with the Honua web components directly.
How it differs from the other targets
The migration codemod (src/migration/codemod.ts) exposes three
targets selected with --target on the CLI:
| Target | Output shape | What it rewrites natively |
|---|---|---|
honua-compat (alias honua) |
@honua/sdk-esri-compat shims that mimic the ArcGIS JS API surface |
Every kind in REWRITE_SPECS (SUPPORTED_ARCGIS_MODULE_KIND_BY_PATH) — full 2D constructor surface (layers, views, widgets, controls, tasks, support). |
honua-maplibre |
@honua/sdk-js/map helpers + raw MapLibre style/layer objects |
The five kinds in HONUA_MAPLIBRE_NATIVE_KINDS: feature-layer, map-image-layer, tile-layer, map, map-view. Everything else still falls through to a manual TODO. |
esri-leaflet |
Mixed esri-leaflet primitives + compat fallback | feature-layer, map-image-layer, tile-layer natively; the remaining 2D surface via ESRI_LEAFLET_COMPAT_FALLBACK_KINDS. |
honua-compat is the broadest deterministic option — it covers the
full REWRITE_SPECS matrix and is the default. honua-maplibre
trades surface coverage for a MapLibre-native output: it only emits
native helpers for the layer/map/view core, and everything else
(widgets, controls, renderers, geometry constructors, tasks)
becomes a manual TODO because there is no @honua/sdk-js/map
helper for it yet. esri-leaflet sits in between — three native
layer kinds with a compat fallback for the rest.
Auto-migrated, assisted, and unsupported kinds
The exact set of kinds the honua-maplibre target rewrites natively
is defined in src/migration/codemod.ts as
HONUA_MAPLIBRE_NATIVE_KINDS and consumed by
TARGET_SUPPORTED_KINDS["honua-maplibre"]:
feature-layer(@arcgis/core/layers/FeatureLayer) →createHonuaFeatureServiceLayer({ url, ... })returning aHonuaFeatureServiceSourceSpecificationplus a default MapLibre render layer (circle/line/fillchosen vialayerType).map-image-layer(@arcgis/core/layers/MapImageLayer) →createHonuaMapServiceLayer({ url, ... })returning aHonuaMapServiceSourceSpecificationplus a default raster render layer.tile-layer(@arcgis/core/layers/TileLayer) →createHonuaTileServiceLayer({ url, ... })returning a MapLibre raster source pointing at<url>/tile/{z}/{y}/{x}plus a raster render layer.map(@arcgis/core/Map) →createHonuaMapLibreStyle({ basemap, layers, ... })returning aHonuaStyleSpecification(MapLibre style version 8) that aggregates the migrated layers' sources and layers.map-view(@arcgis/core/views/MapView) →createHonuaMapLibreMapOptions({ container, style, center, zoom, bearing, pitch }), the options bag passed tonew maplibregl.Map(...).
The helper signatures live in src/map/maplibre-target.ts.
Rendering feature-service layers (custom source → geojson)
createHonuaTileServiceLayer and createHonuaMapServiceLayer emit
MapLibre-native raster sources that render as soon as you add them to
a map. createHonuaFeatureServiceLayer is different: its source uses
the custom honua-feature-service type, which MapLibre's renderer does
not understand. Adding that source to a maplibre-gl Map directly is
a silent no-op — no error, no features.
To render feature-service layers, run them through the MapLibre runtime
adapter exported from @honua/sdk-js/map. It fetches features via the
SDK query path and produces a standard geojson source MapLibre renders
natively:
import * as maplibregl from "maplibre-gl";
import { HonuaClient } from "@honua/sdk-js";
import {
createHonuaFeatureServiceLayer,
loadHonuaFeatureServiceGeoJson,
} from "@honua/sdk-js/map";
const client = new HonuaClient({ baseUrl: "https://gis.example.com" });
const layer = createHonuaFeatureServiceLayer({ url, outFields: ["*"] });
// Replace the custom honua-feature-service source with a fetched geojson source.
const source = await loadHonuaFeatureServiceGeoJson(client, layer.source.url);
map.addSource(layer.sourceId, source);
map.addLayer(layer.layer);
For a whole HonuaMap, registerHonuaFeatureServiceSources(map, honuaMap, client) walks every honua-feature-service source and live-patches it
on the MapLibre map (adding a new geojson source or calling setData
on an existing one). Both helpers live in
src/map/feature-service-adapter.ts.
Every other constructor kind in REWRITE_SPECS — geometries,
symbols, renderers, WebMap, SceneView, GraphicsLayer,
GroupLayer, VectorTileLayer, GeoJSONLayer, WMSLayer,
WFSLayer, ImageryLayer, FeatureFilter, all widgets (LayerList,
Legend, Popup, Search, Sketch, Editor, TimeSlider, …),
all controls (Home, Zoom, Compass, ScaleBar, Fullscreen,
BasemapToggle, …), Query, OAuthInfo, IdentityManager,
esriRequest, esriConfig, reactiveUtils, RouteTask, etc. —
is not in HONUA_MAPLIBRE_NATIVE_KINDS. The codemod emits a
manual TODO with a reason at each call site and lists the unhandled
ArcGIS module under unhandledArcGisModules in the report. The
runtime parity matrix
(src/migration/runtime-matrix.ts::inferHonuaMapLibreRuntimeStatus)
categorizes these surfaces as assisted for honua-maplibre,
except for the feature-layer, map-image-layer, and the
map-view.navigation-go-to capability which are tagged native.
Use the canonical fixture
test/fixtures/esri-maplibre-simple-app/
as a worked example — it imports Map, MapView, FeatureLayer,
MapImageLayer, and TileLayer from @arcgis/core and exercises
exactly the kinds that honua-maplibre can rewrite natively.
CLI flag and invocations
The codemod is selected with --target honua-maplibre on the
codemod, fixtures, and demo subcommands of
node dist/src/migration/cli.js. The accepted target tokens are
honua (alias of honua-compat), honua-compat, honua-maplibre,
and esri-leaflet — anything else is rejected by the parser.
# Dry-run the codemod against the bundled MapLibre fixture
node dist/src/migration/cli.js codemod test/fixtures/esri-maplibre-simple-app \
--target honua-maplibre \
--report reports/maplibre-fixture-report.json
# Write the rewrite in place with inline TODOs for manual sites
node dist/src/migration/cli.js codemod test/fixtures/esri-maplibre-simple-app \
--target honua-maplibre \
--write --annotate-todos \
--report reports/maplibre-fixture-report.json
# Run the codemod against the hand-written parcel viewer example
node dist/src/migration/cli.js codemod examples/arcgis-source-app \
--target honua-maplibre \
--report reports/arcgis-source-app-maplibre-report.json
# Per-fixture readiness metrics for a single MapLibre fixture, no gating
node dist/src/migration/cli.js fixtures test/fixtures \
--target honua-maplibre \
--fixtures esri-maplibre-simple-app \
--report reports/maplibre-fixture-metrics.json
The shared codemod flags (--write, --annotate-todos,
--report <path>, --compat-import-path <pkg>, --fail-on-manual,
--fail-on-unhandled, --fail-on-blocked, --max-manual-ratio,
--max-manual-intervention-ratio) all apply unchanged. With
--target honua-maplibre the codemod only rewrites the five native
kinds; manual TODOs and unhandled-module entries are expected to
appear for any constructor outside that set, and gating flags like
--fail-on-manual will fail closed against apps with widgets,
renderers, or non-2D-core surfaces.
For contributor validation, use npm run test:migration:cli. Its
prerequisite prepares the SDK once before Vitest starts, and every CLI spec
executes the same manifest-owned dist/src/migration/cli.js artifact. The
atomic preparation manifest hashes every compiler/control input and the
complete dist/ tree, binds workers to one run ID, and revalidates both trees
at teardown. A direct Vitest invocation may run source-only tests without a
manifest; a test that consumes built output fails with an actionable
npm run prepare:test-sdk prerequisite error. Composed CI and publish lanes
use their :prepared variants after the single root build.
Migration report fields
The report writer is buildJsMigrationReport
(src/migration/report.ts); its JsMigrationReport shape is what
gets written to the path passed to --report. The fields you can
key off when consuming a honua-maplibre report are:
codemodTarget:"honua-maplibre"for runs launched with--target honua-maplibre. The union is"honua-compat" | "esri-leaflet" | "honua-maplibre".rootDir: absolute path of the codemod root that was scanned.scanSummary/scanReport: thescanArcGisUsage/summarizeArcGisScanoutput (import counts, flags likescene-3d-detected,advanced-widget-or-networking-detected).codemodResult.metrics.totalCodemodScopedCallSites,codemodResult.metrics.autoMigratedCallSites,codemodResult.metrics.manualCallSites,codemodResult.metrics.byKind[<kind>](per-kindauto/manual/totalcounts).manualRewriteMetric:{ numerator, denominator, ratio, scope }wherenumerator/denominator= manual call sites / total codemod-scoped call sites.ratiois the value compared against--max-manual-ratio.manualInterventionMetric:{ numerator, denominator, ratio, scope, manualCodemodCallSites, unhandledUsageHits }— extends the rewrite metric by addingunhandledArcGisModuleshits into both numerator and denominator. Compared against--max-manual-intervention-ratio.readiness:"ready" | "assisted" | "blocked"— derived fromgates. Blocking flags (scene-3d-detected,advanced-widget-or-networking-detected) forceblocked.gates: array of{ gate: "no-manual-todos" | "no-unhandled-modules" | "no-blocking-flags", passed, detail }. These are the MapLibre readiness gates: withhonua-maplibre, expectno-manual-todosandno-unhandled-modulesto fail on any app that uses widgets, renderers, geometry constructors, or other kinds outsideHONUA_MAPLIBRE_NATIVE_KINDS.manualTodos: per-call-site{ file, line, column, kind, reason, difficulty? }.manualTodoReasons: rolled-up reasons sorted by count, each with akindslist — useful for grouping cleanup work by category.manualTodosByKind: dense map of everyCodemodConstructorKindto its manual-call-site count for this run.unhandledArcGisModules:{ modulePath, usageStyle, count }entries for ArcGIS modules outside the codemod's per-target supported set.usageStyleis"static-import","dynamic-import", or"require".
The parity matrix tooling (matrix / runtime-matrix subcommands)
also emits a honuaMapLibre summary block alongside honuaCompat
and esriLeaflet with native/assisted/unsupported counts —
see summarizeJsParityMatrix and summarizeJsRuntimeParity.
Manual gaps
Issue #205 is a slice ladder, not a single landing. honua-maplibre
ships the native-rewrite path for the layer/map/view core; the
remaining acceptance items live in
docs/migration-punch-list.md and stay
open until they ship. The notable ones, framed against the punch
list:
- WebMap → MapLibre style conversion. The
web-mapkind is not inHONUA_MAPLIBRE_NATIVE_KINDS, sonew WebMap(...)constructors emit a manual TODO under this target. Server-side / CLI WebMap conversion lives behind thecontent-webmapsubcommand and is tracked separately. - Widget visual parity. Per the punch list "Widget UI behavior"
entry: the compat shims accept ArcGIS option shapes but render
through the Honua widget host with non-byte-identical visuals.
honua-maplibredoes not paper over this — widget constructors fall to manual TODO instead of being silently rewritten. Underhonua-compatthey are rewritten, but rendering still depends on the app performing the widget kit registration the codemod does not yet insert. - 3D / SceneView and scene layers.
scene-view,SceneLayer/BuildingSceneLayer/IntegratedMeshLayer/PointCloudLayer/MeshLayer/ElevationLayer/VoxelLayerare flagged with thescene-3d-detectedblocking flag and forcereadiness: "blocked". MapLibre is 2D-only by design; 3D parity is not in this target's scope. Locator/Geoprocessor/NetworkAnalyst(beyondRouteTask). Not inHONUA_MAPLIBRE_NATIVE_KINDS; manual TODO with the unhandled-module list pointing at the missing surfaces.- Renderers, symbols, popup templates,
reactiveUtils.watch, event-name remap,Querydeep-transform. These ship forhonua-compat(some shipped as Tasks D/E/F in the punch list) but they do not exist as native MapLibre helpers — underhonua-maplibrethey remain manual TODO surfaces. - Playwright smoke / runtime evidence lane for MapLibre output. Open acceptance item on #205.
For the broader migration story — including the honua-compat
parity surface, the codemod gates, and the test corpus — start at
docs/migration-punch-list.md and the
fixture-only Esri sample corpus in
docs/esri-sample-corpus.md.