A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 5.3 kB View raw
1// src/lib/embeds/resolve.ts 2/** 3 * Resolve recognised `core/embed` URLs into card DATA at read time, attached onto 4 * the node as `attributes._skypressEmbed`. Server-side fetches go through 5 * `safeFetch` (SSRF guard, AGENTS.md rule 6a); a `fetchImpl` can be injected for 6 * tests and for the browser editor preview (CORS-friendly AppView). Any failure 7 * resolves to null → the node keeps no payload → `render.ts` falls back to a link. 8 * Dependency-free (no `@wordpress/*`). 9 */ 10import type { BlockNode } from '../blocks/render'; 11import { safeFetch } from '../net/safe-fetch'; 12import { atmospherePostWebUrl } from '../social/atmosphere-url'; 13import { detectEmbed, type EmbedKind, type EmbedMatch } from './registry'; 14import type { AtprotoCardData, AtprotoImage, VideoCardData } from './card'; 15 16export type FetchLike = ( url: string ) => Promise< Response >; 17 18const APPVIEW = 'https://public.api.bsky.app'; 19const defaultFetch: FetchLike = ( url ) => safeFetch( url ); 20 21function extractImages( embed: unknown ): AtprotoImage[] { 22 type ImageView = { fullsize?: string; thumb?: string; alt?: string }; 23 const e = embed as { $type?: string; images?: ImageView[]; media?: { images?: ImageView[] } }; 24 // Direct `images#view` carries `images`; a `recordWithMedia#view` quote-post nests them under `media.images`. 25 const images = Array.isArray( e?.images ) ? e.images : Array.isArray( e?.media?.images ) ? e.media.images : null; 26 if ( ! images ) { 27 return []; 28 } 29 return images 30 .map( ( img ) => ( { src: img.fullsize ?? img.thumb ?? '', alt: img.alt ?? '' } ) ) 31 .filter( ( img ) => img.src ); 32} 33 34export async function fetchAtprotoCard( 35 match: EmbedMatch, 36 fetchImpl: FetchLike = defaultFetch 37): Promise< AtprotoCardData | null > { 38 try { 39 const [ authority, rkey ] = match.id.split( '/' ); 40 let did = authority; 41 if ( ! did.startsWith( 'did:' ) ) { 42 const profileRes = await fetchImpl( 43 `${ APPVIEW }/xrpc/app.bsky.actor.getProfile?actor=${ encodeURIComponent( authority ) }` 44 ); 45 if ( ! profileRes.ok ) { 46 return null; 47 } 48 const profile = ( await profileRes.json() ) as { did?: string }; 49 if ( ! profile.did ) { 50 return null; 51 } 52 did = profile.did; 53 } 54 55 const uri = `at://${ did }/app.bsky.feed.post/${ rkey }`; 56 const res = await fetchImpl( 57 `${ APPVIEW }/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( uri ) }` 58 ); 59 if ( ! res.ok ) { 60 return null; 61 } 62 const data = ( await res.json() ) as { posts?: PostView[] }; 63 const post = data.posts?.[ 0 ]; 64 if ( ! post ) { 65 return null; 66 } 67 const handle = post.author?.handle ?? ''; 68 return { 69 kind: 'atproto', 70 authorName: post.author?.displayName || `@${ handle }`, 71 handle, 72 avatar: post.author?.avatar, 73 text: post.record?.text ?? '', 74 images: extractImages( post.embed ), 75 createdAt: post.record?.createdAt, 76 viewUrl: atmospherePostWebUrl( uri ), 77 }; 78 } catch { 79 return null; 80 } 81} 82 83interface PostView { 84 uri: string; 85 author?: { handle?: string; displayName?: string; avatar?: string }; 86 record?: { text?: string; createdAt?: string }; 87 embed?: unknown; 88} 89 90const OEMBED_ENDPOINTS: Record< 'youtube' | 'vimeo', ( id: string ) => string > = { 91 youtube: ( id ) => 92 `https://www.youtube.com/oembed?format=json&url=${ encodeURIComponent( 93 `https://www.youtube.com/watch?v=${ id }` 94 ) }`, 95 vimeo: ( id ) => `https://vimeo.com/api/oembed.json?url=${ encodeURIComponent( `https://vimeo.com/${ id }` ) }`, 96}; 97 98function watchUrl( kind: 'youtube' | 'vimeo', id: string ): string { 99 return kind === 'youtube' ? `https://www.youtube.com/watch?v=${ id }` : `https://vimeo.com/${ id }`; 100} 101 102export async function fetchVideoCard( 103 match: EmbedMatch, 104 fetchImpl: FetchLike = defaultFetch 105): Promise< VideoCardData | null > { 106 if ( match.kind !== 'youtube' && match.kind !== 'vimeo' ) { 107 return null; 108 } 109 try { 110 const res = await fetchImpl( OEMBED_ENDPOINTS[ match.kind ]( match.id ) ); 111 if ( ! res.ok ) { 112 return null; 113 } 114 const data = ( await res.json() ) as { title?: string; thumbnail_url?: string }; 115 if ( ! data.thumbnail_url ) { 116 return null; 117 } 118 return { 119 kind: match.kind, 120 id: match.id, 121 title: data.title ?? '', 122 thumbnail: data.thumbnail_url, 123 url: watchUrl( match.kind, match.id ), 124 }; 125 } catch { 126 return null; 127 } 128} 129 130async function resolveOne( url: string, only: EmbedKind[] | undefined, fetchImpl: FetchLike ) { 131 const match = detectEmbed( url ); 132 if ( ! match || ( only && ! only.includes( match.kind ) ) ) { 133 return null; 134 } 135 return match.kind === 'atproto' 136 ? fetchAtprotoCard( match, fetchImpl ) 137 : fetchVideoCard( match, fetchImpl ); 138} 139 140export async function resolveEmbeds( 141 blocks: BlockNode[], 142 options: { only?: EmbedKind[]; fetchImpl?: FetchLike } = {} 143): Promise< BlockNode[] > { 144 const fetchImpl = options.fetchImpl ?? defaultFetch; 145 return Promise.all( 146 blocks.map( async ( block ) => { 147 const innerBlocks = await resolveEmbeds( block.innerBlocks ?? [], options ); 148 let attributes = { ...block.attributes }; 149 if ( block.name === 'core/embed' && typeof block.attributes?.url === 'string' ) { 150 const data = await resolveOne( block.attributes.url, options.only, fetchImpl ); 151 if ( data ) { 152 attributes = { ...attributes, _skypressEmbed: data }; 153 } 154 } 155 return { name: block.name, attributes, innerBlocks }; 156 } ) 157 ); 158}