doxa-maps/CONTRIBUTING.md
doxa-maps d5ba21fa76 doxa-maps — portable bundler for embeddable JAMstack maps
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.
2026-09-10 01:53:07 +00:00

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 — NOT bun run dev. The build is what writes your bundle into public/, the served web-root. There is no HMR across that boundary. bun run dev is 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.

  1. In this project's own dev server (isolated HMR staging).
  2. In the app shell at public/index.html, which mounts several bundles as one single-page app — the same page a native build wraps.
  3. 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.js and the build tooling — the multi-bundle build pipeline.
  • the bundle pattern itself — one folder per map under app-profiles/, each .vue a 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 .js is 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 a FACET_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 its app-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>.js script. A profile is a .vue file at that folder's root, discovered by import.meta.glob('./*.vue') in the bundle's index.js. The host picks which profile to mount with the profile field of profile-config; the rest of profile-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 via profile-config. (A bundle may additionally register more than one element tag — e.g. doxa-simple-map registers 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); npm works 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 dev is for building the map — fast HMR, no host involved. When it looks right, run bun run build once to push it across.
  • Tab 1 with bun run build:watch is 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: false means 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.