';
}
--self::$depth;
return $html;
}
/**
* Shortcode of the tabs of a product, or of one of them.
*
* @param int $product_id Product.
* @param string $anchor Anchor of one tab; empty for every tab.
*/
public static function shortcode_for( int $product_id, string $anchor = '' ): string {
return '[' . self::SHORTCODE . ' product="' . $product_id . '"' . ( '' !== $anchor ? ' tab="' . $anchor . '"' : '' ) . ']';
}
/**
* Shortcode of a shared tab.
*
* @param int $id Shared tab.
*/
public static function shortcode_shared( int $id ): string {
return '[' . self::SHORTCODE . ' shared="' . $id . '"]';
}
/** Script of the Copy buttons of the shortcodes, with its words. */
public static function copy_script(): void {
if ( wp_script_is( 'prodtabs-copy', 'enqueued' ) ) {
return;
}
wp_enqueue_script( 'prodtabs-copy', PRODTABS_URL . 'assets/js/copy.js', array( 'wp-a11y' ), Plugin::asset_version( 'assets/js/copy.js' ), true );
wp_localize_script(
'prodtabs-copy',
'prodtabsCopy',
array(
'copied' => __( 'Copied', 'prodtabs' ),
'spoken' => __( 'Shortcode copied.', 'prodtabs' ),
)
);
}
/**
* Read-only field with a shortcode and a button that copies it (see copy_script()).
*
* @param string $code Shortcode.
* @param string $label Label of the field.
* @param string $id Id of the field.
*/
public static function shortcode_field( string $code, string $label, string $id ): string {
return '
'
. ' '
. '
';
}
/** Registers the block, with its editor script (no build step: plain script on the wp globals). */
public static function register_block(): void {
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
wp_register_script(
'prodtabs-block-editor',
PRODTABS_URL . 'assets/js/block-editor.js',
array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render' ),
Plugin::asset_version( 'assets/js/block-editor.js' ),
true
);
// Labels from PHP: the translations of the plugin are .mo files, not JSON files for scripts.
wp_add_inline_script(
'prodtabs-block-editor',
'window.prodtabsBlock = ' . wp_json_encode(
array(
'name' => self::BLOCK,
'labels' => array(
'panel' => __( 'Product tabs', 'prodtabs' ),
'product' => __( 'Product ID', 'prodtabs' ),
'productHelp' => __( 'Empty: the product of the page, or of the Single Product block or product list around this block.', 'prodtabs' ),
'tab' => __( 'One tab only', 'prodtabs' ),
'tabHelp' => __( 'Anchor of the tab, such as shipping for #tab-shipping. Empty: every tab.', 'prodtabs' ),
'showTitle' => __( 'Show the title of the tab', 'prodtabs' ),
'showTitleHelp' => __( 'With one tab only.', 'prodtabs' ),
),
)
) . ';',
'before'
);
register_block_type(
PRODTABS_DIR . 'blocks/product-tabs',
array(
'render_callback' => array( __CLASS__, 'render_block' ),
)
);
}
/**
* Server side rendering of the block.
*
* @param array $attributes Attributes: productId, tab, showTitle.
* @param string $content Inner content (none).
* @param \WP_Block|null $block Block, with its context.
*/
public static function render_block( $attributes, $content = '', $block = null ): string {
$attributes = is_array( $attributes ) ? $attributes : array();
$product_id = absint( $attributes['productId'] ?? 0 );
if ( ! $product_id && $block instanceof \WP_Block && ! empty( $block->context['postId'] ) && 'product' === get_post_type( (int) $block->context['postId'] ) ) {
$product_id = (int) $block->context['postId']; // Single Product block, product template, product list.
}
$html = self::render( $product_id, (string) ( $attributes['tab'] ?? '' ), (bool) ( $attributes['showTitle'] ?? true ) );
if ( '' === $html && defined( 'REST_REQUEST' ) && REST_REQUEST ) {
// Preview of the editor, which has no product of the page.
$html = '
' . esc_html__( 'Choose a product in the block settings, or place this block in a product template or in a Single Product block.', 'prodtabs' ) . '
';
}
return '' === $html ? '' : '
' . $html . '
';
}
/**
* Tabs of a product, or one of its tabs.
*
* @param int $product_id Product; 0 for the product of the page.
* @param string $tab Anchor of one tab (shipping, tab-shipping or #tab-shipping); empty for every tab.
* @param bool $show_title With one tab: its heading.
*/
public static function render( int $product_id, string $tab = '', bool $show_title = true ): string {
if ( self::$depth > 0 ) {
return '';
}
$product = self::product( $product_id );
if ( ! $product ) {
return '';
}
global $post;
$previous_post = $post;
$previous_product = $GLOBALS['product'] ?? null;
++self::$depth;
$post = get_post( $product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- globals of WordPress and WooCommerce, put back below.
$GLOBALS['product'] = $product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- globals of WordPress and WooCommerce, put back below.
setup_postdata( $post );
ob_start();
$tab = self::tab_key( $tab );
if ( '' === $tab ) {
Render::enqueue_assets();
wp_enqueue_script( 'wc-single-product' ); // Opens and closes the tabs; registered by WooCommerce on every page.
wc_get_template( 'single-product/tabs/tabs.php' );
} else {
self::one_tab( $tab, $show_title );
}
$html = (string) ob_get_clean();
$GLOBALS['product'] = $previous_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- the product of the page, global of WooCommerce.
$post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- the post of the page.
if ( $post instanceof \WP_Post ) {
setup_postdata( $post );
}
--self::$depth;
return trim( $html );
}
/**
* Product to show: the one asked for, or the product of the page. Unpublished and password protected products show
* nothing to visitors.
*
* @param int $product_id Product; 0 for the product of the page.
*/
private static function product( int $product_id ): ?\WC_Product {
if ( ! $product_id ) {
$product = ( $GLOBALS['product'] ?? null ) instanceof \WC_Product ? $GLOBALS['product'] : wc_get_product( (int) get_the_ID() );
return $product instanceof \WC_Product && self::showable( $product ) ? $product : null;
}
// On a translated page, the translation of the product asked for; while it is not published, the product asked
// for, or the original when the id is itself that translation (WPML points the ids of the shortcode to the
// language of the page).
$ids = array( Multilingual::in_current_language( $product_id, 'product' ), $product_id, Multilingual::original( $product_id, 'product' ) );
foreach ( array_unique( array_filter( $ids ) ) as $id ) {
$product = wc_get_product( $id );
if ( $product instanceof \WC_Product && self::showable( $product ) ) {
return $product;
}
}
return null;
}
/**
* Whether a product can be shown: not a variation, published or readable by the current user, without password.
*
* @param \WC_Product $product Product.
*/
private static function showable( \WC_Product $product ): bool {
$id = $product->get_id();
if ( $product->get_parent_id() ) {
return false; // A variation.
}
return ( 'publish' === get_post_status( $id ) || current_user_can( 'read_post', $id ) ) && ! post_password_required( $id );
}
/**
* Key of a tab from what was typed, # left out (see one_tab()).
*
* @param string $tab Anchor.
*/
private static function tab_key( string $tab ): string {
$tab = trim( $tab );
return 0 === strpos( $tab, '#' ) ? substr( $tab, 1 ) : $tab;
}
/**
* One tab of the product of the page, as its panel shows it, without the tab titles.
*
* @param string $key Tab key.
* @param bool $show_title Heading of the tab.
*/
private static function one_tab( string $key, bool $show_title ): void {
$tabs = apply_filters( 'woocommerce_product_tabs', array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce hook.
if ( ! is_array( $tabs ) ) {
return;
}
// The anchor as the product screen gives it (shipping, tab-2024 for a tab titled 2024), as the address shows it
// (tab-shipping), then the title as it reads (Shipping and returns, 2024).
$address = 0 === strpos( $key, 'tab-' ) ? substr( $key, 4 ) : '';
foreach ( array( $key, $address, Tabs::anchor( $key, '' ) ) as $candidate ) {
if ( '' !== $candidate && isset( $tabs[ $candidate ] ) ) {
$key = $candidate;
break;
}
}
if ( ! isset( $tabs[ $key ]['callback'] ) || ! is_callable( $tabs[ $key ]['callback'] ) ) {
return;
}
$heading = static function ( $heading, $data = array() ) use ( $show_title ) {
if ( ! $show_title ) {
return '';
}
return is_array( $data ) && isset( $data['title'] ) ? (string) $data['title'] : $heading;
};
// The WooCommerce tabs: their heading even when the settings turn headings off, since it was asked for.
$title = html_entity_decode( wp_strip_all_tags( (string) ( $tabs[ $key ]['title'] ?? '' ) ), ENT_QUOTES, 'UTF-8' );
$native = static function ( $heading ) use ( $show_title, $title ) {
if ( ! $show_title ) {
return '';
}
return '' !== (string) $heading ? $heading : $title;
};
// A theme that hides the heading of the panels (the tab bar already shows the titles, often through the YIKES
// filter) is right on a product page; a tab shown on its own has no tab bar, and its title was asked for.
$restore = static function ( $html, $data = array() ) use ( $show_title ) {
if ( ! $show_title || '' !== trim( (string) $html ) || ! is_array( $data ) || '' === (string) ( $data['title'] ?? '' ) ) {
return $html;
}
return '