diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index d2163e11f..7c33c5c3d 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -237,6 +237,8 @@ and a change here can break other maps. `import … from '@map/composables/useSharedMapToken.js'` - **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in `import … from '@map/composables/useTextHighlight.js'` +- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which + `import … from '@map/composables/useViewAddress.js'` ### Utilities (pure helper functions) diff --git a/internal/check-dev-leaf-escape.mjs b/internal/check-dev-leaf-escape.mjs new file mode 100644 index 000000000..e74478c02 --- /dev/null +++ b/internal/check-dev-leaf-escape.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens. + * + * THE DEFECT. The dev server's leaf route, /__leaf//[/] (and a production embed URL, + * /[/]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the + * and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path + * was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server. + * internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile + * id and instance id has) before either sink, and escapes the <title> as well. + * + * The plugin's own middleware answers each request (no server, no browser): + * A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with + * its element: the rule refuses no real page; + * B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or + * backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to + * Vite, never a page; + * C. escHtml leaves none of & < > " ' raw; + * D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title> + * escaped: the title has its own escaping, not only the rule in front of it. + * Response bodies are never printed: a leaf page carries the build config's token. + * `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses + * the quote must each FAIL. + */ +import { readFileSync, writeFileSync, rmSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { discoverSpaRegistry } from './plugin-gallery/discover.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs') +const SRC = readFileSync(PLUGIN, 'utf8') +const GUARD = 'parts.some(s => !LEAF_PART.test(s))' +const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))" +const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/' +const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}' +const ESC_CLASS = `/[&<>"']/g` + +// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the +// file is removed as soon as it is loaded. +let seq = 0 +async function load(src) { + if (src === SRC) return import(pathToFileURL(PLUGIN).href) + const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`) + writeFileSync(tmp, src) + try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) } +} + +// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would). +function client(mod) { + let mw = null + mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } }) + return (url) => new Promise((done) => { + const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) } + try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) } + catch (e) { done({ status: 'threw', body: String(e && e.message) }) } + }) +} + +// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount. +function advertised(mod) { + const out = [] + const walk = (o, tag) => { + if (!o || typeof o !== 'object') return + for (const v of Object.values(o)) { + if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag }) + else walk(v, tag) + } + } + for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0]) + return [...new Map(out.map((x) => [x.url, x])).values()] +} + +const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"] + +async function audit(src) { + const f = [] + let mod + try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] } + const get = client(mod) + const leaves = advertised(mod) + if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`) + for (const { url, tag } of leaves) { + for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) { + const r = await get(u) + if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`) + } + } + const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/') + for (const h of HOSTILE) { + const e = encodeURIComponent(h) + for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) { + const leaf = '/__leaf/' + parts.join('/') + const r = await get(leaf) + if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`) + const embed = '/' + parts.join('/') + '/index.html' + const r2 = await get(embed) + if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`) + } + } + const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null + if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`) + if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one') + else { + let loose + try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) } + if (loose) { + const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`) + const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1] + if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`) + else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw') + } + } + return f +} + +if (process.argv.includes('--self-test')) { + const poisons = [ + ['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /], + ['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /], + ['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /], + ['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /], + ] + let missed = 0 + for (const [name, src, rx] of poisons) { + if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue } + const f = await audit(src); const hit = f.find((x) => rx.test(x)) + console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`) + if (!hit) missed++ + } + if (missed) process.exitCode = 1 +} else { + const f = await audit(SRC) + if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 } + else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`) +} diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs index 68d526324..32f6e2493 100644 --- a/internal/check-i18n-parity.mjs +++ b/internal/check-i18n-parity.mjs @@ -68,6 +68,10 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) // translations, add the locale here with its count and lower it as they arrive. // Never raise a number; never add a locale that is already complete. 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, } const fails = [] diff --git a/internal/check-map-menu-anchor.mjs b/internal/check-map-menu-anchor.mjs index b25a89aab..0fb0e04e4 100644 --- a/internal/check-map-menu-anchor.mjs +++ b/internal/check-map-menu-anchor.mjs @@ -39,7 +39,19 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/) if (!m) { fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.') } else { - const expr = m[1] + let expr = m[1] + // The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button + // re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found. + const fn = expr.match(/^\s*(\w+)\(\)/) + if (fn) { + const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}')) + // every return that resolves a named area must carry its structural fallback on the SAME expression + // a same-line comment is not a fallback: strip `//…` before testing each return + const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : [] + expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '') + if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`) + else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`) + } if (!/offsetParent|parentElement|document\.body/.test(expr)) { fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n' + ` ${expr.trim()}\n` diff --git a/internal/check-view-address.mjs b/internal/check-view-address.mjs new file mode 100644 index 000000000..96a44083e --- /dev/null +++ b/internal/check-view-address.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write. +// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens); +// the level rule (a tab change drops pane and place, a pane change drops place, unless the change sets them); +// foreign params keep their exact text. +// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current` +// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner +// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is +// only read, never written. +// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps +// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write +// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back +// writes nothing, so Forward still exists. +// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/ +// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason. +// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser. +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason. +const KNOWN = { + 'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'], + 'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'], + 'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/<map> hash; a fragment-only URL keeps the query"], + 'plugin-designs/gospel-internet/src/useBoard.js': [1, 'its own keys on its own leaves (writeUrl); keeps state, foreign params and the hash'], + 'plugin-designs/gospel-resources/data-tables.vue': [1, "clearFilter: replaceState({}, '', pathname) wipes the whole query and hash; reported to its owner"], + 'plugin-designs/regions-dashboard/region-dashboards.vue': [2, '#<slug> on its own leaf (URL_SYNC); P2 moves it to `place`'], +} +// SOURCE files only: gates and node scripts may simulate history. A write is `.pushState(` / `.replaceState(` (an aliased +// history included) or a computed `history[… 'pushState' …]`. +const WRITE = /\.(?:push|replace)State\s*\(|\[[^\]\n]*['"](?:push|replace)State['"]/g +function census() { + const out = execFileSync('git', ['grep', '--untracked', '-cE', '(push|replace)State', '--', 'library', 'plugin-designs', + ':!*.md', ':!*.mjs', ':!**/scripts/**', ':!**/test/**', ':!**/*.test.*', ':!**/*.spec.*'], { cwd: ROOT, encoding: 'utf8' }).trim() + const counts = {} + for (const line of out.split('\n').filter(Boolean)) { + const i = line.lastIndexOf(':') + const file = line.slice(0, i) + // -c counts LINES; count the calls themselves (a ternary writes twice on one line). + const n = (readFileSync(join(ROOT, file), 'utf8').match(WRITE) || []).length + if (n) counts[file] = n + } + return counts +} + +// ── a browser's history, simulated: entries + index, popstate on back/forward, write counters ────────────────────── +function browser(href, state = null) { + const entries = [{ url: new URL(href), state }] + let i = 0 + const writes = { push: 0, replace: 0 } + const listeners = new Set() + const loc = { + get href() { return entries[i].url.href }, + get pathname() { return entries[i].url.pathname }, + get search() { return entries[i].url.search }, + get hash() { return entries[i].url.hash }, + } + const copy = (s) => (s == null ? s : JSON.parse(JSON.stringify(s))) + const hist = { + get state() { return entries[i].state }, + get length() { return entries.length }, + pushState(s, _, url) { writes.push++; entries.splice(i + 1); entries.push({ url: new URL(url, entries[i].url), state: copy(s) }); i++ }, + replaceState(s, _, url) { writes.replace++; entries[i] = { url: new URL(url, entries[i].url), state: copy(s) } }, + back() { if (i > 0) { i--; listeners.forEach((h) => h()) } }, + forward() { if (i < entries.length - 1) { i++; listeners.forEach((h) => h()) } }, + } + globalThis.location = loc; globalThis.history = hist + globalThis.addEventListener = (t, h) => t === 'popstate' && listeners.add(h) + globalThis.removeEventListener = (t, h) => t === 'popstate' && listeners.delete(h) + const at = () => loc.pathname + loc.search + loc.hash + const q = () => new URLSearchParams(loc.search) + return { loc, hist, writes, at, q, listeners } +} +const unset = () => { for (const k of ['location', 'history', 'addEventListener', 'removeEventListener']) delete globalThis[k] } + +async function arms({ va, sv, counts }) { + const fails = [] + const eq = (tag, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${tag}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) } + try { + // ── A. pure helpers ── + eq('A: a plain id is kept', va.cleanKey('regions-dashboard'), 'regions-dashboard') + eq('A: a 64-char id is kept', va.cleanKey('a'.repeat(64)), 'a'.repeat(64)) + for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) { + if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`) + } + eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' }) + eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar') + eq('A: a pane change drops place, keeps tab', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e') + eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f') + eq('A: a change that sets the deeper level keeps it', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'd', pane: 'p' }), '?tab=d&pane=p') + eq('A: the same tab changes nothing', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'a' }), '?tab=a&pane=b&place=c') + eq('A: foreign params keep their exact text', va.viewSearch('?lang=ar&access=yes%7Cpartial&q=rice+%26+beans&tab=a', { tab: 'b' }), '?tab=b&lang=ar&access=yes%7Cpartial&q=rice+%26+beans') + eq('A: removing the last key leaves no "?"', va.viewSearch('?tab=a', { tab: '' }), '') + eq('A: urlOwner', ['app', 'host', 'App', true, undefined, 'partner'].map((o) => va.urlOwnerOf({ urlOwner: o })), ['app', 'host', '', '', '', '']) + + // ── B. writes ── + { + const b = browser('https://x.test/deep-research?lang=ar#top', { back: '/', current: '/deep-research?lang=ar#top', position: 3 }) + const r = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true }) + if (r !== 'written' || b.writes.push !== 1) fails.push(`B: an app owner's tab change is not ONE push (${r}, ${b.writes.push} pushes)`) + if (b.at() !== '/deep-research?tab=regions&lang=ar#top') fails.push(`B: the write lost the hash or a foreign param: ${b.at()}`) + const s = b.hist.state || {} + if (s.position !== 3 || s.back !== '/') fails.push(`B: history.state was not kept: ${JSON.stringify(s)}`) + if (s.current !== b.at()) fails.push(`B: history.state.current is not the new URL (${s.current}): a Vue Router host loses the link`) + const r2 = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true }) + if (r2 !== 'unchanged' || b.writes.push + b.writes.replace !== 1) fails.push(`B: an unchanged address wrote (${r2})`) + if (va.commitUrl({}) !== false || b.writes.push + b.writes.replace !== 1) fails.push('B: commitUrl wrote an identical URL') + } + { + const b = browser('https://partner.test/page?tab=theirs') + for (const owner of ['', undefined, 'partner']) va.writeViewKeys({ tab: 'regions' }, { owner, push: true }) + if (b.writes.push + b.writes.replace) fails.push(`B: a partner page (no owner) was written ${b.writes.push + b.writes.replace}×`) + } + { + const b = browser('https://dx.test/deep-research?lang=ar') + const el = new EventTarget(); const got = [] + el.addEventListener(va.ADDRESS_EVENT, (e) => got.push(e)) + const r = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el }) + if (b.writes.push + b.writes.replace) fails.push("B: a 'host' owner wrote history itself (the host's router must)") + if (r !== 'announced' || got.length !== 1) fails.push(`B: a 'host' owner did not hand the host ONE ${va.ADDRESS_EVENT} (${r}, ${got.length})`) + else { + const e = got[0] + if (!e.bubbles || !e.composed) fails.push(`B: ${va.ADDRESS_EVENT} cannot leave a shadow root (bubbles ${e.bubbles}, composed ${e.composed})`) + if (e.detail?.keys?.tab !== 'posters' || e.detail?.push !== true || e.detail?.href !== '/deep-research?tab=posters&lang=ar') fails.push(`B: ${va.ADDRESS_EVENT} detail is wrong: ${JSON.stringify(e.detail)}`) + } + // the host has not navigated yet; once it has, the same choice again must not ask it to navigate twice + b.hist.replaceState(b.hist.state, '', '/deep-research?tab=posters&lang=ar'); b.writes.replace-- + const r2 = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el }) + if (r2 !== 'unchanged' || got.length !== 1) fails.push(`B: a 'host' owner announced an unchanged address (${r2}, ${got.length} events)`) + } + { + browser('https://x.test/p') + const el = new EventTarget(); const got = [] + el.addEventListener(va.VIEW_EVENT, (e) => got.push(e)) + va.announceView(el, 'pane', 'dashboard') + if (va.announceView(el, 'filters', 'x') !== false) fails.push('B: announceView accepts a key that is not tab/pane/place') + if (got.length !== 1 || !got[0].bubbles || !got[0].composed || got[0].detail?.key !== 'pane' || got[0].detail?.value !== 'dashboard') fails.push(`B: a nested element's ${va.VIEW_EVENT} is wrong or cannot leave its shadow root`) + } + + // ── C. coexistence with the filters, and Back/Forward ── + { + const b = browser('https://x.test/board?tab=a&pane=b&place=c#top', { current: '/board?tab=a&pane=b&place=c#top', position: 2 }) + sv.writeFilterView({ access_level: 'yes' }, 'rice', { mode: 'query', facetKeys: { access: 'access_level' } }) + const q = b.q() + if (q.get('tab') !== 'a' || q.get('pane') !== 'b' || q.get('place') !== 'c') fails.push(`C: a filter write (query) dropped tab/pane/place: ${b.at()}`) + if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#top') fails.push(`C: a filter write (query) lost its own params or the hash: ${b.at()}`) + if (b.hist.state?.position !== 2 || b.hist.state?.current !== b.at()) fails.push(`C: a filter write dropped the router's history.state or its current: ${JSON.stringify(b.hist.state)}`) + if (b.writes.push) fails.push('C: a filter write pushed (filters replace)') + } + { + const b = browser('https://x.test/research?tab=a&pane=b') + sv.writeFilterView({ region: 'asia' }, '', {}) + if (b.loc.search !== '?tab=a&pane=b' || !b.loc.hash.startsWith('#f=region:asia')) fails.push(`C: a filter write (hash) dropped tab/pane/place: ${b.at()}`) + } + { + const b = browser('https://x.test/board?access=yes&q=rice#f=region:asia') + va.writeViewKeys({ tab: 'map' }, { owner: 'app', push: true }) + const q = b.q() + if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#f=region:asia') fails.push(`C: a tab write dropped the filters: ${b.at()}`) + va.writeViewKeys({ place: 'angola' }, { owner: 'app', push: true }) + if (b.q().get('access') !== 'yes' || b.loc.hash !== '#f=region:asia') fails.push(`C: a place write dropped the filters: ${b.at()}`) + } + { + // A shell with the filters inside: first paint, a filter, a tab change, another filter, then Back and Forward. + // The app re-applies the tab on popstate the way an owner does: it sets its tab, and its tab watcher writes. + const b = browser('https://x.test/deep-research') + const app = { tab: '' } + const setTab = (t, push) => { app.tab = t; va.writeViewKeys({ tab: t }, { owner: 'app', push }) } + const off = va.onAddressChange((keys) => setTab(keys.tab || 'home', true)) + setTab('a', false) + sv.writeFilterView({ access_level: 'yes' }, '', { mode: 'query', facetKeys: { access: 'access_level' } }) + setTab('b', true) + sv.writeFilterView({ access_level: 'no' }, '', { mode: 'query', facetKeys: { access: 'access_level' } }) + const len = b.hist.length; const w = b.writes.push + b.writes.replace + b.hist.back() + if (b.q().get('tab') !== 'a' || b.q().get('access') !== 'yes') fails.push(`C: after Back the entry lost its tab or its filters: ${b.at()}`) + if (app.tab !== 'a') fails.push('C: Back did not re-apply the tab') + if (b.writes.push + b.writes.replace !== w) fails.push('C: re-applying the tab after Back WROTE history (Forward is lost)') + b.hist.forward() + if (b.hist.length !== len || b.q().get('tab') !== 'b' || b.q().get('access') !== 'no') fails.push(`C: after Forward the entry lost its tab or its filters, or Forward was gone: ${b.at()} (${b.hist.length}/${len})`) + off() + if (b.listeners.size) fails.push('C: onAddressChange off() left its popstate listener') + } + } finally { unset() } + + // ── D. the census ── + for (const [file, n] of Object.entries(counts)) { + if (!KNOWN[file]) fails.push(`D: a new history writer: ${file} (${n}). Route it through useViewAddress (commitUrl), or list it in KNOWN with its reason`) + else if (KNOWN[file][0] !== n) fails.push(`D: ${file} now has ${n} history writes, the census says ${KNOWN[file][0]}`) + } + for (const file of Object.keys(KNOWN)) if (!counts[file]) fails.push(`D: ${file} is listed as a writer but no longer writes: drop its row`) + return fails +} + +const va = await import(join(ROOT, 'library/composables/useViewAddress.js')) +const sv = await import(join(ROOT, 'library/composables/useShareableView.js')) +const counts = census() +const real = { va, sv, counts } +let code = 0 +if (process.argv.includes('--self-test')) { + // The old filter write, as it was: its own replaceState(null, …) of the whole URL. + const oldFilterWrite = (fs, term, o = {}) => { + const url = o.mode === 'query' ? sv.serializeQuery(fs, term, location.search, o) + location.hash : location.search + sv.serializeView(fs, term, o) + history.replaceState(null, '', location.pathname + url); return true + } + const poisons = [ + ['a write that drops history.state.current', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...history.state }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: history\.state\.current/], + ['a write when not the owner', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^B: a partner page/], + ['a tab change that keeps a stale place', { ...real, va: { ...va, viewSearch: (s, c) => { const k = { ...va.readViewKeys(s), ...Object.fromEntries(Object.entries(c).map(([a, b]) => [a, va.cleanKey(b)])) }; const f = s.replace(/^\?/, '').split('&').filter((p) => p && !va.VIEW_KEYS.includes(p.split('=')[0])); const all = [...va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`), ...f]; return all.length ? '?' + all.join('&') : '' } } }, /^A: a tab change drops pane and place/], + ['a whitelist with no length cap', { ...real, va: { ...va, cleanKey: (v) => (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(v ?? '')) ? String(v) : '') } }, /^A: the whitelist lets/], + ['a write when the address is unchanged', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: location.pathname + s + location.hash }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: an unchanged address wrote|^C: re-applying the tab after Back WROTE/], + ["a 'host' owner that writes history itself", { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: o.owner === 'host' ? 'app' : o.owner }) } }, /^B: a 'host' owner wrote/], + ['a dxm-address that cannot leave a shadow root', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'host') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); o.el.dispatchEvent(new CustomEvent(va.ADDRESS_EVENT, { bubbles: true, detail: { keys: va.readViewKeys(s), href: location.pathname + s, push: o.push } })); return 'announced' } } }, /^B: dxm-address cannot leave/], + // the three coexistence poisons + ['a FILTER write that drops tab/pane/place (no foreign params kept)', { ...real, sv: { ...sv, writeFilterView: (fs, term, o = {}) => va.commitUrl(o.mode === 'query' ? { search: sv.serializeQuery(fs, term, '', o) } : { search: '', hash: sv.serializeView(fs, term, o) }) } }, /^C: a filter write \((query|hash)\) dropped tab\/pane\/place/], + ['the old filter write: its own replaceState(null) (the router state is wiped)', { ...real, sv: { ...sv, writeFilterView: oldFilterWrite } }, /^C: a filter write dropped the router's history\.state/], + ['a TAB write that drops the filters (foreign params and hash)', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const k = va.readViewKeys(va.viewSearch(location.search, c)); const s = va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`).join('&'); return va.commitUrl({ search: s ? '?' + s : '', hash: '' }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^C: a (tab|place) write dropped the filters/], + ['a tab change written as a REPLACE (Back leaves the page)', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, push: false }) } }, /^B: an app owner's tab change is not ONE push|^C: after Back/], + ['a popstate handler that writes the address', { ...real, va: { ...va, onAddressChange: (cb) => va.onAddressChange((k) => { cb(k); history.pushState(history.state, '', location.href) }) } }, /^C: re-applying the tab after Back WROTE|^C: after Forward/], + // the census + ['a new history writer in the tree', { ...real, counts: { ...counts, 'plugin-designs/some-plugin/page.vue': 1 } }, /^D: a new history writer/], + ['useShareableView writing history itself again', { ...real, counts: { ...counts, 'library/composables/useShareableView.js': 1 } }, /^D: a new history writer: library\/composables\/useShareableView\.js/], + ] + for (const [name, inp, rx] of poisons) { + const f = await arms(inp); const ok = f.some((x) => rx.test(x)) + console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}${f.length ? ' (failed instead: ' + f[0] + ')' : ''}`) + if (!ok) code = 1 + } +} else { + const f = await arms(real) + if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 } + else console.log(`PASS check:view-address — the id whitelist and the level rule; one push per tab change with history.state + current kept; nothing written when unchanged, on a partner page, or by a 'host' owner (one dxm-address instead); filters and tab/pane/place keep each other through writes, Back and Forward; ${Object.keys(counts).length} history writers in the tree, all accounted for`) +} +process.exit(code) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 73a44169c..24127a4a8 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -925,7 +925,10 @@ function mapPageHtml(opts) { ? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>` : '' // Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false). - const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config + // urlOwner 'app' (library/composables/useViewAddress.js): this generated page is OURS, so its shell may write the + // view address (?tab=…) for a reload and a sent link. Only the page's own config gets it, never an embed snippet + // (embedCfg), so a partner's page is never written to; and a FRAMED page drops it at runtime (below). + const pageConfig = { ...(DEST_CONFIG.qaWidget ? { ...config, qa: true } : config), urlOwner: 'app' } // PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or // instance whose config says provider:'maplibre' gets the MapLibre peer (token- // free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts @@ -1099,6 +1102,9 @@ ${apiScript} var slot = document.getElementById('slot'); var status = document.getElementById('status'); var BASE_CONFIG = ${JSON.stringify(pageConfig)}; + // Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds + // entries to the partner page's own Back button (the joint session history). + try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner } // MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token: // the ladder below is skipped entirely and the element mounts immediately. // (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.) diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 5b6ff2b62..bdc6fbf5a 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -145,6 +145,18 @@ export function galleryModel(reg) { } } +// ── ONE PART OF A LEAF URL ─────────────────────────────────────────────────────────────────── +// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the +// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a +// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder, +// profile id and instance id in the tree has this shape (the address keys' own pattern, +// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink; +// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions. +export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/ + +// For an HTML text node or a quoted attribute value. +export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) + // ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ───────────────────────────────────── // The manifest advertises, and a person copies, tree-relative embed URLs of the shape // `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in @@ -197,6 +209,16 @@ export function leafRewrite(path, treeName = TREE_NAME) { return '/__leaf/' + segs.join('/') } +// urlOwner 'app' (library/composables/useViewAddress.js) for a dev page that is OURS, the rule generate-tree bakes +// into the built leaf: its shell writes ?tab=, so a clicked tab survives a reload and a copied link opens it. A +// FRAMED page drops it: a partner page may iframe a leaf (the iframe embed), and a history push from an iframe +// lands in the top page's Back stack. (Plugin Studio mounts its rows directly; its panes are unowned by paneConfig.) Write the element unowned and put this classic script right after it; it runs +// before the deferred module script upgrades the element. `<` is escaped: profile/instance come from the URL. +export function ownIfTopScript(cfg = {}) { + const owned = JSON.stringify(JSON.stringify({ ...cfg, urlOwner: 'app' })).replace(/</g, '\\u003c') + return `<script>try{if(window.self===window.top)document.currentScript.previousElementSibling.setAttribute('profile-config',${owned})}catch(e){}</script>` +} + export function autoSpaDevPlugin(token = '') { // One generator for both galleries (internal/generate-gallery.mjs), driven by the same build // config the built page bakes. What dev changes: the model comes from discovery, the live @@ -337,8 +359,8 @@ export function autoSpaDevPlugin(token = '') { try { return decodeURIComponent(s) } catch { return '' } }) const [bundle, profile, instance] = parts - // Path traversal guard: these land in a filesystem-ish URL below. - if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) { + // These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only. + if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) { res.statusCode = 400; return res.end('bad leaf path') } let reg @@ -365,7 +387,8 @@ export function autoSpaDevPlugin(token = '') { // renders the SAME view here as in production rather than the bundle's default. // assetsBase too, as the built leaf's BASE_CONFIG carries it ("../../assets" there; root-absolute // here, because a /__leaf/ URL's depth differs from the tree's and the pool is served at /assets). - const pc = JSON.stringify(instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' }) + const leafPc = instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' } + const pc = JSON.stringify(leafPc) // The composition tier of THIS profile, read from the same discovery the gallery uses. // `nested-elements-mfe` (cap-4) is the StackLayout scroll-stack tier — see the style note // below. An unknown profile falls through to the fixed-viewport leaf, the safe default. @@ -376,7 +399,7 @@ export function autoSpaDevPlugin(token = '') { res.setHeader('Cache-Control', 'no-cache') return res.end([ '<!DOCTYPE html>', - `<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}`, + `${escHtml(bundle)} · ${escHtml(profile)}`, '', '', leafTok ? `` : '', @@ -430,6 +453,7 @@ export function autoSpaDevPlugin(token = '') { // live 19-bundle tree. Passing the base makes a nested shell see exactly what the top // level sees. Elements that ignore tree-base simply drop an unknown attribute. `<${tag} tree-base="/__plugin-gallery" profile-config='${pc}'>`, + ownIfTopScript(leafPc), ``, '', ].filter(Boolean).join('\n')) @@ -499,6 +523,7 @@ export function autoSpaDevPlugin(token = '') { // manifest-shaped model the built tree bakes (19 bundles / 30 profiles), so pointing the // studio at it gives dev the same tab list production has. '', + ownIfTopScript(), '', '', ].filter(Boolean).join('\n')) diff --git a/internal/profile-meta.mjs b/internal/profile-meta.mjs index 3da0f7b94..57e9b2a71 100644 --- a/internal/profile-meta.mjs +++ b/internal/profile-meta.mjs @@ -12,9 +12,42 @@ * posterDesign { family, name, size, mobileFriendly, labelKey?, instance? } or an array of them: the poster * design(s) this profile shows, listed by Poster Design's Styles. Carried as written, ONLY when present * (every other manifest keeps its bytes); check:poster-design-catalogue validates it. + * posterDesignLabels GENERATED beside it: { : { : title } } for every block with a labelKey, + * resolved here from library/i18n/locales//.json. Poster Design's bundle ships only the + * catalogue areas its own code names, so a plugin's own area (upgMap, …) would be missing there and the card + * would read English; carrying the titles in the manifest works for any area, with no Poster Design rebuild. */ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'library', 'i18n', 'locales') +// A catalogue key ("area.a.b") in every locale that has it: { en: '…', ar: '…' }; {} when no locale does. +function catalogueTitles(key) { + const [area, ...path] = String(key || '').split('.') + const out = {} + if (!area || !path.length || !existsSync(LOCALES)) return out + for (const loc of readdirSync(LOCALES)) { + const f = join(LOCALES, loc, `${area}.json`) + if (!existsSync(f)) continue + try { + let o = JSON.parse(readFileSync(f, 'utf8')) + for (const p of path) o = o && typeof o === 'object' ? o[p] : undefined + if (typeof o === 'string' && o.trim()) out[loc] = o + } catch { /* a locale file that does not parse names nothing */ } + } + return out +} +function posterDesignLabels(pd) { + const out = {} + for (const b of Array.isArray(pd) ? pd : [pd]) { + if (b && typeof b.name === 'string' && typeof b.labelKey === 'string') { + const titles = catalogueTitles(b.labelKey) + if (Object.keys(titles).length) out[b.name] = titles + } + } + return out +} const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'] @@ -82,7 +115,7 @@ export function profileMetaFields(dir, profileName) { const m = readProfileMeta(dir, profileName) // visibility is emitted ONLY when it is "public": absent means team, so every manifest in the // tree today keeps the bytes it has and nothing becomes visible by upgrading the generator. - return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}) } + return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}), ...(m.posterDesign && Object.keys(posterDesignLabels(m.posterDesign)).length ? { posterDesignLabels: posterDesignLabels(m.posterDesign) } : {}) } } /** A bundle is production if ANY of its profiles says so. */ diff --git a/library/components/map-controls/MapControlMenu.vue b/library/components/map-controls/MapControlMenu.vue index 4b667baa0..c04a04961 100644 --- a/library/components/map-controls/MapControlMenu.vue +++ b/library/components/map-controls/MapControlMenu.vue @@ -14,6 +14,11 @@ RIGHT of left-hand controls. The side is measured, so a right-to-left page that mirrors the toolbar mirrors the menu as well. • Width: as wide as fits between the toolbar and the opposite edge, capped at `width`. + • A STANDALONE anchor (ShareButton in a page header, no map toolbar: `standalone` on the + provided anchor) opens BELOW its button instead, toward the side with room, inside the + VIEWPORT, at `width` (the maps' readable 340) or the viewport minus its edges, whichever is + less. Where it lives and where it goes are resolved when it OPENS, and its coordinates are + converted into the block that really contains it (see placeBeside). • One z tier for menus (--z-menu, default 3000); only one menu is open per map at a time; closes on Escape or on a tap on the bare map canvas (never on legend/search). @@ -95,23 +100,17 @@ const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]' function place() { const tb = toolbar?.el?.value, area = host.value if (!tb || !area) { style.value = {}; return } + if (toolbar.standalone) return placeBeside(tb) const ar = area.getBoundingClientRect(), tr = tb.getBoundingClientRect() const onRight = (tr.left + tr.right) / 2 > (ar.left + ar.right) / 2 const gap = 8, edge = 12 const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap) const width = Math.max(200, Math.min(props.width, ar.width - offset - edge)) const top = Math.max(0, tr.top - ar.top) - // "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a - // page HEADER, its fallback self-tagged that header band as the area (data-map-area= - // "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the - // menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px - // scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never - // outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the - // fallback area bounds against the VIEWPORT instead and the menu opens at natural height. - const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback' - let maxHeight = isFallbackArea - ? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available - : Math.max(120, ar.height - top - edge) // map host: fit inside the map + // A menu never outgrows the MAP's walls. (A header button is not placed here: placeBeside bounds it by the + // viewport, so it opens at natural height; measured on upg-100-mfe, a header band as the wall gave a 291px + // menu a 69px scrolling body, BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED.) + let maxHeight = Math.max(120, ar.height - top - edge) if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height)) if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight) style.value = { @@ -123,6 +122,45 @@ function place() { } } +// ★ BESIDE ITS BUTTON, INSIDE THE VIEWPORT (a standalone anchor: a Share button in a page header). Below the +// button, toward the side with room (a button right of centre opens leftward, its end edges aligned; RTL headers +// mirror with no special case, because the side is measured), at the readable width the maps use, clamped inside +// the viewport, as tall as its content up to the viewport's bottom edge. +// ★ THE BLOCK THAT CONTAINS IT IS MEASURED, NOT ASSUMED. The menu is position:absolute, so its coordinates are +// relative to its containing block, which the HOST's markup decides: an unpositioned wrapper hands it to the +// header above. Measuring against one element and positioning against another is what sent the header menu far +// to the left (the UPG-100 list page, 2026-10-03). So the target point is computed in viewport coordinates and converted into +// the containing block's own (its padding edge, scrolled), whatever element that turns out to be. +function placeBeside(tb) { + const vw = document.documentElement.clientWidth || window.innerWidth || 1024 + const vh = window.innerHeight || 800 + const r = tb.getBoundingClientRect(), gap = 8, edge = 12 + const width = Math.min(props.width, Math.max(160, vw - 2 * edge)) + const onRight = (r.left + r.right) / 2 > vw / 2 + const x = Math.max(edge, Math.min(onRight ? r.right - width : r.left, vw - edge - width)) + const y = r.bottom + gap + let maxHeight = Math.max(120, vh - y - edge) + if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight) + const o = blockOrigin(el.value) + style.value = { + top: `${Math.round(y - o.y)}px`, + left: `${Math.round(x - o.x)}px`, + width: `${Math.round(width)}px`, + maxHeight: `${Math.round(maxHeight)}px`, + ...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}), + } +} +// The viewport position of the origin an absolutely positioned element is placed from: its offsetParent's padding +// edge, scrolled. A static body (or none) means the initial containing block: the document origin. +function blockOrigin(m) { + const cb = m && m.offsetParent + if (!cb || ((cb === document.body || cb === document.documentElement) && getComputedStyle(cb).position === 'static')) { + return { x: -(window.scrollX || 0), y: -(window.scrollY || 0) } + } + const b = cb.getBoundingClientRect() + return { x: b.left + cb.clientLeft - cb.scrollLeft, y: b.top + cb.clientTop - cb.scrollTop } +} + // Only one menu open per map: opening this one closes any other. const OPEN_EVENT = 'doxa-map-menu-open' function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close') } @@ -139,6 +177,7 @@ const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PA watch(() => props.open, async (open, was) => { if (!open) { if (was) announceClose(); return } + if (toolbar?.standalone) host.value = resolveHost() // layout is known now: where the menu lives await nextTick() place() try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {} @@ -156,15 +195,27 @@ function onClickOutside(e) { if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close') } +// Where the menu lives (the Teleport target). A map control: the map area, else the toolbar's offset parent. A +// standalone button: a named area if its host declares one, else its offset parent, kept in the button's own +// (shadow) root so the menu keeps this root's styles; else the root's top element. +function resolveHost() { + const tbEl = toolbar?.el?.value || null + if (!tbEl) return null + if (!toolbar.standalone) return tbEl.closest(AREA) || tbEl.offsetParent || tbEl.parentElement || null + const root = tbEl.getRootNode(), mine = (n) => n && n.getRootNode() === root ? n : null + let top = tbEl; while (top.parentElement) top = top.parentElement + return mine(tbEl.closest(AREA)) || mine(tbEl.offsetParent) || (root === document ? document.body : top) +} +// The "one menu at a time" listener follows the host, which a standalone button re-resolves on every open. +watch(host, (h, was) => { was?.removeEventListener(OPEN_EVENT, onOtherOpened); h?.addEventListener(OPEN_EVENT, onOtherOpened) }) + onMounted(() => { // ★ NEVER null. A named area is still preferred -- it is the MAP's walls, and a menu must not outgrow // them -- but when a plugin has not tagged one, the toolbar's own offsetParent is the containing block // this menu is absolutely positioned against, so placing against it is geometrically correct by // definition rather than by convention. A plugin that adopts a class or data-map-area later simply // gets the better anchor; nothing has to be registered anywhere for the menu to be on screen. - const tbEl = toolbar?.el?.value || null - host.value = tbEl?.closest(AREA) || tbEl?.offsetParent || tbEl?.parentElement || null - host.value?.addEventListener(OPEN_EVENT, onOtherOpened) + host.value = resolveHost() window.addEventListener('resize', onResize) document.addEventListener('click', onClickOutside, true) document.addEventListener('keydown', onKeyDown) diff --git a/library/components/map-controls/ShareButton.vue b/library/components/map-controls/ShareButton.vue index a34e6a376..c5bb319bb 100644 --- a/library/components/map-controls/ShareButton.vue +++ b/library/components/map-controls/ShareButton.vue @@ -2,10 +2,13 @@ ShareButton.vue — Non-technical share modal for the map toolbar. Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and - Google Maps. Two tabs: + Google Maps. Two tabs, share FIRST (shareable pages P3): - 1. "Share Link" — clean URL display + one-click copy + social share icons - 2. "Add to Your Website" — friendly embed guidance with size presets + 1. "Share this page" — the link to exactly what the reader is looking at (the page's own + address, with its tab, where the page owns its address) + one-click copy. It is what a + person who presses Share expects: something to send to someone. + 2. "Embed" — for putting the map on ANOTHER website: what embedding is and why it helps, the + multiplication steps, then the code with size presets. The word "iframe" never appears in any user-visible text. The embed tab leads with a plain-English description, then shows the code below. @@ -31,6 +34,7 @@ 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 { urlOwnerOf } from '../../composables/useViewAddress.js' import MapControlButton from './MapControlButton.vue' import MapControlMenu from './MapControlMenu.vue' @@ -51,7 +55,7 @@ useShadowStyles(` /* The ring itself rotates, so the colours travel around the button in every browser. */ @keyframes share-ring-turn{to{transform:rotate(360deg);}} @media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}} -.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;} +.share-steps{margin:0 0 12px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;} .share-steps li{margin:0 0 3px;padding-inline-start:3px;} .share-steps li:last-child{font-weight:600;color:#92b195;} .share-pop.light .share-steps{color:#3b463d;} @@ -348,7 +352,8 @@ const rootEl = ref(null) const isOpen = ref(false) const popoverEl = ref(null) const btnEl = ref(null) -const activeTab = ref('link') // 'link' | 'embed' +const activeTab = ref('page') // 'page' | 'embed' +const pageUrl = ref('') // "Share this page": read when the menu opens (see pageLink) const linkCopied = ref(false) const embedCopied = ref(false) const embedSize = ref('medium') // 'small' | 'medium' | 'large' @@ -359,6 +364,9 @@ const sizePresets = { large: { label: 'Large', height: 800 } } +// The size names, each key written out so the catalogue gate sees it. +const sizeLabel = (k) => ({ small: T('share.size_small', 'Small'), medium: T('share.size_medium', 'Medium'), large: T('share.size_large', 'Large') })[k] || k + /* ---- computed ---- */ /* The standalone embed page this map is shared/iframed as — resolved by utils/embedBase.js (its header documents the full ladder). In short: @@ -387,11 +395,42 @@ const embedSnippet = computed(() => { return '' }) +/* ★ 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 + link is the page's own URL, read when the menu opens and again at copy time, since the reader can + switch tabs in between. The owner is this map's own profile-config, or the nearest element above it, + across shadow roots, whose profile-config names one: a map nested in the gallery is told nothing, and + the gallery is the page. On a partner's page (no owner) the address is the partner's, so the link is + this map's own page, as before. */ +const cfgOf = (c) => { if (typeof c !== 'string') return c || null; try { return JSON.parse(c) } catch { return null } } +function pageOwner() { + if (urlOwnerOf(cfgOf(unref(profileConfig)))) return true + for (let n = rootEl.value; n; ) { + n = n.parentNode || n.host + if (n && n.nodeType === 1 && urlOwnerOf(cfgOf(n.profileConfig ?? n.getAttribute('profile-config')))) return true + } + return false +} +// A leaf page reads a token from ?tk= and leaves it in its address, so the page link drops it: a reader must +// never send a credential by pressing Share. The URL is rewritten only when tk is there, so every other link is +// the address exactly as it stands. +function pageLink() { + try { + if (pageOwner() && window.location.href) { + const u = new URL(window.location.href) + if (u.searchParams.has('tk')) { u.searchParams.delete('tk'); return u.href } + return window.location.href + } + } catch { /* no window */ } + return shareUrl.value +} + /* ---- actions ---- */ function toggle() { isOpen.value = !isOpen.value if (isOpen.value) { - activeTab.value = 'link' + activeTab.value = 'page' + pageUrl.value = pageLink() linkCopied.value = false embedCopied.value = false } @@ -425,7 +464,8 @@ async function copyText(text) { } async function copyLink() { - if (await copyText(shareUrl.value)) { + pageUrl.value = pageLink() + if (await copyText(pageUrl.value)) { linkCopied.value = true setTimeout(() => { linkCopied.value = false }, 2000) } @@ -477,39 +517,24 @@ async function copyEmbed() { const providedToolbar = inject('mapToolbar', null) const selfAnchor = ref(null) // Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when -// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. -provide('mapToolbar', providedToolbar || { el: selfAnchor }) +// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. `standalone` tells +// MapControlMenu this anchor is ONE button outside any map (a page header), placed beside the button +// inside the viewport, at open time (MapControlMenu.placeBeside). +provide('mapToolbar', providedToolbar || { el: selfAnchor, standalone: true }) const hasOwnAnchor = !providedToolbar /* - ★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming, - because the fix silently does NOTHING if it is written the obvious way. - - MapControlMenu latches its area ONCE, in its own onMounted: - host.value = toolbar?.el?.value?.closest(AREA) || null - and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and - Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor - one tick too late: the child would have already read `null`, latched `host = null`, and no later - assignment would revive it — the menu would go on opening at its static position and the fix - would look applied while changing nothing. - - A function ref is invoked during RENDER, before any mounted hook on either component, so the - anchor and the area tag are in place by the time the child looks for them. - - `data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET - PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the - menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans - the row, so the menu has the full width to open leftward into. + ★ THE ANCHOR IS THE BUTTON, AND NOTHING IS DECIDED AT RENDER TIME (shareable pages P3). This used to tag + `el.offsetParent || el.parentElement` as the menu's area DURING RENDER (a function ref, so it ran before + MapControlMenu latched its area). Before layout offsetParent is null, so the tag fell on the button's + immediate parent: in a header with a narrow, unpositioned wrapper (measured on the UPG-100 list page: .hdr-btns) the menu + measured against that wrapper, clamped to its 200px floor, and was positioned against the header above + it: far to the left, and narrow (reported 2026-10-03). Now the button only names itself; + MapControlMenu resolves where the menu lives and where it goes when it OPENS, with layout known. */ function bindAnchor(el) { rootEl.value = el || null - if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return } - const area = el.offsetParent || el.parentElement - // Never re-tag a host that already declares itself an area — that host owns the decision. - if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) { - area.setAttribute('data-map-area', 'share-fallback') - } - selfAnchor.value = el + selfAnchor.value = hasOwnAnchor && el ? el : null } /* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every @@ -517,8 +542,8 @@ function bindAnchor(el) {