A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { beforeAll, describe, expect, it } from 'vitest';
2import { createBlock } from '@wordpress/blocks';
3import { registerSkyPressBlocks, serializeBlocks } from './serialize';
4import { renderBlocks, blocksToText } from './render';
5
6/** Strip Gutenberg block-delimiter comments, leaving the frontend HTML. */
7function stripDelimiters( markup: string ): string {
8 return markup.replace( /<!--\s*\/?wp:[\s\S]*?-->/g, '' );
9}
10
11/** Collapse insignificant whitespace so structural comparisons are stable. */
12function normalize( html: string ): string {
13 return html
14 .replace( />\s+</g, '><' )
15 .replace( /\s+/g, ' ' )
16 .trim();
17}
18
19beforeAll( () => {
20 registerSkyPressBlocks();
21} );
22
23describe( 'renderBlocks — fidelity vs @wordpress/blocks.serialize()', () => {
24 it( 'reproduces the save-HTML of the curated sample blocks', () => {
25 // The dependency-free renderer must produce the SAME frontend HTML that
26 // Gutenberg's own save functions produce (the oracle). This locks the light
27 // reader renderer to the real packages without importing them at runtime.
28 const tree = [
29 createBlock( 'core/heading', {
30 level: 1,
31 content: "The open sky meets the typesetter's bench",
32 } ),
33 createBlock( 'core/paragraph', {
34 content: 'You write in <strong>blocks</strong> & own your words.',
35 } ),
36 createBlock( 'core/list', {}, [
37 createBlock( 'core/list-item', { content: 'Your data lives on your PDS.' } ),
38 createBlock( 'core/list-item', { content: 'Rendered with the same packages.' } ),
39 ] ),
40 createBlock( 'core/quote', {}, [
41 createBlock( 'core/paragraph', { content: 'A place to write things worth keeping.' } ),
42 ] ),
43 createBlock( 'core/code', {
44 content: 'agent.com.atproto.repo.createRecord( { collection, record } )',
45 } ),
46 createBlock( 'core/separator' ),
47 ];
48
49 const expected = normalize( stripDelimiters( serializeBlocks( tree ) ) );
50 const actual = normalize( renderBlocks( tree ) );
51
52 expect( actual ).toBe( expected );
53 } );
54
55 it( 'reproduces a paragraph containing a skypress mention', async () => {
56 // Registering the mention format teaches @wordpress/blocks how to serialize the
57 // anchor (incl. data-did). Without it, serialize() would drop the attribute.
58 const { registerMentionFormat } = await import( '../editor/mention-format' );
59 registerMentionFormat();
60
61 const tree = [
62 createBlock( 'core/paragraph', {
63 content:
64 'Thanks <a class="skypress-mention" href="https://bsky.app/profile/alice.bsky.social" data-did="did:plc:alice">@alice.bsky.social</a>!',
65 } ),
66 ];
67
68 const expected = normalize( stripDelimiters( serializeBlocks( tree ) ) );
69 const actual = normalize( renderBlocks( tree ) );
70
71 expect( actual ).toBe( expected );
72 } );
73
74 it( 'renders an ordered list as <ol>', () => {
75 const tree = [
76 createBlock( 'core/list', { ordered: true }, [
77 createBlock( 'core/list-item', { content: 'First' } ),
78 ] ),
79 ];
80 expect( renderBlocks( tree ) ).toContain( '<ol' );
81 } );
82} );
83
84describe( 'blocksToText (textContent extraction)', () => {
85 it( 'yields clean plain text in document order, stripping inline markup', () => {
86 const blocks = [
87 createBlock( 'core/heading', { level: 2, content: 'Welcome' } ),
88 createBlock( 'core/paragraph', {
89 content: 'Hello <strong>world</strong> & friends',
90 } ),
91 ];
92 expect( blocksToText( blocks ) ).toBe( 'Welcome\n\nHello world & friends' );
93 } );
94
95 it( 'recurses into inner blocks (list items)', () => {
96 const blocks = [
97 createBlock( 'core/list', {}, [
98 createBlock( 'core/list-item', { content: 'First' } ),
99 createBlock( 'core/list-item', { content: 'Second' } ),
100 ] ),
101 ];
102 expect( blocksToText( blocks ) ).toBe( 'First\n\nSecond' );
103 } );
104
105 it( 'ignores blocks without text (separator) without leaving blank gaps', () => {
106 const blocks = [
107 createBlock( 'core/paragraph', { content: 'Before' } ),
108 createBlock( 'core/separator' ),
109 createBlock( 'core/paragraph', { content: 'After' } ),
110 ];
111 expect( blocksToText( blocks ) ).toBe( 'Before\n\nAfter' );
112 } );
113} );
114
115describe( 'core/embed', () => {
116 it( 'renders the resolved card when a payload is attached', () => {
117 const html = renderBlocks( [
118 {
119 name: 'core/embed',
120 attributes: {
121 url: 'https://bsky.app/profile/a/post/b',
122 _skypressEmbed: {
123 kind: 'atproto',
124 authorName: 'A',
125 handle: 'a.bsky.social',
126 text: 'hi',
127 images: [],
128 viewUrl: 'https://mu.social/profile/did:plc:a/post/b',
129 },
130 },
131 },
132 ] );
133 expect( html ).toContain( 'skypress-embed--atproto' );
134 expect( html ).toContain( 'hi' );
135 } );
136
137 it( 'falls back to a plain link when there is no payload', () => {
138 const html = renderBlocks( [
139 { name: 'core/embed', attributes: { url: 'https://example.com/x' } },
140 ] );
141 expect( html ).toContain( '<a' );
142 expect( html ).toContain( 'https://example.com/x' );
143 expect( html ).not.toContain( 'skypress-embed' );
144 } );
145
146 it( 'renders nothing for an embed with no url and no payload', () => {
147 expect( renderBlocks( [ { name: 'core/embed', attributes: {} } ] ) ).toBe( '' );
148 } );
149} );