A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1/**
2 * The light reader renderer (Decision 0003) — block tree → HTML and → plain text.
3 *
4 * DEPENDENCY-FREE BY DESIGN: this module imports nothing from `@wordpress/*`, so
5 * it runs anywhere (pure Node build, Cloudflare/Vercel edge, the browser) and
6 * keeps reading pages free of the editor's weight (brief §6). Its output fidelity
7 * is locked to `@wordpress/blocks.serialize()` by `render.test.ts`, which uses the
8 * real packages as the oracle.
9 *
10 * Covers the curated allowlist (Decision 0002). Image/gallery/pullquote/embed
11 * rendering is stubbed pending SP3 (blobs) / SP4 (oEmbed + sanitisation).
12 */
13
14export interface BlockNode {
15 name: string;
16 attributes?: Record< string, unknown >;
17 innerBlocks?: BlockNode[];
18}
19
20function attr( node: BlockNode, key: string ): string {
21 const value = node.attributes?.[ key ];
22 return value === undefined || value === null ? '' : String( value );
23}
24
25function renderBlock( node: BlockNode ): string {
26 const inner = () => renderBlocks( node.innerBlocks ?? [] );
27
28 switch ( node.name ) {
29 case 'core/paragraph':
30 return `<p>${ attr( node, 'content' ) }</p>`;
31
32 case 'core/heading': {
33 const level = Number( node.attributes?.level ?? 2 );
34 return `<h${ level } class="wp-block-heading">${ attr( node, 'content' ) }</h${ level }>`;
35 }
36
37 case 'core/list': {
38 const tag = node.attributes?.ordered ? 'ol' : 'ul';
39 return `<${ tag } class="wp-block-list">${ inner() }</${ tag }>`;
40 }
41
42 case 'core/list-item':
43 return `<li>${ attr( node, 'content' ) }</li>`;
44
45 case 'core/quote': {
46 const citation = attr( node, 'citation' );
47 const cite = citation ? `<cite>${ citation }</cite>` : '';
48 return `<blockquote class="wp-block-quote">${ inner() }${ cite }</blockquote>`;
49 }
50
51 case 'core/pullquote': {
52 const citation = attr( node, 'citation' );
53 const cite = citation ? `<cite>${ citation }</cite>` : '';
54 return `<figure class="wp-block-pullquote"><blockquote><p>${ attr( node, 'value' ) }</p>${ cite }</blockquote></figure>`;
55 }
56
57 case 'core/code':
58 return `<pre class="wp-block-code"><code>${ attr( node, 'content' ) }</code></pre>`;
59
60 case 'core/separator':
61 return '<hr class="wp-block-separator has-alpha-channel-opacity"/>';
62
63 case 'core/image': {
64 const url = attr( node, 'url' );
65 const alt = attr( node, 'alt' );
66 if ( ! url ) {
67 return '';
68 }
69 const caption = attr( node, 'caption' );
70 const fig = caption ? `<figcaption>${ caption }</figcaption>` : '';
71 return `<figure class="wp-block-image"><img src="${ url }" alt="${ alt }"/>${ fig }</figure>`;
72 }
73
74 default:
75 // Unknown / not-yet-supported block: degrade gracefully to its text.
76 return attr( node, 'content' ) ? `<p>${ attr( node, 'content' ) }</p>` : '';
77 }
78}
79
80/** Render a block tree to frontend HTML (no block-delimiter comments). */
81export function renderBlocks( blocks: BlockNode[] ): string {
82 return blocks.map( renderBlock ).join( '\n' );
83}
84
85const TEXT_ATTRIBUTES = [ 'content', 'value', 'citation', 'caption' ];
86
87export function decodeEntities( input: string ): string {
88 return input
89 .replace( /</g, '<' )
90 .replace( />/g, '>' )
91 .replace( /"/g, '"' )
92 .replace( /�?39;/g, "'" )
93 .replace( /'/g, "'" )
94 .replace( / /g, ' ' )
95 .replace( /&/g, '&' );
96}
97
98function stripMarkup( value: string ): string {
99 return decodeEntities(
100 value.replace( /<!--[\s\S]*?-->/g, '' ).replace( /<[^>]*>/g, '' )
101 )
102 .replace( /\s+/g, ' ' )
103 .trim();
104}
105
106function collectText( blocks: BlockNode[], out: string[] ): void {
107 for ( const block of blocks ) {
108 for ( const key of TEXT_ATTRIBUTES ) {
109 const raw = block.attributes?.[ key ];
110 if ( raw === undefined || raw === null || raw === '' ) {
111 continue;
112 }
113 const text = stripMarkup( typeof raw === 'string' ? raw : String( raw ) );
114 if ( text ) {
115 out.push( text );
116 }
117 }
118 if ( block.innerBlocks?.length ) {
119 collectText( block.innerBlocks, out );
120 }
121 }
122}
123
124/**
125 * Render a block tree to clean plain text in document order — the source for the
126 * lexicon's `textContent` (brief §3: Bluesky computes reading-time + search from
127 * it, ignoring the structured `content`).
128 */
129export function blocksToText( blocks: BlockNode[] ): string {
130 const out: string[] = [];
131 collectText( blocks, out );
132 return out.join( '\n\n' );
133}