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: recognise atproto/youtube/vimeo URLs (registry)

+115
+49
src/lib/embeds/registry.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { detectEmbed } from './registry'; 3 + 4 + describe( 'detectEmbed', () => { 5 + it( 'recognises a bsky.app post URL', () => { 6 + expect( detectEmbed( 'https://bsky.app/profile/jeremy.herve.bzh/post/3momrv24o4wlj' ) ).toEqual( 7 + { kind: 'atproto', id: 'jeremy.herve.bzh/3momrv24o4wlj' } 8 + ); 9 + } ); 10 + 11 + it( 'recognises a mu.social post URL', () => { 12 + expect( detectEmbed( 'https://mu.social/profile/renderg.host/post/3mokzwgfyck2e' ) ).toEqual( 13 + { kind: 'atproto', id: 'renderg.host/3mokzwgfyck2e' } 14 + ); 15 + } ); 16 + 17 + it( 'recognises a DID-based post URL', () => { 18 + expect( detectEmbed( 'https://bsky.app/profile/did:plc:abc123/post/xyz' ) ).toEqual( 19 + { kind: 'atproto', id: 'did:plc:abc123/xyz' } 20 + ); 21 + } ); 22 + 23 + it( 'recognises a raw at:// post URI', () => { 24 + expect( detectEmbed( 'at://did:plc:abc123/app.bsky.feed.post/xyz' ) ).toEqual( 25 + { kind: 'atproto', id: 'did:plc:abc123/xyz' } 26 + ); 27 + } ); 28 + 29 + it( 'recognises youtube watch + youtu.be + nocookie URLs', () => { 30 + expect( detectEmbed( 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' ) ).toEqual( { kind: 'youtube', id: 'dQw4w9WgXcQ' } ); 31 + expect( detectEmbed( 'https://youtu.be/dQw4w9WgXcQ' ) ).toEqual( { kind: 'youtube', id: 'dQw4w9WgXcQ' } ); 32 + } ); 33 + 34 + it( 'recognises a vimeo URL', () => { 35 + expect( detectEmbed( 'https://vimeo.com/123456789' ) ).toEqual( { kind: 'vimeo', id: '123456789' } ); 36 + } ); 37 + 38 + it( 'does NOT match look-alike hosts (dot-boundary safety)', () => { 39 + expect( detectEmbed( 'https://notbsky.app/profile/a/post/b' ) ).toBeNull(); 40 + expect( detectEmbed( 'https://bsky.app.evil.com/profile/a/post/b' ) ).toBeNull(); 41 + expect( detectEmbed( 'https://evil-youtube.com/watch?v=x' ) ).toBeNull(); 42 + } ); 43 + 44 + it( 'returns null for unrelated or malformed URLs', () => { 45 + expect( detectEmbed( 'https://example.com/article' ) ).toBeNull(); 46 + expect( detectEmbed( 'not a url' ) ).toBeNull(); 47 + expect( detectEmbed( 'https://bsky.app/profile/alice.bsky.social' ) ).toBeNull(); // profile, not a post 48 + } ); 49 + } );
+66
src/lib/embeds/registry.ts
··· 1 + /** 2 + * Recognise which embed provider a URL belongs to (the embed content model). 3 + * 4 + * Dependency-free (no `@wordpress/*`) so both the reader path and the editor can 5 + * import it. Hosts are matched at a dot boundary — `host === domain || 6 + * host.endsWith( '.' + domain )` — so look-alikes (`notbsky.app`, 7 + * `bsky.app.evil.com`) do NOT match. Mirrors the `detectProvider` pattern 8 + * (Decision 0017). A URL we don't recognise returns null → it stays a plain link. 9 + */ 10 + export type EmbedKind = 'atproto' | 'youtube' | 'vimeo'; 11 + 12 + export interface EmbedMatch { 13 + kind: EmbedKind; 14 + /** atproto: "<authority>/<rkey>" (authority = handle or did). video: the id. */ 15 + id: string; 16 + } 17 + 18 + /** AppView web hosts that use the standard `/profile/<id>/post/<rkey>` scheme. */ 19 + const ATPROTO_HOSTS = [ 'bsky.app', 'mu.social' ]; 20 + 21 + function hostMatches( host: string, domain: string ): boolean { 22 + const h = host.toLowerCase(); 23 + return h === domain || h.endsWith( '.' + domain ); 24 + } 25 + 26 + /** 27 + * Detect whether a URL points at a supported embed provider. 28 + * 29 + * @param url - The candidate URL (an `https://` AppView/video link or a raw `at://` URI). 30 + * @returns The matched embed (`kind` + `id`), or `null` when the URL is unrelated or malformed. 31 + */ 32 + export function detectEmbed( url: string ): EmbedMatch | null { 33 + // Raw at:// post URI. 34 + const at = url.match( /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/?#]+)/ ); 35 + if ( at ) { 36 + return { kind: 'atproto', id: `${ at[ 1 ] }/${ at[ 2 ] }` }; 37 + } 38 + 39 + let parsed: URL; 40 + try { 41 + parsed = new URL( url ); 42 + } catch { 43 + return null; 44 + } 45 + const host = parsed.hostname; 46 + 47 + if ( ATPROTO_HOSTS.some( ( d ) => hostMatches( host, d ) ) ) { 48 + const m = parsed.pathname.match( /^\/profile\/([^/]+)\/post\/([^/?#]+)/ ); 49 + return m ? { kind: 'atproto', id: `${ m[ 1 ] }/${ m[ 2 ] }` } : null; 50 + } 51 + 52 + if ( hostMatches( host, 'youtube.com' ) ) { 53 + const v = parsed.searchParams.get( 'v' ); 54 + return v ? { kind: 'youtube', id: v } : null; 55 + } 56 + if ( hostMatches( host, 'youtu.be' ) ) { 57 + const id = parsed.pathname.slice( 1 ).split( '/' )[ 0 ]; 58 + return id ? { kind: 'youtube', id } : null; 59 + } 60 + if ( hostMatches( host, 'vimeo.com' ) ) { 61 + const id = parsed.pathname.split( '/' ).filter( Boolean )[ 0 ]; 62 + return id && /^\d+$/.test( id ) ? { kind: 'vimeo', id } : null; 63 + } 64 + 65 + return null; 66 + }