A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 5.0 kB View raw
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( /&lt;/g, '<' ) 106 .replace( /&gt;/g, '>' ) 107 .replace( /&quot;/g, '"' ) 108 .replace( /&#0?39;/g, "'" ) 109 .replace( /&apos;/g, "'" ) 110 .replace( /&nbsp;/g, ' ' ) 111 .replace( /&amp;/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}