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.

Editor: live atproto preview + placeholders via apiFetch middleware

+137
+1
package-lock.json
··· 15 15 "@atproto/common-web": "^0.5.0", 16 16 "@atproto/oauth-client-browser": "^0.4.1", 17 17 "@fontsource/ibm-plex-mono": "^5.2.7", 18 + "@wordpress/api-fetch": "^7.48.1", 18 19 "@wordpress/block-editor": "15.21.1", 19 20 "@wordpress/block-library": "9.48.1", 20 21 "@wordpress/blocks": "15.21.1",
+1
package.json
··· 24 24 "@atproto/common-web": "^0.5.0", 25 25 "@atproto/oauth-client-browser": "^0.4.1", 26 26 "@fontsource/ibm-plex-mono": "^5.2.7", 27 + "@wordpress/api-fetch": "^7.48.1", 27 28 "@wordpress/block-editor": "15.21.1", 28 29 "@wordpress/block-library": "9.48.1", 29 30 "@wordpress/blocks": "15.21.1",
+2
src/components/SkyEditor.tsx
··· 48 48 import type { MediaUploadHandler } from '../lib/media/mediaUpload'; 49 49 import { registerMentionFormat } from '../lib/editor/mention-format'; 50 50 import { registerMentionAutocompleter } from '../lib/editor/mention-autocompleter'; 51 + import { registerEmbedPreviewMiddleware } from '../lib/editor/embed-preview'; 51 52 import type { BlockNode } from '../lib/blocks/render'; 52 53 53 54 export const SPIKE_BLOCKS_KEY = 'skypress:spike:blocks'; ··· 94 95 registerSkyPressBlocks(); 95 96 registerMentionFormat(); 96 97 registerMentionAutocompleter(); 98 + registerEmbedPreviewMiddleware(); 97 99 return initialBlocks && initialBlocks.length > 0 ? toEditorBlocks( initialBlocks ) : []; 98 100 // eslint-disable-next-line react-hooks/exhaustive-deps 99 101 }, [] );
+32
src/lib/editor/embed-preview.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import { buildEmbedPreview } from './embed-preview'; 3 + 4 + function jsonResponse( body: unknown, ok = true ): Response { 5 + return { ok, json: async () => body } as unknown as Response; 6 + } 7 + 8 + describe( 'buildEmbedPreview', () => { 9 + it( 'returns a rich card for an atproto URL', async () => { 10 + const fetchImpl = vi.fn().mockResolvedValue( 11 + jsonResponse( { 12 + posts: [ { uri: 'at://did:plc:a/app.bsky.feed.post/b', author: { handle: 'a.bsky.social', displayName: 'A' }, record: { text: 'hello' } } ], 13 + } ) 14 + ); 15 + const preview = await buildEmbedPreview( 'https://bsky.app/profile/did:plc:a/post/b', fetchImpl ); 16 + expect( preview ).toMatchObject( { type: 'rich', provider_name: 'Bluesky' } ); 17 + expect( preview!.html ).toContain( 'skypress-embed--atproto' ); 18 + expect( preview!.html ).toContain( 'hello' ); 19 + } ); 20 + 21 + it( 'returns a placeholder for a youtube URL (no network)', async () => { 22 + const fetchImpl = vi.fn(); 23 + const preview = await buildEmbedPreview( 'https://youtu.be/dQw4w9WgXcQ', fetchImpl ); 24 + expect( fetchImpl ).not.toHaveBeenCalled(); 25 + expect( preview!.html ).toContain( 'renders on your published page' ); 26 + expect( preview!.provider_name ).toBe( 'YouTube' ); 27 + } ); 28 + 29 + it( 'returns null for an unrecognised URL', async () => { 30 + expect( await buildEmbedPreview( 'https://example.com/x', vi.fn() ) ).toBeNull(); 31 + } ); 32 + } );
+101
src/lib/editor/embed-preview.ts
··· 1 + /** 2 + * Make `core/embed` resolve previews without a WordPress backend. `core/embed` 3 + * fetches `/oembed/1.0/proxy?url=…` via `@wordpress/api-fetch`; we register a 4 + * middleware that answers that path from our own embed registry: 5 + * - atproto → a real card (CORS-friendly AppView), reusing the reader's 6 + * `fetchAtprotoCard` + `renderEmbedCard` so editor and reader match exactly; 7 + * - youtube/vimeo → a placeholder (their oEmbed isn't browser-CORS-reachable); 8 + * - anything else → pass through (the URL stays a plain link). 9 + */ 10 + import apiFetch from '@wordpress/api-fetch'; 11 + import { detectEmbed } from '../embeds/registry'; 12 + import { fetchAtprotoCard, type FetchLike } from '../embeds/resolve'; 13 + import { renderEmbedCard } from '../embeds/card'; 14 + 15 + interface EmbedPreview { 16 + type: 'rich'; 17 + version: '1.0'; 18 + provider_name: string; 19 + html: string; 20 + } 21 + 22 + const PROVIDER_LABEL: Record< 'youtube' | 'vimeo', string > = { 23 + youtube: 'YouTube', 24 + vimeo: 'Vimeo', 25 + }; 26 + 27 + function placeholder( label: string ): string { 28 + return ( 29 + `<figure class="wp-block-embed skypress-embed skypress-embed--placeholder">` + 30 + `<span class="skypress-embed__text">▶ ${ label } — renders on your published page</span>` + 31 + `</figure>` 32 + ); 33 + } 34 + 35 + /** 36 + * Resolve a candidate embed URL into an oEmbed-shaped preview for `core/embed`. 37 + * 38 + * @param url - The URL `core/embed` is trying to embed. 39 + * @param fetchImpl - Fetch used for atproto card lookups (injectable for tests). 40 + * @returns A rich oEmbed-shaped preview, or `null` when the URL is not embeddable. 41 + */ 42 + export async function buildEmbedPreview( 43 + url: string, 44 + fetchImpl: FetchLike = ( u ) => fetch( u ) 45 + ): Promise< EmbedPreview | null > { 46 + const match = detectEmbed( url ); 47 + if ( ! match ) { 48 + return null; 49 + } 50 + if ( match.kind === 'atproto' ) { 51 + const card = await fetchAtprotoCard( match, fetchImpl ); 52 + if ( ! card ) { 53 + return null; 54 + } 55 + return { type: 'rich', version: '1.0', provider_name: 'Bluesky', html: renderEmbedCard( card ) }; 56 + } 57 + return { 58 + type: 'rich', 59 + version: '1.0', 60 + provider_name: PROVIDER_LABEL[ match.kind ], 61 + html: placeholder( PROVIDER_LABEL[ match.kind ] ), 62 + }; 63 + } 64 + 65 + /** Extract the `url` query arg from the embed-proxy apiFetch path. */ 66 + function proxyUrl( path?: string ): string | null { 67 + if ( ! path || ! path.includes( '/oembed/1.0/proxy' ) ) { 68 + return null; 69 + } 70 + const q = path.indexOf( '?' ); 71 + if ( q === -1 ) { 72 + return null; 73 + } 74 + return new URLSearchParams( path.slice( q + 1 ) ).get( 'url' ); 75 + } 76 + 77 + let registered = false; 78 + 79 + /** 80 + * Register the `@wordpress/api-fetch` middleware that answers `core/embed`'s 81 + * `/oembed/1.0/proxy` requests from our embed registry. Idempotent — registers once. 82 + */ 83 + export function registerEmbedPreviewMiddleware(): void { 84 + if ( registered ) { 85 + return; 86 + } 87 + registered = true; 88 + apiFetch.use( async ( options, next ) => { 89 + const url = proxyUrl( ( options as { path?: string } ).path ); 90 + if ( ! url ) { 91 + return next( options ); 92 + } 93 + const preview = await buildEmbedPreview( url ); 94 + // Resolve with our preview, or surface a 404 so core/embed shows "cannot 95 + // embed" (a clean link) rather than spinning forever. 96 + if ( preview ) { 97 + return preview as unknown as ReturnType< typeof next >; 98 + } 99 + throw { code: 'oembed_invalid_url', message: 'Not an embeddable URL' }; 100 + } ); 101 + }