A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1// src/lib/embeds/card.ts
2/**
3 * Build trusted embed-card HTML from resolved data. Dependency-free (no
4 * `@wordpress/*`) so the reader path and the editor share one renderer.
5 *
6 * SECURITY: the card structure is trusted, but every interpolated value comes
7 * from a PDS or a provider's oEmbed (UNTRUSTED) and is passed through
8 * `escapeHtml`. The video card is a facade — it carries no `<iframe>`; the
9 * iframe is injected client-side on a play click, validated against a two-host
10 * allowlist (see `playback.ts`). The whole card still passes through
11 * `sanitizeArticleHtml` last (Decision 0018).
12 */
13export interface AtprotoImage { src: string; alt: string }
14
15export interface AtprotoCardData {
16 kind: 'atproto';
17 authorName: string;
18 handle: string;
19 avatar?: string;
20 text: string;
21 images: AtprotoImage[];
22 createdAt?: string;
23 viewUrl: string;
24}
25
26export interface VideoCardData {
27 kind: 'youtube' | 'vimeo';
28 id: string;
29 title: string;
30 thumbnail: string;
31 url: string;
32}
33
34export type EmbedData = AtprotoCardData | VideoCardData;
35
36/** Allow only http(s) URLs in href/src; anything else (javascript:, data:, …) → ''. */
37function safeHttpUrl( value: string ): string {
38 try {
39 const protocol = new URL( value ).protocol;
40 return protocol === 'https:' || protocol === 'http:' ? value : '';
41 } catch {
42 return '';
43 }
44}
45
46export function escapeHtml( value: string ): string {
47 return value
48 .replace( /&/g, '&' )
49 .replace( /</g, '<' )
50 .replace( />/g, '>' )
51 .replace( /"/g, '"' )
52 .replace( /'/g, ''' );
53}
54
55function atprotoCard( d: AtprotoCardData ): string {
56 const avatar = d.avatar
57 ? `<img class="skypress-embed__avatar" src="${ escapeHtml( safeHttpUrl( d.avatar ) ) }" alt=""/>`
58 : '';
59 const images = d.images
60 .map(
61 ( img ) =>
62 `<img class="skypress-embed__image" src="${ escapeHtml( safeHttpUrl( img.src ) ) }" alt="${ escapeHtml( img.alt ) }"/>`
63 )
64 .join( '' );
65 const text = escapeHtml( d.text ).replace( /\n/g, '<br/>' );
66 return (
67 `<figure class="wp-block-embed skypress-embed skypress-embed--atproto">` +
68 `<a class="skypress-embed__link" href="${ escapeHtml( safeHttpUrl( d.viewUrl ) ) }">` +
69 `<span class="skypress-embed__head">${ avatar }` +
70 `<span class="skypress-embed__author">${ escapeHtml( d.authorName ) }</span>` +
71 `<span class="skypress-embed__handle">@${ escapeHtml( d.handle ) }</span></span>` +
72 `<span class="skypress-embed__text">${ text }</span>` +
73 ( images ? `<span class="skypress-embed__media">${ images }</span>` : '' ) +
74 `<span class="skypress-embed__footer">🌀 View on the ATmosphere</span>` +
75 `</a></figure>`
76 );
77}
78
79function videoCard( d: VideoCardData ): string {
80 return (
81 `<figure class="wp-block-embed skypress-embed skypress-embed--video">` +
82 `<button type="button" class="skypress-embed__play" data-embed-provider="${ escapeHtml( d.kind ) }" data-embed-id="${ escapeHtml( d.id ) }">` +
83 `<img class="skypress-embed__thumb" src="${ escapeHtml( safeHttpUrl( d.thumbnail ) ) }" alt=""/>` +
84 `<span class="skypress-embed__playicon" aria-hidden="true">▶</span>` +
85 `<span class="skypress-embed__title">${ escapeHtml( d.title ) }</span>` +
86 `</button>` +
87 `<a class="skypress-embed__fallback" href="${ escapeHtml( safeHttpUrl( d.url ) ) }">Watch on ${ d.kind === 'youtube' ? 'YouTube' : 'Vimeo' }</a>` +
88 `</figure>`
89 );
90}
91
92export function renderEmbedCard( data: EmbedData ): string {
93 return data.kind === 'atproto' ? atprotoCard( data ) : videoCard( data );
94}