Audited README.md and all 35 documentation/*.md against main. Script names and markdown links were already clean — 59 documented scripts all exist in package.json, 0 broken links. What was wrong was file-map.md describing files that no longer exist, and three paths that had moved. REMOVED, all archived inafb998f17or earlier and still documented as current: internal/staging.js — the repo-root staging page; the plugin studio is the only entry now index.html — the same page's HTML library/composables/useDoxaSearch.js — deleted inea0c47b27, and dropped from a prose list too REPOINTED: the three colour strategies documented under plugin-designs/doxa-research-map/src/colors/ live in library/colors/. THIRD-PARTY.md's two vendored files live under data/vendor/. embeddability.md's reference to the staging script now says it was archived and where the entry is. WHAT I DID NOT "FIX", because it was not broken: ~20 relative shorthand paths in the architecture docs (`composables/useMapData.js` inside a document about library/) read correctly in context, and three paths name things that are not repo files at all — a runtime endpoint (public/api/maps/token.json), a generated artifact (out/_records.json) and a placeholder (./bundle/bundle.js). Reporting those as defects would have buried the five real ones in 23 false positives. TWO MEASUREMENT TRAPS I fell into on the way, both corrected before reporting: my first env-var scan grepped only internal/, plugin-designs/ and library/ and declared 13 documented env vars unread — 11 of them are read in internal/build-lock.sh, .env.example, deployment/cloud/Dockerfile and the Go backend. And an earlier sweep of my own data files reported six "rega" hits that were all the word "aggregates".
5.4 KiB
Architecture — the embeddability design (generated pages + manifest)
The simple recipes are in ../how-to.md. This is the design that makes every map embeddable by default: the build generates a self-contained page per map.
What bun run tree generates
After the per-bundle vite builds, internal/generate-tree.mjs writes the served tree:
public/doxa-maps/
├── index.html ← staging root (semantic tree of every map)
├── manifest.json ← machine-readable catalog + ready-made embed snippets
└── <bundle>/
├── <bundle>.js ← the bundle IIFE (the island)
├── index.html ← per-bundle staging/showcase page
├── <profile>/index.html ← ★ standalone page per profile — link it or iframe it
└── <instance>/index.html ← ★ same, per parameterized instance (e.g. prayer/)
The ★ pages are the embeddable-by-default surface: every .vue profile (and every
instance in a *.instances.json) automatically gets a directly linkable, iframe-able URL —
no per-map work.
Anatomy of a generated profile page
Each <profile>/index.html is a bare full-page map — no chrome, so the URL is the map
("Share shares THE MAP"). It loads: the Mapbox GL peer (once, same realm), the bundle IIFE via
a relative ../<bundle>.js, and mounts the element with that profile's config. The token
resolves by the ladder (prop → ?tk= → baked → window.MAP_TOKEN_URL → tokenUrl →
same-origin /api/maps/token), fetched at page load — tokens are treated as data, never eval'd.
All URLs stay relative unless embedBaseUrl is set, so the whole tree can be dropped under
any web-root or subpath and keeps working (check:drop gates this).
The manifest — embed snippets as data
manifest.json (doxa-maps-manifest@1) lists every bundle → profiles, each with element,
scriptUrl, iframeUrl, and a ready-made embed object:
embed.iframe— a paste-ready<iframe src=".../<profile>/index.html" …>snippetembed.script— the<script src>+<element profile-config='…'>pair
Hosts, staging pages, and the share button all read this one catalog — snippets are generated once, never hand-written.
Two embed modes, one rule about iframes
| Mode | What | When |
|---|---|---|
| script + element | host page loads the IIFE, drops the tag | full integration: host styles/sizes it, passes config |
| iframe / direct link | point at the generated <profile>/index.html |
zero-effort embeds, CMSs that only allow iframes, shared links |
Both are supported on built pages. The one "never iframe" rule applies only to the dev
staging script (the archived repo-root staging page, removed in afb998f17 — the plugin studio is the only entry now), which mounts elements directly because a dev-server
iframe crosses realms (instanceof breaks). Built pages have no such problem — the generated
staging (staging-forge.js) itself uses an iframe stage.
Direct-embed vs staging — the framing contract (serving layer)
The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
| Surface | Paths | frame-ancestors |
|---|---|---|
| Direct embed (the product) | <bundle>/<profile>/index.html, <bundle>/<profile>/<instance>/index.html — the bare map pages the manifest's iframeUrl points at |
* — framable on any partner site (that's the whole point) |
| Staging (internal preview) | tree root index.html, per-bundle <bundle>/index.html |
'self' — preview only |
The Research Gallery (doxa-maps-spa/doxa-maps-spa/index.html) is a direct-embed page like any
other leaf. Its full embed contract (script and iframe) is in
plugin-designs/doxa-maps-spa/EMBED.md.
CORS is a separate requirement, and it covers the whole tree. A script embed runs in the
partner's document, so everything a composed surface fetch()es — manifest.json at the tree
root, and assets/ — needs Access-Control-Allow-Origin: *. Applying it only to the ≥2-segment
embed pages leaves manifest.json uncovered. bun run check:gallery-embed proves the whole
chain from a second origin.
frame-ancestors can ONLY be set by an HTTP response header (HTML <meta> cannot set it), so
the server/CDN owns this split — the bundler cannot enforce it. Whoever serves the tree must
apply it. Rule of thumb: a path with ≥2 directory segments under the tree root is a direct-embed
page → serve it framable.
Portable ways to implement the split, by host:
- Traefik (a self-hosted reverse proxy): a higher-priority router on
PathRegexp(^/plugin-assets/[^/]+/[^/]+/)setsContent-Security-Policy: frame-ancestors *+ clearsX-Frame-Options. (Live example: a Traefik dynamic-configuration file on the test host.) - Cloudflare Pages / Netlify: a
_headersfile —/<bundle>/*/* Content-Security-Policy: frame-ancestors *. - Nginx:
location ~ ^/[^/]+/[^/]+/ { add_header Content-Security-Policy "frame-ancestors *"; } - GitHub Pages: serves no custom headers → pages are framable by default (no frame-ancestors), which is already correct for direct embeds.
If you serve the WHOLE tree with one blanket frame-ancestors 'self', direct embeds break on
partner sites (the staging policy leaks onto the product pages). That is the one mistake to avoid.