forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1export type OgTarget =
2 | { kind: 'profile'; handle: string }
3 | { kind: 'scrobble'; did: string; rkey: string }
4 | { kind: 'album'; did: string; rkey: string }
5 | { kind: 'artist'; did: string; rkey: string }
6 | { kind: 'song'; did: string; rkey: string }
7 | null;
8
9export function matchOgTarget(pathname: string): OgTarget {
10 let m = pathname.match(/^\/profile\/([^/]+)$/);
11 if (m) return { kind: 'profile', handle: decodeURIComponent(m[1]) };
12
13 m = pathname.match(/^\/(did:plc:[^/]+)\/(scrobble|album|artist|song)\/([^/]+)$/);
14 if (m) {
15 const did = decodeURIComponent(m[1]);
16 const kind = m[2] as 'scrobble' | 'album' | 'artist' | 'song';
17 const rkey = decodeURIComponent(m[3]);
18 if (kind === 'song') return { kind: 'song', did, rkey };
19 if (kind === 'album') return { kind: 'album', did, rkey };
20 if (kind === 'artist') return { kind: 'artist', did, rkey };
21 return { kind: 'scrobble', did, rkey };
22 }
23
24 return null;
25}
26
27export type OgData = {
28 title: string;
29 description: string;
30 image: string;
31 url: string;
32 type?: string; // og:type
33 twitterCard?: 'summary' | 'summary_large_image';
34};
35
36export async function fetchOgData(url: URL, request: Request): Promise<OgData | null> {
37 const api = new URL('https://api.rocksky.app/public/og');
38 api.searchParams.set('path', url.pathname);
39
40 const res = await fetch(api.toString());
41
42 if (!res.ok) {
43 console.log(res.statusText);
44 console.log(await res.text());
45 return null;
46 }
47
48 const og = (await res.json<OgData>()) as OgData;
49 og.url ??= url.toString();
50 og.type ??= 'website';
51 og.twitterCard ??= 'summary_large_image';
52 return og;
53}
54
55function escapeAttr(s: string) {
56 return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
57}
58
59export class StripMeta {
60 element(el: Element) {
61 el.remove();
62 }
63}
64
65export function isHtmlResponse(res: Response) {
66 const ct = res.headers.get('content-type') || '';
67 return ct.toLowerCase().includes('text/html');
68}
69
70export class HeadMeta {
71 private og: OgData;
72 constructor(og: OgData) {
73 this.og = og;
74 }
75
76 element(head: Element) {
77 const tags = [
78 `<title>${escapeAttr(this.og.title)}</title>`,
79 `<meta name="description" content="${escapeAttr(this.og.description)}">`,
80
81 `<meta property="og:title" content="${escapeAttr(this.og.title)}">`,
82 `<meta property="og:description" content="${escapeAttr(this.og.description)}">`,
83 `<meta property="og:image" content="${escapeAttr(this.og.image)}">`,
84 `<meta property="og:url" content="${escapeAttr(this.og.url)}">`,
85 `<meta property="og:type" content="${escapeAttr(this.og.type ?? 'website')}">`,
86
87 `<meta name="twitter:card" content="${escapeAttr(this.og.twitterCard ?? 'summary_large_image')}">`,
88 `<meta name="twitter:title" content="${escapeAttr(this.og.title)}">`,
89 `<meta name="twitter:description" content="${escapeAttr(this.og.description)}">`,
90 `<meta name="twitter:image" content="${escapeAttr(this.og.image)}">`,
91 ].join('\n');
92
93 head.append(tags, { html: true });
94 }
95}