A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { describe, expect, it } from 'vitest';
2import { renderArticle } from './render-article';
3
4const AUTHOR = { pdsUrl: 'https://pds.example', did: 'did:plc:alice' };
5
6describe( 'renderArticle', () => {
7 it( 'renders block content to HTML', () => {
8 const { html } = renderArticle(
9 {
10 content: {
11 blocks: [
12 { name: 'core/heading', attributes: { level: 2, content: 'Section' } },
13 { name: 'core/paragraph', attributes: { content: 'A paragraph.' } },
14 ],
15 },
16 },
17 AUTHOR
18 );
19 expect( html ).toContain( '<h2 class="wp-block-heading">Section</h2>' );
20 expect( html ).toContain( '<p>A paragraph.</p>' );
21 } );
22
23 it( 'never lets a script in block content survive (sanitise is the last step)', () => {
24 const { html } = renderArticle(
25 {
26 content: {
27 blocks: [
28 {
29 name: 'core/paragraph',
30 attributes: { content: 'Hello <script>alert(1)</script>world' },
31 },
32 ],
33 },
34 },
35 AUTHOR
36 );
37 expect( html ).not.toContain( '<script' );
38 expect( html ).not.toContain( 'alert(1)' );
39 expect( html ).toContain( 'Hello' );
40 } );
41
42 it( 'strips event-handler attributes smuggled through rich text', () => {
43 const { html } = renderArticle(
44 {
45 content: {
46 blocks: [
47 {
48 name: 'core/paragraph',
49 attributes: { content: '<em onmouseover="steal()">hover me</em>' },
50 },
51 ],
52 },
53 },
54 AUTHOR
55 );
56 expect( html ).toContain( '<em>hover me</em>' );
57 expect( html ).not.toContain( 'onmouseover' );
58 } );
59
60 it( 'rewrites blob-backed image URLs to the author PDS before rendering', () => {
61 const { html } = renderArticle(
62 {
63 content: {
64 blocks: [
65 {
66 name: 'core/image',
67 attributes: {
68 url: 'https://stale-pds.example/old-blob.png',
69 alt: 'A photo',
70 skypressBlob: {
71 $type: 'blob',
72 ref: { $link: 'bafyreidcid' },
73 mimeType: 'image/png',
74 size: 1234,
75 },
76 },
77 },
78 ],
79 },
80 },
81 AUTHOR
82 );
83 // `&` (not `&`) — the URL came out the far side of the sanitiser.
84 expect( html ).toContain(
85 'src="https://pds.example/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aalice&cid=bafyreidcid"'
86 );
87 expect( html ).not.toContain( 'stale-pds.example' );
88 } );
89
90 it( 'prefers the stored textContent for the plain-text view', () => {
91 const { text } = renderArticle(
92 {
93 textContent: 'Stored summary.',
94 content: {
95 blocks: [ { name: 'core/paragraph', attributes: { content: 'Body.' } } ],
96 },
97 },
98 AUTHOR
99 );
100 expect( text ).toBe( 'Stored summary.' );
101 } );
102
103 it( 'falls back to the block text when textContent is absent or empty', () => {
104 const { text } = renderArticle(
105 {
106 textContent: '',
107 content: {
108 blocks: [
109 { name: 'core/heading', attributes: { level: 2, content: 'Section' } },
110 { name: 'core/paragraph', attributes: { content: 'Body text.' } },
111 ],
112 },
113 },
114 AUTHOR
115 );
116 expect( text ).toBe( 'Section\n\nBody text.' );
117 } );
118
119 it( 'returns empty html and text for a document with no blocks', () => {
120 const { html, text } = renderArticle( {}, AUTHOR );
121 expect( html ).toBe( '' );
122 expect( text ).toBe( '' );
123 } );
124
125 it( 'highlights code blocks only when opted in', () => {
126 const doc = {
127 content: {
128 blocks: [ { name: 'core/code', attributes: { content: 'const answer = 42;' } } ],
129 },
130 };
131 const off = renderArticle( doc, AUTHOR );
132 const on = renderArticle( doc, AUTHOR, { highlight: true } );
133
134 // Default (RSS path) stays plain.
135 expect( off.html ).toContain( '<pre class="wp-block-code"><code>' );
136 expect( off.html ).not.toContain( 'hljs' );
137
138 // Opt-in produces sanitiser-safe token spans.
139 expect( on.html ).toContain( 'class="hljs' );
140 expect( on.html ).toContain( '<span class="hljs-' );
141 } );
142} );