doxa-maps/library/utils/embedBase.js
doxa-maps-88655c70 852310d05e
Some checks are pending
build-gate / build (push) Waiting to run
embedBase: the asset pool is INSIDE the tree, not beside it
resolveAssetsBase computed `<treeRoot>/../assets` -- one path segment too high.
This reached the live site. MEASURED by publishing to the catalog and reverting:

  leaf  .../doxa-maps/my-upg-100-list/upg-100-list/index.html + '../../assets'
                            -> /plugin-assets/doxa-maps/assets   200
  code  root '/plugin-assets/doxa-maps' + '/../assets'
                            -> /plugin-assets/assets             404

The UPG-100 map went from "195 polys · 27/27 zones · 2107 pins" to
"⚠ boundary fetch 404 world-adm0.geojson" -- a basemap with none of our data on
it. Reverted within minutes; live is healthy.

★ THE COMMENT JUSTIFIED THE OFF-BY-ONE IN PROSE, which is how it survived. It
said the pool is "a SIBLING of the tree -- <root>/../assets -- which is why the
leaf page's generated value is '../../assets' (two up from
<tree>/<bundle>/<profile>/)". Two up from <tree>/<bundle>/<profile>/ is <tree>,
not the tree's parent. The arithmetic was wrong and the sentence made it read
deliberate. generate-tree stages assets INSIDE the tree on purpose -- "staged N
runtime asset(s) -> doxa-maps/assets", so a dropped folder is self-contained.

⚠ WHY FIFTEEN TICKS OF GREEN GATES MISSED IT, three independent reasons:
  1. INVISIBLE LOCALLY. The dev web-root has BOTH public/assets (a sibling) and
     public/doxa-maps/assets (inside), each holding the boundaries -- so the
     wrong path RESOLVES on 127.0.0.1. The catalog publishes app/doxa-maps only;
     no sibling exists there. No local run can tell the answers apart.
  2. THE BYTE GATES PASSED. check:published and check:catalog-currency both went
     GREEN on the broken publish -- 22 of 22 byte-identical, 0 stale. They prove
     the bytes match, not that the page works.
  3. NO GATE REFERENCED THE FUNCTION, while 10+ profiles across 6 bundles call
     it.

So the fix ships with check:embed-base, which is PURE ARITHMETIC -- no browser,
no network, no served tree, so it cannot SKIP itself into irrelevance. It
computes what a leaf's own '../../assets' means and asserts the resolver agrees:
the oracle is what the generated HTML already says, not an opinion about where
assets belong. It also pins the explicit ladder rungs, so a narrow fix cannot
swallow a host-supplied assetsBase.

MUTATION-TESTED: restoring the original '/../assets' fails all four tree roots
(live catalog, dev, doxa.life drop-in, deep subpath) in milliseconds.
2026-09-25 01:30:56 +00:00

194 lines
11 KiB
JavaScript

/**
* embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
*
* WHY: the built tree (<root>/<bundle>/<bundle>.js + <bundle>/<profile>/index.html …) is
* dropped onto many hosts — the dev plugin gallery, doxa.life under /js/doxa-maps-build/,
* any CDN subpath. Share links + embed snippets must point INTO that tree wherever it
* lands, so profiles declare their embed page TREE-RELATIVE (`<bundle>/<profile>/index.html`
* or `<bundle>/<profile>/<instance>/index.html`) and this module supplies the tree root.
* A hardcoded `/js/doxa-maps/…` 404s the moment the tree is served anywhere else.
*
* Tree root — derived from the bundle's OWN script URL. Rollup rewrites `import.meta.url`
* in the IIFE build to `document.currentScript.src` (captured at script start, with a
* `document.baseURI` fallback), so in a built bundle it is `<treeRoot>/<bundle>/<bundle>.js`
* and the root is two path segments up. It is trusted ONLY when the last two segments look
* like `<name>/<name>.js` (the layout generate-tree.mjs emits). In dev (ESM) it is this
* module's own URL (`…/library/utils/embedBase.js`) and is rejected — the plugin gallery passes
* a whole `shareUrl` instead.
*
* Embed base ladder — resolveEmbedBase(profileConfig):
* 1. profileConfig.embedBaseUrl — host-supplied per embed (`profile-config` JSON)
* 2. window.MAP_EMBED_BASE_URL — host-supplied page global, set before the IIFE loads
* 3. bundleTreeRoot() — derived from the script URL (a dropped tree works anywhere)
* 4. '' — none known; a tree-relative path resolves against the page
*
* Share URL ladder — resolveShareUrl({ shareUrl, embedPath, profileConfig }):
* 1. shareUrl | profileConfig.shareUrl — a WHOLE url from the host wins (plugin gallery deep link)
* 2. embedPath | profileConfig.shareEmbedPath
* absolute http(s) → passes through
* leading '/' → legacy root-relative: location.origin + path
* otherwise (tree-relative) → embedBase + '/' + path when a base is known,
* else resolved against document.baseURI
* 3. location.origin + location.pathname — the page itself ("Share shares THE MAP")
*
* Host usage (inject before the IIFE loads):
* <script>window.MAP_EMBED_BASE_URL = 'https://cdn.example.org/maps/doxa-maps';</script>
* <script src="https://cdn.example.org/maps/doxa-maps/doxa-simple-map/doxa-simple-map.js"></script>
*
* Pure JS, SSR-safe: every window/document access is guarded.
*/
// Evaluated ONCE at module init — inside the IIFE that is script start, while
// document.currentScript still points at the bundle's own <script> tag.
const SCRIPT_URL = import.meta.url
const stripSlashes = (s) => String(s || '').trim().replace(/\/+$/, '')
/**
* The served root of the built tree, derived from the bundle's own script URL.
* Returns null when that URL does not look like a built bundle (`…/<name>/<name>.js`),
* e.g. the dev ESM module URL — never guess from an untrusted layout.
* @returns {string|null} e.g. "https://doxa.life/js/doxa-maps-build/doxa-maps"
*/
export function bundleTreeRoot() {
try {
const href = String(SCRIPT_URL || '').split(/[?#]/)[0]
// …/<dir>/<file>.js — the dir must equal the file's basename (generate-tree layout).
const m = href.match(/^(.*)\/([^/]+)\/([^/]+)\.js$/)
if (!m || m[2] !== m[3]) return null
return m[1] || null
} catch {
return null
}
}
/**
* The base URL tree-relative embed pages are joined to (no trailing slash).
* Ladder: profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL → bundleTreeRoot() → ''.
* @param {object|null} [profileConfig] the parsed profile-config (already unref'd)
* @returns {string}
*/
export function resolveEmbedBase(profileConfig) {
const fromConfig = profileConfig?.embedBaseUrl
if (typeof fromConfig === 'string' && fromConfig.trim()) return stripSlashes(fromConfig)
if (typeof window !== 'undefined') {
const fromHost = window.MAP_EMBED_BASE_URL
if (typeof fromHost === 'string' && fromHost.trim()) return stripSlashes(fromHost)
}
return stripSlashes(bundleTreeRoot() || '')
}
/**
* Where the SHARED asset pool (tables/, boundaries/) is served from — no trailing slash.
*
* ★ THE BUG THIS EXISTS TO END (measured 2026-09-23). Eleven profiles across six bundles each wrote
* the same line: `profileConfig?.assetsBase || './assets'`. That default is correct on a standalone
* leaf page, where the generated host passes `../../assets` and the tree root is two segments up. It
* is WRONG the moment the element is nested in a cap-5 shell, because a RELATIVE path resolves
* against the SHELL's page, not the child's. Proven in a browser against the built tree:
*
* leaf /doxa-maps/gospel-resources/resources-map/index.html ....... 0 failed requests
* shell /doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html .......... 404 …/doxa-maps-spa/doxa-maps-spa/assets/tables/gospel-by-country.json
* 404 …/doxa-maps-spa/doxa-maps-spa/assets/boundaries/world-adm0.geojson
*
* ★ WHY NO BUILD-TIME GATE COULD SEE IT. `'./assets'` is a VALID STRING in both hosts; only the host
* decides what it resolves to. check-composite-srcs proves a nested `src:` exists on disk, which is a
* different question. This class is visible only from a browser, in the shell, which is exactly the
* two-host rule: a defect invisible in one host and fatal in the other.
*
* THE FIX IS IN THE CHILD, NOT THE SHELL. The shell already forwards `treeBase` and says so in its
* own comment ("a nested shell cannot guess it, and guessing is what produced the original 404
* class") — but no asset-reading profile ever read `treeBase`, so that lesson never reached them.
* Teaching each child to derive its own absolute base keeps the shell generic: a NEW bundle that
* fetches assets works nested on day one, with no edit to doxa-maps-spa.vue.
*
* Ladder (first hit wins):
* 1. profileConfig.assetsBase — ABSOLUTE or root-relative only. A host that knows the answer wins,
* but a RELATIVE value is deliberately NOT trusted: that is the broken input this fixes, and
* honouring it would reintroduce the defect through the very config meant to cure it.
* 2. profileConfig.treeBase + '/../assets' — what the cap-5 shell already passes down.
* 3. bundleTreeRoot() + '/../assets' — derived from this bundle's own script URL, so a tree
* dropped on any host resolves correctly with no configuration at all.
* 4. './assets' — the historical default; correct on a leaf page and
* the only sane answer when nothing above is knowable (dev ESM, a hand-written host).
*
* @param {object|null} [profileConfig] the parsed profile-config (already unref'd)
* @returns {string}
*/
export function resolveAssetsBase(profileConfig) {
const fromConfig = profileConfig?.assetsBase
if (typeof fromConfig === 'string' && fromConfig.trim()) {
const v = stripSlashes(fromConfig)
// Absolute (http://…) or root-relative (/…) is a real answer; './assets' or '../../assets' is
// the ambiguous input whose meaning depends on the host, which is the whole problem.
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v
}
// The tree root is <root>/<bundle>/<bundle>.js, and the asset pool lives INSIDE the tree at
// <root>/assets — generate-tree.mjs stages it there precisely so a dropped folder is
// self-contained ("staged N runtime asset(s) -> doxa-maps/assets").
//
// ⚠ THIS SAID SIBLING AND COMPUTED <root>/../assets, WHICH IS ONE LEVEL TOO HIGH. The comment
// justified it as "why the leaf page's generated value is '../../assets' (two up from
// <tree>/<bundle>/<profile>/)" — but two up from <tree>/<bundle>/<profile>/ is <tree>, not the
// tree's PARENT. The arithmetic was off by one and the prose made it look deliberate.
//
// MEASURED 2026-09-25 by publishing to the live catalog and reverting:
// leaf .../doxa-maps/my-upg-100-list/upg-100-list/index.html + '../../assets'
// -> /plugin-assets/doxa-maps/assets 200 (what the leaf means)
// code root '/plugin-assets/doxa-maps' + '/../assets'
// -> /plugin-assets/assets 404 (what this returned)
// The UPG-100 map went from "195 polys · 27/27 zones · 2107 pins" to
// "⚠ boundary fetch 404 world-adm0.geojson" — a basemap with none of our data on it.
//
// ⚠ AND IT IS INVISIBLE LOCALLY, which is why every gate stayed green. The dev web-root has BOTH
// public/assets (a sibling) and public/doxa-maps/assets (inside), each holding the boundaries, so
// the sibling path resolves on 127.0.0.1. The catalog publishes app/doxa-maps ONLY — there is no
// sibling to find. A local run cannot distinguish the two answers; only the live prefix can.
const root = stripSlashes(profileConfig?.treeBase || '') || stripSlashes(bundleTreeRoot() || '')
if (root) {
try {
const base = typeof document !== 'undefined' ? document.baseURI : undefined
return stripSlashes(new URL(root + '/assets', base).href)
} catch {
// A non-URL treeBase (a bare path) still yields a usable string by plain joining.
return stripSlashes(root + '/assets')
}
}
return './assets'
}
/**
* The URL the Share button shows, copies and iframes. See the header for the ladder.
* @param {object} [opts]
* @param {string} [opts.shareUrl] explicit whole url (beats everything)
* @param {string} [opts.embedPath] the profile's embed page — tree-relative, root-relative or absolute
* @param {object|null} [opts.profileConfig] the parsed profile-config (already unref'd)
* @returns {string}
*/
export function resolveShareUrl({ shareUrl, embedPath, profileConfig } = {}) {
const cfg = profileConfig || null
const hasWindow = typeof window !== 'undefined'
// (1) a whole url supplied by the host — the plugin gallery's own deep link, a CMS page, …
const whole = shareUrl || cfg?.shareUrl
if (typeof whole === 'string' && whole.trim()) return whole.trim()
// (2) the profile's embed page
const path = String(embedPath || cfg?.shareEmbedPath || '').trim()
if (path) {
if (/^https?:\/\//i.test(path)) return path
if (path.startsWith('/')) return hasWindow ? window.location.origin + path : path
const base = resolveEmbedBase(cfg)
const rel = path.replace(/^\.\//, '')
if (base) return base + '/' + rel
if (typeof document !== 'undefined') {
try { return new URL(rel, document.baseURI).href } catch { /* fall through */ }
}
return rel
}
// (3) the page itself — each map builds to a self-contained page.
return hasWindow ? window.location.origin + window.location.pathname : ''
}