Universal links for the ATmosphere. Share ATProto content with anyone, let them choose where to view it.
at testing 204 lines 6.4 kB view raw
1/** 2 * Extracts AT URI components from various URL formats and generates aturi.to links 3 */ 4 5interface AtUriComponents { 6 identifier: string; // DID or handle 7 collection?: string; 8 rkey?: string; 9} 10 11/** 12 * Extracts AT URI components from a URL or AT URI string 13 * Supports various formats from all Waypoint platforms: 14 * - https://bsky.app/profile/did:plc:xxx 15 * - https://bsky.app/profile/handle.bsky.social/post/rkey 16 * - https://blacksky.community/profile/handle/post/rkey 17 * - https://anisota.net/profile/handle/post/rkey 18 * - https://anisota.net/explorer/handle/collection/rkey 19 * - https://reddwarf.app/profile/handle/post/rkey 20 * - https://leaflet.pub/p/identifier 21 * - https://pdsls.dev/at/identifier/collection/rkey 22 * - https://atp.tools/record/identifier/collection/rkey 23 * - https://atp.tools/profile/identifier 24 * - https://witchsky.app/profile/handle/post/rkey 25 * - https://catsky.social/profile/handle/post/rkey 26 * - https://deer.social/profile/handle/post/rkey 27 * - at://did:plc:xxx/app.bsky.feed.post/rkey 28 */ 29export function extractAtUriComponents(input: string): AtUriComponents | null { 30 const trimmedInput = input.trim(); 31 32 // Case 1: Native AT URI format (at://...) 33 if (trimmedInput.startsWith('at://')) { 34 const withoutProtocol = trimmedInput.substring(5); // Remove "at://" 35 const parts = withoutProtocol.split('/'); 36 37 if (parts.length === 1) { 38 // Just a profile: at://did:plc:xxx or at://handle.bsky.social 39 return { identifier: parts[0] }; 40 } else if (parts.length === 3) { 41 // Full record: at://identifier/collection/rkey 42 return { 43 identifier: parts[0], 44 collection: parts[1], 45 rkey: parts[2], 46 }; 47 } 48 } 49 50 // Case 2: URL formats (https://...) 51 try { 52 const url = new URL(trimmedInput); 53 const pathname = url.pathname; 54 const hostname = url.hostname; 55 56 // Standard /profile/identifier format (bsky.app, blacksky.community, anisota.net, 57 // reddwarf.app, witchsky.app, catsky.social, deer.social) 58 if (pathname.startsWith('/profile/')) { 59 const parts = pathname.substring(9).split('/'); // Remove "/profile/" 60 61 if (parts.length === 1) { 62 // Profile only: /profile/identifier 63 return { identifier: parts[0] }; 64 } else if (parts.length === 3 && parts[1] === 'post') { 65 // Post: /profile/identifier/post/rkey 66 return { 67 identifier: parts[0], 68 collection: 'app.bsky.feed.post', 69 rkey: parts[2], 70 }; 71 } else if (parts.length === 3 && parts[1] === 'lists') { 72 // List: /profile/identifier/lists/rkey 73 return { 74 identifier: parts[0], 75 collection: 'app.bsky.graph.list', 76 rkey: parts[2], 77 }; 78 } 79 } 80 81 // Anisota explorer format: /explorer/identifier/collection/rkey 82 if (pathname.startsWith('/explorer/') && hostname === 'anisota.net') { 83 const parts = pathname.substring(10).split('/'); // Remove "/explorer/" 84 85 if (parts.length === 1) { 86 // Profile only 87 return { identifier: parts[0] }; 88 } else if (parts.length === 3) { 89 // Full record 90 return { 91 identifier: parts[0], 92 collection: parts[1], 93 rkey: parts[2], 94 }; 95 } 96 } 97 98 // Leaflet format: /p/identifier 99 if (pathname.startsWith('/p/')) { 100 const parts = pathname.substring(3).split('/'); // Remove "/p/" 101 102 if (parts.length === 1) { 103 return { identifier: parts[0] }; 104 } 105 } 106 107 // pdsls.dev format: /at/identifier or /at/identifier/collection/rkey 108 if (pathname.startsWith('/at/')) { 109 const parts = pathname.substring(4).split('/'); // Remove "/at/" 110 111 if (parts.length === 1) { 112 // Profile only 113 return { identifier: parts[0] }; 114 } else if (parts.length === 3) { 115 // Full record 116 return { 117 identifier: parts[0], 118 collection: parts[1], 119 rkey: parts[2], 120 }; 121 } 122 } 123 124 // pdsls.dev legacy format: /at://identifier/collection/rkey 125 if (pathname.startsWith('/at://')) { 126 const atUri = pathname.substring(1); // Remove leading "/" 127 return extractAtUriComponents(atUri); // Recursive call 128 } 129 130 // atp.tools format: /record/identifier/collection/rkey or /profile/identifier 131 if (pathname.startsWith('/record/')) { 132 const parts = pathname.substring(8).split('/'); // Remove "/record/" 133 134 if (parts.length === 1) { 135 // Just identifier 136 return { identifier: parts[0] }; 137 } else if (parts.length === 3) { 138 // Full record 139 return { 140 identifier: parts[0], 141 collection: parts[1], 142 rkey: parts[2], 143 }; 144 } 145 } 146 147 } catch { 148 // Not a valid URL, might be a bare identifier 149 } 150 151 // Case 3: Bare identifier (DID or handle) 152 if (trimmedInput.startsWith('did:')) { 153 return { identifier: trimmedInput }; 154 } 155 156 // Case 4: Handle-like string (contains dots and no slashes) 157 if (trimmedInput.includes('.') && !trimmedInput.includes('/')) { 158 return { identifier: trimmedInput }; 159 } 160 161 return null; 162} 163 164/** 165 * Generates an aturi.to link from AT URI components 166 * @param useAtPrefix - If true, keeps the literal at:// prefix (e.g., aturi.to/at://did:plc:xxx/...) 167 */ 168export function generateAturiLink(components: AtUriComponents, useAtPrefix: boolean = false): string { 169 const { identifier, collection, rkey } = components; 170 171 if (collection && rkey) { 172 if (useAtPrefix) { 173 return `https://aturi.to/at://${identifier}/${collection}/${rkey}`; 174 } 175 return `https://aturi.to/${identifier}/${collection}/${rkey}`; 176 } 177 178 if (useAtPrefix) { 179 return `https://aturi.to/at://${identifier}`; 180 } 181 return `https://aturi.to/${identifier}`; 182} 183 184/** 185 * Main function to convert any input to an aturi.to link 186 * @param useAtPrefix - If true, keeps the literal at:// prefix for full AT URI format 187 */ 188export function convertToAturiLink(input: string, useAtPrefix: boolean = false): string | null { 189 const components = extractAtUriComponents(input); 190 191 if (!components) { 192 return null; 193 } 194 195 return generateAturiLink(components, useAtPrefix); 196} 197 198/** 199 * Validates if the input can be converted to an aturi.to link 200 */ 201export function isValidInput(input: string): boolean { 202 return extractAtUriComponents(input) !== null; 203} 204