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.

Embeds: trusted card + facade renderer with escaping

+145
+61
src/lib/embeds/card.test.ts
··· 1 + // src/lib/embeds/card.test.ts 2 + import { describe, expect, it } from 'vitest'; 3 + import { escapeHtml, renderEmbedCard } from './card'; 4 + 5 + describe( 'escapeHtml', () => { 6 + it( 'escapes the HTML metacharacters', () => { 7 + expect( escapeHtml( `<img src=x onerror="alert(1)"> & "q" 'a'` ) ).toBe( 8 + '&lt;img src=x onerror=&quot;alert(1)&quot;&gt; &amp; &quot;q&quot; &#39;a&#39;' 9 + ); 10 + } ); 11 + } ); 12 + 13 + describe( 'renderEmbedCard — atproto', () => { 14 + const data = { 15 + kind: 'atproto' as const, 16 + authorName: 'Jeremy', 17 + handle: 'jeremy.herve.bzh', 18 + avatar: 'https://cdn.example/av.jpg', 19 + text: 'Hello <script>alert(1)</script> world', 20 + images: [ { src: 'https://cdn.example/i.jpg', alt: 'a "nice" pic' } ], 21 + createdAt: '2026-06-19T10:00:00.000Z', 22 + viewUrl: 'https://mu.social/profile/did:plc:x/post/abc', 23 + }; 24 + 25 + it( 'renders a static card with escaped post text', () => { 26 + const html = renderEmbedCard( data ); 27 + expect( html ).toContain( 'class="wp-block-embed skypress-embed skypress-embed--atproto"' ); 28 + expect( html ).toContain( 'Hello &lt;script&gt;alert(1)&lt;/script&gt; world' ); 29 + expect( html ).not.toContain( '<script>' ); 30 + expect( html ).toContain( 'href="https://mu.social/profile/did:plc:x/post/abc"' ); 31 + expect( html ).toContain( '@jeremy.herve.bzh' ); 32 + } ); 33 + 34 + it( 'escapes hostile image alt text', () => { 35 + expect( renderEmbedCard( data ) ).toContain( 'alt="a &quot;nice&quot; pic"' ); 36 + } ); 37 + 38 + it( 'contains no iframe', () => { 39 + expect( renderEmbedCard( data ) ).not.toContain( '<iframe' ); 40 + } ); 41 + } ); 42 + 43 + describe( 'renderEmbedCard — video facade', () => { 44 + const data = { 45 + kind: 'youtube' as const, 46 + id: 'dQw4w9WgXcQ', 47 + title: 'Cool <b>video</b>', 48 + thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 49 + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 50 + }; 51 + 52 + it( 'renders a facade with a play button and data attributes, no iframe', () => { 53 + const html = renderEmbedCard( data ); 54 + expect( html ).toContain( 'skypress-embed--video' ); 55 + expect( html ).toContain( 'data-embed-provider="youtube"' ); 56 + expect( html ).toContain( 'data-embed-id="dQw4w9WgXcQ"' ); 57 + expect( html ).toContain( '<button' ); 58 + expect( html ).toContain( 'Cool &lt;b&gt;video&lt;/b&gt;' ); 59 + expect( html ).not.toContain( '<iframe' ); 60 + } ); 61 + } );
+84
src/lib/embeds/card.ts
··· 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 + */ 13 + export interface AtprotoImage { src: string; alt: string } 14 + 15 + export 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 + 26 + export interface VideoCardData { 27 + kind: 'youtube' | 'vimeo'; 28 + id: string; 29 + title: string; 30 + thumbnail: string; 31 + url: string; 32 + } 33 + 34 + export type EmbedData = AtprotoCardData | VideoCardData; 35 + 36 + export function escapeHtml( value: string ): string { 37 + return value 38 + .replace( /&/g, '&amp;' ) 39 + .replace( /</g, '&lt;' ) 40 + .replace( />/g, '&gt;' ) 41 + .replace( /"/g, '&quot;' ) 42 + .replace( /'/g, '&#39;' ); 43 + } 44 + 45 + function atprotoCard( d: AtprotoCardData ): string { 46 + const avatar = d.avatar 47 + ? `<img class="skypress-embed__avatar" src="${ escapeHtml( d.avatar ) }" alt=""/>` 48 + : ''; 49 + const images = d.images 50 + .map( 51 + ( img ) => 52 + `<img class="skypress-embed__image" src="${ escapeHtml( img.src ) }" alt="${ escapeHtml( img.alt ) }"/>` 53 + ) 54 + .join( '' ); 55 + const text = escapeHtml( d.text ).replace( /\n/g, '<br/>' ); 56 + return ( 57 + `<figure class="wp-block-embed skypress-embed skypress-embed--atproto">` + 58 + `<a class="skypress-embed__link" href="${ escapeHtml( d.viewUrl ) }">` + 59 + `<span class="skypress-embed__head">${ avatar }` + 60 + `<span class="skypress-embed__author">${ escapeHtml( d.authorName ) }</span>` + 61 + `<span class="skypress-embed__handle">@${ escapeHtml( d.handle ) }</span></span>` + 62 + `<span class="skypress-embed__text">${ text }</span>` + 63 + ( images ? `<span class="skypress-embed__media">${ images }</span>` : '' ) + 64 + `<span class="skypress-embed__footer">🌀 View on the ATmosphere</span>` + 65 + `</a></figure>` 66 + ); 67 + } 68 + 69 + function videoCard( d: VideoCardData ): string { 70 + return ( 71 + `<figure class="wp-block-embed skypress-embed skypress-embed--video">` + 72 + `<button type="button" class="skypress-embed__play" data-embed-provider="${ escapeHtml( d.kind ) }" data-embed-id="${ escapeHtml( d.id ) }">` + 73 + `<img class="skypress-embed__thumb" src="${ escapeHtml( d.thumbnail ) }" alt=""/>` + 74 + `<span class="skypress-embed__playicon" aria-hidden="true">▶</span>` + 75 + `<span class="skypress-embed__title">${ escapeHtml( d.title ) }</span>` + 76 + `</button>` + 77 + `<a class="skypress-embed__fallback" href="${ escapeHtml( d.url ) }">Watch on ${ d.kind === 'youtube' ? 'YouTube' : 'Vimeo' }</a>` + 78 + `</figure>` 79 + ); 80 + } 81 + 82 + export function renderEmbedCard( data: EmbedData ): string { 83 + return data.kind === 'atproto' ? atprotoCard( data ) : videoCard( data ); 84 + }