Universal links for the ATmosphere. Share ATProto content with anyone, let them choose where to view it.
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