A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at main 95 lines 3.0 kB view raw
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 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}