$max ) {
return (int) $max;
}
return $value;
}
/**
* Pick a value from an allowlist.
*
* @param mixed $value Raw value.
* @param string[] $allowed Allowed values.
* @param string $fallback Fallback.
* @return string
*/
public static function pickFrom( $value, array $allowed, $fallback ) {
$value = is_string( $value ) ? trim( $value ) : '';
return in_array( $value, $allowed, true ) ? $value : $fallback;
}
/**
* Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
*
* @param mixed $color Raw color.
* @param string $fallback Fallback when invalid.
* @return string
*/
public static function sanitizeColor( $color, $fallback = '' ) {
$color = trim( (string) $color );
if ( '' === $color ) {
return $fallback;
}
if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
return $color;
}
if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
return $color;
}
if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
return $color;
}
if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
return $color;
}
return $fallback;
}
/**
* Normalize and sanitize the full attribute set into a safe, typed array.
*
* @param array $attributes Raw block attributes.
* @return array
*/
public static function resolveAttributes( array $attributes ) {
$noProductMessage = isset( $attributes['noProductMessage'] ) ? wp_strip_all_tags( (string) $attributes['noProductMessage'] ) : '';
$noProductMessage = '' !== trim( $noProductMessage ) ? $noProductMessage : __( 'Please select a product.', 'b-blocks' );
return [
'productId' => self::clampInt( $attributes['productId'] ?? 0, 0, PHP_INT_MAX, 0 ),
'layout' => self::pickFrom( $attributes['layout'] ?? 'horizontal', [ 'horizontal', 'vertical' ], 'horizontal' ),
'gallerySplit' => self::clampInt( $attributes['gallerySplit'] ?? 50, 20, 70, 50 ),
'showGallery' => ! isset( $attributes['showGallery'] ) || (bool) $attributes['showGallery'],
'showSummary' => ! isset( $attributes['showSummary'] ) || (bool) $attributes['showSummary'],
'showAfterSummary' => ! empty( $attributes['showAfterSummary'] ),
'columnGap' => self::clampInt( $attributes['columnGap'] ?? 32, 0, 80, 32 ),
'maxWidth' => self::clampInt( $attributes['maxWidth'] ?? 0, 0, 1600, 0 ),
'blockAlign' => self::pickFrom( $attributes['blockAlign'] ?? 'center', [ 'left', 'center', 'right' ], 'center' ),
'paddingVertical' => self::clampInt( $attributes['paddingVertical'] ?? 0, 0, 120, 0 ),
'paddingHorizontal' => self::clampInt( $attributes['paddingHorizontal'] ?? 0, 0, 120, 0 ),
'blockBG' => self::sanitizeColor( $attributes['blockBG'] ?? '', 'transparent' ),
'blockBorderRadius' => self::clampInt( $attributes['blockBorderRadius'] ?? 0, 0, 48, 0 ),
'mobileStackOrder' => self::pickFrom( $attributes['mobileStackOrder'] ?? 'gallery-first', [ 'gallery-first', 'summary-first' ], 'gallery-first' ),
'mobileBreakpoint' => self::clampInt( $attributes['mobileBreakpoint'] ?? 767, 480, 960, 767 ),
'noProductMessage' => $noProductMessage,
];
}
/**
* Demote the first
in an HTML string to .
*
* WooCommerce's single-product title template wraps the_title() output in
* . Because this block is embedded on a
* non-product page, an would harm the document heading outline. We apply
* this transformation on the fully-rendered ob_get_clean() HTML (not via the
* the_title filter, which only sees the raw text of the title) so the first
* ...
pair inside the summary wrapper is reliably swapped to .
*
* @param string $html Rendered HTML of the bb-wsp-summary section.
* @return string
*/
public static function demoteTitleHeading( $html ) {
if ( ! is_string( $html ) ) {
return $html;
}
$replaced = preg_replace(
[ '/)/i', '/<\/h1>/i' ],
[ '' ],
$html,
1
);
return null === $replaced ? $html : $replaced;
}
/**
* Render the native purchase area for one product.
*
* Sets up the global product/post context, fires WooCommerce's own template
* hooks for the gallery, summary and (optionally) after-summary sections,
* then resets the globals. Returns the rendered HTML string (the gallery and
* summary column wrappers only — the outer block wrapper is added by render.php).
*
* @param WC_Product $product The resolved, visible product.
* @param array $a Sanitized attributes from resolveAttributes().
* @return string
*/
public static function renderProductArea( $product, array $a ) {
// Save the globals we are about to overwrite so we can restore them
// after rendering and never pollute the main query / page context.
$savedPost = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
$savedProduct = isset( $GLOBALS['product'] ) ? $GLOBALS['product'] : null;
// Set the global $product / $post WooCommerce template functions expect.
$postObject = get_post( $product->get_id() );
if ( $postObject instanceof \WP_Post ) {
$GLOBALS['post'] = $postObject; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
setup_postdata( $postObject );
}
wc_setup_product_data( $postObject instanceof \WP_Post ? $postObject : $product->get_id() );
ob_start();
if ( $a['showGallery'] ) {
echo '
';
/**
* Fires WooCommerce's native product image gallery (featured image +
* thumbnail strip, sale flash, zoom/lightbox triggers).
*/
woocommerce_show_product_images();
echo '
';
}
if ( $a['showSummary'] ) {
ob_start();
/**
* Fires the native summary column: title, rating, price, short
* description, the add-to-cart form (including variation selectors
* for variable products), and the product meta (SKU / categories /
* tags), in standard WooCommerce priority order.
*/
do_action( 'woocommerce_single_product_summary' );
// Demote the first to in the rendered summary HTML so the
// embedded product does not create a duplicate top-level heading on
// the host page. Applied to the fully-buffered output so the regex
// can reliably match the tag
// (the_title filter only sees the raw post title text, not the tag).
$summaryHtml = self::demoteTitleHeading( ob_get_clean() );
echo '
';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WooCommerce core template output with heading tag already demoted.
echo $summaryHtml;
echo '
';
}
if ( $a['showAfterSummary'] ) {
echo '';
/**
* Fires the native after-summary hooks: product data tabs
* (Description, Additional information, Reviews) and upsells.
*/
do_action( 'woocommerce_after_single_product_summary' );
echo '
';
}
$html = ob_get_clean();
// Tear down the product context and restore the original globals so the
// rest of the page sees an untouched main query and product global.
if ( null !== $savedProduct ) {
$GLOBALS['product'] = $savedProduct; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring saved value.
} else {
unset( $GLOBALS['product'] );
}
if ( null !== $savedPost ) {
$GLOBALS['post'] = $savedPost; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring saved value.
setup_postdata( $savedPost );
} else {
wp_reset_postdata();
}
return $html;
}
}