* @return array Accordion item. */ private function create_accordion_item_block( $title, $content ) { $template = '

%2$s
'; return parse_blocks( sprintf( $template, $title, $content ) )[0]; } /** * Inject parsed accordion blocks. * * @param array $parsed_block Parsed block. * @param array $accordion_blocks Accordion blocks. * * @return array Parsed block. */ private function inject_parsed_accordion_blocks( $parsed_block, $accordion_blocks ) { if ( 'woocommerce/accordion-group' === $parsed_block['blockName'] ) { $parsed_block['innerBlocks'] = array_merge( $parsed_block['innerBlocks'], $accordion_blocks ); $parsed_block['innerBlocks'] = array_values( array_filter( $parsed_block['innerBlocks'] ) ); $opening_tag = reset( $parsed_block['innerContent'] ); $closing_tag = end( $parsed_block['innerContent'] ); $parsed_block['innerContent'] = array_merge( array( $opening_tag ), array_fill( 0, count( $parsed_block['innerBlocks'] ), null ), array( $closing_tag ) ); return $parsed_block; } foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) { $parsed_block['innerBlocks'][ $key ] = $this->inject_parsed_accordion_blocks( $inner_block, $accordion_blocks ); } return $parsed_block; } /** * Hide empty accordion items. * * @param array $parsed_block Parsed block. * @param array $context Context. * * @return array Parsed block. */ private function hide_empty_accordion_items( $parsed_block, $context ) { if ( ! $this->has_accordion( $parsed_block ) ) { return $parsed_block; } if ( 'woocommerce/accordion-group' === $parsed_block['blockName'] ) { foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) { $parsed_block['innerBlocks'][ $key ] = $this->mark_accordion_item_hidden( $inner_block, $context ); } $parsed_block['innerBlocks'] = array_values( array_filter( $parsed_block['innerBlocks'] ) ); $opening_tag = reset( $parsed_block['innerContent'] ); $closing_tag = end( $parsed_block['innerContent'] ); $parsed_block['innerContent'] = array_merge( array( $opening_tag ), array_fill( 0, count( $parsed_block['innerBlocks'] ), null ), array( $closing_tag ) ); return $parsed_block; } foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) { $parsed_block['innerBlocks'][ $key ] = $this->hide_empty_accordion_items( $inner_block, $context ); } return $parsed_block; } /** * Mark an accordion item as hidden if it has no content. * * @param array $item Item to mark. * @param array $context Context. * * @return array Item. */ private function mark_accordion_item_hidden( $item, $context ) { $content_block = end( $item['innerBlocks'] ); $rendered_content_block = ( new WP_Block( $content_block, $context ) )->render(); $p = new WP_HTML_Tag_Processor( $rendered_content_block ); $has_content = $p->next_tag( 'img' ) || $p->next_tag( 'iframe' ) || $p->next_tag( 'video' ) || $p->next_tag( 'meter' ) || ! empty( wp_strip_all_tags( $rendered_content_block, true ) ); if ( ! $has_content ) { return array(); } return $item; } /** * Check if a parsed block has an accordion. * * @param array $parsed_block Parsed block. * * @return bool True if the block has an accordion, false otherwise. */ private function has_accordion( $parsed_block ) { if ( 'woocommerce/accordion-group' === $parsed_block['blockName'] ) { return true; } foreach ( $parsed_block['innerBlocks'] as $inner_block ) { if ( $this->has_accordion( $inner_block ) ) { return true; } } return false; } /** * Validate hooked blocks data. Remove duplicated entries with the same title * and invalid entries with invalid content. Log errors to the WC logger. * * @param array $hooked_blocks { Hooked blocks data. * @type string $title Title of the hooked block. * @type string $content Content of the hooked block, as block markup. * } * * @return array Validated hooked blocks. */ private function validate_hooked_blocks( $hooked_blocks ) { $logger = wc_get_logger(); $validated_hooked_blocks = []; foreach ( $hooked_blocks as $block ) { $invalid = ! is_array( $block ) || ! isset( $block['title'] ) || ! isset( $block['content'] ) || ! is_string( $block['title'] ) || ! is_string( $block['content'] ); if ( ! $invalid ) { $parsed_content = parse_blocks( $block['content'] ); foreach ( $parsed_content as $content_block ) { if ( ! isset( $content_block['blockName'] ) ) { $invalid = true; break; } } } if ( $invalid ) { $logger->error( 'Invalid hooked block data. Expected array with `title` and `content` keys with string values. Content must be valid block markup.', $block ); continue; } $slug = sanitize_title( $block['title'] ); /** * If the block is already registered, replace the block. We use the * last registered block for the same slug. This makes overriding * hooked block easier. */ if ( isset( $validated_hooked_blocks[ $slug ] ) ) { $validated_hooked_blocks[ $slug ] = $block; continue; } $validated_hooked_blocks[ $slug ] = $block; } return $validated_hooked_blocks; } /** * Register a product details item using Block Hooks API. * * @param string $slug The slug of the item. * @param array $block The block data. * @return void */ private function register_hooked_block( $slug, $block ) { add_filter( 'hooked_block_types', function ( $hooked_block_types, $relative_position, $anchor_block_type ) use ( $slug ) { if ( 'woocommerce/accordion-group' === $anchor_block_type && 'last_child' === $relative_position && ! in_array( $slug, $hooked_block_types, true ) ) { $hooked_block_types[] = $slug; } return $hooked_block_types; }, 10, 3 ); add_filter( "hooked_block_{$slug}", function ( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block ) use ( $block ) { if ( is_null( $parsed_hooked_block ) || 'woocommerce/accordion-group' !== $parsed_anchor_block['blockName'] || 'last_child' !== $relative_position || empty( $parsed_anchor_block['attrs']['metadata']['isDescendantOfProductDetails'] ) ) { return null; } return $this->create_accordion_item_block( $block['title'], $block['content'] ); }, 10, 4 ); } /** * Enqueue legacy assets when this block is used as we don't enqueue them for block themes anymore. * * @see https://github.com/woocommerce/woocommerce/pull/60223 */ public function enqueue_legacy_assets() { wp_enqueue_script( 'wc-single-product' ); } /** * Previously, the Product Details block was a standalone block. It doesn't * have any inner blocks and it rendered the tabs directly like the classic * template. When upgrading, we want the existing stores using the block to * continue working as before, so we moved the logic the legacy render * method here. * * @see https://github.com/woocommerce/woocommerce/pull/59005 * * @param array $attributes Block attributes. * @param string $content Block content. * @param WP_Block $block Block instance. * * @return string Rendered block output. */ protected function render_legacy_block( $attributes, $content, $block ) { if ( ! is_singular( 'product' ) ) { return $content; } add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_legacy_assets' ], 20 ); $hide_tab_title = isset( $attributes['hideTabTitle'] ) ? $attributes['hideTabTitle'] : false; if ( $hide_tab_title ) { add_filter( 'woocommerce_product_description_heading', '__return_empty_string' ); add_filter( 'woocommerce_product_additional_information_heading', '__return_empty_string' ); add_filter( 'woocommerce_reviews_title', '__return_empty_string' ); } $tabs = $this->render_tabs(); if ( $hide_tab_title ) { remove_filter( 'woocommerce_product_description_heading', '__return_empty_string' ); remove_filter( 'woocommerce_product_additional_information_heading', '__return_empty_string' ); remove_filter( 'woocommerce_reviews_title', '__return_empty_string' ); // Remove the first `h2` of every `.wc-tab`. This is required for the Reviews tabs when there are no reviews and for plugin tabs. $tabs_html = new WP_HTML_Tag_Processor( $tabs ); while ( $tabs_html->next_tag( array( 'class_name' => 'wc-tab' ) ) ) { if ( $tabs_html->next_tag( 'h2' ) ) { $tabs_html->set_attribute( 'hidden', 'true' ); } } $tabs = $tabs_html->get_updated_html(); } $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); return sprintf( '
%3$s
', esc_attr( $classes_and_styles['classes'] ), esc_attr( $classes_and_styles['styles'] ), $tabs ); } /** * Gets the tabs with their content to be rendered by the block. * * @return string The tabs html to be rendered by the block */ protected function render_tabs() { ob_start(); rewind_posts(); while ( have_posts() ) { the_post(); woocommerce_output_product_data_tabs(); } $tabs = ob_get_clean(); return $tabs; } }