' prefixed lines
* - core/code → fenced ``` block
* - core/image → ''
* - core/separator → '---'
* - core/group / columns / column /
* core/buttons → render children only
* - everything else → stripped plaintext fallback
*
* Inline mapping (applied to each block's innerHTML before stripping):
* - / → **bold**
* - / → _italic_
* - → [text](href)
* - inline → `code`
*
* Blocks are separated by a blank line.
*
* @since 1.2.0
*/
class BlockMarkdown {
/**
* Render raw block markup to Markdown.
*
* @param string $post_content Raw block markup.
* @return string Markdown rendering (no trailing newline).
*
* @since 1.2.0
*/
public function render( string $post_content ): string {
if ( trim( $post_content ) === '' ) {
return '';
}
$blocks = parse_blocks( $post_content );
return $this->render_blocks( $blocks );
}
/**
* Render a list of parsed blocks, joining each rendered block with a blank line.
*
* @param array> $blocks Parsed blocks.
* @return string Markdown.
*
* @since 1.2.0
*/
private function render_blocks( array $blocks ): string {
$parts = [];
foreach ( $blocks as $block ) {
$name = $block['blockName'] ?? null;
// Skip the empty whitespace "freeform" separators parse_blocks emits.
if ( $name === null && trim( (string) ( $block['innerHTML'] ?? '' ) ) === '' ) {
continue;
}
$rendered = $this->render_block( $block );
if ( trim( $rendered ) !== '' ) {
$parts[] = $rendered;
}
}
return implode( "\n\n", $parts );
}
/**
* Render a single parsed block to Markdown.
*
* @param array $block Parsed block.
* @return string Markdown.
*
* @since 1.2.0
*/
private function render_block( array $block ): string {
$name = $block['blockName'] ?? null;
$attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
$inner_html = (string) ( $block['innerHTML'] ?? '' );
$inner_blocks = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
switch ( $name ) {
case 'core/heading':
$level = isset( $attrs['level'] ) ? (int) $attrs['level'] : 2;
if ( $level < 1 || $level > 6 ) {
$level = 2;
}
return str_repeat( '#', $level ) . ' ' . $this->inline( $inner_html );
case 'core/list':
return $this->render_list( $block );
case 'core/list-item':
// A list-item rendered on its own (defensive) — emit one bullet.
return '- ' . $this->inline( $inner_html );
case 'core/quote':
case 'core/pullquote':
return $this->render_quote( $block, $attrs, $inner_html, $inner_blocks );
case 'core/code':
case 'core/preformatted':
return $this->render_code( $inner_html );
case 'core/image':
return $this->render_image( $attrs, $inner_html );
case 'core/separator':
return '---';
case 'core/group':
case 'core/columns':
case 'core/column':
case 'core/buttons':
// Structural wrappers — render their children only.
return $this->render_blocks( $inner_blocks );
case 'core/paragraph':
return $this->inline( $inner_html );
default:
// Unknown / unmapped block: render children if any, else fall
// back to the block's own stripped plaintext.
if ( $inner_blocks !== [] ) {
$children = $this->render_blocks( $inner_blocks );
if ( trim( $children ) !== '' ) {
return $children;
}
}
return $this->plaintext( $inner_html );
}
}
/**
* Render a core/list block (and its list-item children) to Markdown.
*
* Ordered lists use '1.' markers; unordered lists use '-'. Nested lists are
* indented by two spaces per level.
*
* @param array $block The list block.
* @param int $depth Current nesting depth (0 = top level).
* @return string Markdown list.
*
* @since 1.2.0
*/
private function render_list( array $block, int $depth = 0 ): string {
$attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
$ordered = ! empty( $attrs['ordered'] );
$items = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
$lines = [];
$number = 1;
$indent = str_repeat( ' ', $depth );
foreach ( $items as $item ) {
if ( ( $item['blockName'] ?? null ) !== 'core/list-item' ) {
continue;
}
$marker = $ordered ? ( $number . '. ' ) : '- ';
$text = $this->inline( (string) ( $item['innerHTML'] ?? '' ) );
$lines[] = $indent . $marker . $text;
++$number;
// A list-item may contain a nested core/list.
$nested = isset( $item['innerBlocks'] ) && is_array( $item['innerBlocks'] ) ? $item['innerBlocks'] : [];
foreach ( $nested as $child ) {
if ( ( $child['blockName'] ?? null ) === 'core/list' ) {
$lines[] = $this->render_list( $child, $depth + 1 );
}
}
}
return implode( "\n", $lines );
}
/**
* Render a quote/pullquote block to a Markdown blockquote.
*
* @param array $block The quote block.
* @param array $attrs Block attributes.
* @param string $inner_html The block's inner HTML.
* @param array> $inner_blocks Inner blocks (paragraphs).
* @return string Markdown blockquote.
*
* @since 1.2.0
*/
private function render_quote( array $block, array $attrs, string $inner_html, array $inner_blocks ): string {
// Quotes wrap inner paragraph blocks; pullquotes store text inline.
if ( $inner_blocks !== [] ) {
$body = $this->render_blocks( $inner_blocks );
} else {
$body = $this->inline( $inner_html );
}
$lines = [];
foreach ( explode( "\n", $body ) as $line ) {
$lines[] = trim( $line ) === '' ? '>' : '> ' . $line;
}
$citation = isset( $attrs['citation'] ) ? trim( (string) $attrs['citation'] ) : '';
if ( $citation === '' ) {
// Pullquote/quote can also carry the citation in a tag.
$citation = $this->cite_from_html( $inner_html );
}
if ( $citation !== '' ) {
$lines[] = '>';
$lines[] = '> — ' . $citation;
}
return implode( "\n", $lines );
}
/**
* Render a code/preformatted block as a fenced Markdown code block.
*
* @param string $inner_html The block's inner HTML.
* @return string Fenced code block.
*
* @since 1.2.0
*/
private function render_code( string $inner_html ): string {
$code = $this->plaintext( $inner_html );
return "```\n" . $code . "\n```";
}
/**
* Render an image block as Markdown image syntax.
*
* The url/alt come from attributes when present, otherwise they are read
* from the inner
tag.
*
* @param array $attrs Block attributes.
* @param string $inner_html The block's inner HTML.
* @return string Markdown image (with optional caption line).
*
* @since 1.2.0
*/
private function render_image( array $attrs, string $inner_html ): string {
$url = isset( $attrs['url'] ) ? (string) $attrs['url'] : '';
$alt = isset( $attrs['alt'] ) ? (string) $attrs['alt'] : '';
if ( $url === '' && preg_match( '/
]*\bsrc="([^"]*)"/i', $inner_html, $m ) ) {
$url = $m[1];
}
if ( $alt === '' && preg_match( '/
]*\balt="([^"]*)"/i', $inner_html, $m ) ) {
$alt = $m[1];
}
$markdown = '';
$caption = isset( $attrs['caption'] ) ? trim( (string) $attrs['caption'] ) : '';
if ( $caption === '' && preg_match( '/]*>(.*?)<\/figcaption>/is', $inner_html, $m ) ) {
$caption = $this->plaintext( $m[1] );
}
if ( $caption !== '' ) {
$markdown .= "\n\n" . $this->inline( $caption );
}
return $markdown;
}
/**
* Extract a citation from a trailing tag in the HTML.
*
* @param string $html Inner HTML.
* @return string Citation text, or an empty string.
*
* @since 1.2.0
*/
private function cite_from_html( string $html ): string {
if ( preg_match( '/]*>(.*?)<\/cite>/is', $html, $m ) ) {
return $this->plaintext( $m[1] );
}
return '';
}
/**
* Convert a small set of inline HTML tags in a fragment to Markdown, then
* strip any remaining tags and decode entities.
*
* Block-level boundaries (
,
, , ) collapse to a single
* space so multi-paragraph innerHTML does not run words together.
*
* @param string $html Block inner HTML.
* @return string Inline Markdown text.
*
* @since 1.2.0
*/
private function inline( string $html ): string {
$text = $html;
// Links: capture href and inner text. Run before generic tag stripping.
$text = preg_replace_callback(
'/]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/is',
static function ( array $m ): string {
$label = trim( wp_strip_all_tags( $m[2] ) );
$href = $m[1];
if ( $label === '' ) {
return $href;
}
return '[' . $label . '](' . $href . ')';
},
$text
) ?? $text;
// Inline code.
$text = preg_replace( '/]*>(.*?)<\/code>/is', '`$1`', $text ) ?? $text;
// Bold.
$text = preg_replace( '/<(strong|b)\b[^>]*>(.*?)<\/\1>/is', '**$2**', $text ) ?? $text;
// Italic.
$text = preg_replace( '/<(em|i)\b[^>]*>(.*?)<\/\1>/is', '_$2_', $text ) ?? $text;
// Collapse block boundaries to spaces so words don't fuse together.
$text = preg_replace( '/<\/(p|li|h[1-6]|blockquote|figcaption)>/i', ' ', $text ) ?? $text;
$text = preg_replace( '/
/i', ' ', $text ) ?? $text;
// Strip any remaining tags.
$text = wp_strip_all_tags( $text );
// Decode entities the inner HTML carried (e.g. &, <).
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
// Collapse runs of whitespace introduced by boundary replacement.
$text = preg_replace( '/[ \t]+/', ' ', $text ) ?? $text;
return trim( $text );
}
/**
* Strip all tags and decode entities to bare text (the unknown-block fallback).
*
* @param string $html Inner HTML.
* @return string Plain text.
*
* @since 1.2.0
*/
private function plaintext( string $html ): string {
$text = wp_strip_all_tags( $html );
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
return trim( $text );
}
}