/** * 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=&pane=&place= * 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(). The view keys and useShareableView's filters both write through it, so those * two can never undo each other. The tree's other history writers are pinned by check:view-address's census and move * onto commitUrl in later phases. On a push, commitUrl copies the previous entry's state (Vue Router's `position` and * `back` included). That is correct for a replace. A Vue Router page is a 'host' owner, so its router does its own * pushes. 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) }