this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Feat/rel alternate probe (#17)

* feat: probe rel alternate at uris

* docs: sync supported services list

* fix: gate weaver on record keys

* fix: rerun rel alternate probe on cached miss

* style: align metadata banner text size

* AGENTS.md update

* logic fixes

* fixes pt2

* fixes pt3

* comments

* remove toolify

authored by

Alice and committed by
GitHub
cd94caf7 2c4d2d7d

+413 -109
+5
AGENTS.md
··· 38 38 - `src/shared/resolver.ts` + `src/shared/cache.ts` ➜ handle↔DID resolution with a debounced `DidHandleCache` and inlined retry logic. 39 39 - `src/shared/services.ts` ➜ service registry + destination builders; parsing helpers moved out. 40 40 - `src/shared/options.ts` ➜ minimal options API (`showEmojis`, `strictMode`, `showCacheDebug`) with listener helpers. 41 + - `src/shared/rel-alternate.ts` ➜ parses `<link rel="alternate" href="at://...">` metadata into canonical `TransformInfo` for Leaflet/WhtWnd-style pages. 41 42 - `src/background/service-worker.ts` ➜ message router plus a lightweight `tabs.onUpdated` listener that precaches DID/handle pairs for any URL `parseInput` understands (all supported services). 43 + - `src/background/service-worker.ts` also handles `PROBE_PAGE_FOR_AT_URI` (triggered by the popup) by injecting a short DOM scanner via `chrome.scripting`/`activeTab`, caching successful rel-alternate hits in `chrome.storage.session`. 42 44 - `src/popup/*` ➜ DOM-only rendering (no `innerHTML`), Firefox theme via CSS variables, inline cache debug panel. 43 45 - `src/options/*` ➜ simple UI with three toggles; errors revert to the last known good state. 44 46 - Builds via Vite + `@crxjs/vite-plugin`; remember to run all validation commands listed above. ··· 52 54 - [x] **Options revert bug** (`src/options/options.ts:13-35`): Error handling always reverts to the _initial_ checkbox state, not the last successful save, because the captured `options` object never updates. _Fixed 2025-11-15 by tracking the last persisted values and reverting to those on failures._ 53 55 - [ ] **Tooling determinism** (`package.json`): Almost every devDependency is pinned to `"latest"`, which makes CI/CD non-reproducible and has already caused surprise build breaks. 54 56 - [x] **Cache write amplification** (`src/shared/cache.ts:138-205`): Every cache hit triggers a full `chrome.storage.local.set`, risking quota overruns (120 writes/min) and throttled service-worker lifetimes. _Fixed 2025-11-15 by debouncing read-hit persistence while keeping writes synchronous._ 57 + - [x] **Weaver repo limitation** (`src/shared/services.ts`): alpha.weaver.sh only renders single records; profiles with no `rkey` produced dead links. _Fixed 2025-11-15 by requiring `rkey` for Weaver destinations._ 58 + - [x] **Rel-alternate stale cache** (`src/background/service-worker.ts`): Cached “misses” blocked future metadata probes until the tab reloaded. _Fixed 2025-11-15 by skipping cache reuse unless both `info` and `atUri` are present._ 55 59 56 60 _When you clear an item, document the fix (date + PR/commit) here before removing it so future agents see the history._ 57 61 ··· 62 66 - Service worker precaches DID/handle pairs again by parsing every completed tab URL and only acting when `parseInput` recognizes a supported service. 63 67 - `retry.ts`, legacy options helpers, and `wormholeDebug` hooks are gone; parser owns service-specific parsing logic. 64 68 - Options include a third “Show cache debug info” toggle; popup shows cache hit/miss status when enabled. 69 + - Popup now triggers an on-demand rel=alternate probe (via `activeTab` + `chrome.scripting`) so Leaflet-style pages expose AT URIs without needing `<all_urls>` permissions. 65 70 - Remaining backlog: pin dependencies for deterministic builds.
-1
README.md
··· 20 20 - [frontpage.fyi](https://frontpage.fyi) 21 21 - [boat.kelinci.net](https://boat.kelinci.net) 22 22 - [plc.directory](https://plc.directory) 23 - - [toolify.blue](https://toolify.blue) 24 23 25 24 If you'd like to add support for another service, please open an issue or submit a pull request. 26 25
+1 -1
docs/index.html
··· 193 193 <li><a href="https://deer.social">deer.social</a></li> 194 194 <li><a href="https://bsky.app">bsky.app</a></li> 195 195 <li><a href="https://atp.tools">atp.tools</a></li> 196 + <li><a href="https://alpha.weaver.sh">alpha.weaver.sh</a></li> 196 197 <li><a href="https://pdsls.dev">pdsls.dev</a></li> 197 198 <li><a href="https://repoview.edavis.dev">repoview.edavis.dev</a></li> 198 199 <li><a href="https://astrolabe.at">astrolabe.at</a></li> ··· 203 204 <li><a href="https://frontpage.fyi">frontpage.fyi</a></li> 204 205 <li><a href="https://boat.kelinci.net">boat.kelinci.net</a></li> 205 206 <li><a href="https://plc.directory">plc.directory</a></li> 206 - <li><a href="https://toolify.blue">toolify.blue</a></li> 207 207 </ul> 208 208 <p>If you’d like to add support for another service, <a href="https://github.com/aliceisjustplaying/at-wormhole-webextension">please open an 209 209 issue or submit a pull request.</a></p>
+17
planning/2025-11-15-rel-alternate-probe.md
··· 1 + # 2025-11-15 rel=alternate AT URI probe 2 + 3 + ## Goal 4 + 5 + Detect `at://` links exposed via `<link rel="alternate" href="at://...">` on the active tab (Leaflet, WhtWnd, etc.) and feed those AT URIs into the popup so users can jump to any supported destination without guessing the handle. 6 + 7 + ## Plan 8 + 9 + 1. **Manifest & Types** – add the `scripting` permission, extend shared message types for a probe request/response, and describe the new detection source so later features can refer to it explicitly. _(Done 2025-11-15)_ 10 + 2. **Head Scanner Helper** – build a pure helper (e.g., `extractAtUriFromHead(html: string)`) that sanitizes/validates `<link rel="alternate">` tags and returns canonical AT URIs; unit-test with representative Leaflet/WhtWnd markup. _(Done 2025-11-15)_ 11 + 3. **Service Worker Probe** – add a new `PROBE_PAGE_FOR_AT_URI` handler that runs `chrome.scripting.executeScript` (activeTab gated) to collect the page’s `<head>` HTML, uses the helper to parse it, and caches the best `TransformInfo` in `chrome.storage.session` keyed by tab. _(Done 2025-11-15)_ 12 + 4. **Popup Integration** – when loading the popup, fetch the cached probe info (or trigger a fresh probe) and merge it with the existing `parseInput` result; surface clear status text (“Detected via rel=alternate metadata”) so users know why new actions appeared. _(Done 2025-11-15)_ 13 + 5. **Destinations & UX polish** – ensure `buildDestinations` already handles AT URIs with collection/rkey; if not, extend it plus add defensive logging, update docs/AGENTS backlog if review or permissions changed. _(Pending)_ 14 + 6. **Merge Semantics** – fix `mergeTransformInfo` to treat empty strings correctly (use nullish coalescing for `bskyAppPath`) so the probe result can’t overwrite a valid empty path. _(Done 2025-11-15)_ 15 + 7. **URL-aware Probe Cache** – include the page URL in the probe cache so navigating within a tab doesn’t reuse stale rel=alternate data; invalidate entries when the stored URL doesn’t match the current tab URL. _(Done 2025-11-15)_ 16 + 17 + _Status: in progress_
+14
planning/2025-11-15-remove-toolify.md
··· 1 + # 2025-11-15 remove toolify.blue 2 + 3 + ## Goal 4 + 5 + Drop toolify.blue support entirely so the extension no longer offers or parses that destination. 6 + 7 + ## Plan 8 + 9 + 1. **Service + Manifest cleanup** – delete the Toolify config from `src/shared/services.ts` and remove its host permission from `public/manifest.json`. _(Done 2025-11-15)_ 10 + 2. **Docs + UX copy** – update README/docs supported-service lists to stop advertising toolify. _(Done 2025-11-15)_ 11 + 3. **Tests + Parser** – remove Toolify-specific parsing tests/expectations so the suite reflects the new support matrix. _(Done 2025-11-15)_ 12 + 4. **Validation** – rerun the required format/lint/typecheck/test/build/web-ext commands. _(Done 2025-11-15)_ 13 + 14 + _Status: completed_
+1 -2
public/manifest.json
··· 16 16 "512": "images/icon_512.png" 17 17 }, 18 18 19 - "permissions": ["activeTab", "storage", "theme"], 19 + "permissions": ["activeTab", "storage", "theme", "scripting"], 20 20 "host_permissions": [ 21 21 "https://public.api.bsky.app/*", 22 22 "https://plc.directory/*", ··· 31 31 "https://frontpage.fyi/*", 32 32 "https://boat.kelinci.net/*", 33 33 "https://repoview.edavis.dev/*", 34 - "https://toolify.blue/*", 35 34 "https://astrolabe.at/*" 36 35 ], 37 36
+171 -1
src/background/service-worker.ts
··· 2 2 import { resolveDidToHandle, resolveHandleToDid } from '../shared/resolver'; 3 3 import { DidHandleCache } from '../shared/cache'; 4 4 import { debugLog, logError } from '../shared/logging'; 5 - import type { SWMessage } from '../shared/types'; 5 + import type { PageProbeResponse, ProbeSource, SWMessage, TransformInfo } from '../shared/types'; 6 + import { extractAtUriFromAlternateLinks, type AlternateLinkCandidate } from '../shared/rel-alternate'; 6 7 7 8 const cache = new DidHandleCache(); 8 9 9 10 // Create initialization promise immediately at module level 10 11 const cacheInitialized = initializeCache(); 12 + 13 + const PROBE_CACHE_PREFIX = 'pageProbe:'; 14 + const PROBE_CACHE_TTL_MS = 60_000; 15 + const probeInFlight = new Map<string, Promise<ProbeCacheEntry | null>>(); 16 + 17 + interface ProbeCacheEntry { 18 + info: TransformInfo | null; 19 + atUri: string | null; 20 + source: ProbeSource | null; 21 + detectedAt: number; 22 + url: string; 23 + } 11 24 12 25 async function initializeCache(): Promise<void> { 13 26 try { ··· 40 53 } 41 54 } 42 55 56 + function getSessionStorageArea(): chrome.storage.StorageArea | null { 57 + if ('session' in chrome.storage) { 58 + return chrome.storage.session; 59 + } 60 + return null; 61 + } 62 + 63 + async function getProbeCache(tabId: number): Promise<ProbeCacheEntry | null> { 64 + const session = getSessionStorageArea(); 65 + if (!session) return null; 66 + const key = `${PROBE_CACHE_PREFIX}${tabId}`; 67 + const result: Record<string, unknown> = await session.get(key); 68 + const entry = result[key]; 69 + if (!entry) { 70 + return null; 71 + } 72 + return entry as ProbeCacheEntry; 73 + } 74 + 75 + async function setProbeCache(tabId: number, entry: ProbeCacheEntry): Promise<void> { 76 + const session = getSessionStorageArea(); 77 + if (!session) return; 78 + const key = `${PROBE_CACHE_PREFIX}${tabId}`; 79 + await session.set({ [key]: entry }); 80 + } 81 + 82 + async function clearProbeCache(tabId: number): Promise<void> { 83 + const session = getSessionStorageArea(); 84 + if (!session) return; 85 + const key = `${PROBE_CACHE_PREFIX}${tabId}`; 86 + await session.remove(key); 87 + } 88 + 89 + function shouldSkipProbe(url?: string): boolean { 90 + if (!url) return true; 91 + return !(url.startsWith('http://') || url.startsWith('https://')); 92 + } 93 + 94 + function getProbeKey(tabId: number, tabUrl?: string): string { 95 + return `${tabId}:${tabUrl ?? ''}`; 96 + } 97 + 98 + function getOrCreateProbe(tabId: number, tabUrl?: string): Promise<ProbeCacheEntry | null> { 99 + const key = getProbeKey(tabId, tabUrl); 100 + const existing = probeInFlight.get(key); 101 + if (existing) { 102 + return existing; 103 + } 104 + 105 + const pending = (async () => { 106 + try { 107 + return await runRelAlternateProbe(tabId, tabUrl); 108 + } finally { 109 + probeInFlight.delete(key); 110 + } 111 + })(); 112 + 113 + probeInFlight.set(key, pending); 114 + return pending; 115 + } 116 + 117 + async function runRelAlternateProbe(tabId: number, tabUrl?: string): Promise<ProbeCacheEntry | null> { 118 + try { 119 + const injectionResults = (await chrome.scripting.executeScript({ 120 + target: { tabId }, 121 + func: () => { 122 + const head = document.head; 123 + const links = Array.from(head.querySelectorAll('link[rel]')); 124 + return links.map((link) => ({ 125 + rel: link.getAttribute('rel'), 126 + href: link.getAttribute('href'), 127 + type: link.getAttribute('type'), 128 + title: link.getAttribute('title'), 129 + })); 130 + }, 131 + })) as chrome.scripting.InjectionResult<AlternateLinkCandidate[]>[]; 132 + 133 + const candidates: AlternateLinkCandidate[] = []; 134 + for (const result of injectionResults) { 135 + if (result.result) { 136 + candidates.push(...result.result); 137 + } 138 + } 139 + 140 + const match = extractAtUriFromAlternateLinks(candidates); 141 + return { 142 + info: match?.info ?? null, 143 + atUri: match?.atUri ?? null, 144 + source: match ? 'rel-alternate' : null, 145 + detectedAt: Date.now(), 146 + url: tabUrl ?? '', 147 + }; 148 + } catch (error) { 149 + logError('serviceWorker', error); 150 + return null; 151 + } 152 + } 153 + 154 + async function handleProbeRequest(tabId: number, tabUrl?: string, force = false): Promise<PageProbeResponse> { 155 + if (shouldSkipProbe(tabUrl)) { 156 + return { info: null, atUri: null, source: null, cached: false }; 157 + } 158 + 159 + if (!force) { 160 + try { 161 + const cached = await getProbeCache(tabId); 162 + if (cached && Date.now() - cached.detectedAt < PROBE_CACHE_TTL_MS) { 163 + if (tabUrl && (!cached.url || cached.url !== tabUrl)) { 164 + await clearProbeCache(tabId); 165 + } else if (cached.info && cached.atUri) { 166 + return { info: cached.info, atUri: cached.atUri, source: cached.source, cached: true }; 167 + } else { 168 + await clearProbeCache(tabId); 169 + } 170 + } 171 + } catch (error) { 172 + logError('serviceWorker', error); 173 + } 174 + } 175 + 176 + const fresh = await getOrCreateProbe(tabId, tabUrl); 177 + if (fresh) { 178 + if (fresh.info && fresh.atUri) { 179 + try { 180 + await setProbeCache(tabId, fresh); 181 + } catch (error) { 182 + logError('serviceWorker', error); 183 + } 184 + return { info: fresh.info, atUri: fresh.atUri, source: fresh.source, cached: false }; 185 + } 186 + 187 + // We intentionally do not cache negative results (no metadata) so that 188 + // future popup opens can re-check the page. This trades a tiny bit of 189 + // extra scripting work for avoiding stale "no metadata" responses when 190 + // the page content changes without a navigation event. 191 + } 192 + 193 + return { info: null, atUri: null, source: null, cached: false }; 194 + } 195 + 43 196 // Handle messages from the popup 44 197 const messageListener = ( 45 198 request: SWMessage, 46 199 _sender: chrome.runtime.MessageSender, 47 200 sendResponse: (response?: unknown) => void, 48 201 ): boolean => { 202 + if (request.type === 'PROBE_PAGE_FOR_AT_URI' && typeof request.tabId === 'number') { 203 + void (async () => { 204 + try { 205 + const response = await handleProbeRequest(request.tabId, request.tabUrl, request.force === true); 206 + sendResponse(response); 207 + } catch (error) { 208 + logError('serviceWorker', error); 209 + sendResponse({ info: null, atUri: null, source: null, cached: false }); 210 + } 211 + })(); 212 + return true; 213 + } 214 + 49 215 // UPDATE_CACHE 50 216 if (request.type === 'UPDATE_CACHE' && typeof request.did === 'string' && typeof request.handle === 'string') { 51 217 void (async () => { ··· 184 350 }; 185 351 186 352 chrome.tabs.onUpdated.addListener(tabUpdateListener); 353 + 354 + chrome.tabs.onRemoved.addListener((tabId) => { 355 + void clearProbeCache(tabId).catch((error: unknown) => logError('serviceWorker', error)); 356 + }); 187 357 188 358 async function precacheFromUrl(rawUrl: string): Promise<void> { 189 359 try {
+10
src/popup/popup.css
··· 71 71 margin: 6px 0; 72 72 } 73 73 74 + .metadata-info { 75 + font-size: 11px; 76 + text-align: center; 77 + margin-top: 4px; 78 + color: #555; 79 + } 80 + 74 81 #emptyCacheBtn { 75 82 width: auto; 76 83 text-align: center; ··· 138 145 @media (prefers-color-scheme: dark) { 139 146 .debug-info { 140 147 color: #aaa; 148 + } 149 + .metadata-info { 150 + color: #bbb; 141 151 } 142 152 } 143 153 #controls {
+1
src/popup/popup.html
··· 12 12 <button id="emptyCacheBtn">Empty Handle+DID Cache</button> 13 13 </div> 14 14 <div id="debugInfo" class="debug-info" hidden></div> 15 + <div id="metadataInfo" class="metadata-info" hidden></div> 15 16 <script type="module" src="./popup.ts"></script> 16 17 </body> 17 18 </html>
+84 -12
src/popup/popup.ts
··· 1 1 import { parseInput } from '../shared/parser'; 2 2 import { buildDestinations } from '../shared/services'; 3 3 import { getOptions, getDefaultOptions } from '../shared/options'; 4 - import type { BrowserWithTheme, Destination } from '../shared/types'; 4 + import type { BrowserWithTheme, Destination, PageProbeResponse, TransformInfo } from '../shared/types'; 5 5 import { ResultAsync } from 'neverthrow'; 6 6 import { runtimeError, type RuntimeError } from '../shared/errors'; 7 7 import { debugLog } from '../shared/logging'; ··· 67 67 ); 68 68 } 69 69 70 + function mergeTransformInfo(primary: TransformInfo | null, secondary: TransformInfo | null): TransformInfo | null { 71 + if (!primary && !secondary) { 72 + return null; 73 + } 74 + if (!secondary) { 75 + return primary; 76 + } 77 + if (!primary) { 78 + return secondary; 79 + } 80 + 81 + const mergedPath = primary.bskyAppPath !== '' ? primary.bskyAppPath : secondary.bskyAppPath; 82 + 83 + return { 84 + atUri: primary.atUri ?? secondary.atUri, 85 + did: primary.did ?? secondary.did, 86 + handle: primary.handle ?? secondary.handle, 87 + rkey: primary.rkey ?? secondary.rkey, 88 + nsid: primary.nsid ?? secondary.nsid, 89 + bskyAppPath: mergedPath, 90 + }; 91 + } 92 + 93 + function requestPageProbe(tabId: number, tabUrl?: string): Promise<PageProbeResponse | null> { 94 + return sendRuntimeMessage<PageProbeResponse>({ 95 + type: 'PROBE_PAGE_FOR_AT_URI', 96 + tabId, 97 + tabUrl, 98 + }).match( 99 + (response) => response, 100 + (error) => { 101 + console.error('PROBE_PAGE_FOR_AT_URI error', error); 102 + return null; 103 + }, 104 + ); 105 + } 106 + 70 107 // Local type for list items 71 108 72 109 /** ··· 111 148 }; 112 149 113 150 const debugInfo = document.getElementById('debugInfo') as HTMLDivElement | null; 151 + const metadataInfo = document.getElementById('metadataInfo') as HTMLDivElement | null; 114 152 115 153 const setDebugInfo = (msg: string): void => { 116 154 if (!debugInfo) return; ··· 123 161 debugInfo.textContent = msg; 124 162 }; 125 163 164 + const setMetadataInfo = (msg: string | null): void => { 165 + if (!metadataInfo) return; 166 + if (!msg) { 167 + metadataInfo.hidden = true; 168 + metadataInfo.textContent = ''; 169 + return; 170 + } 171 + metadataInfo.hidden = false; 172 + metadataInfo.textContent = msg; 173 + }; 174 + 175 + setMetadataInfo(null); 176 + 126 177 if (debugInfo) { 127 178 if (options.showCacheDebug) { 128 179 debugInfo.hidden = false; ··· 154 205 // Determine input: payload param or active tab URL 155 206 const payload = new URLSearchParams(location.search).get('payload'); 156 207 const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 157 - const activeUrl = tabs[0]?.url ?? ''; 208 + let activeTab: chrome.tabs.Tab | null = null; 209 + if (tabs.length > 0) { 210 + activeTab = tabs[0]; 211 + } 212 + const activeUrl = activeTab?.url ?? ''; 213 + const activeTabId = activeTab && typeof activeTab.id === 'number' ? activeTab.id : null; 158 214 const raw: string = payload ?? activeUrl; 159 215 debugLog('parsing', 'Processing input:', raw); 160 216 if (!raw) { ··· 166 222 void parseResult.match( 167 223 async (info) => { 168 224 debugLog('parsing', 'Parse result:', info); 169 - if (!info || (!info.did && !info.handle && !info.atUri)) { 225 + let currentInfo = info; 226 + 227 + if (activeTabId !== null) { 228 + const probeResponse = await requestPageProbe(activeTabId, activeUrl); 229 + if (probeResponse?.info) { 230 + currentInfo = mergeTransformInfo(probeResponse.info, currentInfo); 231 + if (probeResponse.source === 'rel-alternate') { 232 + setMetadataInfo('Found rel=alternate at:// metadata on this page.'); 233 + } 234 + } else { 235 + setMetadataInfo(null); 236 + } 237 + } else { 238 + setMetadataInfo(null); 239 + } 240 + 241 + if (!currentInfo || (!currentInfo.did && !currentInfo.handle && !currentInfo.atUri)) { 170 242 showStatus('No DID or at:// URI found in current tab.'); 171 243 return; 172 244 } 173 245 174 - let ds = buildDestinations(info, options.showEmojis, options.strictMode); 246 + let ds = buildDestinations(currentInfo, options.showEmojis, options.strictMode); 175 247 render(ds); 176 248 177 - if (info.did && !info.handle) { 249 + if (currentInfo.did && !currentInfo.handle) { 178 250 // Ask SW for a handle (from cache or resolved) 179 251 showStatus('Resolving...'); 180 252 ··· 183 255 fromCache: boolean; 184 256 }>({ 185 257 type: 'GET_HANDLE', 186 - did: info.did, 258 + did: currentInfo.did, 187 259 }).match( 188 260 (response) => { 189 261 const handle = response.handle; ··· 206 278 207 279 // After attempting to get handle from cache or by fetching: 208 280 if (handleToUse) { 209 - info.handle = handleToUse; 210 - ds = buildDestinations(info, options.showEmojis, options.strictMode); // Re-build destinations with the handle 281 + currentInfo.handle = handleToUse; 282 + ds = buildDestinations(currentInfo, options.showEmojis, options.strictMode); // Re-build destinations with the handle 211 283 render(ds); // Re-render the list 212 284 } else { 213 285 // Handle was not obtained. An error status might have already been set. ··· 219 291 } 220 292 221 293 // If we have a handle but no did, resolve DID via SW 222 - if (info.handle && !info.did) { 294 + if (currentInfo.handle && !currentInfo.did) { 223 295 showStatus('Resolving...'); 224 296 225 297 const { didToUse, errorStatusWasSet } = await sendRuntimeMessage<{ did: string | null; fromCache: boolean }>({ 226 298 type: 'GET_DID', 227 - handle: info.handle, 299 + handle: currentInfo.handle, 228 300 }).match( 229 301 (response) => { 230 302 const did = response.did; ··· 246 318 ); 247 319 248 320 if (didToUse) { 249 - info.did = didToUse; 250 - ds = buildDestinations(info, options.showEmojis, options.strictMode); 321 + currentInfo.did = didToUse; 322 + ds = buildDestinations(currentInfo, options.showEmojis, options.strictMode); 251 323 render(ds); 252 324 } else if (!ds.length && !errorStatusWasSet) { 253 325 showStatus('No actions available');
+53
src/shared/rel-alternate.ts
··· 1 + import { canonicalize } from './canonicalizer'; 2 + import type { TransformInfo } from './types'; 3 + 4 + export interface AlternateLinkCandidate { 5 + href?: string | null; 6 + rel?: string | null; 7 + type?: string | null; 8 + title?: string | null; 9 + } 10 + 11 + export interface RelAlternateProbeResult { 12 + atUri: string; 13 + info: TransformInfo; 14 + rel: string | null; 15 + type: string | null; 16 + } 17 + 18 + const AT_URI_PREFIX = 'at://'; 19 + 20 + function hasAlternateRel(rel: string | null | undefined): boolean { 21 + if (!rel) return false; 22 + return rel 23 + .split(/\s+/) 24 + .filter(Boolean) 25 + .map((token) => token.toLowerCase()) 26 + .includes('alternate'); 27 + } 28 + 29 + export function extractAtUriFromAlternateLinks(links: AlternateLinkCandidate[]): RelAlternateProbeResult | null { 30 + for (const link of links) { 31 + if (!hasAlternateRel(link.rel)) continue; 32 + const href = (link.href ?? '').trim(); 33 + if (!href.toLowerCase().startsWith(AT_URI_PREFIX)) continue; 34 + 35 + const canonicalResult = canonicalize(href); 36 + if (canonicalResult.isErr()) { 37 + continue; 38 + } 39 + const info = canonicalResult.value; 40 + if (!info) { 41 + continue; 42 + } 43 + 44 + return { 45 + atUri: info.atUri ?? href, 46 + info, 47 + rel: link.rel ?? null, 48 + type: link.type ?? null, 49 + }; 50 + } 51 + 52 + return null; 53 + }
+1 -16
src/shared/services.ts
··· 60 60 name: 'alpha.weaver.sh', 61 61 contentSupport: 'full', 62 62 buildUrl: (info) => (info.atUri ? `https://alpha.weaver.sh/record/${info.atUri}` : null), 63 + requiredFields: { rkey: true }, 63 64 }, 64 65 ], 65 66 [ ··· 253 254 }, 254 255 }, 255 256 buildUrl: (info) => `https://plc.directory/${info.did}`, 256 - requiredFields: { plcOnly: true }, 257 - }, 258 - ], 259 - [ 260 - 'TOOLIFY_BLUE', 261 - { 262 - emoji: '🔧', 263 - name: 'toolify.blue', 264 - contentSupport: 'profiles-and-posts', 265 - parsing: { 266 - hostname: 'toolify.blue', 267 - patterns: { 268 - profileIdentifier: /^\/profile\/([^/]+)/, 269 - }, 270 - }, 271 - buildUrl: (info) => `https://toolify.blue${info.bskyAppPath}`, 272 257 requiredFields: { plcOnly: true }, 273 258 }, 274 259 ],
+11 -1
src/shared/types.ts
··· 16 16 | { type: 'UPDATE_CACHE'; did: string; handle: string } 17 17 | { type: 'GET_HANDLE'; did: string } 18 18 | { type: 'GET_DID'; handle: string } 19 - | { type: 'CLEAR_CACHE' }; 19 + | { type: 'CLEAR_CACHE' } 20 + | { type: 'PROBE_PAGE_FOR_AT_URI'; tabId: number; tabUrl?: string; force?: boolean }; 21 + 22 + export type ProbeSource = 'rel-alternate'; 23 + 24 + export interface PageProbeResponse { 25 + info: TransformInfo | null; 26 + atUri: string | null; 27 + source: ProbeSource | null; 28 + cached: boolean; 29 + } 20 30 21 31 export interface Destination { 22 32 url: string;
+35
tests/rel-alternate.test.ts
··· 1 + import { describe, expect, test } from 'bun:test'; 2 + import { extractAtUriFromAlternateLinks } from '../src/shared/rel-alternate'; 3 + 4 + describe('extractAtUriFromAlternateLinks', () => { 5 + test('returns first valid AT URI with alternate rel', () => { 6 + const result = extractAtUriFromAlternateLinks([ 7 + { rel: 'stylesheet', href: 'https://example.com/app.css' }, 8 + { rel: 'alternate', href: 'at://did:plc:example/app.bsky.feed.post/abc' }, 9 + ]); 10 + 11 + expect(result?.atUri).toEqual('at://did:plc:example/app.bsky.feed.post/abc'); 12 + expect(result?.info.did).toEqual('did:plc:example'); 13 + expect(result?.info.nsid).toEqual('app.bsky.feed.post'); 14 + expect(result?.info.rkey).toEqual('abc'); 15 + }); 16 + 17 + test('ignores entries without alternate rel tokens', () => { 18 + const result = extractAtUriFromAlternateLinks([ 19 + { rel: 'icon', href: 'at://did:plc:example/app.bsky.feed.post/abc' }, 20 + { rel: 'ALTERNATE next', href: 'https://example.com/not-at' }, 21 + ]); 22 + 23 + expect(result).toBeNull(); 24 + }); 25 + 26 + test('handles rel tokens with mixed casing and trimmed hrefs', () => { 27 + const result = extractAtUriFromAlternateLinks([ 28 + { rel: 'feed Alternate', href: ' at://did:plc:other/app.bsky.feed.generator/xyz ' }, 29 + ]); 30 + 31 + expect(result?.info.did).toEqual('did:plc:other'); 32 + expect(result?.info.nsid).toEqual('app.bsky.feed.generator'); 33 + expect(result?.info.rkey).toEqual('xyz'); 34 + }); 35 + });
+9 -75
tests/transform.test.ts
··· 216 216 }); 217 217 }); 218 218 }); 219 - 220 - describe('toolify.blue URLs', () => { 221 - test('should parse profile URL with handle', () => { 222 - const result = parseInput('https://toolify.blue/profile/alice.mosphere.at'); 223 - expect(result._unsafeUnwrap()).toEqual({ 224 - atUri: 'at://alice.mosphere.at', 225 - did: null, 226 - handle: 'alice.mosphere.at', 227 - rkey: undefined, 228 - nsid: undefined, 229 - bskyAppPath: '/profile/alice.mosphere.at', 230 - }); 231 - }); 232 - 233 - test('should parse post URL with handle', () => { 234 - const result = parseInput('https://toolify.blue/profile/alice.mosphere.at/post/3lqeyxrcx6k2p'); 235 - expect(result._unsafeUnwrap()).toEqual({ 236 - atUri: 'at://alice.mosphere.at/app.bsky.feed.post/3lqeyxrcx6k2p', 237 - did: null, 238 - handle: 'alice.mosphere.at', 239 - rkey: '3lqeyxrcx6k2p', 240 - nsid: 'app.bsky.feed.post', 241 - bskyAppPath: '/profile/alice.mosphere.at/post/3lqeyxrcx6k2p', 242 - }); 243 - }); 244 - 245 - test('should parse profile URL with DID', () => { 246 - const result = parseInput('https://toolify.blue/profile/did:plc:by3jhwdqgbtrcc7q4tkkv3cf'); 247 - expect(result._unsafeUnwrap()).toEqual({ 248 - atUri: 'at://did:plc:by3jhwdqgbtrcc7q4tkkv3cf', 249 - did: 'did:plc:by3jhwdqgbtrcc7q4tkkv3cf', 250 - handle: null, 251 - rkey: undefined, 252 - nsid: undefined, 253 - bskyAppPath: '/profile/did:plc:by3jhwdqgbtrcc7q4tkkv3cf', 254 - }); 255 - }); 256 - 257 - test('should parse post URL with DID', () => { 258 - const result = parseInput('https://toolify.blue/profile/did:plc:by3jhwdqgbtrcc7q4tkkv3cf/post/3lqeyxrcx6k2p'); 259 - expect(result._unsafeUnwrap()).toEqual({ 260 - atUri: 'at://did:plc:by3jhwdqgbtrcc7q4tkkv3cf/app.bsky.feed.post/3lqeyxrcx6k2p', 261 - did: 'did:plc:by3jhwdqgbtrcc7q4tkkv3cf', 262 - handle: null, 263 - rkey: '3lqeyxrcx6k2p', 264 - nsid: 'app.bsky.feed.post', 265 - bskyAppPath: '/profile/did:plc:by3jhwdqgbtrcc7q4tkkv3cf/post/3lqeyxrcx6k2p', 266 - }); 267 - }); 268 - }); 269 219 }); 270 220 271 221 describe('resolveHandleToDid', () => { ··· 319 269 else if (dest.label.includes('cred.blue')) acc['cred.blue'] = dest.url; 320 270 else if (dest.label.includes('tangled.sh')) acc['tangled.sh'] = dest.url; 321 271 else if (dest.label.includes('frontpage.fyi')) acc['frontpage.fyi'] = dest.url; 322 - else if (dest.label.includes('toolify.blue')) acc['toolify.blue'] = dest.url; 323 272 else if (dest.label.includes('boat.kelinci')) acc['boat.kelinci'] = dest.url; 324 273 else if (dest.label.includes('plc.directory')) acc['plc.directory'] = dest.url; 325 274 return acc; ··· 344 293 expect(destMap['cred.blue']).toBe('https://cred.blue/now.alice.mosphere.at'); 345 294 expect(destMap['tangled.sh']).toBe('https://tangled.sh/@now.alice.mosphere.at'); 346 295 expect(destMap['frontpage.fyi']).toBe('https://frontpage.fyi/profile/now.alice.mosphere.at'); 347 - expect(destMap['toolify.blue']).toBe('https://toolify.blue/profile/now.alice.mosphere.at/post/3lqcw7n4gly2u'); 348 296 expect(destMap['boat.kelinci']).toBe('https://boat.kelinci.net/plc-oplogs?q=did:plc:kkkcb7sys7623hcf7oefcffg'); 349 297 expect(destMap['plc.directory']).toBe('https://plc.directory/did:plc:kkkcb7sys7623hcf7oefcffg'); 350 298 }); ··· 356 304 expect(hasSkythreadUrl).toBe(false); 357 305 }); 358 306 307 + test('should exclude alpha.weaver.sh when record key missing', () => { 308 + const profileOnlyInfo = { ...realPostInfo, rkey: undefined, nsid: undefined }; 309 + const destinations = buildDestinations(profileOnlyInfo); 310 + const hasWeaver = destinations.some((dest) => dest.label.includes('alpha.weaver.sh')); 311 + expect(hasWeaver).toBe(false); 312 + }); 313 + 359 314 test('should exclude handle-based services when no handle', () => { 360 315 const didOnlyInfo = { ...realPostInfo, handle: null }; 361 316 const destinations = buildDestinations(didOnlyInfo); ··· 390 345 expect(bskyDestination?.label).toBe('bsky.app'); 391 346 }); 392 347 393 - test('should include toolify.blue with emoji when showEmojis is true', () => { 394 - const destinations = buildDestinations(realPostInfo, true); 395 - const toolifyDestination = destinations.find((dest) => dest.url.includes('toolify.blue')); 396 - expect(toolifyDestination?.label).toBe('🔧 toolify.blue'); 397 - }); 398 - 399 - test('should include toolify.blue without emoji when showEmojis is false', () => { 400 - const destinations = buildDestinations(realPostInfo, false); 401 - const toolifyDestination = destinations.find((dest) => dest.url.includes('toolify.blue')); 402 - expect(toolifyDestination?.label).toBe('toolify.blue'); 403 - }); 404 - 405 348 describe('strict mode', () => { 406 349 const postInfo = { 407 350 atUri: 'at://did:plc:kkkcb7sys7623hcf7oefcffg/app.bsky.feed.post/3lqcw7n4gly2u', ··· 442 385 443 386 // Should include all service types 444 387 expect(destinations.some((d) => d.url.includes('deer.social'))).toBe(true); // full 445 - expect(destinations.some((d) => d.url.includes('toolify.blue'))).toBe(true); // profiles-and-posts 446 388 expect(destinations.some((d) => d.url.includes('skythread'))).toBe(true); // only-posts 447 389 expect(destinations.some((d) => d.url.includes('cred.blue'))).toBe(true); // only-profiles (fallback) 448 390 }); ··· 453 395 // Should include post-supporting services 454 396 expect(destinations.some((d) => d.url.includes('deer.social'))).toBe(true); // full 455 397 expect(destinations.some((d) => d.url.includes('bsky.app'))).toBe(true); // full 456 - expect(destinations.some((d) => d.url.includes('toolify.blue'))).toBe(true); // profiles-and-posts 457 398 expect(destinations.some((d) => d.url.includes('skythread'))).toBe(true); // only-posts 458 399 459 400 // Should exclude profile-only services ··· 465 406 expect(destinations.some((d) => d.url.includes('plc.directory'))).toBe(false); 466 407 }); 467 408 468 - test('should exclude toolify.blue in strict mode for feeds', () => { 409 + test('should exclude post-only services in strict mode for feeds', () => { 469 410 const destinations = buildDestinations(feedInfo, true, true); 470 411 471 412 // Should include full content support services ··· 474 415 expect(destinations.some((d) => d.url.includes('pdsls.dev'))).toBe(true); 475 416 expect(destinations.some((d) => d.url.includes('atp.tools'))).toBe(true); 476 417 477 - // Should exclude toolify.blue (only supports posts, not feeds) 478 - expect(destinations.some((d) => d.url.includes('toolify.blue'))).toBe(false); 479 - 480 418 // Should exclude skythread (only supports posts, not feeds) 481 419 expect(destinations.some((d) => d.url.includes('skythread'))).toBe(false); 482 420 ··· 484 422 expect(destinations.some((d) => d.url.includes('cred.blue'))).toBe(false); 485 423 }); 486 424 487 - test('should exclude toolify.blue in strict mode for lists', () => { 425 + test('should exclude post-only services in strict mode for lists', () => { 488 426 const destinations = buildDestinations(listInfo, true, true); 489 427 490 428 // Should include full content support services ··· 492 430 expect(destinations.some((d) => d.url.includes('bsky.app'))).toBe(true); 493 431 expect(destinations.some((d) => d.url.includes('pdsls.dev'))).toBe(true); 494 432 expect(destinations.some((d) => d.url.includes('atp.tools'))).toBe(true); 495 - 496 - // Should exclude toolify.blue (only supports posts, not lists) 497 - expect(destinations.some((d) => d.url.includes('toolify.blue'))).toBe(false); 498 433 499 434 // Should exclude skythread (only supports posts, not lists) 500 435 expect(destinations.some((d) => d.url.includes('skythread'))).toBe(false); ··· 509 444 // Profile viewing should include all services that don't require rkey 510 445 expect(destinations.some((d) => d.url.includes('deer.social'))).toBe(true); 511 446 expect(destinations.some((d) => d.url.includes('bsky.app'))).toBe(true); 512 - expect(destinations.some((d) => d.url.includes('toolify.blue'))).toBe(true); 513 447 expect(destinations.some((d) => d.url.includes('cred.blue'))).toBe(true); 514 448 expect(destinations.some((d) => d.url.includes('tangled.sh'))).toBe(true); 515 449 expect(destinations.some((d) => d.url.includes('frontpage.fyi'))).toBe(true);