how-to's provider note said MapLibre was 'shipping on a branch / preview' — it is on main with token-free generated pages and a living demo instance, guarded by the embed smoke. reference.md: provider line updated (token-free, no tk needed under maplibre) and the geocoder search-bar opt-in is now documented (simple-map ships search OFF by design; research-map keeps it).
18 KiB
How-to — task recipes
Short recipes for specific tasks. New to the project? Read the tutorial first, then use this page to look up a task. For contracts and field tables, see reference.md.
The three zones govern everything: reuse library/ (@map), build in
app-profiles/<your-map>/, never touch internal/.
Pick your task: embed on a site · link/iframe directly · add a map · preset variants · imitate a profile · change colors · choose a CDN · build a SPA · mobile app · swap engine · API + token · agent guardrail · troubleshooting
Embed a map on a partner site
A built bundle is a plain <script src> plus a custom element — no framework, no build step
on the partner's end. A partner page loads the Mapbox peer, then the bundle, then drops in the
element with its own profile-config.
<!-- Mapbox peer (once per page, before the bundle) -->
<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>
<!-- The bundle (relative path = host serves it; absolute https = external CDN) -->
<script src="/doxa-maps/my-map/my-map.js"></script>
<!-- The map. `profile` = your .vue filename. `tk` = the Mapbox token. -->
<my-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></my-map>
profile-config is read at runtime, so the same bundle re-points at different data,
colors, or language with no rebuild — just change the JSON on the page. Without a valid
tk the bundle loads but the basemap stays blank (see Set the API + Mapbox token below).
Link or iframe a map directly (zero-effort embed)
You don't have to use the script tag. Every profile also builds its own standalone page — a bare, full-screen map at:
https://<your-cdn>/doxa-maps/<bundle>/<profile>/index.html
Link it, share it, or iframe it (perfect for CMSs and tools that only accept iframes, like Start.me or Notion):
<iframe src="https://<your-cdn>/doxa-maps/doxa-research-map/research-map/index.html"
width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>
You never write these snippets by hand — public/doxa-maps/manifest.json lists a ready-made
embed.iframe and embed.script snippet for every profile, and the staging pages show them
copy-paste ready. Design details: architecture/embeddability.md.
Add a new map profile
The fastest path is copy template-bundle and rename it. A bundle is one folder under
app-profiles/; its name becomes the bundle name and the output filename. Don't prefix it
with _ or . — those are skipped by the build.
- Copy the template.
cp -r app-profiles/template-bundle app-profiles/my-new-map - Rename the element tag. In
app-profiles/my-new-map/index.js, change thecustomElements.define('…', …)tag to your map's tag. The entry must glob the bundle's flat profiles and hand them to the loader — it already does this; keep the shape:const profileModules = import.meta.glob('./*.vue') // FLAT — matches only this bundle's screens // …app.provide('profileModules', profileModules) inside configureApp… customElements.define('my-new-map', buildElement()) - Keep
index.html. Vite needs it to discover the bundle; it's also the dev staging page. At minimum it ends with<script type="module" src="./index.js"></script>. - Write your profile screen
my-new-map.vue, flat in the folder (noprofiles/subfolder — the glob is./*.vue). The filename (without.vue) is the profile name the host requests viaprofile-config.profile. Compose it from@mapblocks and read host inputs withinject(mapboxToken,dataSource,profileConfig,instanceId, and the instance-scoped Pinia stores) — never prop-drill, never calluseXxxStore()directly. - Pick reusable pieces from the
@maplibrary index in reference.md. Import each with the@map/…alias; do not copy their code into your folder. - Build and verify.
A green build is not a working map — the build proves the bundle compiles; the guards prove the data is right and the app renders it. Confirm the pane actually paints; a profile that fails to mount looks identical to one that mounted and rendered nothing.BUNDLE=my-new-map bun run build:bundle # builds just yours → public/doxa-maps/my-new-map/my-new-map.js bun run verify # data guards (fast, no browser)
Two ways to ship several maps from one bundle: (A) Parameterized — one
.vue, mounted many times with differentprofile-config(sameprofile, different data/colors). (B) Multi-profile — several.vuefiles in one folder, selected by theprofilefield. Both ship one script tag. Pick A when maps differ only by config, B when they're genuinely different map types that share the same slice of the library.
Ship N preset variants of one screen (instances)
One parameterized profile can emit many standalone embed pages — no extra .vue. Drop a
<profile>.instances.json next to the profile:
{
"profile": "doxa-simple-map",
"apiBaseUrl": "https://…", // optional
"instances": [
{ "id": "prayer", "title": "Prayer Map",
"props": { "instanceId": "embed-prayer", "dataSource": "pray-tools",
"tabs": [{ "id": "prayer", "colorStrategy": "prayer" }] } }
]
}
bun run build then emits a page per instance at doxa-maps/<bundle>/<id>/index.html —
each directly linkable/iframe-able with its preset props baked into the page (the base profile
still accepts live props). Instances split one profile into many render targets; tabs
nest many views into one element — different tools.
Imitate an existing profile (don't re-derive it)
You have a map that works and want a second one that behaves almost the same. Copy the
profile and change parameters in the copy — do not build it from scratch, and do not add a
mode flag to the original.
The rule: PROFILES duplicate. COMPONENTS get promoted and parameterized. A profile is cheap — a duplicated profile that reads top-to-bottom beats one profile with a flag threaded through 40 branches. A component used by two profiles is different: promote it to
library/components/and give it props. Duplicating a profile duplicates configuration (the part that's supposed to differ); duplicating a component duplicates behaviour (the part that shouldn't).
Steps:
- Find the profile you're imitating — including nested ones. A
.vueprofile can contain another full profile inside a tab. Search for the behaviour you want, not the app name; if you copy the wrong level you inherit the wrong thing. - Copy the folder, then the profile file.
profileis the filename, not the folder. One folder = one bundle = one element; each flat.vueinside is one profile. - Change ONLY named parameters. If you're typing new logic, you've stopped imitating — go
find the parameter in the original that already does what you're about to write. Count
parameter sites by diffing the two files, not by memory.
Trap:
onMountedruns last and overwrites your default. If the source initializes a value in its mount handler, setting it at declaration is not enough — one constant must drive both the initialrefand theonMountedpath. - Wire it into the shell registry (
layouts/app-wrapper-material/index.html, thePROFILESobject). A nav entry with no registry entry renders a silent placeholder. Takesrcfrom the folder,tagfromcustomElements.defineinindex.js,profilefrom the.vuefilename — never from a guess. - Load data per tab with
fetch, neverimport. The single-file IIFE inlines every import, so a staticimportof a data table inlines it into the bundle (one such import took two bundles to 2.0 MB and 2.9 MB; per-tabfetchbrought them to 308 KB and 647 KB). - Verify it mounts. Open it and confirm the pane paints — don't infer it from a clean build.
One-line test: are you changing a named constant, or writing logic? Constant → you're imitating. Logic → stop.
Change colors
Colors are values, not logic — you edit values, the library owns the mechanism. Which of two places a color lives follows one rule: reused by more than one map → shared library; only your map uses it → your profile.
Path A — a shared color (affects every map that uses it). Edit
library/colors/<name>.js (e.g. religion.js) and change a hex in the PALETTE. The library
auto-discovers every <name>.js in that folder, so there's no registration step. Be
deliberate — this changes every map using that mode.
Path B — a color private to your map. Create
app-profiles/<your-map>/src/colors/<name>.js (copy template-bundle/src/colors/example-mode.js
as a starter). Your bundle's index.js runs registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))
at startup, merging your local files over the shared set. The filename derives the
mode key (language-family.js → languageFamily):
- Same key as a shared strategy → your file overrides it (for your map only).
- A new key → your file adds a private strategy (only your map sees it).
Because each bundle is its own self-contained IIFE with its own copy of the registry, a local
strategy can never leak into another map. The required export default shape (the strategy
contract) is in architecture/color-strategies.md;
religion.js is the canonical example.
Where do the maps get served from?
Your built maps are just files. To show them on other websites, the files need a public home on the internet that any site can pull from — that's what a "CDN" is. You choose the home, and there are options:
- Your own site (doxa-life). Put the built maps in doxa-life's
public/folder — its Nuxt site serves them, so doxa-life is the CDN. No third party. - GitHub. Push the built maps to a public GitHub repo and serve them free through GitHub Pages or jsDelivr — GitHub is the CDN.
- doxa-base's built-in home (Forgejo). doxa-base ships Forgejo as a CDN — push the maps there and it serves them, with no extra service to set up.
- Anywhere else. Any static host works — Cloudflare, an S3-style bucket, etc.
Pick one home and every partner site embeds the same map from it — change the map once and it updates everywhere.
How it's wired (one file: vite.doxa-maps-build-config.json; full field table in
reference.md): bun run build always writes to public/doxa-maps/<bundle>/.
Set paths to also copy the tree into another host's folder (e.g. doxa-life's public/). Set
embedBaseUrl to the public URL of your chosen home — it generates absolute, paste-anywhere embed
snippets (leave it empty for relative URLs that work wherever the folder is dropped).
devnever updates a builtpublic/. Runbun run build(orBUNDLE=<name> bun run build:bundle) and refresh to see a change on the served site.bun run devis for iterating on a map in isolation.
Compose maps into a SPA (the app shell)
Several maps + one layout = a single-page app, served from the same public/ tree.
- Pick a wrapper in
layouts/(app-wrapper-materialis the full shell: sidebar, lazy-loading, i18n). - Register your map in two places inside the wrapper:
nav.js— add a sidebar entry ({ kind, id, label, icon, target };targetcontrols desktop/mobile packaging).- the
PROFILESobject in itsindex.html— tell the shell what to mount:{ tag: '<your-element>', src: './doxa-maps/<bundle>/<bundle>.js', views: ['map'] }.
- Install the shell:
bun run shell— copies the wrapper topublic/index.html, so the site root IS the SPA and the bundles sit beneath it. (Also required for the mobile app.) - Build & serve:
bun run build, then servepublic/— the shell lazy-loads each bundle the first time you open its nav entry.
Contracts and gotchas (PROFILES shape, tag-vs-bundle names, --no-token):
architecture/app-wrappers.md.
Build the Android / iOS app (optional)
Mobile is separate from bun run build. The default build produces only the doxa-maps
web folder — the embeddable maps (and the SPA, if configured). It never touches Capacitor. The
Android/iOS app is an opt-in step that reuses that web build: Capacitor's webDir points at
public/ (appId: life.doxa.maps, appName: "Doxa Maps"), so cap sync wraps the already-built
folder into a native app. You never need to build mobile to ship the web maps.
Android (project is committed; needs Android Studio + a JDK):
bun run apk:debug # build web → cap sync → verify → assemble a debug APK
bun run apk:install # adb install onto a connected device/emulator
Step by step instead: bun run build → bun run cap:sync → open capacitor/android in Android Studio.
iOS (scaffold once on a Mac): bunx cap add ios, then bun run build && bunx cap sync ios, and
open capacitor/ios in Xcode. Same webDir: public.
cap synccopiespublic/into the native project's assets. A wrongwebDirgives a blank white screen at launch (empty assets), not an error —bun run cap:verifyguards against it.
Swap the map engine (Mapbox ↔ MapLibre)
The engine is a runtime parameter — same bundle, no rebuild. Resolution order
(first defined wins): profile-config.provider → ?provider= in the page URL →
window.MAP_PROVIDER → default mapbox.
<!-- per embed -->
<my-map profile-config='{"profile":"my-map","provider":"maplibre"}'></my-map>
<!-- or flip any generated page from the URL -->
https://<cdn>/doxa-maps/<bundle>/<profile>/index.html?provider=maplibre
maplibre runs token-free on the OpenFreeMap style (the right GL script/CSS is injected
automatically); the geocoder search bar keeps working in both engines. mapbox (the default)
behaves exactly as before — existing embeds never change.
Status: on
main. Generated pages honorprovider:'maplibre'end-to-end — the page loads the MapLibre peer instead of Mapbox and skips the token ladder entirely (no account, no key). Living demo:demo-map/demo-map/maplibre/index.html(a preconfigured instance); the embed smoke (bun run smoke:embeds) verifies it mounts with a live canvas.
Set the API + Mapbox token
Both the data backend and the Mapbox token are runtime inputs, never baked into a bundle.
Data (the API). Maps read data through @map/api/, driven by library/api/sources.json,
which lists every connection by id, picks one activeSource, and maps each source's raw field
names onto the system field names components expect (fieldMappings). Three connection types:
rest-api (the default — the live people-group API), csv (a local file), api (a single
JSON URL). To add or change a source, edit sources.json — components stay source-agnostic.
The base URL resolves at runtime from window.MAP_APP_API_URL → VITE_API_BASE_URL → ''
(same-origin), so a host repoints the API with no rebuild.
The Mapbox token. Supply it per embed as tk in profile-config, or point
window.MAP_TOKEN_URL (or the build-config tokenUrl) at an endpoint that returns it. The
token stays remote — only the URL ships. Set tokenUrl absolute
(https://…/api/maps/token) so a CDN-hosted folder and the mobile app both work; the endpoint
must send CORS headers permitting the embedding origins. In dev, the token comes from .env's
VITE_MAPBOX_TOKEN. A Mapbox pk. token is designed to be client-visible — protect it with
URL restrictions at Mapbox, not by hiding it.
Give your agent the guardrail
If you point an AI agent at this repo, paste this first, before it reads any code:
This is the doxa-maps bundler. Three zones:
library/(@map) = reusable shared blocks — reuse via the@map/…alias, never edit.app-profiles/<map>/= the sandbox for one map — build everything here.internal/= the build machine — never touch it. To build a map: copyapp-profiles/template-bundle/, read its README, pick reusable pieces fromdocs/reference.md, then build the map's own code insideapp-profiles/<the-map>/. Change colors by editing values in that map'ssrc/colors/folder — not logic inlibrary/. Then runbun run build. If a change seems to belong inlibrary/, stop and ask — that's an expert move that can break every other map.
Optional power-up: Mapbox publishes official agent skills (cartography, style patterns,
performance, integration) that make an agent much better at map work —
/plugin marketplace add mapbox/mapbox-agent-skills then /plugin install mapbox, or
npx skills add mapbox/mapbox-agent-skills.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Changed a .vue, served site shows old map |
You ran dev, or didn't rebuild |
bun run build, then refresh |
Set BUNDLE=<name> for build |
Ran vite build with no bundle selected |
bun run build (all) or BUNDLE=<name> bun run build:bundle |
| New bundle doesn't appear | Missing index.html or index.js in the folder |
Vite discovers a bundle only when both exist |
| Old bundle still loads after a build | Browser cached the static .js |
Hard-refresh / disable cache in devtools |
| Map is blank on a partner page | Mapbox peer not loaded, or no token | Load mapbox-gl.js + geocoder before the bundle; pass a valid tk |
Profile not found error box |
profile doesn't match a flat .vue filename in the bundle |
Fix the profile field to match a filename |