` — markdown's own rule (a blank line starts a * paragraph) is what an agent writing prose expects. * * @since 4.9.0 * * @param string $md Markdown. * @return string HTML. */ public static function markdown_to_html( $md ) { $md = (string) $md; if ( '' === trim( $md ) ) { return ''; } if ( ! class_exists( 'Parsedown' ) ) { // The bundled runtime is missing; do not silently drop the content. return function_exists( 'wp_kses_post' ) ? wp_kses_post( $md ) : $md; } $parsedown = new \Parsedown(); $parsedown->setSafeMode( true ); $parsedown->setBreaksEnabled( false ); $html = $parsedown->text( $md ); return function_exists( 'wp_kses_post' ) ? wp_kses_post( $html ) : $html; } /** * HTML → serialised core blocks. * * Walks the top-level elements only. Anything without a mapping — a `
' . esc_html( $text ) . '
' ); } if ( XML_COMMENT_NODE === $node->nodeType || XML_PI_NODE === $node->nodeType ) { return ''; } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); switch ( $tag ) { case 'p': return self::paragraph_block( $node ); case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': $level = (int) substr( $tag, 1 ); return self::block( 'core/heading', [ 'level' => $level ], sprintf( '' . self::code_text( $node ) . ''
);
case 'blockquote':
return self::block(
'core/quote',
[],
'' . self::inner_blocks_of( $node ) . '' ); case 'img': return self::image_block( $node ); case 'figure': return self::figure_block( $node ); case 'table': return self::block( 'core/table', [], '
` — or, when it holds nothing but an image, an image block. * * Markdown's `` on its own line produces `
`. * @return string */ private static function paragraph_block( \DOMNode $node ) { $image = self::only_child_image( $node ); if ( null !== $image ) { return self::image_block( $image ); } $inner = self::inner_html( $node ); if ( '' === trim( $inner ) ) { return ''; } return self::block( 'core/paragraph', [], '
' . $inner . '
' ); } /** * `ul`/`ol` → `core/list` with one `core/list-item` per `li`. * * A nested list becomes a `core/list` **inside** its parent list item, which * is the WP ≥ 6.2 shape. * * @since 4.9.0 * * @param \DOMNode $node The list element. * @return string */ private static function list_block( \DOMNode $node ) { $ordered = 'ol' === strtolower( $node->nodeName ); $items = []; foreach ( $node->childNodes as $child ) { if ( XML_ELEMENT_NODE !== $child->nodeType || 'li' !== strtolower( $child->nodeName ) ) { continue; } $text = ''; $nested = ''; foreach ( $child->childNodes as $part ) { if ( XML_ELEMENT_NODE === $part->nodeType && in_array( strtolower( $part->nodeName ), [ 'ul', 'ol' ], true ) ) { $nested .= self::list_block( $part ); continue; } $text .= self::outer_html( $part ); } $items[] = self::block( 'core/list-item', [], '`. * @return string */ private static function inner_blocks_of( \DOMNode $node ) { $blocks = []; foreach ( $node->childNodes as $child ) { $block = self::node_to_block( $child ); if ( '' !== $block ) { $blocks[] = $block; } } if ( empty( $blocks ) ) { $text = trim( self::inner_html( $node ) ); if ( '' === $text ) { return ''; } $blocks[] = self::block( 'core/paragraph', [], '' . $text . '
' ); } return implode( "\n\n", $blocks ); } /** * The single `` a paragraph wraps, if that is all it holds. * * @since 4.9.0 * * @param \DOMNode $node The `
`. * @return \DOMNode|null */ private static function only_child_image( \DOMNode $node ) { $image = null; foreach ( $node->childNodes as $child ) { if ( XML_TEXT_NODE === $child->nodeType ) { if ( '' !== trim( $child->textContent ) ) { return null; } continue; } if ( XML_ELEMENT_NODE === $child->nodeType && 'img' === strtolower( $child->nodeName ) && null === $image ) { $image = $child; continue; } return null; } return $image; } /** * The text of a `
`, entity-escaped for a `` element. * * @since 4.9.0 * * @param \DOMNode $node The ``. * @return string */ private static function code_text( \DOMNode $node ) { return esc_html( $node->textContent ); } /** * A node's children, serialised. * * @since 4.9.0 * * @param \DOMNode $node Node. * @return string */ private static function inner_html( \DOMNode $node ) { $html = ''; foreach ( $node->childNodes as $child ) { $html .= self::outer_html( $child ); } return $html; } /** * A node, serialised. * * @since 4.9.0 * * @param \DOMNode $node Node. * @return string */ private static function outer_html( \DOMNode $node ) { $html = $node->ownerDocument->saveHTML( $node ); return false === $html ? '' : $html; } // ------------------------------------------------------------------------- // Block-tree helpers // ------------------------------------------------------------------------- /** * Whether any group in the list is missing its label. * * @since 4.9.0 * * @param array $groups Decoded groups. * @return bool */ private static function needs_repair( array $groups ) { foreach ( $groups as $group ) { if ( null === $group['label'] || '' === $group['label'] ) { return true; } } return false; } /** * Call `$visitor` on every block, innermost included. * * @since 4.9.0 * * @param array $blocks Parsed blocks. * @param callable $visitor `fn( array $block ): void`. * @return void */ private static function walk_blocks( array $blocks, callable $visitor ) { foreach ( $blocks as $block ) { call_user_func( $visitor, $block ); if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { self::walk_blocks( $block['innerBlocks'], $visitor ); } } } /** * Rewrite every block through `$mapper`, innermost first. * * @since 4.9.0 * * @param array $blocks Parsed blocks. * @param callable $mapper `fn( array $block ): array`. * @return array */ private static function map_blocks( array $blocks, callable $mapper ) { $mapped = []; foreach ( $blocks as $block ) { if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $block['innerBlocks'] = self::map_blocks( $block['innerBlocks'], $mapper ); } $mapped[] = call_user_func( $mapper, $block ); } return $mapped; } /** * Drop every FAQ block from a parsed tree, at any depth. * * A removed inner block also has to lose its `null` placeholder in the * parent's `innerContent`, or `serialize_block()` walks off the end of * `innerBlocks`. * * @since 4.9.0 * * @param array $blocks Parsed blocks. * @return array */ private static function reject_faq_blocks( array $blocks ) { $kept = []; foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? $block['blockName'] : null; if ( self::FAQ_BLOCK === $name ) { continue; } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $before = count( $block['innerBlocks'] ); $block['innerBlocks'] = self::reject_faq_blocks( $block['innerBlocks'] ); if ( count( $block['innerBlocks'] ) !== $before ) { $block['innerContent'] = self::rebuild_inner_content( $block, $before - count( $block['innerBlocks'] ) ); } } $kept[] = $block; } return $kept; } /** * Drop `$removed` of the `null` placeholders from a block's `innerContent`. * * @since 4.9.0 * * @param array $block The parent block. * @param int $removed How many inner blocks went away. * @return array */ private static function rebuild_inner_content( array $block, $removed ) { $content = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : []; $rebuilt = []; foreach ( $content as $chunk ) { if ( null === $chunk && $removed > 0 ) { --$removed; continue; } $rebuilt[] = $chunk; } return $rebuilt; } /** * `wp_json_encode()` with the two flags block attributes are written with: * unescaped slashes (a URL in an attribute stays readable) and unescaped * unicode (so `\u65e5` does not appear where `日` belongs). Both match * core's `serialize_block_attributes()`. * * @since 4.9.0 * * @param mixed $value Value. * @return string */ private static function json( $value ) { $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return false === $json ? '[]' : $json; } }