/** * Deep links. Hash-based so any static host serves them and a URL copied * from dev works verbatim on a published site. The URL is a projection of state - the * shell writes it as board/selection/play change and parses it once on boot; camera * intent is always fit semantics (fit-all / fit-selection), never raw coordinates. * * #/ the landing board, fit all * #/b/ board, fit all * #/b/~unplaced the Not on a board view (UNPLACED) * #/b/all-scenes... a link from before 0.25: lands on a real board (legacy-links.ts) * #/b/?n=k1,k2 board with nodes selected, camera fit to selection * #/b/?f=,... the same, by FRAME id - the link an agent can write * (node keys are the shell's; `marver link` prints these) * #/b/?s=,... the same for every frame of a scene * #/b/?c= board with a comment thread open * #/i/ invite link - opens the claim dialog with the token * #/p/?at=&device=&theme= play mode * #/f/?device=&theme= focus - one frame, full screen, no board in * the chrome (a frame deep link is presentation, * not access: 01-sharing ยง3.5) */ export interface HashState { board?: string n?: string[] /** Frame ids to select + fit - resolved to node keys once the board is loaded. */ f?: string[] /** Scenes whose frames to select + fit. */ s?: string[] c?: string invite?: string play?: { at?: string; device?: string; theme?: string; slides?: boolean } /** A frame deep link: lands that visit in focus, over every other rule. */ focus?: { at: string; device?: string; theme?: string } } const BOARD_RE = /^[a-z0-9][a-z0-9-]*$/ /** The Not on a board view (spec 26, Part 7): a temporary board of the frames no board shows, * never saved. Off the board-name grammar, so no board file can ever be called this. */ export const UNPLACED = '~unplaced' const isFrameId = (at: string) => /^[\w./-]{1,200}$/.test(at) && !at.includes('..') /** The node keys a link names on the loaded board: its keys (`n`), the nodes showing its frames * (`f`) and every node of its scenes (`s`) - in board order, each once. A name the board does not * show resolves to nothing (the caller decides what an empty answer means). */ export function linkTargets(h: Pick, nodes: readonly { key: string; frame: string; missing?: boolean }[]): string[] { const keys = new Set(h.n ?? []) const frames = new Set(h.f ?? []) const scenes = h.s ?? [] return nodes.filter((n) => keys.has(n.key) || (!n.missing && (frames.has(n.frame) || scenes.some((sc) => n.frame.startsWith(`${sc}/`))))).map((n) => n.key) } export function parseHash(hash: string = location.hash): HashState { try { const raw = hash.replace(/^#/, '') if (!raw || raw === '/') return {} const q = raw.indexOf('?') const path = q === -1 ? raw : raw.slice(0, q) const params = new URLSearchParams(q === -1 ? '' : raw.slice(q + 1)) const mi = path.match(/^\/i\/([\w-]{8,128})$/) if (mi) return { invite: mi[1] } // frame ids are / (or bare ), so the f route takes the rest of the path const mf = path.match(/^\/f\/(.+)$/) if (mf) { const at = decodeURIComponent(mf[1]) if (!isFrameId(at)) return {} return { focus: { at, device: params.get('device') ?? undefined, theme: params.get('theme') ?? undefined } } } const m = path.match(/^\/(b|p)\/([^/?]+)$/) if (!m) return {} const board = decodeURIComponent(m[2]) if (!BOARD_RE.test(board) && board !== UNPLACED) return {} if (m[1] === 'b') { const list = (k: string) => (params.get(k) ?? '').split(',').map((s) => s.trim()).filter(Boolean) const n = list('n') const f = list('f').filter(isFrameId) const sc = list('s').filter((x) => /^[\w.-]{1,120}$/.test(x) && !x.includes('..')) const c = params.get('c') ?? undefined return { board, ...(n.length ? { n } : {}), ...(f.length ? { f } : {}), ...(sc.length ? { s: sc } : {}), ...(c && /^[\w-]+$/.test(c) ? { c } : {}) } } return { board, play: { at: params.get('at') ?? undefined, device: params.get('device') ?? undefined, theme: params.get('theme') ?? undefined, ...(params.get('slides') === '1' ? { slides: true } : {}), }, } } catch { return {} } // a malformed hash (#/b/%) is a default view, never a crash } export function buildHash(s: HashState): string { if (s.focus?.at) { const p = new URLSearchParams() if (s.focus.device) p.set('device', s.focus.device) if (s.focus.theme) p.set('theme', s.focus.theme) const q = p.toString() return `#/f/${s.focus.at}${q ? `?${q}` : ''}` } if (s.play?.at && s.board) { const p = new URLSearchParams({ at: s.play.at }) if (s.play.device) p.set('device', s.play.device) if (s.play.theme) p.set('theme', s.play.theme) if (s.play.slides) p.set('slides', '1') return `#/p/${s.board}?${p}` } if (!s.board) return '#/' const p = new URLSearchParams() if (s.n?.length) p.set('n', s.n.join(',')) if (s.c) p.set('c', s.c) const q = p.toString() return `#/b/${s.board}${q ? `?${decodeURIComponent(q)}` : ''}` } /** Write the hash; identical URLs are skipped so restore paths never loop. */ export function writeHash(s: HashState, push = false) { const h = buildHash(s) if (h === location.hash || (h === '#/' && !location.hash)) return if (push) history.pushState(null, '', h) else history.replaceState(null, '', h) } /** The hash as it stood when the app loaded - boot intent, captured once; re-aimed only when an old * link resolves to where it lands now (legacy-links.ts), before the board boots. */ export let bootHash = parseHash() export function retargetBoot(h: HashState) { bootHash = h }