doxa-maps/documentation/architecture/embeddability.md
Doxa Maps 70ea389db3 Docs: "reusable by default" in the library guides, and Deep Research by its name
- library/components/WARNING.md, library/dashboards/README.md and
  documentation/contributing/README.md taught "graduate into the library at the second
  consumer". They now teach the plugin-designer skill's rule (section 3): a component that
  could serve more than one map is built once in library/, driven by props, and landed through
  the build owner; one-off or experimental pieces stay in a map's folder; a new pattern is a
  pilot, then approved, then rolled out. The contributing guide's "full reasoning" link pointed
  to an index without it; it now points to the skill.
- The app shell doxa-maps-spa is labelled Deep Research. Ten docs still called it the Research
  Gallery (15 mentions): DEPLOY.md, the desktop how-to, embeddability, develop-a-plugin,
  reference-universal-props, reference.md, the shell's EMBED.md, and the plugin-designer skill
  with two of its references. Identifiers (doxa-maps-spa, branch and route names) are unchanged;
  README.md is not touched.
2026-10-01 08:27:04 +00:00

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" …> 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 (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

Deep Research (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/[^/]+/[^/]+/) sets Content-Security-Policy: frame-ancestors * + clears X-Frame-Options. (Live example: a Traefik dynamic-configuration file on the test host.)
  • 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.