Shareable pages P1: one address helper, and the filters write through it

library/composables/useViewAddress.js is the page's address for the view a reader is on: ?tab= &pane= &place=, with
a level rule, an id whitelist, and ONE history write (commitUrl). It keeps history.state and sets its `current` to
the new URL, so a link survives the next in-app navigation on a Vue Router host. The OUTERMOST owner writes,
named by profile-config urlOwner:
- 'app' writes history itself;
- 'host' hands its router one dxm-address;
- absent, nothing is written and links are still read.
Nested elements announce dxm-view. Back/Forward is read and never written.

useShareableView's filter write used to call replaceState(null, ...) itself, which wiped the router's state. It
now goes through commitUrl (writeFilterView). A filter write keeps tab/pane/place, and a tab/pane/place write keeps
the filters.

Level 2 is `pane`, not `view`: ?view= is already published by four of our pages.

check:view-address (gates:pre) runs on a simulated browser history. It covers:
- the whitelist and the level rule;
- one push per tab change, with state + current kept;
- no write when unchanged, on a partner page, or by a host owner;
- the filters and the view keys keeping each other through writes, Back and Forward;
- a census of every history writer in library/ and plugin-designs/ (6 today). A new writer fails until it is listed.

The self-test has 14 poisons, each caught. Real-source mutations are caught too: drop current, drop the unchanged
guard, drop the level rule, an uncomposed event, no length cap, an aliased pushState in a plugin.
This commit is contained in:
Doxa Maps 2026-10-03 05:20:43 +00:00
parent 1d8f5f1ccd
commit 30dc9a1457
4 changed files with 368 additions and 10 deletions

View file

@ -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)

View file

@ -44,6 +44,7 @@
* stays unconditional, because restoring a link someone sent must work everywhere.
*/
import { watch, onMounted } from 'vue'
import { commitUrl } from './useViewAddress.js'
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
// Minimal escaping, maximal legibility: reserve our delimiters + %, keep spaces as `+` (the
@ -177,17 +178,25 @@ export function parseQuery(search, facetKeys = [], { searchKey = 'q' } = {}) {
return out
}
/**
* The filter write. It goes through useViewAddress's commitUrl, the page's ONE history write, so it can never undo
* a tab/pane/place write and keeps history.state (+ `current`) on a Vue Router host. It used to write
* replaceState(null, …) itself, which wiped the router's state there. Query mode replaces only its own params; hash
* mode replaces only the hash. Everything else in the URL stays. Exported so check:view-address drives this exact
* write. Returns whether it wrote.
*/
export function writeFilterView(filterSet, searchTerm, options = {}) {
try {
if (typeof location === 'undefined') return false
return options.mode === 'query'
? commitUrl({ search: serializeQuery(filterSet, searchTerm, location.search, options) })
: commitUrl({ hash: serializeView(filterSet, searchTerm, options) })
} catch (_) { return false }
}
export function useShareableView(bus, options = {}) {
const { mode = 'hash', write: shouldWrite = true, facetKeys } = options
const push = () => {
try {
if (typeof location === 'undefined' || !shouldWrite) return
const url = mode === 'query'
? serializeQuery(bus.filterSet, bus.searchTerm?.value, location.search, { ...options, facetKeys }) + location.hash
: location.search + serializeView(bus.filterSet, bus.searchTerm?.value, options)
history.replaceState(null, '', location.pathname + url)
} catch (_) { /* sandboxed */ }
}
const push = () => { if (shouldWrite) writeFilterView(bus.filterSet, bus.searchTerm?.value, options) }
watch(() => ({ ...bus.filterSet, __q: bus.searchTerm?.value }), push, { deep: true })
onMounted(() => {
try {

View file

@ -0,0 +1,108 @@
/**
* useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
* place. One shared vocabulary and ONE history writer, so a tab, a view or a selection can be reloaded and sent as a
* link on every host, while a partner's page is never written to.
*
* ★ THE SCHEME (query, not hash: a hash is a host page's anchor, and filters already live there).
* ?tab=<app tab>&pane=<view inside the tab>&place=<selection>
* Levels: changing `tab` drops `pane` and `place`; changing `pane` drops `place`. Every other parameter (a host's
* own, the filters useShareableView writes) and the hash are kept as they are. A value that is not a plain id
* (letters, digits, `-`, `_`, at most 64) reads as absent, so a stale or hand-edited link falls back to the default.
* `pane`, not `view`: `?view=` is already published by four of our own pages (gospel-internet's board/map, Gospel
* Resources' map ?view=zero, pplr-data-maps and upg-neighbor lenses), and one of them sits inside a shell tab.
*
* ★ ONE WRITER PER PAGE. Only the OUTERMOST element that owns the address writes it; profile-config `urlOwner` says
* who that is:
* 'app' our own pages (a generated leaf, Plugin Studio): writeViewKeys() writes history itself;
* 'host' a host with its own router (DX Life): writeViewKeys() dispatches ADDRESS_EVENT and the HOST writes;
* absent a partner's page: nothing is written; links are still READ (readViewKeys works everywhere).
* A nested element never writes: it announces its choice upward (announceView) and the owner records it.
*
* ★ THE ONE HISTORY WRITE: commitUrl(). Every write to the address bar goes through it (useShareableView's filter
* writes included), so two modules can never undo each other. It keeps `history.state` and sets its `current` to the
* new URL. MEASURED on a Vue Router host (2026-09-30): a pushState that drops Router's state fields makes the next
* in-app navigation rewrite the entry back to Router's last-known URL, and the link is lost. It writes nothing when
* the URL would not change. A user's tab/pane/place change is a push (one Back step); first paint, link
* normalisation and filters are a replace.
*
* Pure helpers, no dependency. Safe under node and in a sandboxed frame (every access is guarded).
*/
export const VIEW_KEYS = ['tab', 'pane', 'place']
/** nested element → the address owner: { key, value, push }. Composed + bubbling, so it leaves shadow roots. */
export const VIEW_EVENT = 'dxm-view'
/** an owner with urlOwner 'host' → the host's router: { keys, href, push }; the host does router[push ? 'push' : 'replace'](href). */
export const ADDRESS_EVENT = 'dxm-address'
/** A plain id (letters, digits, `-`, `_`, at most 64), or '' for anything else. */
export const cleanKey = (v) => { const s = String(v ?? '').trim(); return /^[A-Za-z0-9][\w-]{0,63}$/.test(s) ? s : '' }
/** { tab, pane, place } from a search string; '' for a key that is absent or not a plain id. The FIRST of a repeated
* key decides, as URLSearchParams.get does for the shells that already read ?tab=; a malformed escape reads as absent. */
export function readViewKeys(search) {
const q = new URLSearchParams(search || '')
return Object.fromEntries(VIEW_KEYS.map((k) => [k, cleanKey(q.get(k))]))
}
// Bubbling + composed, so the event leaves every shadow root on its way to the owner or the host.
const send = (el, type, detail) => !!el?.dispatchEvent && (el.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })), true)
/**
* The search string after `changes` ({ tab?, pane?, place? }; '' or null removes a key), with the level rule: a
* changed level drops the deeper levels that `changes` does not set itself. Foreign parameters keep their place and
* their exact text (useShareableView's legible `,` and unicode are not re-encoded). Returns '' or '?…'.
*/
export function viewSearch(search, changes = {}) {
const next = readViewKeys(search)
VIEW_KEYS.forEach((k, level) => {
if (!(k in changes) || cleanKey(changes[k]) === next[k]) return
next[k] = cleanKey(changes[k])
VIEW_KEYS.slice(level + 1).forEach((d) => { if (!(d in changes)) next[d] = '' })
})
const all = VIEW_KEYS.filter((k) => next[k]).map((k) => k + '=' + next[k])
.concat((search || '').replace(/^\?/, '').split('&').filter((p) => p && !VIEW_KEYS.includes(p.split('=')[0])))
return all.length ? '?' + all.join('&') : ''
}
/**
* THE one write to the address bar. `search` ('' or '?…') and `hash` ('' or '#…') default to the current ones, which
* is the shape viewSearch, serializeQuery and serializeView all return. Keeps history.state and sets its
* `current` (Vue Router's field) to the new URL. Returns true when it wrote; false when the URL was already that, or
* the page cannot be written (no history, a sandbox).
*/
export function commitUrl({ search, hash } = {}, { push = false } = {}) {
try {
const p = location.pathname
const url = p + (search ?? location.search) + (hash ?? location.hash)
if (url === p + location.search + location.hash) return false
history[push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: url }, '', url)
return true
} catch (_) { return false }
}
/** 'app' | 'host' | '' from a profile-config. Anything else is '' (a partner's page: read only). */
export const urlOwnerOf = (cfg) => (cfg && (cfg.urlOwner === 'app' || cfg.urlOwner === 'host') ? cfg.urlOwner : '')
/**
* The owner's write for tab/pane/place. owner 'app': commitUrl (push for a reader's choice). owner 'host': dispatch
* ADDRESS_EVENT from `el` for the host's router; nothing is written here. Anything else: nothing. Returns what it did:
* 'written' | 'announced' | 'unchanged' | 'none'.
*/
export function writeViewKeys(changes, { owner = '', push = true, el = null } = {}) {
if ((owner !== 'app' && owner !== 'host') || !globalThis.location) return 'none'
const search = viewSearch(location.search, changes)
if (search === location.search) return 'unchanged'
if (owner === 'app') return commitUrl({ search }, { push }) ? 'written' : 'unchanged'
return send(el, ADDRESS_EVENT, { keys: readViewKeys(search), href: location.pathname + search + location.hash, push }) ? 'announced' : 'none'
}
/** A nested element reports a reader's choice to whoever owns the address (it never writes itself). */
export const announceView = (el, key, value, { push = true } = {}) =>
VIEW_KEYS.includes(key) && send(el, VIEW_EVENT, { key, value: cleanKey(value), push })
/** Back / Forward: cb(readViewKeys(location.search)). Re-apply state only; never write from here. Returns off(). */
export function onAddressChange(cb) {
const h = () => cb(readViewKeys(location.search))
globalThis.addEventListener?.('popstate', h)
return () => globalThis.removeEventListener?.('popstate', h)
}

View file

@ -357,7 +357,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test check:pinned-fetch-fresh:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh check:view-address check:view-address:self-test",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -426,6 +426,8 @@
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
"check:poster-section": "node plugin-designs/country-posters/scripts/check-poster-section.mjs",
"check:poster-section:self-test": "node plugin-designs/country-posters/scripts/check-poster-section.mjs --self-test",
"check:view-address": "node internal/check-view-address.mjs",
"check:view-address:self-test": "node internal/check-view-address.mjs --self-test",
"check:poster-sections": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs",
"check:poster-sections:self-test": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs --self-test",
"gen:poster-sections-presets": "node plugin-designs/poster-sections/scripts/gen-presets.mjs",
@ -715,6 +717,8 @@
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
"check:poster-section": "the reusable poster section (<dxm-poster-section>): the type registry has 'country'; DX Life's /regions slugs (and every other spelling of a region) each show their countries, every country once; a withheld whole-country poster stays withheld through the type; the element's attributes map to its config; PosterSection.vue injects every name its template uses.",
"check:poster-section:self-test": "a registry without 'country', a region key without \"&\" → \"and\", and a section that uses a name it does not inject must each make check:poster-section fail.",
"check:view-address": "the page's address (library/composables/useViewAddress.js: ?tab= &pane= &place=) and its ONE history write, on a simulated browser history: the id whitelist + 64 cap and the level rule; an app owner's tab change is one push that keeps history.state with current (Vue Router's field) and the hash; nothing is written when unchanged, on a partner page (no urlOwner), or by a host owner (it hands the host one composed dxm-address). Coexistence with the filters (useShareableView writes through the same commitUrl): a filter write keeps tab/pane/place, a tab/pane/place write keeps the filters, both survive Back and Forward, and re-applying the tab after Back writes nothing. Plus the census: every history.pushState/replaceState in library/ and plugin-designs/ source is the helper or a listed writer. Pure node, no build.",
"check:view-address:self-test": "14 poisons must each make check:view-address fail: a write without history.state.current, a write when not the owner, a tab change that keeps a stale place, no length cap, a write when unchanged, a host owner writing history, a dxm-address that cannot leave a shadow root, a filter write that drops tab/pane/place, the old replaceState(null) filter write, a tab write that drops the filters, a tab change written as a replace, a popstate handler that writes, a new history writer in the tree, useShareableView writing history itself again.",
"check:poster-sections": "the Poster Sections page (plugin-designs/poster-sections): every panel choice (each region or All regions, each language, open or collapsed, 1-5 per row) builds <dxm-poster-section> tags that the element's own attribute reader turns back into exactly those choices; All regions is one section per region; Copy embed is the bundle script plus those same tags. The presets file holds, per poster type, exactly an all-regions preset plus one per region its adapter's scopes() returns, so a region the data drops (or gains) cannot drift from the presets; a poster type not on the tree has no presets (its pages could show no posters); and the built preset pages are exactly the presets (no stale page).",
"check:poster-sections:self-test": "a tag builder that drops columns, one that drops collapsed, one that repeats a region under All regions, a preset for a region the type does not have, and a built page for a preset that no longer exists must each make check:poster-sections fail.",
"gen:poster-sections-presets": "writes plugin-designs/poster-sections/poster-sections.instances.json from each poster type's own scopes() (all regions + one preset per region, titled by the type's own section title, then configure); the list check:poster-sections holds the file to.",