A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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 */
10import apiFetch from '@wordpress/api-fetch';
11import { detectEmbed } from '../embeds/registry';
12import { fetchAtprotoCard, type FetchLike } from '../embeds/resolve';
13import { renderEmbedCard } from '../embeds/card';
14
15interface EmbedPreview {
16 type: 'rich';
17 version: '1.0';
18 provider_name: string;
19 html: string;
20}
21
22const PROVIDER_LABEL: Record< 'youtube' | 'vimeo', string > = {
23 youtube: 'YouTube',
24 vimeo: 'Vimeo',
25};
26
27/**
28 * Self-contained card styles for the editor preview. `core/embed` renders the
29 * preview html inside a sandboxed iframe (`<SandBox>`) that has no access to the
30 * editor's stylesheet OR the site's theme tokens, so these must be inlined with
31 * the html and use literal colors (not `var(--token)`). Kept roughly in sync with
32 * `src/styles/embeds.css` — the reader uses that external stylesheet (themed),
33 * this is only the in-editor preview approximation.
34 */
35const PREVIEW_STYLES =
36 'body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#1a1a1a}' +
37 '.skypress-embed{margin:0;border:1px solid #e6e3dd;border-radius:10px;overflow:hidden;background:#faf9f6}' +
38 '.skypress-embed--atproto .skypress-embed__link{display:block;padding:1rem 1.2rem;text-decoration:none;color:#1a1a1a}' +
39 '.skypress-embed__head{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}' +
40 '.skypress-embed__avatar{width:32px;height:32px;border-radius:50%;object-fit:cover}' +
41 '.skypress-embed__author{font-weight:600}' +
42 '.skypress-embed__handle{color:#6b6b6b;font-size:.92rem}' +
43 '.skypress-embed__text{display:block;white-space:pre-wrap;line-height:1.5}' +
44 '.skypress-embed__media{display:grid;gap:.4rem;margin-top:.75rem}' +
45 '.skypress-embed__image{width:100%;border-radius:8px}' +
46 '.skypress-embed__footer{display:block;margin-top:.75rem;font-size:.88rem;color:#6b6b6b}' +
47 '.skypress-embed--placeholder .skypress-embed__text{padding:1rem 1.2rem;color:#6b6b6b}';
48
49/** Prepend the inline preview styles so the card renders styled inside the SandBox iframe. */
50function withPreviewStyles( html: string ): string {
51 return `<style>${ PREVIEW_STYLES }</style>${ html }`;
52}
53
54function placeholder( label: string ): string {
55 return (
56 `<figure class="wp-block-embed skypress-embed skypress-embed--placeholder">` +
57 `<span class="skypress-embed__text">▶ ${ label } — renders on your published page</span>` +
58 `</figure>`
59 );
60}
61
62/**
63 * Resolve a candidate embed URL into an oEmbed-shaped preview for `core/embed`.
64 *
65 * @param url - The URL `core/embed` is trying to embed.
66 * @param fetchImpl - Fetch used for atproto card lookups (injectable for tests).
67 * @returns A rich oEmbed-shaped preview, or `null` when the URL is not embeddable.
68 */
69export async function buildEmbedPreview(
70 url: string,
71 // Raw `fetch` (not `safeFetch`) is correct here: the editor is browser-only and
72 // runs post-OAuth against the fixed public AppView host, so there's no SSRF surface.
73 fetchImpl: FetchLike = ( u ) => fetch( u )
74): Promise< EmbedPreview | null > {
75 const match = detectEmbed( url );
76 if ( ! match ) {
77 return null;
78 }
79 if ( match.kind === 'atproto' ) {
80 const card = await fetchAtprotoCard( match, fetchImpl );
81 if ( ! card ) {
82 return null;
83 }
84 return { type: 'rich', version: '1.0', provider_name: 'Bluesky', html: withPreviewStyles( renderEmbedCard( card ) ) };
85 }
86 return {
87 type: 'rich',
88 version: '1.0',
89 provider_name: PROVIDER_LABEL[ match.kind ],
90 html: withPreviewStyles( placeholder( PROVIDER_LABEL[ match.kind ] ) ),
91 };
92}
93
94/** Extract the `url` query arg from the embed-proxy apiFetch path. */
95function proxyUrl( path?: string ): string | null {
96 if ( ! path || ! path.includes( '/oembed/1.0/proxy' ) ) {
97 return null;
98 }
99 const q = path.indexOf( '?' );
100 if ( q === -1 ) {
101 return null;
102 }
103 return new URLSearchParams( path.slice( q + 1 ) ).get( 'url' );
104}
105
106let registered = false;
107
108/**
109 * Register the `@wordpress/api-fetch` middleware that answers `core/embed`'s
110 * `/oembed/1.0/proxy` requests from our embed registry. Idempotent — registers once.
111 */
112export function registerEmbedPreviewMiddleware(): void {
113 if ( registered ) {
114 return;
115 }
116 registered = true;
117 apiFetch.use( async ( options, next ) => {
118 const url = proxyUrl( ( options as { path?: string } ).path );
119 if ( ! url ) {
120 return next( options );
121 }
122 const preview = await buildEmbedPreview( url );
123 // Resolve with our preview, or surface a 404 so core/embed shows "cannot
124 // embed" (a clean link) rather than spinning forever.
125 if ( preview ) {
126 return preview as unknown as ReturnType< typeof next >;
127 }
128 throw { code: 'oembed_invalid_url', message: 'Not an embeddable URL' };
129 } );
130}