* Frontend::get_builder_content(). This is a normal front-end request, so * it WOULD also be reachable from a `wp`-gated instance, but is registered * here too for symmetry with the pathways below and so both share one stack. * - Editor admin page bootstrap ( post.php?action=elementor, which never * fires the `wp` action ): Document::get_config() bakes each widget's * server-rendered `render()` output into `ElementorConfig.initial_document` * as `htmlCache` via get_elements_raw_data( null, true ), fired right after * `before_get_config`. This does NOT go through Frontend::get_builder_content() * at all, so it needs its own switch/restore pair. * - Adding a widget to the LIVE canvas ( no save/reload in between ): a PHP-only * widget with no JS `content_template()` — every EA Woo widget, and most of * ours — is rendered via the `render_widget` ajax action * ( Widgets_Manager::ajax_render_widget() -> Document::render_element() ), * which goes through NEITHER get_content() NOR get_config(). Confirmed by * direct testing: dropping one of these widgets onto a brand-new, unsaved * product_single template shows the widget's placeholder until the whole * document is reloaded ( which bakes via get_config() and so IS covered ) — * reloading immediately after masks the gap, which is why it wasn't caught * sooner. * * Two other approaches were tried and confirmed NOT to work, by direct * testing with temporary logging (kept here so the next person doesn't * re-walk the same dead ends): * - Switching the query earlier ( e.g. on `wp_ajax_elementor_ajax` ), on the * theory that `ajax_render_widget()`'s own `query_posts( [ 'p' => * $editor_post_id, 'post_type' => 'any' ] )` (widgets.php) would silently * undo it. Red herring: `query_posts()` only replaces `$wp_query` — it * does NOT touch `global $post` at all ( that only happens via * `the_post()` / `setup_postdata()`, neither of which `ajax_render_widget()` * calls on its query ) — confirmed via a `the_posts` filter showing the * query itself resolving to the right product while `$GLOBALS['post']` * stayed on the templately_library document throughout. * - Rewriting that same `query_posts()` query via `pre_get_posts`. The query * rewrite itself worked ( confirmed the SQL fetched the preview product * correctly ) but was moot for the same reason: since `query_posts()` * never touches `global $post`, redirecting it changes nothing observable. * * The actual cause: `Ajax\Module::handle_ajax_request()` calls * `Plugin::$instance->db->switch_to_post( $editor_post_id )` — which DOES * directly set `$GLOBALS['post']` + `setup_postdata()` — *before* dispatching * to any registered action (including `render_widget`), and nothing after * that point ever touches `global $post` again. So the fix has to override * `global $post` a second time, strictly *after* that call. `elementor/ajax/ * register_actions` fires right after it and before the action dispatch loop * — see maybe_switch_global_post_for_ajax_render(). No restore needed: this * is a one-shot admin-ajax.php process that exits right after responding. */ public static function register_preview_product_hooks() { add_action( 'elementor/frontend/before_get_builder_content', [ self::class, 'maybe_switch_to_preview_product_query' ] ); add_filter( 'elementor/frontend/the_content', [ self::class, 'maybe_restore_preview_product_query' ] ); add_action( 'elementor/document/before_get_config', [ self::class, 'maybe_switch_to_preview_product_query' ] ); add_filter( 'elementor/document/config', [ self::class, 'maybe_restore_preview_product_query' ] ); add_action( 'elementor/ajax/register_actions', [ self::class, 'maybe_switch_global_post_for_ajax_render' ] ); } public function __construct( $builder, $views ) { $this->views = $views; // new ThemeCompatibility(); add_action( 'get_header', [ $this, 'get_header' ], 0 ); add_action( 'get_footer', [ $this, 'get_footer' ], 0 ); add_action( 'elementor/document/wrapper_attributes', [ $this, 'wrapper_attributes' ], 10, 2 ); add_action( 'template_redirect', array( $this, 'set_global_product' ) ); add_action( 'templately_builder_header_after', [ self::class, 'print_style_tags' ], 0 ); add_action( 'templately_builder_footer_before', [ self::class, 'print_style_tags' ], 0 ); /** * Only for Development Mode. */ if ( defined( 'TEMPLATELY_DEV_VIEWS' ) && TEMPLATELY_DEV_VIEWS ) { add_action( 'templately_builder_header_before', [ $this, 'header_helper' ], 0 ); add_action( 'templately_builder_footer_before', [ $this, 'footer_helper' ], 0 ); } } public function header_helper() { echo 'Header'; } public function footer_helper() { echo 'Footer'; } public function get_header() { if(!self::is_header_footer('header')){ return; } $this->views->get_header(); $templates = []; $templates[] = 'header.php'; remove_all_actions( 'wp_head' ); ob_start(); locate_template( $templates, true ); ob_get_clean(); } public function get_footer( $name ) { if(!self::is_header_footer('footer')){ return; } $this->views->get_footer(); $templates = []; $name = (string) $name; if ( '' !== $name ) { $templates[] = "footer-{$name}.php"; } $templates[] = 'footer.php'; // remove_all_actions( 'wp_footer' ); ob_start(); locate_template( $templates, true ); ob_get_clean(); } public static function is_header_footer($type = null){ if(class_exists( 'Elementor\Plugin' )){ $pid = get_the_ID(); $post_type = get_post_type($pid); $document = Plugin::$instance->documents->get( $pid ); if( $post_type === 'elementor_library' && ( $document->get_type() === 'header' || $document->get_type() === 'footer') ){ return false; } } $header = templately()->theme_builder::$conditions_manager->get_templates_by_location( 'header' ); $footer = templately()->theme_builder::$conditions_manager->get_templates_by_location( 'footer' ); if($type === 'header'){ return $header; } else if($type === 'footer'){ return $footer; } if(!empty($header) || !empty($footer)){ return true; } return false; } public function wrapper_attributes( $attributes, $document ) { $post_type = get_post_type($document->get_main_id()); if( $post_type === 'templately_library' ){ $template = templately()->theme_builder->get_template( $document->get_main_id() ); if($template){ $attributes['data-elementor-type'] = 'templately-' . $template->get_type(); $attributes['data-elementor-id'] = $template->get_main_id(); $attributes['data-elementor-post-type'] = 'templately_library'; $attributes['data-elementor-title'] = $template->get_title(); $attributes['class'] .= ' ' . implode( ' ', get_post_class() ); } } return $attributes; } public function set_global_product(){ global $product; if ( ! function_exists( 'wc_setup_product_data' ) ) { return; } // A real product is already in context (e.g. a live single-product page) — never override it. if ( $product instanceof \WC_Product ) { return; } $document_id = get_the_ID(); if ( ! $document_id ) { return; } // The current post is itself a product — set it up (prior behaviour, kept explicit). if ( get_post_type( $document_id ) === 'product' ) { wc_setup_product_data( $document_id ); return; } // Otherwise only fabricate a preview product for a WooCommerce single-product // Theme Builder template being rendered in the editor / preview. This mirrors what // Elementor Pro's Single Product document does: point the global $product at a real // product so any WooCommerce widget — ours and third-party (e.g. EA Woo Product Tabs) — // renders real data in the editor instead of appearing empty. The live front end is // unaffected (there a real product is always in context, handled above). // // This only sets `global $product`; it does NOT cover widgets/plugins that re-derive // the product from `global $post` (e.g. via `wc_get_product( false )`'s fallback, which // checks `get_post_type( $post->ID )`) instead of trusting a pre-set `$product`. That // gap is closed separately by maybe_switch_to_preview_product_query() below. if ( ! self::is_woo_single_preview( $document_id ) ) { return; } $preview_product_id = self::get_preview_product_id(); if ( $preview_product_id ) { wc_setup_product_data( $preview_product_id ); } } /** * Swap the main query ( and `global $post` ) to the preview product for the * duration of a `product_single` Theme Builder document's content render. * * Mirrors Elementor Pro's `Theme_Document::get_content()` / `Preview_Manager` * ( `switch_to_preview_query()` + `DB::switch_to_query( $query_vars, true )` ), * but built only on Elementor core APIs so it works without Elementor Pro. * This is what makes widgets that re-derive the product from `global $post` * ( e.g. EA's Woo Product Description, or `wc_get_product( false )`'s own * fallback ) render real content instead of a placeholder — setting * `global $product` alone ( set_global_product() above ) isn't enough for them. * * Hooked to both `elementor/frontend/before_get_builder_content` (real front end / * live preview iframe) and `elementor/document/before_get_config` (editor admin * page bootstrap), paired respectively with maybe_restore_preview_product_query() * on `elementor/frontend/the_content` and `elementor/document/config`. * `DB::switch_to_query()` / `restore_current_query()` are themselves stack-based, * but the hook only gives us the document on the "before" call, so a small * boolean stack here is what lets the "after" filter know whether to restore. * * @param \Elementor\Core\Base\Document|null $document The document about to render. */ public static function maybe_switch_to_preview_product_query( $document ) { $switched = false; if ( $document && function_exists( 'wc_setup_product_data' ) ) { $document_id = $document->get_main_id(); if ( self::is_woo_single_preview( $document_id ) ) { $preview_product_id = self::get_preview_product_id(); if ( $preview_product_id ) { Plugin::$instance->db->switch_to_query( [ 'p' => $preview_product_id, 'post_type' => 'product', ], true ); $switched = true; } } } self::$preview_query_switch_stack[] = $switched; } /** * Restore the query switched by maybe_switch_to_preview_product_query(), if any. * * @param string $content Unmodified — this is a restore point, not a content filter. * @return string */ public static function maybe_restore_preview_product_query( $content ) { if ( array_pop( self::$preview_query_switch_stack ) ) { Plugin::$instance->db->restore_current_query(); } return $content; } /** * Whether `$document_id` is a WooCommerce single-product ( `product_single` ) * Theme Builder template at all — regardless of edit/preview mode. * * @param int $document_id The templately_library post being checked. * @return bool */ private static function is_woo_single_document( $document_id ) { if ( get_post_meta( $document_id, Source::TYPE_META_KEY, true ) !== 'product_single' ) { return false; } return post_type_exists( 'product' ); } /** * Whether the current request is the editor/preview render of a WooCommerce * single-product ( `product_single` ) Theme Builder template. * * @param int $document_id The templately_library post being rendered. * @return bool */ private static function is_woo_single_preview( $document_id ) { if ( ! self::is_woo_single_document( $document_id ) ) { return false; } return Plugin::$instance->editor->is_edit_mode() || Plugin::$instance->preview->is_preview_mode( $document_id ) || ( isset( $_GET['templately_library'], $_GET['preview_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** * Third preview-product switch point: overrides `global $post` for the * `render_widget` ajax action — used when a widget with no JS * `content_template()` is freshly added to the live editor canvas ( see * register_preview_product_hooks() for the two dead ends this replaced ). * * `Ajax\Module::handle_ajax_request()` calls `Plugin::$instance->db * ->switch_to_post( $editor_post_id )` — which sets `$GLOBALS['post']` + * `setup_postdata()` directly — before dispatching to any action, and nothing * in `ajax_render_widget()` ( including its own `query_posts()` call, which * never touches `global $post` ) touches it again afterwards. So this has to * override `global $post` a second time, the same way `switch_to_post()` * itself does, timed to run strictly after it: `elementor/ajax/register_actions` * fires right after that call and before the action-dispatch loop. * * Reads `$_REQUEST['actions']` — the same raw JSON `Ajax\Module` itself decodes * moments later — read-only, purely to detect whether a `render_widget` call is * queued in this batch; no nonce check needed for that inspection since nothing * is mutated based on it beyond which product post to point `global $post` at. * * No restore call: a single, short-lived admin-ajax.php process that exits * right after responding, so nothing persists past it. */ public static function maybe_switch_global_post_for_ajax_render() { if ( ! function_exists( 'wc_setup_product_data' ) || empty( $_REQUEST['actions'] ) || empty( $_REQUEST['editor_post_id'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only inspection, see docblock above. // A JSON payload: sanitize the raw string before decoding; the decoded values // are validated by the action handlers below. $actions = json_decode( sanitize_textarea_field( wp_unslash( $_REQUEST['actions'] ) ), true ); if ( ! is_array( $actions ) ) { return; } $has_render_widget = false; foreach ( $actions as $action_data ) { if ( isset( $action_data['action'] ) && 'render_widget' === $action_data['action'] ) { $has_render_widget = true; break; } } if ( ! $has_render_widget ) { return; } $document_id = absint( $_REQUEST['editor_post_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! self::is_woo_single_document( $document_id ) ) { return; } $preview_product_id = self::get_preview_product_id(); if ( ! $preview_product_id ) { return; } $GLOBALS['post'] = get_post( $preview_product_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited setup_postdata( $GLOBALS['post'] ); } /** * Resolve the newest published product to use as the editor preview product. * Matches the "latest product" approach used by the dynamic Theme Builder * widgets ( Post_Content / Post_Title / Featured_Image ) and Elementor Pro. * * @return int Product ID, or 0 when the store has no published products. */ private static function get_preview_product_id() { $products = get_posts( [ 'post_type' => 'product', 'post_status' => 'publish', 'numberposts' => 1, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => false, ] ); return ! empty( $products ) ? (int) $products[0] : 0; } /** * Print remaining enqueued styles with error handling. */ public static function print_style_tags() { try { $wp_styles = wp_styles(); // handles dependencies $wp_styles->do_items(); if ( is_object( $wp_styles ) && is_array( $wp_styles->queue ?? null ) ) { foreach ( $wp_styles->queue as $style ) { if ( is_string( $style ) && ! $wp_styles->query( $style, 'done' ) ) { $wp_styles->do_item( $style ); $wp_styles->done[] = $style; } } } } catch ( \Throwable $e ) { // Full detail to Templately's dedicated log file (WP_DEBUG_LOG-gated inside). \Templately\Utils\Log\Logger::exception( $e, 'theme-builder' ); } } }