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). `core/embed` renders a resolved
11 * card (atproto post / video facade) when `resolveEmbeds` has attached a payload,
12 * else a plain link. Card markup intentionally diverges from `serialize()` — a
13 * read-time enhancement, like blob-image URL resolution (rule 4).
14 */
15
16import { renderEmbedCard, type EmbedData } from '../embeds/card';
17
18export interface BlockNode {
19 name: string;
20 attributes?: Record< string, unknown >;
21 innerBlocks?: BlockNode[];
22}
23
24function attr( node: BlockNode, key: string ): string {
25 const value = node.attributes?.[ key ];
26 return value === undefined || value === null ? '' : String( value );
27}
28
29function renderBlock( node: BlockNode ): string {
30 const inner = () => renderBlocks( node.innerBlocks ?? [] );
31
32 switch ( node.name ) {
33 case 'core/paragraph':
34 return `<p>${ attr( node, 'content' ) }</p>`;
35
36 case 'core/heading': {
37 const level = Number( node.attributes?.level ?? 2 );
38 return `<h${ level } class="wp-block-heading">${ attr( node, 'content' ) }</h${ level }>`;
39 }
40
41 case 'core/list': {
42 const tag = node.attributes?.ordered ? 'ol' : 'ul';
43 return `<${ tag } class="wp-block-list">${ inner() }</${ tag }>`;
44 }
45
46 case 'core/list-item':
47 return `<li>${ attr( node, 'content' ) }</li>`;
48
49 case 'core/quote': {
50 const citation = attr( node, 'citation' );
51 const cite = citation ? `<cite>${ citation }</cite>` : '';
52 return `<blockquote class="wp-block-quote">${ inner() }${ cite }</blockquote>`;
53 }
54
55 case 'core/pullquote': {
56 const citation = attr( node, 'citation' );
57 const cite = citation ? `<cite>${ citation }</cite>` : '';
58 return `<figure class="wp-block-pullquote"><blockquote><p>${ attr( node, 'value' ) }</p>${ cite }</blockquote></figure>`;
59 }
60
61 case 'core/code':
62 return `<pre class="wp-block-code"><code>${ attr( node, 'content' ) }</code></pre>`;
63
64 case 'core/separator':
65 return '<hr class="wp-block-separator has-alpha-channel-opacity"/>';
66
67 case 'core/image': {
68 const url = attr( node, 'url' );
69 const alt = attr( node, 'alt' );
70 if ( ! url ) {
71 return '';
72 }
73 const caption = attr( node, 'caption' );
74 const fig = caption ? `<figcaption>${ caption }</figcaption>` : '';
75 return `<figure class="wp-block-image"><img src="${ url }" alt="${ alt }"/>${ fig }</figure>`;
76 }
77
78 case 'core/embed': {
79 // Safe even if a hostile PDS pre-set `_skypressEmbed`: card values are escaped + scheme-guarded in card.ts, video cards carry no iframe, and output is sanitised last.
80 const data = node.attributes?._skypressEmbed as EmbedData | undefined;
81 if ( data ) {
82 return renderEmbedCard( data );
83 }
84 const url = attr( node, 'url' );
85 return url
86 ? `<figure class="wp-block-embed"><a href="${ url }">${ url }</a></figure>`
87 : '';
88 }
89
90 default:
91 // Unknown / not-yet-supported block: degrade gracefully to its text.
92 return attr( node, 'content' ) ? `<p>${ attr( node, 'content' ) }</p>` : '';
93 }
94}
95
96/** Render a block tree to frontend HTML (no block-delimiter comments). */
97export function renderBlocks( blocks: BlockNode[] ): string {
98 return blocks.map( renderBlock ).join( '\n' );
99}
100
101const TEXT_ATTRIBUTES = [ 'content', 'value', 'citation', 'caption' ];
102
103export function decodeEntities( input: string ): string {
104 return input
105 .replace( /</g, '<' )
106 .replace( />/g, '>' )
107 .replace( /"/g, '"' )
108 .replace( /�?39;/g, "'" )
109 .replace( /'/g, "'" )
110 .replace( / /g, ' ' )
111 .replace( /&/g, '&' );
112}
113
114function stripMarkup( value: string ): string {
115 return decodeEntities(
116 value.replace( /<!--[\s\S]*?-->/g, '' ).replace( /<[^>]*>/g, '' )
117 )
118 .replace( /\s+/g, ' ' )
119 .trim();
120}
121
122function collectText( blocks: BlockNode[], out: string[] ): void {
123 for ( const block of blocks ) {
124 for ( const key of TEXT_ATTRIBUTES ) {
125 const raw = block.attributes?.[ key ];
126 if ( raw === undefined || raw === null || raw === '' ) {
127 continue;
128 }
129 const text = stripMarkup( typeof raw === 'string' ? raw : String( raw ) );
130 if ( text ) {
131 out.push( text );
132 }
133 }
134 if ( block.innerBlocks?.length ) {
135 collectText( block.innerBlocks, out );
136 }
137 }
138}
139
140/**
141 * Render a block tree to clean plain text in document order — the source for the
142 * lexicon's `textContent` (brief §3: Bluesky computes reading-time + search from
143 * it, ignoring the structured `content`).
144 */
145export function blocksToText( blocks: BlockNode[] ): string {
146 const out: string[] = [];
147 collectText( blocks, out );
148 return out.join( '\n\n' );
149}