Share > Embed: three codes for THIS plugin (URL, Iframe, JavaScript), from the tree it is served from (shareable pages P3b)

The Embed tab now offers the plugin's own page (URL), that page in an iframe
(with the sizes), and its own bundle + element (JavaScript), each with one plain
sentence and one Copy button. They embed the plugin whose Share was pressed, even
nested in Plugin Studio, Deep Research or Posterverse.

Where the codes point: a new build-config key shareBaseUrl, baked as
__DOXA_SHARE_BASE__ and overridable per embed (profile-config.shareBaseUrl) or
per page (window.MAP_SHARE_BASE_URL), https only (or a local test server). It is
committed EMPTY: the codes then point at the tree the bundle is served from, so
the stable site gives its own public URLs by construction. embedBaseUrl stays
empty, so the manifest's snippets and a dropped tree are unchanged.

The JavaScript code is the plugin's embed.script from that tree's manifest
(library/utils/publicEmbed.js, fetched once when Embed opens, default
credentials), made absolute, with integrity dropped from our unversioned bundle
URL (a pinned hash would break every partner's embed at the next release). A
plugin that tree does not carry gets a plain note instead of codes that would
404. Share this page stays first; the explanation and the 5 steps stay. English
only (parity debt 19 per locale). The library index lists publicEmbed.js.
This commit is contained in:
Doxa Maps 2026-10-03 08:10:31 +00:00
parent bcbaa816ae
commit 896775903b
10 changed files with 207 additions and 44 deletions

View file

@ -280,6 +280,8 @@ and a change here can break other maps.
`import … from '@map/utils/peopleGroupName.js'`
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
`import … from '@map/utils/peopleGroupPoints.js'`
- **publicEmbed** — publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
`import … from '@map/utils/publicEmbed.js'`
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
`import … from '@map/utils/scrollport.js'`
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,

View file

@ -50,6 +50,19 @@ function readGated() {
}
export const GATED_PROFILES = readGated()
// The PUBLIC address of the tree for the Share menu's Embed tab (the config's shareBaseUrl): an https URL with no
// trailing slash, or '' (unset). vite.config.js bakes it as __DOXA_SHARE_BASE__; library/utils/embedBase.js reads it.
function readShareBase() {
let v = ''
try {
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
v = typeof cfg.shareBaseUrl === 'string' ? cfg.shareBaseUrl.trim().replace(/\/+$/, '') : ''
} catch { return '' }
if (v && !/^https:\/\/[^\s"'<>]+$/i.test(v)) throw new Error(`[_tree-name] build config "shareBaseUrl" must be an https URL, got ${JSON.stringify(v)}`)
return v
}
export const SHARE_BASE = readShareBase()
// The name escaped for use inside a RegExp source (a '.' in a future name must not match any char).
export const TREE_NAME_RE = TREE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

View file

@ -71,7 +71,8 @@ const DEBT = {
// 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now.
// tStatic falls back to the English text, so these locales show English until the translations arrive;
// lower each to 0 then, and empty the map again.
ar: 10, de: 10, es: 10, fr: 10, hi: 10, it: 10, pt: 10, ro: 10, ru: 10, zh: 10,
// + the Embed tab's 9 keys (URL / Iframe / JavaScript, P3b), the same English-only rule.
ar: 19, de: 19, es: 19, fr: 19, hi: 19, it: 19, pt: 19, ro: 19, ru: 19, zh: 19,
}
const fails = []

View file

@ -33,7 +33,8 @@ import { tStatic } from '@map/i18n/index.js'
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { resolveShareUrl } from '../../utils/embedBase.js'
import { resolveShareUrl, resolvePublicBase, resolveEmbedBase } from '../../utils/embedBase.js'
import { publicLeafUrl, publicScriptCode } from '../../utils/publicEmbed.js'
import { urlOwnerOf } from '../../composables/useViewAddress.js'
import MapControlButton from './MapControlButton.vue'
import MapControlMenu from './MapControlMenu.vue'
@ -357,6 +358,8 @@ const pageUrl = ref('') // "Share this page": read when the menu
const linkCopied = ref(false)
const embedCopied = ref(false)
const embedSize = ref('medium') // 'small' | 'medium' | 'large'
const embedKind = ref('url') // 'url' | 'iframe' | 'script': the Embed tab's three codes
const scriptState = ref({ loading: false }) // the JavaScript code from the public manifest: { code } | { missing } | { failed }
const sizePresets = {
small: { label: 'Small', height: 400 },
@ -390,10 +393,35 @@ const shareUrl = computed(() => resolveShareUrl({
profileConfig: unref(profileConfig) || null,
}))
/* ★ EMBED THIS PLUGIN, FROM THE PUBLIC SITE (shareable pages P3b). The Embed tab's three codes are for the plugin
whose Share was pressed (its own page; its own bundle + element), even nested in Plugin Studio, Deep Research or
Posterverse, and they point at the tree's PUBLIC address (resolvePublicBase: the build's shareBaseUrl), never a dev
server or the page's own host. With no public address configured, the tree this bundle loaded from stands in. */
const embedBase = computed(() => resolvePublicBase(unref(profileConfig) || null) || resolveEmbedBase(unref(profileConfig) || null))
const embedPathOf = () => props.embedPath || (unref(profileConfig) || {}).shareEmbedPath || ''
// URL: the plugin's own generated page on the public site; an absolute or root path keeps the old resolution.
const leafUrl = computed(() => publicLeafUrl(embedBase.value, embedPathOf()) || shareUrl.value)
const embedSnippet = computed(() => {
const h = sizePresets[embedSize.value].height
return '<iframe src="' + shareUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
return '<iframe src="' + leafUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
})
// JavaScript: the plugin's embed.script from the public manifest, read once the Embed tab opens. "missing" means the
// public site does not carry this plugin yet, so the tab shows a plain note instead of codes that would 404.
function loadScriptCode() {
if (scriptState.value.code || scriptState.value.loading) return
scriptState.value = { loading: true }
publicScriptCode(embedBase.value, embedPathOf()).then((r) => { scriptState.value = r })
}
const embedText = computed(() => embedKind.value === 'url' ? leafUrl.value
: embedKind.value === 'iframe' ? embedSnippet.value : (scriptState.value.code || ''))
const EMBED_KINDS = ['url', 'iframe', 'script']
const kindLabel = (k) => ({ url: T('share.kind_url', 'URL'), iframe: T('share.kind_iframe', 'Iframe'), script: T('share.kind_script', 'JavaScript') })[k]
// One plain sentence per code, for a reader who has never embedded anything.
const kindHint = (k) => ({
url: T('share.url_hint', 'A link to open the map on its own page.'),
iframe: T('share.iframe_hint', 'Put the map in your page, in a frame.'),
script: T('share.script_hint', 'Load the map straight into your page, with no frame.'),
})[k]
/* ★ SHARE THIS PAGE. Where the page OWNS its address (useViewAddress: urlOwner 'app' on our own pages,
'host' on DX Life), the address bar already names the exact view (the tab, and the filters), so the
@ -433,6 +461,7 @@ function toggle() {
pageUrl.value = pageLink()
linkCopied.value = false
embedCopied.value = false
embedKind.value = 'url'
}
}
@ -472,7 +501,7 @@ async function copyLink() {
}
async function copyEmbed() {
if (await copyText(embedSnippet.value)) {
if (embedText.value && await copyText(embedText.value)) {
embedCopied.value = true
setTimeout(() => { embedCopied.value = false }, 2000)
}
@ -578,7 +607,7 @@ function bindAnchor(el) {
<button
class="share-tab" role="tab" data-share-tab="embed"
:class="{ active: activeTab === 'embed' }" :aria-selected="String(activeTab === 'embed')"
@click="activeTab = 'embed'"
@click="activeTab = 'embed'; loadScriptCode()"
>{{ T('share.tab_embed', 'Embed') }}</button>
</div>
@ -612,7 +641,7 @@ function bindAnchor(el) {
</button>
</div>
<!-- ===== Tab 2: Embed (for another website) ===== -->
<!-- ===== Tab 2: Embed (for another website): THIS plugin, from the public site ===== -->
<div v-if="activeTab === 'embed'" class="share-body">
<!-- What embedding is and why it helps, in plain words, before any code -->
<div class="share-embed-desc">
@ -622,43 +651,57 @@ function bindAnchor(el) {
<ul class="share-steps">
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
</ul>
<div class="share-embed-desc">
{{ T('share.embed_paste_hint', 'Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).') }}
<!-- Not on the public site yet: one plain note instead of codes that would 404 -->
<div v-if="scriptState.missing" class="share-embed-desc" data-embed-note>
{{ T('share.not_public', "This map isn't on the public site yet. Its embed codes will appear here once it's released.") }}
</div>
<!-- Size presets -->
<div class="share-size-pills">
<template v-else>
<!-- Three ways, one at a time: a link, a frame, or straight into the page -->
<div class="share-size-pills" role="tablist">
<button
v-for="k in EMBED_KINDS" :key="k" type="button" role="tab"
class="share-size-pill" :data-embed-kind="k"
:class="{ active: embedKind === k }" :aria-selected="String(embedKind === k)"
@click="embedKind = k"
>{{ kindLabel(k) }}</button>
</div>
<div class="share-embed-desc">{{ kindHint(embedKind) }}</div>
<!-- Iframe: its height -->
<div v-if="embedKind === 'iframe'" class="share-size-pills">
<button
v-for="(preset, key) in sizePresets"
:key="key"
class="share-size-pill"
:class="{ active: embedSize === key }"
@click="embedSize = key"
>{{ sizeLabel(key) }}</button>
</div>
<!-- JavaScript: read from the public site when this tab opens -->
<div v-if="embedKind === 'script' && !scriptState.code" class="share-embed-desc">
{{ scriptState.failed ? T('share.public_failed', "Couldn't reach the public site just now. Try again in a moment.") : T('share.script_loading', 'Reading the public site…') }}
</div>
<div v-else class="share-code-box" data-embed-code>{{ embedText }}</div>
<!-- One Copy button -->
<button
v-for="(preset, key) in sizePresets"
:key="key"
class="share-size-pill"
:class="{ active: embedSize === key }"
@click="embedSize = key"
>{{ sizeLabel(key) }}</button>
</div>
<!-- Code box -->
<div class="share-code-box">{{ embedSnippet }}</div>
<!-- Copy Code button -->
<button
class="share-secondary-btn"
:class="{ copied: embedCopied }"
@click="copyEmbed"
>
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ embedCopied ? T('share.copied', 'Copied!') : T('share.copy_code', 'Copy code') }}
</button>
v-if="embedText"
class="share-secondary-btn"
:class="{ copied: embedCopied }"
@click="copyEmbed"
>
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ embedCopied ? T('share.copied', 'Copied!') : (embedKind === 'url' ? T('share.copy_link', 'Copy link') : T('share.copy_code', 'Copy code')) }}
</button>
</template>
</div>
</div>
</MapControlMenu>

View file

@ -11,5 +11,14 @@
"copied": "Copied!",
"size_small": "Small",
"size_medium": "Medium",
"size_large": "Large"
"size_large": "Large",
"kind_url": "URL",
"kind_iframe": "Iframe",
"kind_script": "JavaScript",
"url_hint": "A link to open the map on its own page.",
"iframe_hint": "Put the map in your page, in a frame.",
"script_hint": "Load the map straight into your page, with no frame.",
"script_loading": "Reading the public site…",
"not_public": "This map isn't on the public site yet. Its embed codes will appear here once it's released.",
"public_failed": "Couldn't reach the public site just now. Try again in a moment."
}

View file

@ -212,6 +212,24 @@ export function absoluteEmbedUrl(leaf, profileConfig = null) {
return rel
}
/**
* The tree's PUBLIC address, for the Share menu's Embed tab (no trailing slash). Its URL, Iframe and JavaScript codes
* point here, so an embed follows the stable releases served there, never a dev server or the page's own host.
* Ladder: profileConfig.shareBaseUrl → window.MAP_SHARE_BASE_URL → the build's shareBaseUrl (vite.doxa-maps-build-
* config.json, baked as __DOXA_SHARE_BASE__) → '' (unset: the Embed tab keeps the tree the bundle was loaded from).
* @param {object|null} [profileConfig] the parsed profile-config (already unref'd)
* @returns {string}
*/
export function resolvePublicBase(profileConfig) {
// https only (an http base would give mixed-content codes on an https partner page), plus a local test server
const ok = (v) => typeof v === 'string' && /^(https:\/\/|http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/)/i.test(v.trim())
const fromConfig = profileConfig?.shareBaseUrl
if (ok(fromConfig)) return stripSlashes(fromConfig)
if (typeof window !== 'undefined' && ok(window.MAP_SHARE_BASE_URL)) return stripSlashes(window.MAP_SHARE_BASE_URL)
// typeof-guarded: a build or a test that defines no __DOXA_SHARE_BASE__ reads '' instead of throwing
return stripSlashes(typeof __DOXA_SHARE_BASE__ === 'string' ? __DOXA_SHARE_BASE__ : '')
}
export function resolveShareUrl({ shareUrl, embedPath, profileConfig } = {}) {
const cfg = profileConfig || null
const hasWindow = typeof window !== 'undefined'

View file

@ -0,0 +1,73 @@
/**
* publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
*
* Three codes, all for the plugin itself (its own leaf page, its own bundle + element), never for the app it is
* nested in (Plugin Studio, Deep Research, Posterverse):
* URL <base>/<bundle>/<profile>/index.html, the plugin's generated page;
* Iframe <iframe src="that URL">;
* JavaScript the plugin's own embed.script from the PUBLIC manifest (<base>/manifest.json), made absolute.
*
* ★ THE SCRIPT IS READ FROM THE PUBLIC MANIFEST, NOT BUILT HERE. The manifest's per-profile embed.script is the one
* source of truth for the element tag, its profile-config and the page globals a plugin needs; the PUBLIC copy is the
* release partners will load, so its tag and config match what they get. It is fetched once per base and cached
* (the public host serves it with Access-Control-Allow-Origin: *).
*
* ★ NO INTEGRITY ON OUR BUNDLE. The public bundle URL is not versioned and is rebuilt in place at each release, so a
* pinned integrity="sha384-…" would break every partner's embed at the next release. It is dropped from the copied
* code for scripts served from the base; a versioned third-party URL (the Mapbox preload) keeps its own. Versioned
* release URLs + SRI are a later improvement.
*/
const cache = new Map() // base → Promise<manifest|null>
/** The tree-relative leaf path of an embedPath ('./x/y/index.html' → 'x/y/index.html'); '' for absolute or root paths. */
export function treeRelative(embedPath) {
const p = String(embedPath || '').trim()
if (!p || /^https?:\/\//i.test(p) || p.startsWith('/')) return ''
return p.replace(/^\.\//, '')
}
/** The plugin's public page: <base>/<leaf>. '' when either is unknown. */
export const publicLeafUrl = (base, embedPath) => {
const rel = treeRelative(embedPath)
return base && rel ? base + '/' + rel : ''
}
function loadManifest(base) {
if (!cache.has(base)) {
// the default credentials ('same-origin'): nothing is sent to a cross-origin public host, and behind a login gate
// a same-origin base keeps its session
const p = fetch(base + '/manifest.json')
.then((r) => (r.ok ? r.json() : null))
.catch(() => { cache.delete(base); return undefined }) // a network failure may be retried later
cache.set(base, p)
}
return cache.get(base)
}
/**
* The plugin's JavaScript embed code from the PUBLIC manifest.
* Resolves { code } | { missing: true } (the public site does not carry this plugin yet) | { failed: true }.
*/
export async function publicScriptCode(base, embedPath) {
const rel = treeRelative(embedPath)
if (!base || !rel) return { missing: true }
const m = await loadManifest(base)
if (m === undefined) return { failed: true }
if (!m || !Array.isArray(m.bundles)) return { missing: true }
const norm = (u) => String(u || '').replace(/^\.\//, '')
const [bundle, profile] = rel.split('/')
let hit = null
for (const b of m.bundles) for (const pr of (b.profiles || [])) {
if (norm(pr.iframeUrl) === rel) hit = pr
else if (!hit && b.bundle === bundle && pr.profile === profile) hit = pr // an instance path: its profile's code
}
const raw = hit && hit.embed && hit.embed.script
if (!raw) return { missing: true }
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const code = raw
.replace(/(\s(?:src|href)=")\.\//g, '$1' + base + '/')
.replace(/YOUR_TREE_URL/g, base)
.replace(new RegExp('(<script\\b[^>]*\\ssrc="' + esc + '/[^"]*")\\s+integrity="[^"]*"', 'g'), '$1')
return { code }
}

View file

@ -77,7 +77,7 @@ const GLOBALS = [
'DataView', 'WeakRef', 'FinalizationRegistry', 'ClipboardItem', 'CSSStyleSheet', 'XMLSerializer',
// Build-time defines: vite REPLACES these before the browser ever sees them (vite.config.js
// `define`), so they are bound by the bundler rather than by the file.
'__DOXA_TREE_NAME__', '__DOXA_QA__',
'__DOXA_TREE_NAME__', '__DOXA_QA__', '__DOXA_SHARE_BASE__',
// Bound by the build too, but by a transform rather than `define`: with DX_SHIP_ONE_LOCALE the
// i18nAreasPlugin prepends `const __DX_LOCALE_SRI__ = {…}` to library/i18n/index.js; without it the
// name does not exist, which is why its one read sits behind `typeof … !== 'undefined'`.

View file

@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url'
import { resolve, dirname, join } from 'node:path'
import { readdirSync, statSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { TREE_NAME, GATED_PROFILES } from './internal/_tree-name.mjs'
import { TREE_NAME, GATED_PROFILES, SHARE_BASE } from './internal/_tree-name.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
// Ship only the ACTIVE locale: inline `en` (the fallbackLocale) and emit the other ten beside each
@ -201,6 +201,8 @@ export default defineConfig(({ command, mode }) => {
// The output tree's name (vite.doxa-maps-build-config.json `name`, the same value OUT_ROOT uses),
// for the few runtime paths that must name the tree — so renaming the tree needs no source edit.
__DOXA_TREE_NAME__: JSON.stringify(MFE_NAME),
// The tree's PUBLIC address for the Share menu's Embed tab (the config's shareBaseUrl), or '' when unset.
__DOXA_SHARE_BASE__: JSON.stringify(SHARE_BASE),
},
// Build only: shadow-DOM CSS is injected from useShadowStyles(`…`) literals that
// ship verbatim, so their block comments are stripped before the Vue compiler

View file

@ -3,6 +3,8 @@
"name": "dx-maps",
"paths": [],
"embedBaseUrl": "",
"//shareBaseUrl": "The PUBLIC address of this tree (https, no trailing slash) for the Share menu's Embed tab only: its URL, Iframe and JavaScript codes point there. Empty (the default, committed): the codes point at the tree the bundle is served from, so the stable site gives its own public URLs by construction. Set it at deploy time (or per page: window.MAP_SHARE_BASE_URL, or per embed: profile-config.shareBaseUrl) to send every code to one address. It does NOT touch the manifest's snippets (embedBaseUrl).",
"shareBaseUrl": "",
"assetsBaseUrl": "",
"tokenUrl": "",
"token": "",