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.

at trunk 3.5 kB View raw
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, '&amp;' ) 49 .replace( /</g, '&lt;' ) 50 .replace( />/g, '&gt;' ) 51 .replace( /"/g, '&quot;' ) 52 .replace( /'/g, '&#39;' ); 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}