A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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}