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.
14 KiB
Explanation — why doxa-maps is shaped this way
The reasoning behind the architecture. For how to build a map, see the tutorial and how-to.md; for lookups, reference.md.
One shared library, many bundles, one build
There are exactly two kinds of source:
| Path | Role | |
|---|---|---|
| The shared library | library/ (alias @map) |
One Apache-2.0 template of building blocks: components, composables, color strategies, API connectors, stores, i18n. Reusable across every map. |
| The bundles | app-profiles/<bundle>/ |
One folder per shippable <script src> bundle. Each holds the .vue screens that compose @map blocks into a specific map. |
The mental model: one .vue file = one map = one profile. The library provides the blocks;
your profile composes them. The only place a map's identity lives is its
app-profiles/<bundle>/ folder — never fork library/ per map. Fix a legend or control once
in @map and every map gets it on the next build.
bun run build runs one Vite build per bundle folder, emitting one self-contained IIFE to
public/doxa-maps/<bundle>/<bundle>.js, plus a manifest.json scraped from each profile's
optional @description header. (Build pipeline details and settings: reference.md.)
The three zones, and why they're separated
| Zone | What it is | Why it exists |
|---|---|---|
library/ |
Shared runtime code, reused via the @map alias. |
One copy of every shared block. Edited only by people who know the whole system — a change here reaches every map. |
app-profiles/ |
One folder per bundle — the map-maker's sandbox. | A programmer building one map gets one folder that is entirely theirs; nothing inside can affect another map. |
internal/ |
The bundler's own machinery: the tree/clean/index scripts, portability + parse checks, staging generators. | Named after Go's enforced internal/ convention: code consumers must not touch. It's the machine, not the product. |
The zones separate three audiences: map-makers (app-profiles/), library authors
(library/), and the build system (internal/).
The bundle-root contract. A bundle's root holds only the profile .vue file(s) plus
the scanner-contract files (index.js, index.html, *.instances.json, README.md).
Everything else lives in that bundle's src/. Why: the scanner discovers profiles by
flat-listing the root, so every root-level .vue is a profile and needs no configuration — a
profile moved into src/ silently vanishes from discovery.
The _-prefix convention. Files starting with _ are engine code (e.g.
colors/_registry.js, api/_registry.js). The auto-discovery globs mechanically skip
_-prefixed names, so an engine file can sit next to the content files it discovers without
discovering itself. Reading rule: see _, don't edit.
Why each library folder has its name. api/ = the data layer, a declarative connector
registry (sources.json) plus the engine that reads it. colors/ = one engine plus shared
strategy files; a map's own colors live in its src/colors/ — same folder name on both sides
on purpose. components/ + composables/ are grouped by feature (legends/,
map-controls/, poster/), the Vue folder-by-feature convention, chosen because it makes
micro-frontend extraction easy — you can lift a whole feature out in one move, which is exactly
what this bundler does. composables/ (reactive Composition-API logic) is kept separate from
utils/ (pure functions, no reactivity). constants/, data/, stores/, i18n/ are the
standard homes for values, static lookups, Pinia state, and locales.
The reusability rule — library vs profile folder
The single most important call for keeping the library clean as maps grow into the hundreds:
| A component belongs in… | when… |
|---|---|
the shared library (library/) |
it is used by 2+ profiles AND is truly reusable — parameterized so each profile passes its own config. |
the profile folder (app-profiles/<bundle>/) |
it is used by one profile, is experimental / not-yet-proven reusable, or hard-codes one map's behaviour. |
Default to the profile folder. A component only earns promotion into library/ once a
second profile genuinely needs it and it has been generalized. "It might be reusable someday"
is not enough. The rule solves two problems at once:
- No orphan library components. Because a profile's custom components live in its own
folder, excluding or
.gitignore-ing a profile removes its components with it. You never publish shared blocks that no visible map consumes. - Profiles don't break each other. A component is allowed in
library/only if it is truly parameterized, so every dependent profile passes its own options and keeps working. If a component cannot be generalized without one map fighting another for it, fork it into the profile folder — a clean profile-specific copy beats a brittle forced share. A staging environment that renders every map at once is how you verify a shared-component change didn't regress the others.
The same rule governs dashboards: a dashboard is just an app-profile that composes the
shared library/components/dashboards/ blocks and passes a config object (facets, columns,
charts, KPIs, data source) instead of writing panels by hand. "Build a dashboard" is the same
job as "build a map."
Publishing a subset. Keep one local kit with every profile; to ship only some maps,
.gitignore the profiles a target shouldn't receive. Their folders (and their profile-specific
components) drop out cleanly while the genuinely-shared library blocks remain. Git worktrees
make this ergonomic — one worktree per branch/target.
The no-rebuild contract
profile-config is an HTML attribute read at runtime in the browser, so nothing about a
map's identity is fixed at build time. The same built bundle re-points to a different screen,
token, data source, color set, or language just by changing the JSON on the page. This is what
lets one bundle serve many sites and teams: build once, configure per embed. ProfileLoader.vue
reads the attribute in setup, and the data layer resolves its source at runtime (from
sources.json's activeSource unless profile-config.dataSource overrides it). (Field-by-field
contract: reference.md.)
One build, three ways to ship
The same web-component bundles serve three shapes — all from one bun run build (mobile is a
separate opt-in step, never part of the build):
- Embed — drop one bundle on any partner site (iframe, or
<script>+ custom element). - SPA — the
layouts/wrapper composes many bundles into one single-page app: a nav/tab shell that lazy-mounts each map on demand (WebGL-context-safe — only the active map holds a live GL context), with a nested pane-wrapper for showing several maps in one view. Theshellbuild step installs the chosen wrapper (layouts/<wrapper>/, defaultapp-wrapper-material) as thepublic/entry point. Optional — skip it and you just have standalone bundles. - App — Capacitor wraps
public/(the SPA + bundles) into an Android/iOS app. Opt-in (apk:debug), reusing the same build.
Same bundles, same build: embed one, compose many into a SPA, or wrap the whole thing as an app.
Why a standalone JAMstack bundler (not a framework)
The thesis: the bundle compiles to a web-standard custom element, carries its own framework, takes its backend and data locations as runtime config, and serves static assets from one exposed directory. So the same artifact runs inside any host — React, Vue, Nuxt, WordPress, a Capacitor WebView, a bare directory on nginx — without a rebuild, and any backend can be swapped without touching the client.
Decoupled is drop-in — the doxa-base plugin pattern. Because the artifact is a standard custom element, it is tied to no host (fully decoupled) and a zero-modification drop-in into any host that speaks the custom-element contract — including doxa-base's plugin tier, where a built bundle loads as a plugin with no changes. Decoupling and drop-in are the same property, not a trade-off. That's why doxa-maps is the reference pattern for every doxa-base plugin: build a standard bundle, and it runs standalone and drops into doxa-base.
Five load-bearing decisions:
- Ship a custom element, not a framework component. A Vue component needs a Vue host; a custom element is a browser primitive every host consumes natively. The partner integration story is "add two lines to your page," not "adopt our framework."
- Single-file IIFE — one script tag, no loader.
inlineDynamicImports: true; no module graph for the host to resolve. The cost: because everything imported is inlined, one carelessimportof a data file inlines it — a single static import once took two bundles to 2.0 MB and 2.9 MB. The discipline isfetch()at runtime for anything data-shaped, enforced by a size ceiling in the verify guards. - Config-only, fail-closed backend. There is deliberately no default host; an unconfigured build makes zero requests rather than phoning home. This is what makes "swap the backend" real, and it's a privacy property — a fork can't leak users back to the original author.
public/is the one served surface. The web root points atpublic/;app-profiles/,internal/, andlibrary/have no URL that maps to them. That's stronger than a deny-list — it's the absence of a route. Apps read data by URL at runtime, so the public folder is not bundled.- Data location is a runtime input (
assetsBase). One knob, two deployment shapes: default./assetson the web; point it at a remote host for Android/iOS so large datasets stay off the device and out of the app package.
What it costs — stated honestly:
- You own the framework upgrade. Vue lives inside each bundle; a security patch means rebuilding and redistributing every bundle.
- Duplicated runtime across bundles. Each bundle carries its own Vue. Accepted deliberately — isolation beats shared-runtime efficiency when partners control the page.
- The inline trap is one careless import away (decision 2). Guarded, not prevented.
- No SSR / SEO for map content. These render client-side.
- Not fully self-contained today. Maps load Mapbox from a CDN (a few requests per page to
api.mapbox.com, which sees the viewer's IP). A truly airgapped deployment renders data tables but not the map. This is an open design decision (a MapLibre + self-hosted-tiles path is proven in the repo for token-free rendering), not a solved problem. - "Many APIs" cuts both ways. JAMstack makes adding backends trivial, but every backend added is another host that sees your users — which is why the verify guards count third-party hosts and fail the build.
Every architectural claim is backed by the verify guards, each proven to fail on corrupted
input before being trusted: bun run verify (data integrity, shipped bundles, network policy)
and bun run verify:full (+ rendered posters and live apps in a browser). A check that has only
ever printed green proves nothing.
How panes talk — props down, composed events up
For reusable elements to communicate, the web-standard idiom is properties down, events up, and it is a documented contract (MDN Using custom elements, MDN Event.composed, web.dev Custom element best practices):
- Down: attributes for primitives, properties for rich data (objects/arrays can't survive an attribute without serialization).
- Up: a
CustomEventdispatched by the element, listened to by whoever owns it. A component never reaches into a sibling.
The load-bearing constraint: composed. Each pane is a separate custom element with its own
shadow root. A CustomEvent dispatched inside pane A does not escape its shadow boundary
unless created with both composed: true and bubbles: true. A plain
new CustomEvent('selection-changed', { detail }) — the obvious thing to write — dies inside
the pane silently: no error, the listener is correctly attached, and nothing arrives. It's the
same family as the shadow-style trap (useShadowStyles early-returns in light DOM) — a
shadow-DOM behaviour a light-DOM test cannot reproduce.
The decision. Adopt props-down / composed-events-up as the cross-element contract. The host
owns the wiring: it hears doxa:selection-changed from pane A and sets a property on pane B; the
panes never reference each other. A shared Pinia store (keyed by instanceId) may stay as an
internal cache, but never as the contract — a store makes two panes work together, while
props/events make each pane work anywhere, which is the property this architecture exists to
sell. Event names are namespaced (doxa:) so a host embedding several vendors' elements can't
collide.
// PANE A — announce. BOTH flags are REQUIRED or this never leaves the shadow root.
this.dispatchEvent(new CustomEvent('doxa:selection-changed', {
detail: { iso3, people_group },
bubbles: true, // required for propagation at all
composed: true, // required to cross the shadow boundary
}))
// HOST — own the wiring. Rich data is a PROPERTY (objects can't ride an attribute).
paneA.addEventListener('doxa:selection-changed', (e) => { paneB.selection = e.detail })
React interop — verified, with a required workaround. Measured against the real shipped
bundle in React 18.3.1 and 19.2.8: both directions work, but a rich object passed as a JSX
prop is silently stringified to "[object Object]" on both versions. React 19's property
support only applies to accessors present before connectedCallback, and this bundle's are
installed in it, so React falls back to the attribute path. A React host must therefore use a
ref and assign imperatively — ref.current.selection = obj — and bind doxa: events with
addEventListener via that ref (the portable form across React versions). Config still rides a
JSON string attribute (profile-config), which round-trips fine.
Acceptance checks that follow from this: every cross-pane event must carry both bubbles and
composed (an explicit review check, because nothing throws); test the event crossing the
shadow boundary in the bundle, not in a standalone mount; and no pane may hold a reference to
another pane.