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 14 kB View raw
1import { describe, expect, it } from 'vitest'; 2import { 3 publicationHomeUrl, 4 articlePath, 5 canonicalArticleUrl, 6 publicationSlugFromUrl, 7 isSkyPressPublicationUrl, 8 slugify, 9 uniquePublicationSlug, 10 buildContentObject, 11 buildPublicationRecord, 12 buildDocumentRecord, 13 buildBskyPost, 14 normalizeBlocks, 15 CONTENT_TYPE, 16 CONTENT_VERSION, 17 SITE_BASE, 18} from './records'; 19import type { BlockNode } from '../blocks/render'; 20import type { BlobRefJson } from '../media/blob'; 21import { THEME_PRESETS } from './themes'; 22 23const BLOCKS: BlockNode[] = [ 24 { name: 'core/heading', attributes: { level: 1, content: 'Hi' }, innerBlocks: [] }, 25 { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] }, 26]; 27 28describe( 'URLs', () => { 29 it( 'namespaces publications under @handle/slug and documents under that', () => { 30 expect( publicationHomeUrl( 'alice.bsky.social', 'my-blog' ) ).toBe( 31 `${ SITE_BASE }/@alice.bsky.social/my-blog` 32 ); 33 expect( articlePath( '3kabcrkey' ) ).toBe( '/3kabcrkey' ); 34 expect( canonicalArticleUrl( 'alice.bsky.social', 'my-blog', '3kabcrkey' ) ).toBe( 35 `${ SITE_BASE }/@alice.bsky.social/my-blog/3kabcrkey` 36 ); 37 } ); 38} ); 39 40describe( 'publicationSlugFromUrl', () => { 41 it( 'returns the trailing slug segment of a publication url', () => { 42 expect( publicationSlugFromUrl( `${ SITE_BASE }/@alice.bsky.social/my-blog` ) ).toBe( 43 'my-blog' 44 ); 45 } ); 46 it( 'returns null for a slugless (legacy) or malformed url', () => { 47 expect( publicationSlugFromUrl( `${ SITE_BASE }/@alice.bsky.social` ) ).toBeNull(); 48 expect( publicationSlugFromUrl( 'not a url' ) ).toBeNull(); 49 } ); 50} ); 51 52describe( 'isSkyPressPublicationUrl', () => { 53 it( 'accepts only urls on the SkyPress origin', () => { 54 expect( isSkyPressPublicationUrl( `${ SITE_BASE }/@alice.bsky.social/my-blog` ) ).toBe( 55 true 56 ); 57 expect( isSkyPressPublicationUrl( 'https://leaflet.pub/lish/did/abc' ) ).toBe( false ); 58 expect( isSkyPressPublicationUrl( 'garbage' ) ).toBe( false ); 59 } ); 60} ); 61 62describe( 'slugify', () => { 63 it( 'lowercases, dashes spaces, strips to [a-z0-9-] and trims/collapses dashes', () => { 64 expect( slugify( ' My Great Blog! ' ) ).toBe( 'my-great-blog' ); 65 expect( slugify( 'Hello---World' ) ).toBe( 'hello-world' ); 66 expect( slugify( '___weird@@@name___' ) ).toBe( 'weirdname' ); 67 } ); 68 it( 'normalises accents to their base letters', () => { 69 expect( slugify( 'Café Life' ) ).toBe( 'cafe-life' ); 70 } ); 71 it( 'returns empty string for empty / emoji-only names', () => { 72 expect( slugify( '🎉🎉' ) ).toBe( '' ); 73 expect( slugify( ' ' ) ).toBe( '' ); 74 } ); 75} ); 76 77describe( 'uniquePublicationSlug', () => { 78 it( 'returns the bare slug when free', () => { 79 expect( uniquePublicationSlug( 'My Blog', [] ) ).toBe( 'my-blog' ); 80 } ); 81 it( 'appends -2, -3 … on collision within the repo', () => { 82 expect( uniquePublicationSlug( 'My Blog', [ 'my-blog' ] ) ).toBe( 'my-blog-2' ); 83 expect( uniquePublicationSlug( 'My Blog', [ 'my-blog', 'my-blog-2' ] ) ).toBe( 84 'my-blog-3' 85 ); 86 } ); 87 it( 'returns empty string for an unslugifiable name (caller applies pub-{rkey})', () => { 88 expect( uniquePublicationSlug( '🎉', [] ) ).toBe( '' ); 89 } ); 90} ); 91 92describe( 'normalizeBlocks', () => { 93 it( 'strips clientId and keeps name/attributes/innerBlocks recursively', () => { 94 const live = [ 95 { 96 name: 'core/list', 97 clientId: 'abc-123', 98 attributes: { ordered: false }, 99 innerBlocks: [ 100 { name: 'core/list-item', clientId: 'def-456', attributes: { content: 'One' }, innerBlocks: [] }, 101 ], 102 }, 103 ]; 104 expect( normalizeBlocks( live ) ).toEqual( [ 105 { 106 name: 'core/list', 107 attributes: { ordered: false }, 108 innerBlocks: [ 109 { name: 'core/list-item', attributes: { content: 'One' }, innerBlocks: [] }, 110 ], 111 }, 112 ] ); 113 } ); 114} ); 115 116describe( 'buildContentObject', () => { 117 it( 'wraps the block tree with the SkyPress content $type + version', () => { 118 const content = buildContentObject( BLOCKS ); 119 expect( content.$type ).toBe( CONTENT_TYPE ); 120 expect( content.version ).toBe( CONTENT_VERSION ); 121 expect( content.blocks ).toEqual( BLOCKS ); 122 } ); 123} ); 124 125describe( 'buildContentObject — mentions', () => { 126 it( 'omits the mentions field when there are none', () => { 127 const content = buildContentObject( BLOCKS ); 128 expect( 'mentions' in content ).toBe( false ); 129 } ); 130 131 it( 'stores a flat {did, handle} list when mentions are passed', () => { 132 const content = buildContentObject( BLOCKS, [ 133 { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 134 ] ); 135 expect( content.mentions ).toEqual( [ { did: 'did:plc:a', handle: 'alice.bsky.social' } ] ); 136 } ); 137} ); 138 139describe( 'buildPublicationRecord', () => { 140 it( 'bakes the slug into the required url + name', () => { 141 const pub = buildPublicationRecord( { 142 handle: 'alice.bsky.social', 143 slug: 'my-blog', 144 name: 'My Blog', 145 } ); 146 expect( pub.$type ).toBe( 'site.standard.publication' ); 147 expect( pub.url ).toBe( `${ SITE_BASE }/@alice.bsky.social/my-blog` ); 148 expect( pub.name ).toBe( 'My Blog' ); 149 } ); 150 151 it( 'falls back to the handle when no name is given', () => { 152 const pub = buildPublicationRecord( { handle: 'alice.bsky.social', slug: 'pub-x' } ); 153 expect( pub.name ).toBe( 'alice.bsky.social' ); 154 } ); 155 156 it( 'includes description and icon only when provided', () => { 157 const bare = buildPublicationRecord( { handle: 'a.b', slug: 's', name: 'N' } ); 158 expect( 'description' in bare ).toBe( false ); 159 expect( 'icon' in bare ).toBe( false ); 160 161 const icon: BlobRefJson = { 162 $type: 'blob', 163 ref: { $link: 'bafyicon' }, 164 mimeType: 'image/png', 165 size: 1234, 166 }; 167 const full = buildPublicationRecord( { 168 handle: 'a.b', 169 slug: 's', 170 name: 'N', 171 description: 'A blog', 172 icon, 173 } ); 174 expect( full.description ).toBe( 'A blog' ); 175 expect( full.icon ).toEqual( icon ); 176 } ); 177 178 it( 'includes basicTheme when provided and omits it otherwise', () => { 179 const themed = buildPublicationRecord( { 180 handle: 'a.b', 181 slug: 's', 182 name: 'N', 183 basicTheme: THEME_PRESETS[ 0 ].colors, 184 } ); 185 expect( themed.basicTheme ).toEqual( THEME_PRESETS[ 0 ].colors ); 186 187 const bare = buildPublicationRecord( { handle: 'a.b', slug: 's', name: 'N' } ); 188 expect( 'basicTheme' in bare ).toBe( false ); 189 } ); 190 191 it( 'stamps the colour union $type so the stored basicTheme validates against the lexicon', () => { 192 // `site.standard.theme.basic` colours are a union of `site.standard.theme.color#rgb`; without 193 // the `$type` discriminator the record is invalid and Bluesky drops the enhanced link card. 194 const themed = buildPublicationRecord( { 195 handle: 'a.b', 196 slug: 's', 197 name: 'N', 198 basicTheme: THEME_PRESETS[ 0 ].colors, 199 } ); 200 for ( const color of [ 201 themed.basicTheme!.background, 202 themed.basicTheme!.foreground, 203 themed.basicTheme!.accent, 204 themed.basicTheme!.accentForeground, 205 ] ) { 206 expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 207 } 208 } ); 209 210 it( 'drops an invalid basicTheme rather than persisting it (write-boundary validation)', () => { 211 const record = buildPublicationRecord( { 212 handle: 'a.b', 213 slug: 's', 214 name: 'N', 215 // Out-of-range channel — must never reach storage. 216 basicTheme: { 217 $type: 'site.standard.theme.basic', 218 background: { r: 300, g: 0, b: 0 }, 219 foreground: { r: 0, g: 0, b: 0 }, 220 accent: { r: 0, g: 0, b: 0 }, 221 accentForeground: { r: 0, g: 0, b: 0 }, 222 } as never, 223 } ); 224 expect( 'basicTheme' in record ).toBe( false ); 225 } ); 226} ); 227 228describe( 'buildDocumentRecord', () => { 229 const base = { 230 title: 'Hello, World!', 231 rkey: '3kabcrkey', 232 blocks: BLOCKS, 233 textContent: 'Hi\n\nBody', 234 siteUri: 'at://did:plc:abc/site.standard.publication/xyz', 235 publishedAt: '2026-06-08T12:00:00.000Z', 236 }; 237 238 it( 'includes the required fields, content object and textContent', () => { 239 const doc = buildDocumentRecord( base ); 240 expect( doc.$type ).toBe( 'site.standard.document' ); 241 expect( doc.site ).toBe( base.siteUri ); 242 expect( doc.title ).toBe( base.title ); 243 expect( doc.path ).toBe( '/3kabcrkey' ); 244 expect( doc.publishedAt ).toBe( base.publishedAt ); 245 expect( doc.textContent ).toBe( base.textContent ); 246 expect( ( doc.content as { $type: string } ).$type ).toBe( CONTENT_TYPE ); 247 } ); 248 249 it( 'omits bskyPostRef unless provided', () => { 250 expect( 'bskyPostRef' in buildDocumentRecord( base ) ).toBe( false ); 251 const ref = { uri: 'at://did:plc:abc/app.bsky.feed.post/p', cid: 'bafy' }; 252 expect( buildDocumentRecord( { ...base, bskyPostRef: ref } ).bskyPostRef ).toEqual( ref ); 253 } ); 254 255 it( 'omits updatedAt on first publish, includes it on edit', () => { 256 expect( 'updatedAt' in buildDocumentRecord( base ) ).toBe( false ); 257 const edited = buildDocumentRecord( { ...base, updatedAt: '2026-06-09T09:00:00.000Z' } ); 258 expect( edited.updatedAt ).toBe( '2026-06-09T09:00:00.000Z' ); 259 expect( edited.publishedAt ).toBe( base.publishedAt ); // preserved 260 } ); 261 262 it( 'omits coverImage unless provided, includes it when set', () => { 263 expect( 'coverImage' in buildDocumentRecord( base ) ).toBe( false ); 264 const cover = { 265 $type: 'blob' as const, 266 ref: { $link: 'bafycover' }, 267 mimeType: 'image/png', 268 size: 4242, 269 }; 270 expect( 271 buildDocumentRecord( { ...base, coverImage: cover } ).coverImage 272 ).toEqual( cover ); 273 } ); 274} ); 275 276describe( 'buildBskyPost', () => { 277 const ARTICLE_URL = 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey'; 278 279 it( 'creates a post with an external embed pointing at the article', () => { 280 const post = buildBskyPost( { 281 title: 'Hello, World!', 282 articleUrl: ARTICLE_URL, 283 description: 'An excerpt', 284 createdAt: '2026-06-08T12:00:00.000Z', 285 } ); 286 expect( post.$type ).toBe( 'app.bsky.feed.post' ); 287 expect( post.text ).toContain( 'Hello, World!' ); 288 expect( post.createdAt ).toBe( '2026-06-08T12:00:00.000Z' ); 289 expect( post.embed.$type ).toBe( 'app.bsky.embed.external' ); 290 expect( post.embed.external.uri ).toBe( ARTICLE_URL ); 291 expect( post.embed.external.title ).toBe( 'Hello, World!' ); 292 expect( typeof post.embed.external.description ).toBe( 'string' ); 293 } ); 294 295 it( 'marks the article URL as a clickable link facet over its byte range', () => { 296 const post = buildBskyPost( { 297 title: 'Hello, World!', 298 articleUrl: ARTICLE_URL, 299 createdAt: '2026-06-08T12:00:00.000Z', 300 } ); 301 const bytes = ( s: string ) => new TextEncoder().encode( s ).length; 302 const byteStart = bytes( `Hello, World!\n\n` ); 303 expect( post.facets ).toEqual( [ 304 { 305 $type: 'app.bsky.richtext.facet', 306 index: { byteStart, byteEnd: byteStart + bytes( ARTICLE_URL ) }, 307 features: [ { $type: 'app.bsky.richtext.facet#link', uri: ARTICLE_URL } ], 308 }, 309 ] ); 310 } ); 311 312 it( 'computes facet byte offsets in UTF-8 (multibyte title)', () => { 313 const post = buildBskyPost( { 314 title: '🍔 Burgers', 315 articleUrl: ARTICLE_URL, 316 createdAt: '2026-06-08T12:00:00.000Z', 317 } ); 318 const bytes = ( s: string ) => new TextEncoder().encode( s ).length; 319 // The emoji is 4 UTF-8 bytes, so the link starts well past its JS string index. 320 expect( post.facets[ 0 ].index.byteStart ).toBe( bytes( '🍔 Burgers\n\n' ) ); 321 expect( post.facets[ 0 ].index.byteEnd ).toBe( 322 bytes( '🍔 Burgers\n\n' ) + bytes( ARTICLE_URL ) 323 ); 324 } ); 325 326 it( 'embeds associatedRefs (document, publication strongRefs) when provided', () => { 327 const documentRef = { uri: 'at://did:plc:abc/site.standard.document/d1', cid: 'bafydoc' }; 328 const publicationRef = { 329 uri: 'at://did:plc:abc/site.standard.publication/p1', 330 cid: 'bafypub', 331 }; 332 const post = buildBskyPost( { 333 title: 'Hello, World!', 334 articleUrl: ARTICLE_URL, 335 createdAt: '2026-06-08T12:00:00.000Z', 336 associatedRefs: [ documentRef, publicationRef ], 337 } ); 338 expect( post.embed.external.associatedRefs ).toEqual( [ 339 { $type: 'com.atproto.repo.strongRef', ...documentRef }, 340 { $type: 'com.atproto.repo.strongRef', ...publicationRef }, 341 ] ); 342 } ); 343 344 it( 'omits associatedRefs when none are provided', () => { 345 const post = buildBskyPost( { 346 title: 'Hello, World!', 347 articleUrl: ARTICLE_URL, 348 createdAt: '2026-06-08T12:00:00.000Z', 349 } ); 350 expect( 'associatedRefs' in post.embed.external ).toBe( false ); 351 } ); 352 353 it( 'includes embed.external.thumb only when a blob ref is provided (Decision 0014)', () => { 354 const base = { 355 title: 'Hello, World!', 356 articleUrl: ARTICLE_URL, 357 createdAt: '2026-06-08T12:00:00.000Z', 358 }; 359 expect( 'thumb' in buildBskyPost( base ).embed.external ).toBe( false ); 360 361 const thumb: BlobRefJson = { 362 $type: 'blob', 363 ref: { $link: 'bafythumb' }, 364 mimeType: 'image/png', 365 size: 4242, 366 }; 367 expect( buildBskyPost( { ...base, thumb } ).embed.external.thumb ).toEqual( thumb ); 368 } ); 369} ); 370 371describe( 'buildBskyPost — lede + mentions', () => { 372 const URL = 'https://skypress.blog/@me/pub/3kabcde12345'; 373 374 it( 'keeps the card description separate from the post body', () => { 375 const post = buildBskyPost( { 376 title: 'Title', 377 articleUrl: URL, 378 description: 'Card subtitle', 379 createdAt: '2026-06-12T00:00:00.000Z', 380 bodyLede: 'Body lede', 381 } ); 382 expect( post.text ).toBe( `Title\n\nBody lede\n\n${ URL }` ); 383 expect( post.embed.external.description ).toBe( 'Card subtitle' ); 384 } ); 385 386 it( 'emits a mention facet for each cc-ed account', () => { 387 const post = buildBskyPost( { 388 title: 'Title', 389 articleUrl: URL, 390 description: 'd', 391 createdAt: '2026-06-12T00:00:00.000Z', 392 mentions: [ 393 { did: 'did:plc:a', handle: 'alice.bsky.social', displayText: '@alice.bsky.social' }, 394 ], 395 } ); 396 expect( post.text ).toContain( 'cc @alice.bsky.social' ); 397 const hasMention = post.facets.some( 398 ( f ) => f.features[ 0 ].$type === 'app.bsky.richtext.facet#mention' 399 ); 400 expect( hasMention ).toBe( true ); 401 } ); 402 403 it( 'throws when the assembled post exceeds 300 graphemes', () => { 404 const longLede = 'x'.repeat( 320 ); 405 expect( () => 406 buildBskyPost( { 407 title: 'Title', 408 articleUrl: URL, 409 description: 'd', 410 createdAt: '2026-06-12T00:00:00.000Z', 411 bodyLede: longLede, 412 } ) 413 ).toThrow( /300/ ); 414 } ); 415} );