16 KiB
| name | description |
|---|---|
| plugin-designer | Use this skill to design and build a new map plugin in this repo's plugin-designs/ folder, or to add a screen to an existing one. Teaches the bundle = folder = one JS file pattern, reusing library/ before writing code, the universal props every plugin must honour (map engine, language, Mapbox token or token service), the rules for working under both MapLibre and Mapbox, localization and right-to-left, the five capability levels, the one build step, and the three outputs (web folder, mobile app, desktop app). Works directly with the Plugin Studio (`bun run dev`). |
Plugin designer
You build map plugins for this repo. A plugin is a folder under plugin-designs/; one build
turns it into one JavaScript file that any page can embed as an HTML tag. Your plugin must work
the same under both map engines, in every language, and with or without a Mapbox token.
Run every command from the repo root (the folder with package.json).
1. The pattern: bundle = folder = one JS file
plugin-designs/<your-plugin>/ one FOLDER = one BUNDLE (the folder name is the bundle name)
├── index.js entry: registers the custom element, finds the profiles
├── index.html a staging page that mounts the element
├── <screen>.vue one .vue = one PROFILE (one screen), FLAT in the folder
├── <screen>.meta.json optional: {"type": "<capability>", "label": "…"}
├── <screen>.instances.json optional: named presets of one screen (capability 2)
├── data/ optional: the bundle's own data files (imported, so they ship inside the bundle)
└── src/ the bundle's PRIVATE code: components, colors, helpers
builds to ─────────────► public/dx-maps/<your-plugin>/<your-plugin>.js
- Profiles are flat.
index.jsfinds them withimport.meta.glob('./*.vue'); a.vuein a subfolder is not found. The filename (without.vue) is the profile's name. - Elements live inside. One bundle registers one tag named after the folder, and can hold more
elements for its own screens to nest (see
plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json). - Folders starting with
_are skipped by the build: a free place for work in progress. - New plugins appear in the Plugin Studio at once, and in the Research Gallery's Experimental
section automatically. Moving one into a production section is a deliberate edit to the
gallery's curated sections (
plugin-designs/doxa-maps-spa/doxa-maps-spa.vue), not your call alone.
2. Start by copying the starter
cp -r plugin-designs/template-bundle plugin-designs/<your-plugin>
Then rename exactly these, and nothing else:
- the tag in
index.js— both thecustomElements.define('template-bundle', …)call and thecustomElements.get('template-bundle')guard; - the tag, the
profilestrings and the CSS selector inindex.html; - the
.vuefilenames (template-map-a.vue→<screen>.vue); deletetemplate-map-b.vueif you need one screen.
template-map-a.vue is a real minimal map: it draws data/places.geojson on a basemap with one
translated title, and already follows the page's engine, language (with right to left) and token,
falling back to keyless MapLibre. Replace data/places.geojson with your own points and you have
a working map to build on. Its title key (template.title) lives in the library catalogues. Your own
translated text needs its own catalogue file in library/i18n/locales/, which is a library change
(section 3). Until that lands, plain English in your .vue is fine.
Keep the entry's configureApp exactly as the starter has it: it installs Pinia, installs i18n
and provides it as appI18n, and provides profileModules. The element's root
(library/ProfileLoader.vue) cannot call useI18n(); it drives the language through the
provided instance. Drop the provide and a host can no longer set your plugin's language.
3. Reuse library/ first
library/ (imported as @map/…) is shared by every plugin. Its menu is
documentation/reference.md.
- Reuse by import, never by copy.
import { useMapInstance } from '@map/composables/useMapInstance.js'. - Your own code goes in your
src/, imported by relative path. Nothing there reaches another plugin. - Don't edit
library/to customise one plugin. A library change reaches every plugin that imports it, so it needs every consumer rebuilt. On a team with a build owner, hand library changes to that owner rather than landing them yourself. - Graduate late. Move code into
library/only when a second plugin needs the same thing and it takes props instead of hard-coded specifics.
4. The universal props — every plugin honours these
A plugin never decides these for itself; it reads them from whoever embeds it. The Plugin Studio header sets all of them, which is how you test yours.
| Prop | Values | Channels, first found wins | Default |
|---|---|---|---|
Map engine (provider) |
mapbox · maplibre · auto (any case) |
profile-config "provider" → ?provider= in the page address → window.MAP_PROVIDER; an explicit choice always wins |
auto: Mapbox when a token is available (any row below), MapLibre otherwise. Confirm in library/composables/useMapProvider.js, never assume it |
Language (locale) |
one of the 11 in library/i18n/locales/ |
profile-config "locale" (or "lang") → ?lang= / ?locale= → <html lang> |
English |
Mapbox token (tk) |
a public pk.… token |
profile-config "tk" → ?tk= on a generated page → window.MAP_TOKEN; generated pages also keep a token pasted once in the tree (browser storage) |
none. The embed snippet's YOUR_MAPBOX_TOKEN placeholder counts as no token |
Token service (tokenUrl) |
a URL that returns a pk. token |
profile-config "tokenUrl" (the element asks it when it has no token; the studio header's field feeds this) → on generated pages also window.MAP_TOKEN_URL → the build config's tokenUrl → same-origin /api/maps/token |
none |
<your-plugin profile-config='{"profile":"<screen>","provider":"maplibre","locale":"ar"}'></your-plugin>
Read them through the library (resolveMapProvider, the injected mapboxToken, the i18n
instance), never by parsing the URL yourself, and never fetch a token service yourself: the
library asks it, accepts only a pk. answer, and a Mapbox map waits for it before it starts.
Your plugin must draw under MapLibre with no token at all. library/composables/useMapProvider.js and
library/ProfileLoader.vue are the authority when this table and the code disagree.
5. Two engines: rules that keep MapLibre and Mapbox equal
Write every map feature once, so it works under both engines. These rules each come from a real bug:
-
Ask the map which engine it is. Use
engineFor(map)/isMapboxMap(map)from@map/composables/useMapProvider.js, never a global that may still be defined after a switch. -
Some APIs differ in shape.
getProjection()is{name}on Mapbox but{type}orundefinedon MapLibre;setProjectiontakes'mercator'on Mapbox but{type:'mercator'}on MapLibre;setConfigPropertyand light presets exist only on Mapbox. Wrap these calls intry, and never assume the result's shape. An uncaught throw silently skips everything after it. -
Mapbox-only paint properties drop the whole layer on MapLibre. Never write
'circle-emissive-strength'(or any*-emissive-strength) literally. Spread...emissivePaint(map, kind)fromuseMapProvider.js. -
Fonts must exist on the engine's glyph server. Under MapLibre, a Mapbox font name (
DIN Offc Pro,Arial Unicode MS) gets a 404, and the layer draws no text with no error. Spread...labelFont(map, {…})from@map/maps/label-font.jsinto every symbol layer. -
mapbox://sources don't load on MapLibre. The country polygons come from the Mapbox tilesetmapbox.country-boundaries-v1under Mapbox. Under MapLibre, use the local fileboundaries/world-adm0.geojsonunder the assets base. It names the country codeiso3, where the tileset saysiso_3166_1_alpha_3, so normalise once.library/composables/useMapLayers.js(the "region polygons, per engine" block) is the worked example. -
Load assets from the assets base, never a page-relative path. Use
resolveAssetsBase(profileConfig)from@map/utils/embedBase.js. A hard-coded./assetsworks in the built folder and breaks underbun run dev. -
Prove your expressions parse under MapLibre without a browser. MapLibre's own parser,
@maplibre/maplibre-gl-style-spec, is installed as a dependency ofmaplibre-gl. It is NOT declared inpackage.json, so a lockfile change can remove it: if the import fails, add it as a dev dependency rather than dropping the check.import { expression, featureFilter } from '@maplibre/maplibre-gl-style-spec' const e = expression.createExpression(['match', ['get', 'status'], 'reached', '#2e7d32', '#c62828']) if (e.result !== 'success') throw new Error(e.value.map(x => x.message).join('; ')) const admits = featureFilter(['==', ['get', 'iso3'], 'TUN']).filter({ zoom: 2 }, { properties: { iso3: 'TUN' } })Run each paint and filter expression your layers use against a few real features from the data.
6. Localization and right to left
- Every word on screen comes from a catalogue in
library/i18n/locales/<code>/*.json, the same key in all 11 languages. Components uset('file.key'); code that runs outside the Vue app usestStatic(path, locale)from@map/i18n/index.js. - Add a key to all 11 catalogues at once.
bun run check:i18n-parityfails when one is missing;bun run check:i18n-never-translatedflags a key that was copied, not translated. - Names from the data stay as the data has them (people groups, places); translate only the interface.
- Right to left belongs to your element, not the page. Arabic mirrors the plugin's own box; the
library sets
diron it. Never writedocument.documentElement.dirfrom a plugin: an embedded plugin that does flips the whole partner page.
7. Choose the capability level
Pick the lowest level that does the job. Declare levels 3–5 in <screen>.meta.json "type";
levels 1 and 2 are detected on their own.
| Level | Name (type) |
What it is | Example |
|---|---|---|---|
| 1 | single element (single) |
one screen, one element | plugin-designs/prayer-walk-planner/ |
| 2 | preset element (parameterized) |
one screen, several named presets from <screen>.instances.json |
plugin-designs/doxa-simple-map/ |
| 3 | multi-view element (monolithic) |
many views fused inside one element | plugin-designs/doxa-research-map/ |
| 4 | composite element (nested-elements-mfe) |
nests other elements, each still embeddable alone, wired by props and events | plugin-designs/dashboard-plus-grid/ (the Big Picture tab) |
| 5 | app shell (nested-mfe-spa) |
nests whole apps | plugin-designs/doxa-maps-spa/ (the Research Gallery) |
8. Learn from the reference plugins
The seven production plugins are the reference: each shows what a good plugin does. Before you write a rule's code, read the plugin that shows it best. Find code by the name given here, not by line number, because lines move.
| Rule | Read this | What to look at |
|---|---|---|
| Entry: i18n installed and provided; one bundle, two tags | Prayer Progress: plugin-designs/doxa-simple-map/index.js |
configureApp and its app.provide('appI18n', …) |
| Presets from one screen (capability 2) | Prayer Progress: plugin-designs/doxa-simple-map/simple-map.instances.json |
Prayer / Engagement / Adoption as three embeddable pages |
| Engine-asymmetric APIs, guarded | Regions: plugin-designs/regions-dashboard/src/RegionCountryMap.vue |
projectionOf and setProjectionOn — both shapes, inside try |
| Polygons both engines can draw; data from the assets base | Cities with Internet: plugin-designs/cities-internet/connectivity-map.vue |
resolveAssetsBase(…), the boundaries/world-adm0.geojson fetch (polygon data with no mapbox:// source), and resolveNamedStyle(…, provider) for a basemap that matches the engine |
| Interface text from the catalogues | Gospel Resources: plugin-designs/gospel-resources/resources-map.vue |
its interface text goes through t('…') (about 80 calls) |
| Basemap labels in the reader's language; right to left | Prayer Progress: plugin-designs/doxa-simple-map/doxa-simple-map.vue |
the name_<locale> label switch and isRTL |
| Interop: one element reacts to another's events | Simple Research: plugin-designs/research-map-plus-grid/research-map-plus-grid.vue |
the map listening for the grid's groups-filtered event |
| Composite element (capability 4) | Big Picture: plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json |
"composes": two elements, each still embeddable alone |
| One bundle, several embeddable screens | UPG-100: plugin-designs/my-upg-100-list/ |
upg-100-mfe, upg-100-list and poster-wall from one bundle |
| App shell (capability 5) | Research Gallery: plugin-designs/doxa-maps-spa/doxa-maps-spa.vue |
every production plugin as a tab, and the Experimental folder filled from the manifest |
emissivePaint and labelFont (section 5) have no production-plugin exemplar, because the
production plugins get them through the library's own layers. The starter uses both directly:
read the pin and label layers in plugin-designs/template-bundle/template-map-a.vue.
9. Develop in the Plugin Studio
bun install # once
bun run dev # the Plugin Studio at http://localhost:5173/
- Your plugin appears in the studio as soon as its folder exists, and edits reload live.
- One screen on its own:
http://localhost:5173/__leaf/<your-plugin>/<screen>. - Test the universal props from the studio header: switch the engine, the language (include Arabic), and the token (none, a token, a token service). Your plugin must work in every combination.
bun run check:dev-assetschecks that every map's data files load under the dev server.
10. Build: one step
BUNDLE=<your-plugin> bun x vite build # just your bundle → public/dx-maps/<your-plugin>/
BUILD_ALLOW=1 bun run build # every bundle, the tree, the manifest, the studio
The full build rewrites every plugin's output. It refuses to run without BUILD_ALLOW=1, and
on a team one agent owns it. Build only your own bundle unless you are that agent.
public/dx-maps/manifest.json then lists your plugin with ready-to-paste embed snippets.
11. The three outputs
| Output | What it is | How |
|---|---|---|
| 1. The web folder | public/dx-maps/: every plugin, embeddable by iframe or script tag, from any host |
copy the folder; snippets are in manifest.json |
| 2. The mobile app | the same folder wrapped by Capacitor (capacitor.config.json webDir: public/dx-maps) |
bun run phone (Android) |
| 3. The desktop app | the same folder in Tauri (deployment/src-tauri/), a proof of concept |
bun run tauri:build |
WordPress (deployment/wordpress/) embeds the web folder's snippets; it's experimental.
12. Done means
BUNDLE=<your-plugin> bun x vite buildsucceeds, and your plugin is inmanifest.json.- It works in the Plugin Studio under both engines, with and without a token, in English and Arabic.
- Every new string is in all 11 catalogues (
bun run check:i18n-parity). - Your layer expressions parse under MapLibre (section 5, rule 7).
- You changed nothing in
library/or in another plugin's folder, or you handed that change to the build owner.
References (all in this repo)
plugin-designs/README.md: authoring guide, production vs experimentalplugin-designs/template-bundle/: the starter, and its READMEdocumentation/reference.md: the@map/…library menu and the profile typesdocumentation/how-to.md: recipes, including swapping the map enginelibrary/composables/useMapProvider.js: engine resolution and engine helperslibrary/maps/label-font.js: fonts per enginelibrary/i18n/index.js: languages,tStatic, right to left