doxa-maps/library/composables/useViewAddress.js
Doxa Maps 30dc9a1457 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.
2026-10-03 05:20:43 +00:00

108 lines
6.8 KiB
JavaScript

/**
* 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)
}