'; } $tag = $attrs['tagName'] ?? 'div'; $class = $attrs['className'] ?? ''; $class_attr = $class ? ' class="' . htmlspecialchars( $class, ENT_QUOTES, 'UTF-8' ) . '"' : ''; $extra_attrs = self::render_attrs( $attrs['attrs'] ?? array() ); if ( in_array( $tag, self::$self_closing, true ) ) { $src = self::decode_content( $attrs['src'] ?? '' ); $alt = self::decode_content( $attrs['alt'] ?? '' ); $parts = array( '<' . $tag . $class_attr . $extra_attrs ); if ( $src ) { $parts[] = ' src="' . htmlspecialchars( $src, ENT_QUOTES, 'UTF-8' ) . '"'; } if ( $alt ) { $parts[] = ' alt="' . htmlspecialchars( $alt, ENT_QUOTES, 'UTF-8' ) . '"'; } $parts[] = ' />'; return implode( '', $parts ); } $children_type = $attrs['children'] ?? ''; $content = self::decode_content( $attrs['content'] ?? '' ); $inner = isset( $block['innerBlocks'] ) ? self::convert_blocks( $block['innerBlocks'] ) : ''; if ( 'blocks' === $children_type ) { $inner_html = $inner; } elseif ( 'text' === $children_type ) { // Match the editor's RichText.Content serialization: escape &, <, > // but leave quotes untouched in text content. $inner_html = htmlspecialchars( $content, ENT_NOQUOTES, 'UTF-8' ); } else { // 'html' or not set (backwards compat) — raw HTML content, fallback to innerBlocks. $inner_html = $content ?: $inner; } return '<' . $tag . $class_attr . $extra_attrs . '>' . $inner_html . ''; } /** * Normalize block data from our format (name/attributes) to the * WordPress parser format (blockName/attrs/innerHTML/innerContent) * required by serialize_blocks(). * * @param array $blocks Array of blocks in our format. * @return array Array of blocks in WP parser format. */ public static function normalize_blocks( $blocks ) { $result = array(); foreach ( $blocks as $block ) { $result[] = self::normalize_block( $block ); } return $result; } /** * Normalize a single block. * * @param array $block Block data in our format. * @return array Block in WP parser format. */ private static function normalize_block( $block ) { $name = $block['name'] ?? ''; $attrs = $block['attributes'] ?? array(); $inner = isset( $block['innerBlocks'] ) ? self::normalize_blocks( $block['innerBlocks'] ) : array(); $tag = $attrs['tagName'] ?? ''; if ( 'core/html' === $name ) { $html = $attrs['content'] ?? ''; return array( 'blockName' => $name, 'attrs' => array( 'content' => $html ), 'innerBlocks' => array(), 'innerHTML' => $html, 'innerContent' => array( $html ), ); } if ( 'tailwind/html' !== $name ) { $inner_html = $block['innerHTML'] ?? ''; $inner_content = $block['innerContent'] ?? array( $inner_html ); return array( 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => $inner, 'innerHTML' => $inner_html, 'innerContent' => $inner_content, ); } $save_class = self::save_class( $attrs['className'] ?? '' ); if ( in_array( $tag, self::$self_closing, true ) ) { $inner_html = self::render_block_for_save( $tag, $save_class, $attrs ); return array( 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => array(), 'innerHTML' => $inner_html, 'innerContent' => array( $inner_html ), ); } // Determine if this is a leaf block (text/html children) or container (blocks children). $children_type = $attrs['children'] ?? ''; $is_leaf = ( 'text' === $children_type || 'html' === $children_type ) || ( '' === $children_type && in_array( $tag, self::$text_tags, true ) && empty( $inner ) ); if ( $is_leaf ) { $inner_html = self::render_block_for_save( $tag, $save_class, $attrs ); return array( 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => array(), 'innerHTML' => $inner_html, 'innerContent' => array( $inner_html ), ); } // Container: build innerContent with null placeholders for inner blocks. $class_attr = $save_class ? ' class="' . htmlspecialchars( $save_class, ENT_QUOTES, 'UTF-8' ) . '"' : ''; $extra_attrs = self::render_attrs( $attrs['attrs'] ?? array() ); $inner_content = array( '<' . $tag . $class_attr . $extra_attrs . '>' ); foreach ( $inner as $child ) { $inner_content[] = null; } $inner_content[] = ''; $inner_html = self::render_block_for_save( $tag, $save_class, $attrs ); return array( 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => $inner, 'innerHTML' => $inner_html, 'innerContent' => $inner_content, ); } /** * Prepend wp-block-tailwind-html class for save() validation. */ private static function save_class( $class ) { $classes = array_filter( explode( ' ', $class ) ); array_unshift( $classes, 'wp-block-tailwind-html' ); return implode( ' ', $classes ); } /** * Render a single tailwind/html block as HTML with save() classes. */ private static function render_block_for_save( $tag, $save_class, $attrs ) { $class_attr = $save_class ? ' class="' . htmlspecialchars( $save_class, ENT_QUOTES, 'UTF-8' ) . '"' : ''; $extra_attrs = self::render_attrs( $attrs['attrs'] ?? array() ); if ( in_array( $tag, self::$self_closing, true ) ) { $src = self::decode_content( $attrs['src'] ?? '' ); $alt = self::decode_content( $attrs['alt'] ?? '' ); $parts = array( '<' . $tag . $class_attr . $extra_attrs ); if ( $src ) { $parts[] = ' src="' . htmlspecialchars( $src, ENT_QUOTES, 'UTF-8' ) . '"'; } if ( $alt ) { $parts[] = ' alt="' . htmlspecialchars( $alt, ENT_QUOTES, 'UTF-8' ) . '"'; } $parts[] = ' />'; return implode( '', $parts ); } $children_type = $attrs['children'] ?? ''; $content = self::decode_content( $attrs['content'] ?? '' ); // RichText.Content escapes HTML; dangerouslySetInnerHTML keeps it raw. if ( 'text' === $children_type ) { $content = htmlspecialchars( $content, ENT_NOQUOTES, 'UTF-8' ); } return '<' . $tag . $class_attr . $extra_attrs . '>' . $content . ''; } /** * Render extra HTML attributes stored in the `attrs` block attribute. * * @param array $attrs Key-value attribute pairs. * @return string HTML attribute string. */ private static function render_attrs( $attrs ) { if ( empty( $attrs ) || ! is_array( $attrs ) ) { return ''; } $html = ''; foreach ( $attrs as $key => $value ) { if ( null === $value || ( '' === $value && 0 !== strpos( strtolower( $key ), 'data-' ) ) ) { continue; } $html .= ' ' . $key . '="' . htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' ) . '"'; } return $html; } /** * Decode a block content value if it is prefixed with "base64:". * * SVG content is base64-encoded before being stored as a block attribute * to avoid JSON/HTML serialization issues with the "<" character. * * @param string $content Block content value. * @return string Decoded content. */ private static function decode_content( $content ) { if ( is_string( $content ) && 0 === strpos( $content, 'base64:' ) ) { $decoded = base64_decode( substr( $content, 7 ), true ); if ( false !== $decoded ) { return $decoded; } } return $content; } }