// Reads standard.site link-tag hints from the page head and reports them to // the background worker. Kept dependency-free; built as an IIFE. // No exports — this only marks the file as a module, so src/content.test.ts // can import it and so the declaration below stays out of the global scope. export {} // The Navigation API is not in TypeScript's DOM lib yet (5.9 ships only // NavigationHistoryEntry), so declare the sliver we use. Access is guarded by // `typeof`, which is safe for an identifier the browser does not define. declare const navigation: EventTarget | undefined function hint(rel: string): string | undefined { const el = document.querySelector(`link[rel="${rel}"]`) const href = el?.getAttribute('href')?.trim() return href && href.startsWith('at://') ? href : undefined } // After the extension reloads, orphaned copies of this script keep running in // open tabs but their chrome.runtime binding is dead: runtime.id is undefined // and sendMessage throws synchronously ("Extension context invalidated"). function orphaned(): boolean { return !chrome.runtime?.id } function readHints(): { pubHint?: string; docHint?: string } { return { pubHint: hint('site.standard.publication'), docHint: hint('site.standard.document'), } } // Detection is keyed on the page URL and the two hints; re-sending an // unchanged triple would only make the worker redo the same lookups. Chrome // fires a navigatesuccess for the initial page load too — measured, it lands // just after the load event — so without this every page would report twice. let lastReported: string | undefined function report() { if (orphaned()) return const hints = readHints() const key = `${location.href}\n${hints.pubHint ?? ''}\n${hints.docHint ?? ''}` if (key === lastReported) return lastReported = key chrome.runtime.sendMessage({ type: 'page-hints', ...hints }).catch(() => { // background worker not ready; it will probe on demand instead }) } report() // The background worker asks for hints again on manual refresh. chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'read-hints') { sendResponse(readHints()) } }) // SPA navigations swap head tags without a page load. // // The Navigation API reports each navigation once — pushState, replaceState, // fragment changes and back/forward included — so this script costs the pages // it runs on one listener instead of a callback per DOM mutation. Measured on // bsky.app (headless Chrome 149, ~20s of scrolling and one in-app link click), // the observer this replaces ran 66 times over 448 mutation records to notice // one URL change; the listener below runs twice. // // `navigatesuccess` fires once the navigation has committed, so location.href // is already the new one. Routers often swap the head tags a tick later, so // read again after the page settles; report() drops that second read unless // something actually changed. const SETTLE_MS = 400 let settleTimer: ReturnType | undefined if (typeof navigation !== 'undefined') { const onNavigated = () => { if (orphaned()) { navigation.removeEventListener('navigatesuccess', onNavigated) clearTimeout(settleTimer) return } console.debug('[substandard] spa navigation', location.href) report() clearTimeout(settleTimer) settleTimer = setTimeout(report, SETTLE_MS) } navigation.addEventListener('navigatesuccess', onNavigated) } else { // Browsers without the Navigation API (it is Chrome-only as of writing). // A content script runs in an isolated world, where patching pushState is // invisible to the page, so there is nothing cheaper to fall back to than // the mutation watch this replaces. let lastUrl = location.href const observer = new MutationObserver(() => { if (orphaned()) { observer.disconnect() return } if (location.href !== lastUrl) { lastUrl = location.href report() } }) observer.observe(document, { subtree: true, childList: true }) }