Standalone repo that builds embeddable JAMstack map bundles (web components), composes them into a lazy-loaded SPA via layouts/, and optionally wraps a Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs. History reset to a single clean commit; no PII, IPs, or internal terms.
17 KiB
Contributing to doxa-maps — building, embedding, and shipping a map bundle
Maps sit on the edge of your ecosystem: built once, they migrate with you. WordPress → Nuxt → whatever comes next, the maps come along unchanged. Embed them anywhere with a single script tag.
TL;DR — the one rule that surprises everyone: To see your map changes in the served site, run
bun run build— NOTbun run dev. The build is what writes your bundle intopublic/, the served web-root. There is no HMR across that boundary.bun run devis the fast HMR loop for working on a map in isolation.
The mental model
This is a standalone Vite project with no host framework of any kind. It
compiles each map into a self-contained .js bundle — an IIFE that registers a
custom element — which any page can load as a plain <script src> static asset.
That single fact is what gives the maps their reach: the same artifact runs three ways without modification.
- In this project's own dev server (isolated HMR staging).
- In the app shell at
public/index.html, which mounts several bundles as one single-page app — the same page a native build wraps. - On any other site (loaded from wherever
public/is served).
library/ + app-profiles/ + layouts/
│
│ bun run build
▼
public/ ← the served web-root, committed
├── index.html the app shell (mounts several bundles)
├── doxa-maps/<bundle>/ one IIFE per bundle — what a partner embeds
└── assets/ posters and data the maps fetch at runtime
│
├──► served by any static host → the app, and embeddable bundles
└──► copied by `cap sync` → the Android / iOS web assets
Because a bundle is a build artifact in public/ — not a source import — a
served page only sees a change after you rebuild.
Editing .vue/.js files here does nothing to the parent until the build runs.
The doxa maps template (what's shared, what's yours)
This project is built on the doxa maps template — an Apache-2.0, open-source base that anyone can use. The template is the shared half of the repo:
library/(aliased@map) — the shared component library: legends, toolbars, composables, color strategies, i18n, the data-source engine.vite.config.jsand the build tooling — the multi-bundle build pipeline.- the bundle pattern itself — one folder per map under
app-profiles/, each.vuea profile, each folder compiling to one self-contained<script src>bundle.
What you add on top — your folders under app-profiles/<your-map>/ — is yours:
your color choices, your data connections, your i18n, your maps. The template is
common to everyone; your application profiles are private and uniquely yours.
This host ships a tree-shaken build of the template. Each built bundle only includes the parts of the shared template a given map actually imports — so a simple map's
.jsis small and a feature-rich map's is larger. You consume the whole Apache template as source, but every output bundle is a lean slice of it.
When this guide says "the template," it means exactly this Apache-2.0 shared library + build tooling + bundle pattern — nothing host-specific.
The reuse rule (reusable → library/, unique → profile config)
The same rule governs dashboards (config-driven CMV kit) and maps:
- Reusable across profiles →
library/(@map). Logic that more than one profile would copy lives in the library, declared ONCE. Examples: the dashboard panels and search header (library/components/dashboards/), the geocoder kind-switch (library/composables/ useGeocoderSearch.js—KIND_PROPERTY+ the filter-expression builder), the selection bus, the CSV exporter. If you are about to paste aFACET_ACC/ kind→property map / filter loop into a 2nd file: STOP — that is the smell the library exists to kill. - Unique to one profile → config, not code. A dashboard's facets / columns / charts /
KPIs / data source / the 4 search-bar definitions live in its
dashboard.config.js; a map's colors / data connection / i18n live in itsapp-profiles/<map>/folder.
The headline proof: a dashboard's 4 search bars reuse the SAME useGeocoderSearch
composable the map profiles use, swapping only the sink (a busAdapter that lands the
result on the dashboard's selectionBus) — the kind-switch is never re-implemented. See
library/components/dashboards/README.md for how a new dashboard reuses the kit.
Two patterns for multiple maps from one bundle
A single bundle can present several different maps. There are two ways to do it, and they solve different problems. Neither is "more correct" — pick the one that matches why your maps differ.
Vocabulary. A bundle is one folder under
app-profiles/<name>/→ one<name>.jsscript. A profile is a.vuefile at that folder's root, discovered byimport.meta.glob('./*.vue')in the bundle'sindex.js. The host picks which profile to mount with theprofilefield ofprofile-config; the rest ofprofile-config(token, data source, colors, instanceId…) are parameters passed to that profile.
Pattern A — Parameterized element (one profile, many instances)
Use when: the maps are the same map differing only by configuration — data source, color set, starting tab, labels.
One bundle, one .vue profile. You mount the element as many times as you
like, each with a different profile-config — same profile, different
parameters. The bundle's script loads once; each element instance is
independent (its own Pinia + i18n).
Example — doxa-simple-map: a single doxa-simple-map.vue profile drives
three maps (Engagement / Prayer / Adoption). All three slots use
profile: 'doxa-simple-map'; only the surrounding config differs.
<script src="/doxa-maps/doxa-simple-map/doxa-simple-map.js"></script>
<doxa-map profile-config='{"profile":"doxa-simple-map","tk":"pk…","dataSource":"engagement"}'></doxa-map>
<doxa-map profile-config='{"profile":"doxa-simple-map","tk":"pk…","dataSource":"prayer"}'></doxa-map>
Pattern B — Multi-profile bundle (several profiles, selected by name)
Use when: the maps are genuinely different map types (different .vue
logic/layout), but you want them to share one build because they import the
same slice of the template.
One bundle, multiple .vue profiles at the folder root. The host chooses which
profile to mount via the profile field — ProfileLoader resolves it against the
bundle's import.meta.glob('./*.vue') registry (key ./<profile>.vue). Still
one script tag; you select the map per element with profile.
Example — my-upg-100-list: two profiles in one bundle —
upg-100-list.vue and research-map-clone.vue — both served by the single
my-upg-100-list-map element.
<script src="/doxa-maps/my-upg-100-list/my-upg-100-list.js"></script>
<my-upg-100-list-map profile-config='{"profile":"upg-100-list","tk":"pk…"}'></my-upg-100-list-map>
<my-upg-100-list-map profile-config='{"profile":"research-map-clone","tk":"pk…"}'></my-upg-100-list-map>
Note — profiles are selected by
profile, not by separate script tags. A bundle ships exactly one<name>.js. Whether it has one profile (Pattern A) or many (Pattern B), you load that one script and pick the profile per element viaprofile-config. (A bundle may additionally register more than one element tag — e.g.doxa-simple-mapregisters both<doxa-map>and<doxa-simple-map>for back-compat — but that's a tag-aliasing concern, separate from how many maps the bundle offers.)
Which to pick
| Pattern A — parameterized | Pattern B — multi-profile | |
|---|---|---|
.vue profiles in the bundle |
one | several |
| Maps differ by | config parameters | distinct .vue logic |
| Select a map with | different profile-config (same profile) |
the profile field |
| Script tags | one | one |
| Example | doxa-simple-map (3 instances) |
my-upg-100-list (2 profiles) |
Where this comes from — the migration story
These maps were built modular-first, as standalone embeddable widgets, while
the host site still ran on WordPress. Each map was a <script src> + a custom
element you could drop onto any page — never tied to WordPress internals, themes,
or plugins. It was just a self-contained bundle that booted itself.
When the host site migrated from WordPress to Nuxt, the maps came across
unchanged. Nothing in this project had to be ported, rewritten, or adapted to
the new framework. The Nuxt host simply loads the same IIFE bundle as a static
asset the same way the WordPress page did — <script src> then the custom
element. The build target moved from one site's static directory to another's;
the bundles themselves did not change a line.
That is the payoff of the modular-first design: the maps are framework-agnostic build outputs, not framework components. A future migration to yet another host would be the same story — re-point the build output, embed the script tag, done.
The two CDN modes
A built bundle is delivered to the page by exactly one of two mechanisms. They are
not configured anywhere in this project — they are simply where the page loads the
<script src> from. The bundle code is identical in both.
Same-origin mode (your own site serves it)
bun run build writes the bundle into public/doxa-maps/<bundle>/, the served
web-root. Whatever hosts that folder serves it from its own domain,
e.g. /doxa-maps/<bundle>/<bundle>.js. The host is the CDN — same origin, no third party.
<!-- a page on the same origin that serves public/ -->
<script src="/doxa-maps/<bundle>/<bundle>.js"></script>
<doxa-map profile-config='{ ... }'></doxa-map>
This is the default for any page served from the same web-root. The build writes straight to where the host reads, so there is no copy step, symlink, or upload — see "Where the bundles go" below.
External CDN mode (a third party serves it)
The exact same built <bundle>.js can be published to a public CDN and loaded
from an absolute URL by a page on any other site. Nothing about the bundle
changes — it is self-contained, registers its own custom element, and pulls the
Mapbox peer from Mapbox's own CDN.
<!-- a page on a completely separate site -->
<script src="https://cdn.example.org/doxa-maps/<bundle>/<bundle>.js"></script>
<doxa-map profile-config='{ ... }'></doxa-map>
The choice between modes is purely the src URL on the page — relative
(/doxa-maps/..., same-origin) vs absolute (https://cdn.../..., external).
The build itself only ever produces one thing: a portable IIFE.
Either way the map needs a Mapbox token, and a page outside this web-root has
no automatic way to get one. Pass it in the element's profile-config as tk, or
set window.MAP_TOKEN_URL to an endpoint that returns it — the same contract the
app shell and the generated pages use. Without one the bundle loads and the
basemap stays blank.
Partner-site embedding
A partner site is any external site that wants to show one of these maps. It
uses external CDN mode: it loads the Mapbox peer scripts, then the bundle from
a CDN URL, then drops in the custom element with its own profile-config. No
framework, no build step, and no access to this repo is required on their end.
<!-- everything a partner page needs -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<script src="https://cdn.example.org/doxa-maps/<bundle>.js"></script>
<doxa-map profile-config='{"profile":"<bundle>","tk":"pk.eyJ..."}'></doxa-map>
Because the bundle is the same artifact in every mode, a map you tested in isolation here, and that runs in the app shell, will run on a partner site too — the only difference is the URL the partner points their script tag at.
The two build steps
There are two commands, and they do different jobs. Knowing which is which is the whole game.
bun run dev — HMR staging (work on a map in isolation)
bun run dev # Vite dev server for THIS project, with HMR
dev serves every bundle at its own staging page inside this project, with
hot module replacement — save a .vue, see it instantly. This is the fast loop
for building and styling a map on its own, away from the host.
dev does NOT touch the served web-root. It writes nothing to
public/doxa-maps/. If you only ever run dev, the served site keeps showing
the old bundle.
bun run build — bundle to public/doxa-maps/
bun run build # build every bundle → public/doxa-maps/<bundle>/<bundle>.js
build compiles each bundle and writes it into the web-root's static
directory. This is the only command that updates what the parent (or a CDN)
serves. After a build, refresh the parent page to see the change.
bun run dev |
bun run build |
|
|---|---|---|
| Purpose | iterate on a map in isolation | publish the bundle for hosts/CDNs |
| HMR | yes — instant on save | n/a (one-shot compile) |
Writes to public/doxa-maps/ |
no | yes |
| Updates the served site | no | yes (after a page refresh) |
| Updates a partner/CDN copy | no | yes (it produces the file to publish) |
Bun is the primary toolchain (see
bun.lock);npmworks identically — the scripts are the same. Use whichever the rest of your environment uses.
The two-terminal dev workflow
For day-to-day work you want two terminal tabs open at once:
Tab 1 — this project (the map source). Pick one:
# in the doxa-maps repo
bun run dev # HMR staging: iterate on the map in isolation
# — or, when you want the parent to update on every save —
bun run build:watch # rebuild into public/doxa-maps/ on every change
Tab 2 — the app shell. Run the host's own dev server (from the host root), and keep its page open in the browser:
# in the served site root
bun run dev # the host dev server; open the page that embeds the map
The split is deliberate:
- Tab 1 with
bun run devis for building the map — fast HMR, no host involved. When it looks right, runbun run buildonce to push it across. - Tab 1 with
bun run build:watchis for integration work — every save rebuilds into the web-root, and the Tab 2 host page picks up the new bundle on refresh. This is the closest you get to a live loop across the boundary.
You do not need both at once. Use dev while shaping a map, switch to
build/build:watch when you want to see it inside the host.
Where the bundles go (and why it just works)
vite.config.js sets the output directory three levels up, into the parent
app's public assets:
build: {
outDir: resolve(__dirname, 'public/doxa-maps'), // ← the served web-root
emptyOutDir: false, // ← never wipes sibling bundles
}
- The build writes straight into
public/doxa-maps/, which is inside the served web-root — so no copy step or symlink is needed; it writes exactly where a host reads. (To serve a bundle from an external CDN, publish that same file to the CDN.) emptyOutDir: falsemeans a build of one bundle leaves the other bundles untouched. You can rebuild a single map without breaking the rest.
Each folder under app-profiles/<bundle>/ produces exactly one file:
public/doxa-maps/<bundle>/<bundle>.js. bun run build loops over every
app-profiles/*/ folder and builds them all.
Build just one bundle (faster iteration)
bun run build rebuilds every bundle. To rebuild only the one you're editing:
BUNDLE=<bundle-name> bun run build:bundle
# e.g. BUNDLE=my-map bun run build:bundle → public/doxa-maps/my-map/my-map.js
(<bundle-name> is the folder name under app-profiles/.)
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Changed a .vue, parent shows old map |
You ran dev, or didn't rebuild |
Run bun run build, then refresh the parent page |
Build error: Set BUNDLE=<name> for build |
Ran vite build directly with no bundle selected |
Use bun run build (loops all) or BUNDLE=<name> bun run build:bundle |
| New bundle doesn't appear in parent | Missing index.html or index.js in the app-profiles/<bundle>/ folder |
Vite auto-discovers a bundle only when both files exist |
| Old bundle still loads after a hard build | Browser cached the static .js |
Hard-refresh / disable cache in devtools |
| Partner/CDN copy is stale | The CDN still serves the old file | Re-publish the freshly built <bundle>.js to the CDN (and bust its cache) |
| Map is blank on a partner page | Mapbox peer scripts not loaded, or no token | Load mapbox-gl.js + geocoder before the bundle; pass a valid tk in profile-config |
One-line recap
Edit here → bun run dev to shape it → bun run build to ship it. The build
is the bridge: it writes the same portable bundle that the served site serves
(internal CDN) and that partner sites load from a CDN URL (external CDN). dev
only runs this project in isolation.