doxa-maps/documentation/architecture/embeddability.md
doxa-maps-88655c70 3a2cf0e8f2 Research Gallery: embed on someone else's page, in both modes
The gallery is going onto the DX-life research page, and nothing had tried
it on a page it didn't generate. From a second origin, a bare
<script> + <doxa-maps-spa> drew over the host's header, and 8 of 13 tabs
showed an error card.

Three causes, each fixed in the shell:

- No :host box. The layout root is position:absolute, so it anchored to the
  viewport instead of the element. :host is now a positioned block with a
  default height the host can override.
- A foreign page sets none of the globals a leaf page bakes. Every data call
  hit the host's own origin and got HTML back. The manifest now publishes a
  `runtime` block (apiBaseUrl + the public token every leaf already carries).
  The shell fills window.MAP_APP_API_URL / window.MAP_TOKEN only when the page
  hasn't set them, and forwards tk in profile-config.
- Nested elements resolved assets to <tree>/../assets, a sibling pool the
  publisher doesn't ship (404 live). The shell now forwards an absolute
  <tree>/assets. This also fixes the iframe mode, where Cities with Internet
  and Gospel Resources were failing the same way.

Also adds a `tab` attribute (plus ?tab=) to deep-link into one map, and a
`tab-change` event for the host.

check:gallery-embed drives every tab in both modes from a second origin. It
passes here and fails on the previous tree (7 findings).

EMBED.md documents the contract. The Traefik template now sends CORS for the
whole tree: live manifest.json answers without Access-Control-Allow-Origin.
2026-09-24 19:03:37 +00:00

5.3 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" …> snippet
  • embed.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 (internal/staging.js), 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 (nuxtbase/go-base): a higher-priority router on PathRegexp(^/plugin-assets/[^/]+/[^/]+/) sets Content-Security-Policy: frame-ancestors * + clears X-Frame-Options. (Live example: /data/coolify/proxy/dynamic/doxa-maps-embed.yaml.)
  • Cloudflare Pages / Netlify: a _headers file — /<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.