This repository has no description
1export const getDomain = (url: string) => {
2 try {
3 return new URL(url).hostname;
4 } catch {
5 return url;
6 }
7};
8
9export const getUrlFromSlug = (slug: string[]) => {
10 const decoded = slug.map(decodeURIComponent);
11 const url = decoded.join('/');
12
13 // only normalize if the scheme has a single slash after it (i.e. malformed)
14 const normalizedUrl = /^([a-zA-Z]+:)\/[^/]/.test(url)
15 ? url.replace(/^([a-zA-Z]+:)\//, '$1//')
16 : url;
17
18 return normalizedUrl;
19};
20
21const getAppOrigin = () => {
22 // Use the configured app URL if available, otherwise fall back to current origin
23 const appUrl = process.env.NEXT_PUBLIC_APP_URL;
24 if (appUrl) {
25 try {
26 return new URL(appUrl).origin;
27 } catch {
28 // invalid app URL, fall back to current origin
29 }
30 }
31 return window.location.origin;
32};
33
34const isSembleUrl = (url: string): boolean => {
35 // Relative paths are always local to the app
36 if (url.startsWith('/')) return true;
37 try {
38 const parsedUrl = new URL(url);
39 return parsedUrl.origin === getAppOrigin();
40 } catch {
41 return false;
42 }
43};
44
45export const isCollectionPage = (url: string) => {
46 try {
47 if (!isSembleUrl(url)) return false;
48 const pathname = url.startsWith('/') ? url : new URL(url).pathname;
49 // expect /profile/:handle/collections/:id
50 const pattern = /^\/profile\/[^/]+\/collections\/[^/]+\/?$/;
51 return pattern.test(pathname);
52 } catch {
53 // invalid URL
54 return false;
55 }
56};
57
58export const isProfilePage = (url: string) => {
59 try {
60 if (!isSembleUrl(url)) return false;
61 const pathname = url.startsWith('/') ? url : new URL(url).pathname;
62 // matches /profile/:handle and any subroutes (e.g. /profile/:handle/likes)
63 const pattern = /^\/profile\/[^/]+/;
64 return pattern.test(pathname);
65 } catch {
66 // invalid URL
67 return false;
68 }
69};
70
71export enum SupportedPlatform {
72 BLUESKY_POST = 'bluesky post',
73 BLACKSKY_POST = 'blacksky post',
74 SEMBLE_COLLECTION = 'semble collection',
75 SEMBLE_PROFILE = 'semble profile',
76 SPOTIFY = 'spotify',
77 PLYRFM_TRACK = 'plyr.fm',
78 YOUTUBE_VIDEO = 'youtube video',
79 BANDCAMP_ALBUM = 'bandcamp album',
80 BANDCAMP_TRACK = 'bandcamp track',
81 SOUNDCLOUD_TRACK = 'soundcloud track',
82 SOUNDCLOUD_SET = 'soundcloud set',
83 DEFAULT = 'default',
84}
85
86type PlatformData =
87 | {
88 type: SupportedPlatform.SEMBLE_COLLECTION;
89 url: string;
90 handle: string;
91 rkey: string;
92 }
93 | {
94 type: SupportedPlatform.SEMBLE_PROFILE;
95 url: string;
96 handle: string;
97 }
98 | {
99 type: Exclude<
100 SupportedPlatform,
101 SupportedPlatform.SEMBLE_COLLECTION | SupportedPlatform.SEMBLE_PROFILE
102 >;
103 url: string;
104 };
105
106export const detectUrlPlatform = (url: string): PlatformData => {
107 if (isCollectionPage(url)) {
108 try {
109 const pathname = url.startsWith('/') ? url : new URL(url).pathname;
110 const pathParts = pathname.split('/').filter(Boolean);
111 // expected format: profile/:handle/collections/:rkey
112 const handle = pathParts[1];
113 const rkey = pathParts[3];
114 return { type: SupportedPlatform.SEMBLE_COLLECTION, url, handle, rkey };
115 } catch {
116 return {
117 type: SupportedPlatform.SEMBLE_COLLECTION,
118 url,
119 handle: '',
120 rkey: '',
121 };
122 }
123 }
124
125 if (isProfilePage(url)) {
126 try {
127 const pathname = url.startsWith('/') ? url : new URL(url).pathname;
128 const pathParts = pathname.split('/').filter(Boolean);
129 // expected format: profile/:handle or profile/:handle/:subroute
130 const handle = pathParts[1];
131 return { type: SupportedPlatform.SEMBLE_PROFILE, url, handle };
132 } catch {
133 return { type: SupportedPlatform.DEFAULT, url };
134 }
135 }
136
137 try {
138 const parsedUrl = new URL(url);
139
140 // bluesky posts
141 // https://bsky.app/profile/handle/post/id
142 if (
143 parsedUrl.hostname === 'bsky.app' &&
144 parsedUrl.pathname.includes('/post/')
145 ) {
146 return { type: SupportedPlatform.BLUESKY_POST, url };
147 }
148
149 // blacksky posts
150 // https://blacksky.community/profile/handle/post/id
151 if (
152 parsedUrl.hostname === 'blacksky.community' &&
153 parsedUrl.pathname.includes('/post/')
154 ) {
155 return { type: SupportedPlatform.BLACKSKY_POST, url };
156 }
157
158 // youtube
159 if (parsedUrl.hostname === 'youtu.be') {
160 const videoId = parsedUrl.pathname.split('/')[1];
161 const t = parsedUrl.searchParams.get('t') ?? '0';
162 const seek = encodeURIComponent(t.replace(/s$/, ''));
163
164 if (videoId) {
165 return {
166 type: SupportedPlatform.YOUTUBE_VIDEO,
167 url: `https://www.youtube.com/embed/${videoId}?start=${seek}`,
168 };
169 }
170 }
171
172 // youtube
173 if (
174 parsedUrl.hostname === 'www.youtube.com' ||
175 parsedUrl.hostname === 'youtube.com' ||
176 parsedUrl.hostname === 'm.youtube.com' ||
177 parsedUrl.hostname === 'music.youtube.com'
178 ) {
179 const [__, page, shortOrLiveVideoId] = parsedUrl.pathname.split('/');
180
181 const isShorts = page === 'shorts';
182 const isLive = page === 'live';
183 const videoId =
184 isShorts || isLive
185 ? shortOrLiveVideoId
186 : (parsedUrl.searchParams.get('v') as string);
187 const t = parsedUrl.searchParams.get('t') ?? '0';
188 const seek = encodeURIComponent(t.replace(/s$/, ''));
189
190 return {
191 type: SupportedPlatform.YOUTUBE_VIDEO,
192 url: `https://www.youtube.com/embed/${videoId}?start=${seek}`,
193 };
194 }
195
196 // spotify
197 if (parsedUrl.hostname === 'open.spotify.com') {
198 const [__, typeOrLocale, idOrType, id] = parsedUrl.pathname.split('/');
199
200 if (typeOrLocale === 'album' || idOrType === 'album') {
201 return {
202 type: SupportedPlatform.SPOTIFY,
203 url: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
204 };
205 }
206 if (typeOrLocale === 'track' || idOrType === 'track') {
207 return {
208 type: SupportedPlatform.SPOTIFY,
209 url: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
210 };
211 }
212 if (typeOrLocale === 'episode' || idOrType === 'episode') {
213 return {
214 type: SupportedPlatform.SPOTIFY,
215 url: `https://open.spotify.com/embed/episode/${id ?? idOrType}`,
216 };
217 }
218 if (typeOrLocale === 'show' || idOrType === 'show') {
219 return {
220 type: SupportedPlatform.SPOTIFY,
221 url: `https://open.spotify.com/embed/show/${id ?? idOrType}`,
222 };
223 }
224 }
225
226 // plyr.fm
227 if (
228 parsedUrl.hostname === 'plyr.fm' ||
229 parsedUrl.hostname === 'www.plyr.fm'
230 ) {
231 const [__, type, id] = parsedUrl.pathname.split('/');
232
233 if (type === 'track' && id) {
234 return {
235 type: SupportedPlatform.PLYRFM_TRACK,
236 url: `https://plyr.fm/embed/track/${id}`,
237 };
238 }
239 }
240
241 // bandcamp
242 const bandcampRegex = /^[a-z\d][a-z\d-]{2,}[a-z\d]\.bandcamp\.com$/i;
243 if (bandcampRegex.test(parsedUrl.hostname)) {
244 const pathComponents = parsedUrl.pathname.split('/');
245 switch (pathComponents[1]) {
246 case 'album':
247 return {
248 type: SupportedPlatform.BANDCAMP_ALBUM,
249 url: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
250 parsedUrl.href,
251 )}/size=medium/minimal=true/transparent=true/`,
252 };
253 case 'track':
254 return {
255 type: SupportedPlatform.BANDCAMP_TRACK,
256 url: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
257 parsedUrl.href,
258 )}/size=medium/minimal=true/transparent=true/`,
259 };
260 }
261 }
262
263 // soundcloud
264 if (
265 parsedUrl.hostname === 'soundcloud.com' ||
266 parsedUrl.hostname === 'www.soundcloud.com'
267 ) {
268 const [, user, trackOrSets, set] = parsedUrl.pathname.split('/');
269
270 if (user && trackOrSets) {
271 if (trackOrSets === 'sets' && set) {
272 return {
273 type: SupportedPlatform.SOUNDCLOUD_SET,
274 url: `https://w.soundcloud.com/player/?url=${url}&auto_play=false&visual=false&hide_related=true`,
275 };
276 }
277
278 return {
279 type: SupportedPlatform.SOUNDCLOUD_TRACK,
280 url: `https://w.soundcloud.com/player/?url=${url}&auto_play=false&visual=false&hide_related=true`,
281 };
282 }
283 }
284
285 return { type: SupportedPlatform.DEFAULT, url }; // no supported service detected
286 } catch (e) {
287 // invalid url
288 return { type: SupportedPlatform.DEFAULT, url };
289 }
290};