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.

Embeds: resolve atproto + video data via safe-fetch

+275
+120
src/lib/embeds/resolve.test.ts
··· 1 + // src/lib/embeds/resolve.test.ts 2 + import { describe, expect, it, vi } from 'vitest'; 3 + import { fetchAtprotoCard, fetchVideoCard, resolveEmbeds } from './resolve'; 4 + import type { EmbedMatch } from './registry'; 5 + 6 + function jsonResponse( body: unknown, ok = true ): Response { 7 + return { ok, json: async () => body } as unknown as Response; 8 + } 9 + 10 + describe( 'fetchAtprotoCard', () => { 11 + it( 'resolves a handle authority to a DID, then fetches the post', async () => { 12 + const fetchImpl = vi 13 + .fn() 14 + .mockResolvedValueOnce( jsonResponse( { did: 'did:plc:abc' } ) ) // getProfile 15 + .mockResolvedValueOnce( 16 + jsonResponse( { 17 + posts: [ 18 + { 19 + uri: 'at://did:plc:abc/app.bsky.feed.post/xyz', 20 + author: { handle: 'jeremy.herve.bzh', displayName: 'Jeremy', avatar: 'https://cdn/av.jpg' }, 21 + record: { text: 'Hi there', createdAt: '2026-06-19T10:00:00Z' }, 22 + embed: { 23 + $type: 'app.bsky.embed.images#view', 24 + images: [ { fullsize: 'https://cdn/i.jpg', alt: 'pic' } ], 25 + }, 26 + }, 27 + ], 28 + } ) 29 + ); 30 + 31 + const match: EmbedMatch = { kind: 'atproto', id: 'jeremy.herve.bzh/xyz' }; 32 + const card = await fetchAtprotoCard( match, fetchImpl ); 33 + 34 + expect( fetchImpl.mock.calls[ 0 ][ 0 ] ).toContain( 'app.bsky.actor.getProfile?actor=jeremy.herve.bzh' ); 35 + expect( fetchImpl.mock.calls[ 1 ][ 0 ] ).toContain( 36 + 'getPosts?uris=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.bsky.feed.post%2Fxyz' 37 + ); 38 + expect( card ).toMatchObject( { 39 + kind: 'atproto', 40 + authorName: 'Jeremy', 41 + handle: 'jeremy.herve.bzh', 42 + text: 'Hi there', 43 + images: [ { src: 'https://cdn/i.jpg', alt: 'pic' } ], 44 + viewUrl: 'https://mu.social/profile/did:plc:abc/post/xyz', 45 + } ); 46 + } ); 47 + 48 + it( 'skips profile resolution when the authority is already a DID', async () => { 49 + const fetchImpl = vi.fn().mockResolvedValueOnce( 50 + jsonResponse( { 51 + posts: [ { uri: 'at://did:plc:abc/app.bsky.feed.post/xyz', author: { handle: 'a.bsky.social' }, record: { text: 't' } } ], 52 + } ) 53 + ); 54 + await fetchAtprotoCard( { kind: 'atproto', id: 'did:plc:abc/xyz' }, fetchImpl ); 55 + expect( fetchImpl ).toHaveBeenCalledTimes( 1 ); 56 + expect( fetchImpl.mock.calls[ 0 ][ 0 ] ).toContain( 'getPosts' ); 57 + } ); 58 + 59 + it( 'returns null when the post is missing', async () => { 60 + const fetchImpl = vi.fn().mockResolvedValue( jsonResponse( { posts: [] } ) ); 61 + expect( await fetchAtprotoCard( { kind: 'atproto', id: 'did:plc:abc/xyz' }, fetchImpl ) ).toBeNull(); 62 + } ); 63 + 64 + it( 'returns null on a thrown fetch', async () => { 65 + const fetchImpl = vi.fn().mockRejectedValue( new Error( 'network' ) ); 66 + expect( await fetchAtprotoCard( { kind: 'atproto', id: 'did:plc:abc/xyz' }, fetchImpl ) ).toBeNull(); 67 + } ); 68 + } ); 69 + 70 + describe( 'fetchVideoCard', () => { 71 + it( 'reads title + thumbnail from youtube oEmbed', async () => { 72 + const fetchImpl = vi.fn().mockResolvedValue( 73 + jsonResponse( { title: 'Cool', thumbnail_url: 'https://i.ytimg/x.jpg' } ) 74 + ); 75 + const card = await fetchVideoCard( { kind: 'youtube', id: 'dQw4w9WgXcQ' }, fetchImpl ); 76 + expect( card ).toEqual( { 77 + kind: 'youtube', 78 + id: 'dQw4w9WgXcQ', 79 + title: 'Cool', 80 + thumbnail: 'https://i.ytimg/x.jpg', 81 + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 82 + } ); 83 + } ); 84 + 85 + it( 'returns null on oEmbed failure', async () => { 86 + const fetchImpl = vi.fn().mockResolvedValue( jsonResponse( {}, false ) ); 87 + expect( await fetchVideoCard( { kind: 'vimeo', id: '1' }, fetchImpl ) ).toBeNull(); 88 + } ); 89 + } ); 90 + 91 + describe( 'resolveEmbeds', () => { 92 + const blocks = [ 93 + { name: 'core/paragraph', attributes: { content: 'hello' } }, 94 + { name: 'core/embed', attributes: { url: 'https://vimeo.com/123' } }, 95 + ]; 96 + 97 + it( 'attaches resolved data onto recognised core/embed nodes', async () => { 98 + const fetchImpl = vi.fn().mockResolvedValue( jsonResponse( { title: 'V', thumbnail_url: 'https://t/x.jpg' } ) ); 99 + const out = await resolveEmbeds( blocks, { fetchImpl } ); 100 + expect( out[ 0 ].attributes ).not.toHaveProperty( '_skypressEmbed' ); 101 + expect( out[ 1 ].attributes?._skypressEmbed ).toMatchObject( { kind: 'vimeo', title: 'V' } ); 102 + } ); 103 + 104 + it( 'leaves unrecognised embeds and other blocks untouched', async () => { 105 + const fetchImpl = vi.fn(); 106 + const out = await resolveEmbeds( 107 + [ { name: 'core/embed', attributes: { url: 'https://example.com/x' } } ], 108 + { fetchImpl } 109 + ); 110 + expect( fetchImpl ).not.toHaveBeenCalled(); 111 + expect( out[ 0 ].attributes ).not.toHaveProperty( '_skypressEmbed' ); 112 + } ); 113 + 114 + it( 'honours the `only` filter (RSS resolves atproto, not video)', async () => { 115 + const fetchImpl = vi.fn(); 116 + const out = await resolveEmbeds( blocks, { only: [ 'atproto' ], fetchImpl } ); 117 + expect( fetchImpl ).not.toHaveBeenCalled(); 118 + expect( out[ 1 ].attributes ).not.toHaveProperty( '_skypressEmbed' ); 119 + } ); 120 + } );
+155
src/lib/embeds/resolve.ts
··· 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 + */ 10 + import type { BlockNode } from '../blocks/render'; 11 + import { safeFetch } from '../net/safe-fetch'; 12 + import { atmospherePostWebUrl } from '../social/atmosphere-url'; 13 + import { detectEmbed, type EmbedKind, type EmbedMatch } from './registry'; 14 + import type { AtprotoCardData, AtprotoImage, VideoCardData } from './card'; 15 + 16 + export type FetchLike = ( url: string ) => Promise< Response >; 17 + 18 + const APPVIEW = 'https://public.api.bsky.app'; 19 + const defaultFetch: FetchLike = ( url ) => safeFetch( url ); 20 + 21 + function extractImages( embed: unknown ): AtprotoImage[] { 22 + const e = embed as { $type?: string; images?: { fullsize?: string; thumb?: string; alt?: string }[] }; 23 + if ( ! e || ! Array.isArray( e.images ) ) { 24 + return []; 25 + } 26 + return e.images 27 + .map( ( img ) => ( { src: img.fullsize ?? img.thumb ?? '', alt: img.alt ?? '' } ) ) 28 + .filter( ( img ) => img.src ); 29 + } 30 + 31 + export async function fetchAtprotoCard( 32 + match: EmbedMatch, 33 + fetchImpl: FetchLike = defaultFetch 34 + ): Promise< AtprotoCardData | null > { 35 + try { 36 + const [ authority, rkey ] = match.id.split( '/' ); 37 + let did = authority; 38 + if ( ! did.startsWith( 'did:' ) ) { 39 + const profileRes = await fetchImpl( 40 + `${ APPVIEW }/xrpc/app.bsky.actor.getProfile?actor=${ encodeURIComponent( authority ) }` 41 + ); 42 + if ( ! profileRes.ok ) { 43 + return null; 44 + } 45 + const profile = ( await profileRes.json() ) as { did?: string }; 46 + if ( ! profile.did ) { 47 + return null; 48 + } 49 + did = profile.did; 50 + } 51 + 52 + const uri = `at://${ did }/app.bsky.feed.post/${ rkey }`; 53 + const res = await fetchImpl( 54 + `${ APPVIEW }/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( uri ) }` 55 + ); 56 + if ( ! res.ok ) { 57 + return null; 58 + } 59 + const data = ( await res.json() ) as { posts?: PostView[] }; 60 + const post = data.posts?.[ 0 ]; 61 + if ( ! post ) { 62 + return null; 63 + } 64 + const handle = post.author?.handle ?? ''; 65 + return { 66 + kind: 'atproto', 67 + authorName: post.author?.displayName || `@${ handle }`, 68 + handle, 69 + avatar: post.author?.avatar, 70 + text: post.record?.text ?? '', 71 + images: extractImages( post.embed ), 72 + createdAt: post.record?.createdAt, 73 + viewUrl: atmospherePostWebUrl( uri ), 74 + }; 75 + } catch { 76 + return null; 77 + } 78 + } 79 + 80 + interface PostView { 81 + uri: string; 82 + author?: { handle?: string; displayName?: string; avatar?: string }; 83 + record?: { text?: string; createdAt?: string }; 84 + embed?: unknown; 85 + } 86 + 87 + const OEMBED_ENDPOINTS: Record< 'youtube' | 'vimeo', ( id: string ) => string > = { 88 + youtube: ( id ) => 89 + `https://www.youtube.com/oembed?format=json&url=${ encodeURIComponent( 90 + `https://www.youtube.com/watch?v=${ id }` 91 + ) }`, 92 + vimeo: ( id ) => `https://vimeo.com/api/oembed.json?url=${ encodeURIComponent( `https://vimeo.com/${ id }` ) }`, 93 + }; 94 + 95 + function watchUrl( kind: 'youtube' | 'vimeo', id: string ): string { 96 + return kind === 'youtube' ? `https://www.youtube.com/watch?v=${ id }` : `https://vimeo.com/${ id }`; 97 + } 98 + 99 + export async function fetchVideoCard( 100 + match: EmbedMatch, 101 + fetchImpl: FetchLike = defaultFetch 102 + ): Promise< VideoCardData | null > { 103 + if ( match.kind !== 'youtube' && match.kind !== 'vimeo' ) { 104 + return null; 105 + } 106 + try { 107 + const res = await fetchImpl( OEMBED_ENDPOINTS[ match.kind ]( match.id ) ); 108 + if ( ! res.ok ) { 109 + return null; 110 + } 111 + const data = ( await res.json() ) as { title?: string; thumbnail_url?: string }; 112 + if ( ! data.thumbnail_url ) { 113 + return null; 114 + } 115 + return { 116 + kind: match.kind, 117 + id: match.id, 118 + title: data.title ?? '', 119 + thumbnail: data.thumbnail_url, 120 + url: watchUrl( match.kind, match.id ), 121 + }; 122 + } catch { 123 + return null; 124 + } 125 + } 126 + 127 + async function resolveOne( url: string, only: EmbedKind[] | undefined, fetchImpl: FetchLike ) { 128 + const match = detectEmbed( url ); 129 + if ( ! match || ( only && ! only.includes( match.kind ) ) ) { 130 + return null; 131 + } 132 + return match.kind === 'atproto' 133 + ? fetchAtprotoCard( match, fetchImpl ) 134 + : fetchVideoCard( match, fetchImpl ); 135 + } 136 + 137 + export async function resolveEmbeds( 138 + blocks: BlockNode[], 139 + options: { only?: EmbedKind[]; fetchImpl?: FetchLike } = {} 140 + ): Promise< BlockNode[] > { 141 + const fetchImpl = options.fetchImpl ?? defaultFetch; 142 + return Promise.all( 143 + blocks.map( async ( block ) => { 144 + const innerBlocks = await resolveEmbeds( block.innerBlocks ?? [], options ); 145 + let attributes = { ...block.attributes }; 146 + if ( block.name === 'core/embed' && typeof block.attributes?.url === 'string' ) { 147 + const data = await resolveOne( block.attributes.url, options.only, fetchImpl ); 148 + if ( data ) { 149 + attributes = { ...attributes, _skypressEmbed: data }; 150 + } 151 + } 152 + return { name: block.name, attributes, innerBlocks }; 153 + } ) 154 + ); 155 + }