A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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} );