$blocks,
'report' => array(
'changed' => ! empty( self::$conversion_report ),
'events' => self::$conversion_report,
'event_count' => count( self::$conversion_report ),
),
);
}
/**
* Convert HTML string or serialized WordPress block markup to block data arrays.
*
* @param string $html Raw HTML input or WordPress block markup.
* @return array Array of block data arrays.
*/
private static function convert_internal( $html ) {
if ( empty( trim( $html ) ) ) {
return array();
}
// Detect native WordPress block markup and parse it directly.
$native_markup = self::extract_native_block_markup( $html );
if ( null !== $native_markup ) {
return self::convert_native_blocks( $native_markup );
}
// DOMDocument/libxml treats bare ampersands (e.g. & followed by ") as
// malformed entity references and may warn or alter the content. Escape
// any ampersand that is not already a valid entity reference so the input
// is well-formed HTML and text like &" is preserved reliably.
$html = self::escape_bare_ampersands( $html );
$dom = new \DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML( '' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$body = $dom->getElementsByTagName( 'body' )->item( 0 );
if ( ! $body ) {
// With LIBXML_HTML_NOIMPLIED, fragments without /
// wrappers may not get a body element. If documentElement is a
// single non-body root, it could be either a lone fragment (e.g.
// a single ) or the first of many siblings that libxml
// placed at the document level. Iterate the document's child
// nodes to catch all root elements.
$root = $dom->documentElement;
if ( $root ) {
$root_tag = strtolower( $root->tagName );
if ( 'body' === $root_tag ) {
$body = $root;
} elseif ( 'html' === $root_tag ) {
// wrapper without an explicit — libxml
// sometimes creates this. Fall through to the child scan.
$body = $dom->getElementsByTagName( 'body' )->item( 0 ) ?: $root;
}
}
}
if ( ! $body ) {
// No body element found — process all element children of the
// document directly (handles multi-root fragments).
$blocks = array();
if ( $dom->documentElement ) {
$blocks[] = self::process_node( $dom->documentElement );
// Check for siblings of documentElement (additional roots).
$sibling = $dom->documentElement->nextSibling;
while ( $sibling ) {
$block = self::process_node( $sibling );
if ( $block ) {
$blocks[] = $block;
}
$sibling = $sibling->nextSibling;
}
}
return array_filter( $blocks );
}
// If the root element is not a body tag, process it directly
// (e.g., the HTML was a single
...
without /).
$root_tag = $body->tagName ? strtolower( $body->tagName ) : '';
if ( $root_tag && 'body' !== $root_tag && 'html' !== $root_tag ) {
$block = self::process_node( $body );
return $block ? array( $block ) : array();
}
$blocks = array();
foreach ( $body->childNodes as $child ) {
$block = self::process_node( $child );
if ( $block ) {
$blocks[] = $block;
}
}
return $blocks;
}
/**
* Process a DOM node and convert it to block data.
*
* @param \DOMNode $node The DOM node.
* @return array|null Block data or null if unsupported.
*/
private static function process_node( $node ) {
if ( $node->nodeType === XML_TEXT_NODE ) {
$text = self::normalize_text_whitespace( $node->textContent );
if ( '' === $text ) {
return null;
}
return self::create_block( 'span', '', self::maybe_base64_encode_content( $text ) );
}
if ( $node->nodeType !== XML_ELEMENT_NODE ) {
return null;
}
$tag_name = strtolower( $node->tagName );
if ( 'style' === $tag_name ) {
$css = trim( $node->textContent );
if ( '' === $css ) {
return null;
}
return array(
'name' => 'core/html',
'attributes' => array(
'content' => '',
'mode' => 'preview',
),
'innerBlocks' => array(),
);
}
if ( ! in_array( $tag_name, self::$supported_tags, true ) ) {
if ( in_array( $tag_name, array( 'script', 'iframe', 'object', 'embed', 'link', 'meta' ), true ) ) {
self::report( 'removed_unsafe_element', $node, array( 'tag' => $tag_name, 'action' => 'removed' ) );
return null;
}
self::report( 'unwrapped_unsupported_element', $node, array( 'tag' => $tag_name, 'action' => 'unwrapped' ) );
return self::create_block( 'span', '', self::maybe_base64_encode_content( self::get_inner_html( $node ) ), 'html' );
}
$attributes = self::get_attributes( $node );
if ( 'svg' === $tag_name ) {
foreach ( $node->getElementsByTagName( '*' ) as $svg_child ) {
$svg_tag = strtolower( $svg_child->tagName );
if ( ! in_array( $svg_tag, self::$supported_svg_tags, true ) ) {
self::report( 'unwrapped_unsupported_element', $svg_child, array( 'tag' => $svg_tag, 'action' => 'rejected' ) );
}
}
$svg_attrs = self::normalize_svg_attributes( $attributes );
unset( $svg_attrs['class'] );
// Remove whitespace between SVG tags so the serialized content
// matches the browser's normalized innerHTML (no extra newlines/spaces).
$svg_content = preg_replace( '/>\s+', '><', self::get_inner_html( $node ) );
return array(
'name' => 'tailwind/html',
'attributes' => array(
'className' => $attributes['class'] ?? '',
'tagName' => 'svg',
'content' => 'base64:' . base64_encode( $svg_content ),
'children' => 'html',
'attrs' => $svg_attrs,
),
'innerBlocks' => array(),
);
}
if ( 'img' === $tag_name ) {
$img_attrs = array(
'className' => $attributes['class'] ?? '',
'tagName' => 'img',
'src' => self::maybe_base64_encode_content( $attributes['src'] ?? '' ),
'alt' => self::maybe_base64_encode_content( $attributes['alt'] ?? '' ),
);
$extra = self::extract_extra_attrs( $attributes );
if ( ! empty( $extra ) ) {
$img_attrs['attrs'] = $extra;
}
return array(
'name' => 'tailwind/html',
'attributes' => $img_attrs,
'innerBlocks' => array(),
);
}
$class_name = self::normalize_text_whitespace( $attributes['class'] ?? '' );
$element_count = 0;
$has_text_nodes = false;
foreach ( $node->childNodes as $child ) {
if ( $child->nodeType === XML_ELEMENT_NODE ) {
$element_count++;
} elseif ( $child->nodeType === XML_TEXT_NODE && '' !== trim( $child->textContent ) ) {
$has_text_nodes = true;
}
}
$self_closing = array( 'img', 'input', 'br' );
if ( 0 === $element_count && ! $has_text_nodes && ! in_array( $tag_name, $self_closing, true ) ) {
return self::create_block( $tag_name, $class_name, '', 'html', self::extract_extra_attrs( $attributes ) );
}
// Elements that contain an SVG should always be decomposed into blocks.
// Keeping raw SVG inside a single `content` attribute can trigger block
// validation mismatches because the editor may escape the markup when it
// is re-rendered by the block's save() function.
$has_svg = false;
foreach ( $node->childNodes as $child ) {
if ( $child->nodeType === XML_ELEMENT_NODE && 'svg' === strtolower( $child->tagName ) ) {
$has_svg = true;
break;
}
}
if ( $has_svg ) {
$inner_blocks = array();
foreach ( $node->childNodes as $child ) {
$child_block = self::process_node( $child );
if ( $child_block ) {
$inner_blocks[] = $child_block;
}
}
if ( empty( $inner_blocks ) ) {
return null;
}
$block_attrs = array(
'className' => $class_name,
'tagName' => $tag_name,
'children' => 'blocks',
);
$extra = self::extract_extra_attrs( $attributes );
if ( ! empty( $extra ) ) {
$block_attrs['attrs'] = $extra;
}
return array(
'name' => 'tailwind/html',
'attributes' => $block_attrs,
'innerBlocks' => $inner_blocks,
);
}
if ( $element_count > 0 && ! $has_text_nodes ) {
// Only element children → blocks.
$inner_blocks = array();
foreach ( $node->childNodes as $child ) {
$child_block = self::process_node( $child );
if ( $child_block ) {
$inner_blocks[] = $child_block;
}
}
$block_attrs = array(
'className' => $class_name,
'tagName' => $tag_name,
'children' => 'blocks',
);
$extra = self::extract_extra_attrs( $attributes );
if ( ! empty( $extra ) ) {
$block_attrs['attrs'] = $extra;
}
return array(
'name' => 'tailwind/html',
'attributes' => $block_attrs,
'innerBlocks' => $inner_blocks,
);
}
if ( $element_count > 0 && $has_text_nodes ) {
// If the only element children are
tags, keep the whole node as
// raw html so the line breaks are preserved instead of dropped.
$only_br_elements = true;
foreach ( $node->childNodes as $child ) {
if ( $child->nodeType === XML_ELEMENT_NODE && 'br' !== strtolower( $child->tagName ) ) {
$only_br_elements = false;
break;
}
}
if ( $only_br_elements ) {
return self::create_block( $tag_name, $class_name, self::maybe_base64_encode_content( self::get_inner_html( $node ) ), 'html', self::extract_extra_attrs( $attributes ) );
}
// Mixed inline/element/text children must remain one raw-HTML leaf so
// sibling order and semantic tags are not lost during decomposition.
return self::create_block( $tag_name, $class_name, self::maybe_base64_encode_content( self::get_inner_html( $node ) ), 'html', self::extract_extra_attrs( $attributes ) );
}
// Text only → text.
$text = self::normalize_text_whitespace( $node->textContent );
return self::create_block( $tag_name, $class_name, self::maybe_base64_encode_content( $text ), 'text', self::extract_extra_attrs( $attributes ) );
}
/**
* Normalize whitespace in text content.
*
* DOMDocument preserves literal newlines and runs of whitespace from the
* source HTML. The block editor's save() function escapes those newlines as
* \n characters, causing a validation mismatch with the post body. Collapse
* whitespace to a single space so the stored content matches the rendered
* output.
*
* @param string $text Raw text content.
* @return string Normalized text.
*/
private static function normalize_text_whitespace( $text ) {
return trim( preg_replace( '/\s+/', ' ', $text ) );
}
/**
* Create a block data array for a text element.
*
* @param string $tag_name HTML tag name.
* @param string $class_name Tailwind class string.
* @param string $content Text/inner HTML content.
* @param string $children_type Content type: 'text' or 'html'.
* @param array $extra_attrs Extra HTML attributes (id, style, data-* etc).
* @return array Block data.
*/
private static function create_block( $tag_name, $class_name, $content, $children_type = 'text', $extra_attrs = array() ) {
$attrs = array(
'className' => $class_name,
'tagName' => $tag_name,
'content' => $content,
'children' => $children_type,
);
if ( ! empty( $extra_attrs ) ) {
$attrs['attrs'] = $extra_attrs;
}
return array(
'name' => 'tailwind/html',
'attributes' => $attrs,
'innerBlocks' => array(),
);
}
/**
* Extract extra HTML attributes excluding those stored as top-level block attributes.
*
* @param array $attributes All attributes from the DOM element.
* @return array Extra attributes.
*/
private static function extract_extra_attrs( $attributes ) {
$extra = array();
foreach ( $attributes as $key => $value ) {
if ( in_array( $key, array( 'class', 'src', 'alt' ), true ) ) {
continue;
}
if ( '' === $value || null === $value ) {
continue;
}
$extra[ $key ] = $value;
}
return $extra;
}
/**
* Extract attributes from a DOM element.
*
* @param \DOMElement $element The DOM element.
* @return array Key-value attribute pairs.
*/
private static function get_attributes( $element ) {
$attrs = array();
if ( $element->hasAttributes() ) {
foreach ( $element->attributes as $attr ) {
$name = strtolower( $attr->nodeName );
if ( 0 === strpos( $name, 'on' ) || ( in_array( $name, array( 'href', 'src', 'action', 'formaction' ), true ) && 0 === stripos( trim( $attr->nodeValue ), 'javascript:' ) ) ) {
self::report( 'removed_unsafe_attribute', $element, array( 'attribute' => $attr->nodeName, 'action' => 'removed' ) );
continue;
}
$attrs[ $attr->nodeName ] = $attr->nodeValue;
}
}
return $attrs;
}
/**
* Get inner HTML of a DOM element (child nodes serialized).
*
* Uses DOM traversal instead of saveHTML to avoid entity-encoding
* of attribute values that would cause block validation mismatches.
*
* @param \DOMElement $element The DOM element.
* @return string Inner HTML.
*/
private static function get_inner_html( $element ) {
$html = '';
foreach ( $element->childNodes as $child ) {
if ( $child->nodeType === XML_TEXT_NODE ) {
// Collapse runs but retain one boundary space around inline siblings;
// trimming each text node concatenates "Before
" and loses
// authored separation.
$html .= htmlspecialchars( preg_replace( '/\s+/', ' ', $child->textContent ), ENT_NOQUOTES, 'UTF-8' );
} elseif ( $child->nodeType === XML_ELEMENT_NODE ) {
$html .= self::render_node_html( $child );
}
}
return trim( $html );
}
/**
* Render a DOM element as an HTML string without entity-encoding
* attribute values.
*
* @param \DOMElement $element The DOM element.
* @return string HTML.
*/
private static function render_node_html( $element ) {
$tag = $element->tagName;
$lower_tag = strtolower( $tag );
if ( ! in_array( $lower_tag, self::$supported_tags, true ) && ! self::is_svg_descendant( $element, $lower_tag ) ) {
if ( in_array( $lower_tag, array( 'script', 'iframe', 'object', 'embed', 'link', 'meta' ), true ) ) {
self::report( 'removed_unsafe_element', $element, array( 'tag' => $lower_tag, 'action' => 'removed' ) );
return '';
}
self::report( 'unwrapped_unsupported_element', $element, array( 'tag' => $lower_tag, 'action' => 'unwrapped' ) );
return self::get_inner_html( $element );
}
$attrs = '';
foreach ( self::get_attributes( $element ) as $name => $value ) {
$attrs .= ' ' . $name . '="' . htmlspecialchars( $value, ENT_COMPAT, 'UTF-8' ) . '"';
}
$inner = self::get_inner_html( $element );
if ( '' === $inner ) {
$void_elements = array(
'br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr',
'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'use', 'stop',
);
if ( in_array( strtolower( $tag ), $void_elements, true ) ) {
return '<' . $tag . $attrs . ' />';
}
}
return '<' . $tag . $attrs . '>' . $inner . '' . $tag . '>';
}
private static function is_svg_descendant( $element, string $tag ): bool {
if ( ! in_array( $tag, self::$supported_svg_tags, true ) ) {
return false;
}
$current = $element->parentNode;
while ( $current instanceof \DOMElement ) {
if ( 'svg' === strtolower( $current->tagName ) ) {
return true;
}
$current = $current->parentNode;
}
return false;
}
/**
* Get outer HTML of a DOM element.
*
* @param \DOMElement $element The DOM element.
* @return string Outer HTML.
*/
private static function get_outer_html( $element ) {
return self::render_node_html( $element );
}
/**
* Detect whether the input is serialized native WordPress block markup.
*
* @param string $html Raw input.
* @return bool True when the trimmed input starts with a block comment.
*/
private static function is_native_block_markup( $html ) {
return null !== self::extract_native_block_markup( $html );
}
/**
* Extract serialized native block markup from model output.
*
* Models often wrap valid block comments in markdown fences or explanatory
* prose. For native block generation, trim everything before the first
* block delimiter and after the last block comment so parse_blocks() does
* not see that prose as freeform HTML.
*
* @param string $html Raw input.
* @return string|null Native block markup, or null when none is present.
*/
private static function extract_native_block_markup( $html ) {
$trimmed = trim( $html );
if ( preg_match( '/^```[^\r\n]*\R([\s\S]*?)```$/', $trimmed, $matches ) ) {
$trimmed = trim( $matches[1] );
}
$start = stripos( $trimmed, '' );
if ( false === $end ) {
return null;
}
return trim( substr( $native, 0, $end + 3 ) );
}
/**
* Convert serialized WordPress block markup to plugin block data arrays.
*
* @param string $html Serialized block markup.
* @return array Array of block data arrays.
*/
private static function convert_native_blocks( $html ) {
$parsed = parse_blocks( $html );
return self::normalize_parsed_blocks( $parsed );
}
/**
* Recursively convert WordPress parsed blocks to the plugin block format.
*
* @param array $parsed_blocks Blocks in WP parser format.
* @return array Blocks in plugin format.
*/
private static function normalize_parsed_blocks( $parsed_blocks ) {
$blocks = array();
foreach ( $parsed_blocks as $parsed ) {
if ( empty( $parsed['blockName'] ) ) {
// Whitespace-only or empty blocks are skipped.
if ( ! empty( trim( $parsed['innerHTML'] ?? '' ) ) ) {
// Freeform HTML can be wrapped in a core/html block.
$blocks[] = array(
'name' => 'core/html',
'attributes' => array( 'content' => trim( $parsed['innerHTML'] ) ),
'innerBlocks' => array(),
);
}
continue;
}
$blocks[] = array(
'name' => $parsed['blockName'],
'attributes' => $parsed['attrs'] ?? array(),
'innerBlocks' => self::normalize_parsed_blocks( $parsed['innerBlocks'] ?? array() ),
'innerHTML' => $parsed['innerHTML'] ?? '',
'innerContent' => $parsed['innerContent'] ?? array( '' ),
);
}
return $blocks;
}
/**
* Escape bare ampersands that are not valid HTML entity references.
*
* Valid entity references (e.g. & " ' ' ©) are left
* untouched. Bare ampersands are escaped to & so DOMDocument/libxml does
* not misinterpret the following characters.
*
* @param string $html Raw HTML input.
* @return string HTML with bare ampersands escaped.
*/
private static function escape_bare_ampersands( $html ) {
return preg_replace( '/&(?![a-zA-Z0-9#]+;)/', '&', $html );
}
/**
* Base64-encode content that contains characters WordPress escapes when
* serializing block attributes.
*
* `serialize_block_attributes()` escapes &, ", <, >, \, and -- to their
* \uXXXX equivalents. In some editor environments the JS block parser does
* not decode those escapes back, leaving literal "u0026" etc. in the
* attribute value and causing block validation failures. Encoding the value
* as base64 avoids those characters entirely.
*
* @param string $content Text or inner HTML content.
* @return string Original content, or base64:... prefixed encoded content.
*/
private static function maybe_base64_encode_content( $content ) {
if ( ! is_string( $content ) || '' === $content ) {
return $content;
}
if ( preg_match( '/[&"<>]|--|\\\\/', $content ) ) {
return 'base64:' . base64_encode( $content );
}
return $content;
}
/**
* Normalize SVG attribute names to their canonical camelCase forms.
*
* Browsers normalize certain lowercase SVG attributes (e.g. viewbox)
* to camelCase (viewBox) when rendering. Storing them in the correct
* case avoids block validation mismatches between the save() output
* and the post body.
*
* @param array $attrs Attribute name-value pairs.
* @return array Normalized attributes.
*/
private static function normalize_svg_attributes( $attrs ) {
$svg_camelcase = array(
'viewbox' => 'viewBox',
'preserveaspectratio' => 'preserveAspectRatio',
'gradienttransform' => 'gradientTransform',
'gradientunits' => 'gradientUnits',
'spreadmethod' => 'spreadMethod',
'clippathunits' => 'clipPathUnits',
'patternunits' => 'patternUnits',
'patterntransform' => 'patternTransform',
'maskunits' => 'maskUnits',
'maskcontentunits' => 'maskContentUnits',
'filterunits' => 'filterUnits',
'primitiveunits' => 'primitiveUnits',
'lengthadjust' => 'lengthAdjust',
'startoffset' => 'startOffset',
'textlength' => 'textLength',
);
$normalized = array();
foreach ( $attrs as $key => $value ) {
$lower = strtolower( $key );
$normalized[ $svg_camelcase[ $lower ] ?? $key ] = $value;
}
return $normalized;
}
private static function report( string $code, $node, array $details = array() ): void {
$path = '';
$current = $node;
while ( $current && $current instanceof \DOMElement ) {
$position = 1;
$sibling = $current->previousSibling;
while ( $sibling ) {
if ( $sibling instanceof \DOMElement && strtolower( $sibling->tagName ) === strtolower( $current->tagName ) ) {
$position++;
}
$sibling = $sibling->previousSibling;
}
$path = '/' . strtolower( $current->tagName ) . '[' . $position . ']' . $path;
$current = $current->parentNode;
}
self::$conversion_report[] = array_merge( array( 'code' => $code, 'path' => $path ?: '/' ), $details );
}
}