*/ private array $current = []; public function __construct(private readonly TabRepository $tabs) { } public function registerHooks(): void { add_filter('woocommerce_product_tabs', [$this, 'addTabs'], 98); add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']); } /** * Enqueue the small panel stylesheet on single product pages only. */ public function enqueueAssets(): void { if (! $this->tabs->isEnabled()) { return; } if (! function_exists('is_product') || ! is_product()) { return; } wp_enqueue_style( 'tabby-tabs', \Tabby\Plugin::instance()->url('assets/css/tabs.css'), [], \Tabby\VERSION, ); } /** * @param array> $tabs * @return array> */ public function addTabs(array $tabs): array { $resolved = $this->tabs->resolveTabs($this->currentProduct()); if ([] === $resolved) { return $tabs; } $this->current = []; // Native WooCommerce tabs use priorities 10/20/30, so 100+ trails them. $priority = 100; $seen = []; foreach ($resolved as $tab) { // A tab with an empty content box used to be registered anyway: the // merchant saved a title with no body, and the shopper got a real // clickable tab that opened onto a heading and nothing else. Content // is what the panel is for, so a bodyless tab never reaches the strip. if ('' === trim($tab->content)) { continue; } // Guarantee a unique array key even if two tabs share an id. $key = 'tabby_' . $tab->id; $n = 1; while (isset($seen[$key])) { $key = 'tabby_' . $tab->id . '_' . $n; ++$n; } $seen[$key] = true; $this->current[$key] = $tab; $tabs[$key] = [ 'title' => $tab->title, 'priority' => $priority, 'callback' => [$this, 'renderPanel'], ]; ++$priority; } return $tabs; } /** * The product currently being rendered, when WooCommerce has set it. * * `woocommerce_product_tabs` fires inside the single-product template, where * the global `$product` is the product on screen. */ private function currentProduct(): ?\WC_Product { $product = $GLOBALS['product'] ?? null; return $product instanceof \WC_Product ? $product : null; } /** * Render a single tab panel. WooCommerce calls this with the tab key and the * tab definition array. * * @param array $tab */ public function renderPanel(string $key, array $tab): void { $resolved = $this->current[$key] ?? null; if (! $resolved instanceof Tab) { return; } if ('' === trim($resolved->content)) { return; } $html = $this->formatPanelHtml($resolved->content, $resolved, $this->currentProduct()); if ('' === trim(wp_strip_all_tags($html))) { return; } // The title is printed after the body checks, not before them: printing // it first is what left shoppers looking at a lone heading whenever the // content came back empty. if ('' !== $resolved->title) { printf( '

%s

', esc_html($resolved->title), ); } printf( '
%s
', wp_kses_post($html) ); } /** * Turn stored tab body markup into storefront HTML. * * @param string $content Raw tab body markup. * @param Tab $tab The tab being rendered. * @param \WC_Product|null $product The product being viewed. */ private function formatPanelHtml(string $content, Tab $tab, ?\WC_Product $product): string { // Shortcodes and blocks inside a tab body. The merchant's own setting // is the default: this used to be a hardcoded false that only the paid // add-on ever flipped, which made it a built-in feature the plugin // refused to run. Off out of the box because running the_content over // stored HTML is a bigger surface than wp_kses_post. $rich = (bool) ($this->tabs->settings()['rich_content'] ?? false); if ((bool) apply_filters('tabby/use_rich_tab_content', $rich, $tab, $product)) { // Filter: tabby/tab_panel_html, rich tab panel HTML after the_content. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- the_content is the core filter, applied on purpose. return (string) apply_filters('tabby/tab_panel_html', apply_filters('the_content', $content), $tab, $product); } $html = wp_kses_post(wpautop($content)); // Filter: tabby/tab_panel_html, plain sanitised tab panel HTML. return (string) apply_filters('tabby/tab_panel_html', $html, $tab, $product); } }