…) so that content * can be stored as proper block markup in the WordPress post content field. * Pure PHP with no WordPress dependencies; safe to use in CLI or test contexts. * * @since 1.9.0 */ class BlockSerializer { /** * Serialises an HTML fragment into Gutenberg block markup. * * Returns an empty string for blank or whitespace-only input. Each * top-level element in the fragment becomes exactly one block. Unknown * elements fall back to the wp:html freeform block. Inline markup within * elements is preserved verbatim. * * @since 1.9.0 * @param string $html Raw HTML fragment (e.g. output of league/commonmark). * @return string Gutenberg block markup, blocks separated by double newlines. */ public function serialise( string $html ): string { if ( '' === trim( $html ) ) { return ''; } $dom = $this->parse_html( $html ); $blocks = []; // With LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, no
wrapper // is injected — top-level nodes are direct children of the document. foreach ( $dom->childNodes as $node ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase // Skip the injected and XML processing instructions. if ( XML_PI_NODE === $node->nodeType ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase continue; } if ( $node instanceof DOMElement && 'meta' === $node->nodeName ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase continue; } // Skip whitespace-only text nodes at the top level. if ( $node instanceof DOMText && '' === trim( $node->nodeValue ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase continue; } $block = $this->node_to_block( $node, $dom ); if ( '' !== $block ) { $blocks[] = $block; } } return implode( "\n\n", $blocks ); } /** * Parses an HTML fragment into a DOMDocument using UTF-8 encoding. * * A charset meta and XML encoding declaration are prepended so that * DOMDocument treats the input as UTF-8 rather than Latin-1. libxml * warnings for valid HTML5 input (e.g. void elements, unknown attributes) * are suppressed because they are false positives. * * @since 1.9.0 * @param string $html Raw HTML fragment. * @return DOMDocument Parsed document. */ private function parse_html( string $html ): DOMDocument { $dom = new DOMDocument( '1.0', 'UTF-8' ); $dom->substituteEntities = false; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase // Force UTF-8 interpretation; LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD // keeps DOMDocument in fragment mode without injecting html/head/body wrappers // when the fragment already has a body context via loadHTML. $prefix = ''; libxml_use_internal_errors( true ); $dom->loadHTML( $prefix . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); return $dom; } /** * Converts a single DOM node into a Gutenberg block string. * * Text nodes at the top level are wrapped in wp:paragraph. Element nodes * are dispatched via their tag name. Unknown elements use the wp:html * fallback block. * * @since 1.9.0 * @param DOMNode $node The top-level node to convert. * @param DOMDocument $dom The owning document (needed for saveHTML). * @return string Gutenberg block markup, or empty string if nothing to emit. */ private function node_to_block( DOMNode $node, DOMDocument $dom ): string { // Bare text at top level becomes a paragraph. if ( $node instanceof DOMText ) { $text = trim( $node->nodeValue ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase if ( '' === $text ) { return ''; } return "\n{$text}
\n"; } if ( ! ( $node instanceof DOMElement ) ) { return ''; } $tag = strtolower( $node->nodeName ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $inner = $this->inner_html( $node, $dom ); switch ( $tag ) { case 'p': return "\n{$inner}
\n"; case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return $this->heading_block( $tag, $inner ); case 'ul': return "\n{$inner}\n"; case 'pre': return $this->code_block( $inner ); case 'hr': return "\n
…` to satisfy Gutenberg's block structure.
*
* @since 1.9.0
* @param string $inner Inner HTML of the pre element.
* @return string Gutenberg code block markup.
*/
private function code_block( string $inner ): string {
$content = str_starts_with( ltrim( $inner ), '{$inner}";
return "\n{$content}\n";
}
/**
* Returns the serialised inner HTML of a DOM element.
*
* Concatenates the saveHTML output of every child node and decodes the
* numeric entities DOMDocument uses for multibyte characters back to raw
* UTF-8. Named entities (<, &, …) are deliberately left intact:
* commonmark escapes code-block content with them, and decoding would
* re-introduce live tags that wp_kses_post() then strips — silently
* losing any code snippet that documents HTML or PHP.
*
* @since 1.9.0
* @param DOMElement $element The element whose children to serialise.
* @param DOMDocument $dom The owning document.
* @return string Inner HTML with multibyte numeric entities decoded to UTF-8.
*/
private function inner_html( DOMElement $element, DOMDocument $dom ): string {
$html = '';
foreach ( $element->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$html .= $dom->saveHTML( $child );
}
// Decode only numeric entities for code points >= U+0080 (the range
// DOMDocument entity-encodes); ASCII named entities must stay escaped.
return mb_decode_numericentity( $html, [ 0x80, 0x10FFFF, 0, 0x1FFFFF ], 'UTF-8' );
}
}