$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 ); } if ( false === stripos( $html, 'loadHTML( '' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous_errors ); $code = array(); $dependencies = array(); $scripts = iterator_to_array( $script_dom->getElementsByTagName( 'script' ) ); foreach ( $scripts as $script ) { $type = strtolower( trim( $script->getAttribute( 'type' ) ) ); $supported = in_array( $type, array( '', 'text/javascript', 'application/javascript', 'text/ecmascript', 'application/ecmascript', 'application/x-javascript' ), true ); if ( $supported && $script->hasAttribute( 'src' ) ) { $url = $script->getAttribute( 'src' ); // Reject this CDN script: Tailwind is already bundled locally. $host_labels = explode( '.', strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) ); if ( array( 'cdn', 'tailwindcss', 'com' ) === $host_labels ) { self::report( 'skipped_script', $script, array( 'tag' => 'script', 'action' => 'removed', 'reason' => 'tailwind_already_included' ) ); $script->parentNode->removeChild( $script ); continue; } if ( preg_match( '~^https?://~i', $url ) && false !== filter_var( $url, FILTER_VALIDATE_URL ) ) { $dependencies[] = $url; self::report( 'extracted_external_script', $script, array( 'tag' => 'script', 'action' => 'extracted' ) ); } else { self::report( 'skipped_script', $script, array( 'tag' => 'script', 'action' => 'removed', 'reason' => 'unsupported_url' ) ); } } elseif ( $supported ) { if ( '' !== trim( $script->textContent ) ) { $code[] = $script->textContent; } self::report( 'extracted_inline_script', $script, array( 'tag' => 'script', 'action' => 'extracted' ) ); } else { self::report( 'skipped_script', $script, array( 'tag' => 'script', 'action' => 'removed', 'reason' => $script->hasAttribute( 'src' ) ? 'external_source' : 'unsupported_type' ) ); } $script->parentNode->removeChild( $script ); } if ( $scripts ) { $html = $script_dom->saveHTML(); } $blocks = self::convert_html( $html ); $blocks = array_values( $blocks ); if ( $code || $dependencies ) { $blocks[] = array( 'name' => 'advanced-addons/custom-javascript', 'attributes' => array( 'code' => implode( "\n;\n", $code ), 'scripts' => array_values( array_unique( $dependencies ) ) ), 'innerBlocks' => array(), ); } return $blocks; } /** Convert script-free HTML using the existing Tailwind conversion rules. */ private static function convert_html( $html ) { // 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 ); // Whitespace is content in these elements. Keep their complete inner HTML // instead of applying the normal editable-text whitespace normalization. if ( in_array( $tag_name, array( 'pre', 'textarea' ), true ) ) { return self::create_block( $tag_name, self::normalize_text_whitespace( $attributes['class'] ?? '' ), self::maybe_base64_encode_content( self::get_whitespace_sensitive_inner_html( $node ) ), 'html', self::extract_extra_attrs( $attributes ) ); } 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 ( null === $value || ( '' === $value && 0 !== strpos( strtolower( $key ), 'data-' ) ) ) { 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 ); } /** Serialize inner HTML without collapsing or trimming text nodes. */ private static function get_whitespace_sensitive_inner_html( $element ) { $html = ''; foreach ( $element->childNodes as $child ) { $html .= $element->ownerDocument->saveHTML( $child ); } return $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 . ''; } 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 ); } }