true, 'strategy' => 'defer', ) ); if ( ! Settings::get( 'accordion' ) ) { return; } wp_add_inline_script( 'prodtabs-tabs', 'window.prodtabsTabs = ' . wp_json_encode( array( 'accordion' => (int) Settings::get( 'accordion_width' ) ) ) . ';', 'before' ); $css = (string) file_get_contents( PRODTABS_DIR . 'assets/css/tabs.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file of the plugin. wp_register_style( 'prodtabs-tabs', false, array(), PRODTABS_VERSION ); wp_enqueue_style( 'prodtabs-tabs' ); wp_add_inline_style( 'prodtabs-tabs', $css ); } /** * Tabs of the current product. * * @param mixed $tabs Tabs from WooCommerce and other plugins, by key. * @return mixed */ public static function filter_tabs( $tabs ) { if ( ! is_array( $tabs ) ) { return $tabs; } $product = self::current_product(); if ( ! $product ) { return $tabs; } $settings = Settings::all(); $tabs = self::native_titles( $tabs, $product ); /** * Entries of a product before they become tabs: lets another plugin add or remove entries. * * @param array[] $entries Entries in order (empty when the product has no list of its own and no shared tab is added to it on its own). * @param \WC_Product $product Product. */ $entries = (array) apply_filters( 'prodtabs_product_entries', Product_Tabs::with_auto( Product_Tabs::for_display( $product->get_id() ), $product ), $product ); $entries = $entries ? Tabs::with_natives( Tabs::normalize( $entries ), $settings['native_order'] ) : array(); $tabs = $entries ? self::product_layout( $tabs, $entries, $product, $settings ) : self::default_layout( $tabs, $settings ); /** * Tabs of a product once the plugin has placed its own. * * @param array $tabs Tabs by key (the key is the anchor: #tab-). * @param \WC_Product $product Product. */ return apply_filters( 'prodtabs_product_tabs', $tabs, $product ); } /** * Products without a list of their own: settings of the WooCommerce tabs only. * * @param array $tabs Tabs by key. * @param array $settings Settings. */ private static function default_layout( array $tabs, array $settings ): array { foreach ( $settings['native_order'] as $i => $key ) { if ( ! isset( $tabs[ $key ] ) ) { continue; } if ( ! $settings['natives'][ $key ]['visible'] ) { unset( $tabs[ $key ] ); continue; } $tabs[ $key ]['priority'] = 10 * ( $i + 1 ); } return $tabs; } /** * Products with a list: every entry becomes a tab, in the order of the list. * * @param array $tabs Tabs by key. * @param array[] $entries Entries in order. * @param \WC_Product $product Product. * @param array $settings Settings. */ private static function product_layout( array $tabs, array $entries, \WC_Product $product, array $settings ): array { $ordered = array(); $taken = array_fill_keys( array_keys( $tabs ), true ); foreach ( $entries as $entry ) { if ( 'native' === $entry['type'] ) { if ( ! isset( $tabs[ $entry['key'] ] ) ) { continue; // WooCommerce does not show it on this product (no description, no attributes, reviews off). } $visible = null === $entry['visible'] ? $settings['natives'][ $entry['key'] ]['visible'] : $entry['visible']; if ( $visible ) { $ordered[] = $entry['key']; } else { unset( $tabs[ $entry['key'] ] ); } continue; } if ( ! $entry['visible'] ) { continue; } if ( 'local' === $entry['type'] ) { if ( '' === trim( $entry['title'] ) || Tabs::is_blank( $entry['content'] ) ) { continue; // No text in any language, no title to click on, or nothing to show once opened. } if ( isset( $entry['translated'] ) && ! $entry['translated'] && Settings::get( 'hide_untranslated' ) ) { continue; // Original text of another language, hidden by the setting. } $key = Tabs::dedupe( $entry['anchor'], $taken ); $tabs[ $key ] = self::tab( $entry['title'], $entry['content'], array( 'type' => 'local', 'key' => $entry['key'], 'origin' => $entry['origin'], 'product_id' => $product->get_id(), // False when the language of the page has no text yet and the text of the original language shows. 'translated' => ! isset( $entry['translated'] ) || (bool) $entry['translated'], ) ); $taken[ $key ] = true; $ordered[] = $key; continue; } $library = Library::resolve( (int) $entry['library_id'] ); if ( ! $library || Tabs::is_blank( $library['content'] ) ) { continue; // Deleted, in draft, empty, or not translated and hidden by the prodtabs_untranslated_library_tab filter. } // The anchor stays the one fixed when the tab was added, whatever the language of the page. $key = Tabs::dedupe( '' !== $entry['anchor'] ? $entry['anchor'] : $library['anchor'], $taken ); $tabs[ $key ] = self::tab( $library['title'], $library['content'], array( 'type' => 'library', 'key' => $entry['key'], 'library_id' => $library['id'], 'product_id' => $product->get_id(), // False when the original shared tab shows because its translation is missing or not published. 'translated' => '' === Multilingual::post_language( $product->get_id() ) || Multilingual::post_language( (int) $library['id'] ) === Multilingual::post_language( $product->get_id() ), ) ); $taken[ $key ] = true; $ordered[] = $key; } foreach ( self::priorities( $ordered, $settings['native_order'] ) as $key => $priority ) { $tabs[ $key ]['priority'] = $priority; } return $tabs; } /** * Whole-number priorities that follow the order of the list. The WooCommerce tabs keep 10, 20 and 30 (in the order * of the settings) whenever the list keeps them in that order, so that tabs added by other plugins keep their usual * place; the plugin's tabs take the values in between. When the list reorders the WooCommerce tabs, or when more * than nine tabs sit between two of them, every tab gets 10, 20, 30… in the order of the list. * * @param string[] $ordered Keys of the visible tabs, in order. * @param string[] $native_order WooCommerce keys in the order of the settings. * @return int[] Priority by key. */ public static function priorities( array $ordered, array $native_order ): array { $ordered = array_values( $ordered ); $base = array(); foreach ( array_values( $native_order ) as $i => $key ) { $base[ $key ] = 10 * ( $i + 1 ); } $anchors = array(); // Position in the list => priority. foreach ( $ordered as $pos => $key ) { if ( isset( $base[ $key ] ) ) { $anchors[ $pos ] = $base[ $key ]; } } $out = array(); if ( $anchors ) { $positions = array_keys( $anchors ); foreach ( $ordered as $pos => $key ) { if ( isset( $anchors[ $pos ] ) ) { $out[ $key ] = $anchors[ $pos ]; continue; } $before = null; $after = null; foreach ( $positions as $anchor_pos ) { if ( $anchor_pos < $pos ) { $before = $anchor_pos; } elseif ( null === $after ) { $after = $anchor_pos; } } if ( null === $before ) { $out[ $key ] = $anchors[ $after ] - ( $after - $pos ); } elseif ( null === $after ) { $out[ $key ] = $anchors[ $before ] + ( $pos - $before ); } else { $step = ( $anchors[ $after ] - $anchors[ $before ] ) / ( $after - $before ); $out[ $key ] = (int) floor( $anchors[ $before ] + $step * ( $pos - $before ) ); } } } // Strictly increasing, or the order of the list would be lost: plain sequence instead. $previous = null; foreach ( $out as $priority ) { if ( null !== $previous && $priority <= $previous ) { $out = array(); break; } $previous = $priority; } if ( ! $out ) { foreach ( $ordered as $pos => $key ) { $out[ $key ] = 10 * ( $pos + 1 ); } } return array_map( 'intval', $out ); } /** * A tab of the plugin, in the format of woocommerce_product_tabs. * * @param string $title Title (plain text). * @param string $content Raw content. * @param array $context Origin of the tab, passed to the content filters. */ private static function tab( string $title, string $content, array $context ): array { return array( // Escaped here: the accordion of the Product Details block prints the title as is. 'title' => esc_html( $title ), 'priority' => 50, 'callback' => array( __CLASS__, 'render_panel' ), 'prodtabs' => array_merge( $context, array( 'title' => $title, 'content' => $content, ) ), ); } /** * Panel of a tab of the plugin. WooCommerce calls it inside the panel element of the theme. * * @param string $key Tab key. * @param array $tab Tab. */ public static function render_panel( $key, $tab ): void { if ( empty( $tab['prodtabs'] ) || ! is_array( $tab['prodtabs'] ) ) { return; } $data = $tab['prodtabs']; // The accordion of the Product Details block passes a number as key and prints the title itself (anchors of the // plugin are never numbers, see Tabs::anchor()). $in_accordion = is_int( $key ); /** * Heading printed at the top of the panel. Empty: no heading. * * @param string $heading Tab title, or empty when headings are off or the block already shows the title. * @param array $data Tab data (type, title, content, product_id, library_id…). * @param string|int $key Tab key. */ $heading = (string) apply_filters( 'prodtabs_tab_heading', Settings::get( 'heading' ) && ! $in_accordion ? $data['title'] : '', $data, $key ); $html = '' !== $heading ? '

' . esc_html( $heading ) . '

' : ''; /** * Heading markup of the panel. * * @param string $html Heading markup, empty when there is none. * @param array $data Tab data. * @param string|int $key Tab key. */ echo wp_kses_post( (string) apply_filters( 'prodtabs_tab_heading_html', $html, $data, $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- formatted like post content, filtered on input. echo self::content( (string) $data['content'], $data ); } /** * Formats the content of a tab: the core formatting of post content (blocks, embeds, paragraphs, shortcodes, * responsive images), or the full the_content filter when the setting asks for it. * * @param string $content Raw content. * @param array $context Tab data. */ public static function content( string $content, array $context = array() ): string { /** * Raw content of a tab, before formatting. * * @param string $content Raw content. * @param array $context Tab data. */ $content = (string) apply_filters( 'prodtabs_tab_raw_content', $content, $context ); $html = Settings::get( 'the_content' ) ? self::full_content( $content ) : self::format( $content ); /** * Formatted content of a tab. * * @param string $html HTML. * @param array $context Tab data. */ return (string) apply_filters( 'prodtabs_tab_content', $html, $context ); } /** * The full the_content filter. Elementor puts the content of a post built with it in place of any content filtered * on its page, and in its editor the area it edits: every tab would show the product description, and the editor * would open in the first tab. Both filters are left out for the tab, and the WordPress filters Elementor removes * after building the description (paragraphs, texturize) are put back first. * * @param string $content Raw content. */ private static function full_content( string $content ): string { $elementor = class_exists( '\Elementor\Plugin' ) ? \Elementor\Plugin::$instance : null; $frontend = $elementor && isset( $elementor->frontend ) && method_exists( $elementor->frontend, 'remove_content_filter' ) && has_filter( 'the_content', array( $elementor->frontend, 'apply_builder_in_content' ) ) ? $elementor->frontend : null; $preview = $elementor && isset( $elementor->preview ) ? $elementor->preview : null; $wrapper = $preview ? has_filter( 'the_content', array( $preview, 'builder_wrapper' ) ) : false; if ( $frontend ) { $frontend->restore_content_filters(); $frontend->remove_content_filter(); } if ( false !== $wrapper ) { remove_filter( 'the_content', array( $preview, 'builder_wrapper' ), (int) $wrapper ); } $html = (string) apply_filters( 'the_content', $content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress hook. if ( false !== $wrapper ) { add_filter( 'the_content', array( $preview, 'builder_wrapper' ), (int) $wrapper ); } if ( $frontend ) { $frontend->add_content_filter(); } return $html; } /** * The formatting WordPress applies to post content, without the additions of other plugins (sharing buttons, * related posts) that would repeat in every tab. * * @param string $content Raw content. */ public static function format( string $content ): string { global $wp_embed; $blocks = has_blocks( $content ); if ( $wp_embed instanceof \WP_Embed ) { $content = $wp_embed->run_shortcode( $content ); $content = $wp_embed->autoembed( $content ); } if ( $blocks ) { $content = do_blocks( $content ); } $content = wptexturize( $content ); if ( ! $blocks ) { $content = wpautop( $content ); } $content = shortcode_unautop( $content ); $content = do_shortcode( $content ); $content = wp_filter_content_tags( $content, 'prodtabs' ); return convert_smilies( $content ); } /** * Shop titles of the WooCommerce tabs. {count} in the title of the reviews tab becomes the number of reviews. * * @param array $tabs Tabs by key. * @param \WC_Product $product Product. */ private static function native_titles( array $tabs, \WC_Product $product ): array { foreach ( Tabs::NATIVE as $key ) { $title = Settings::native_title( $key ); if ( '' === $title || ! isset( $tabs[ $key ] ) ) { continue; } $tabs[ $key ]['title'] = esc_html( 'reviews' === $key ? str_replace( '{count}', (string) $product->get_review_count(), $title ) : $title ); } return $tabs; } /** * Heading of the Description panel: the shop title of the tab, or nothing when headings are off. * * @param string $heading Heading. */ public static function description_heading( $heading ) { return self::native_heading( $heading, 'description' ); } /** * Heading of the Additional information panel. * * @param string $heading Heading. */ public static function additional_heading( $heading ) { return self::native_heading( $heading, 'additional_information' ); } /** * Heading of a WooCommerce panel. * * @param string $heading Heading. * @param string $key Native key. */ private static function native_heading( $heading, string $key ) { if ( ! Settings::get( 'heading' ) ) { return ''; } $title = Settings::native_title( $key ); return '' !== $title ? $title : $heading; } /** * Remembers the product of a Product Details block: inside a Single Product block placed on a page, the global * product is empty and get_the_ID() is the page. * * @param array $context Block context. * @param array $parsed_block Block being rendered. */ public static function block_context( $context, $parsed_block ) { if ( isset( $parsed_block['blockName'] ) && 'woocommerce/product-details' === $parsed_block['blockName'] ) { self::$block_product_id = ! empty( $context['postId'] ) ? (int) $context['postId'] : 0; self::$block_global = ( $GLOBALS['product'] ?? null ) instanceof \WC_Product ? $GLOBALS['product'] : null; } return $context; } /** * Forgets the product of a Product Details block once it is rendered. * * @param string $html Block markup. * @param array $parsed_block Block. */ public static function block_done( $html, $parsed_block ) { if ( isset( $parsed_block['blockName'] ) && 'woocommerce/product-details' === $parsed_block['blockName'] ) { self::$block_product_id = 0; self::$block_global = null; } return $html; } /** * Product of the block being rendered, or of the page. A product set up inside the block (the tabs of another * product shown in one of its tabs by a shortcode or a block) wins over the product of the block. */ private static function current_product(): ?\WC_Product { global $product; $global = $product instanceof \WC_Product ? $product : null; if ( self::$block_product_id && ( ! $global || $global === self::$block_global || $global->get_id() === self::$block_product_id ) ) { $found = wc_get_product( self::$block_product_id ); if ( $found ) { return $found; } } if ( $global ) { return $global; } $found = wc_get_product( get_the_ID() ); return $found ? $found : null; } }