languages = $languages; $this->mapping = $mapping; } /** * Register hooks. * * @return void */ public function init() { add_action( 'init', array( $this, 'register_rewrites' ), 9 ); add_filter( 'query_vars', array( $this, 'query_vars' ) ); add_filter( 'request', array( $this, 'filter_request' ) ); add_filter( 'get_post', array( $this, 'localize_post_object' ), 10, 3 ); add_filter( 'posts_results', array( $this, 'localize_post_results' ), 20, 2 ); add_filter( 'get_post_metadata', array( $this, 'localize_post_meta_reference' ), 20, 4 ); add_filter( 'option_xts-woodmart-options', array( $this, 'localize_woodmart_options' ), 20 ); add_filter( 'option_wd_layouts_conditions', array( $this, 'localize_woodmart_layout_conditions' ), 20 ); add_action( 'template_redirect', array( $this, 'redirect_unprefixed_translation' ), 1 ); add_filter( 'post_link', array( $this, 'filter_post_link' ), 10, 2 ); add_filter( 'page_link', array( $this, 'filter_page_link' ), 10, 2 ); add_filter( 'post_type_link', array( $this, 'filter_post_type_link' ), 10, 2 ); add_filter( 'home_url', array( $this, 'filter_home_url' ), 20, 4 ); add_filter( 'wp_nav_menu_objects', array( $this, 'localize_menu_items' ), 20, 2 ); add_filter( 'mod_rewrite_rules', array( $this, 'mod_rewrite_rules' ), 996 ); add_action( 'send_headers', array( $this, 'send_language_no_cache_headers' ), 0 ); add_action( 'wp_loaded', array( $this, 'maybe_flush_rewrites' ) ); add_action( 'transition_post_status', array( $this, 'purge_translation_route_cache' ), 20, 3 ); } /** * Register rewrite tags and language rules. * * @return void */ public function register_rewrites() { add_rewrite_tag( '%sitelang_lang%', '([^&]+)' ); add_rewrite_tag( '%sitelang_path%', '(.+?)' ); add_rewrite_tag( '%sitelang_home%', '([0-1])' ); $regex = $this->language_regex(); if ( '' === $regex ) { return; } add_rewrite_rule( '^(' . $regex . ')/?$', 'index.php?sitelang_lang=$matches[1]&sitelang_home=1', 'top' ); add_rewrite_rule( '^(' . $regex . ')/(.+?)/?$', 'index.php?sitelang_lang=$matches[1]&sitelang_path=$matches[2]', 'top' ); } /** * Add query vars. * * @param array $vars Query vars. * @return array */ public function query_vars( $vars ) { $vars[] = 'sitelang_lang'; $vars[] = 'sitelang_path'; $vars[] = 'sitelang_home'; return $vars; } /** * Resolve language-prefixed request before WP_Query runs. * * @param array $vars Request vars. * @return array */ public function filter_request( $vars ) { if ( empty( $vars['sitelang_lang'] ) ) { return $vars; } $language = $this->languages->sanitize_code( $vars['sitelang_lang'] ); if ( ! $this->languages->is_enabled( $language ) ) { return $vars; } $this->languages->set_current( $language ); $this->maybe_disable_language_page_cache(); $this->current_path = ! empty( $vars['sitelang_path'] ) ? trim( (string) $vars['sitelang_path'], '/' ) : ''; if ( ! empty( $vars['sitelang_home'] ) ) { return $this->resolve_home( $vars, $language ); } if ( '' === $this->current_path ) { return $vars; } $resolved = $this->resolve_path( $this->current_path, $language ); if ( $resolved ) { unset( $vars['sitelang_path'], $vars['error'], $vars['name'], $vars['pagename'], $vars['product'] ); $vars = array_merge( $vars, $resolved ); } else { unset( $vars['sitelang_path'] ); $vars['error'] = '404'; } return $vars; } /** * Resolve language home. * * @param array $vars Request vars. * @param string $language Language code. * @return array */ private function resolve_home( array $vars, $language ) { $settings = $this->languages->get_settings(); $homepages = isset( $settings['homepages'] ) && is_array( $settings['homepages'] ) ? $settings['homepages'] : array(); $page_id = ! empty( $homepages[ $language ] ) ? absint( $homepages[ $language ] ) : 0; if ( ! $page_id ) { $front_id = (int) get_option( 'page_on_front' ); if ( $front_id ) { $page_id = $this->mapping->get_translation_id( $front_id, $language ); } } if ( $page_id ) { $vars['page_id'] = $page_id; } return $vars; } /** * Resolve a path to a translated page, product, shop page, or basic product taxonomy archive. * * @param string $path Request path without language prefix. * @param string $language Language code. * @return array|null */ private function resolve_path( $path, $language ) { $path = trim( $path, '/' ); $product = $this->resolve_product_path( $path, $language ); if ( $product ) { return $product; } $product_archive = $this->resolve_product_archive_path( $path ); if ( $product_archive ) { return $product_archive; } $page = $this->resolve_page_path( $path, $language ); if ( $page ) { return $page; } $post = $this->resolve_post_path( $path, $language ); if ( $post ) { return $post; } return null; } /** * Add Apache pass-through rules before host/page-cache static shortcuts. * * Some hosts write cache rules above WordPress that serve cached files such * as wp-content/endurance-page-cache/de/_index.html before WordPress runs. * Language routes must reach WordPress so logged-in admins keep their auth * context and newly translated content appears immediately. * * @param string $rules Existing mod_rewrite rules. * @return string */ public function mod_rewrite_rules( $rules ) { $regex = $this->language_regex(); if ( '' === $regex ) { return $rules; } $language_rules = "# SiteLang language URL cache bypass\n"; $language_rules .= "\n"; $language_rules .= "RewriteEngine On\n"; $language_rules .= "RewriteRule ^(" . $regex . ")(/.*)?$ index.php [L,QSA]\n"; $language_rules .= "\n\n"; return $language_rules . $rules; } /** * Resolve standard blog post paths. * * @param string $path Path. * @param string $language Language code. * @return array|null */ private function resolve_post_path( $path, $language ) { $path = trim( $path, '/' ); if ( '' === $path ) { return null; } $direct = get_page_by_path( $path, OBJECT, 'post' ); if ( ! $direct && false !== strpos( $path, '/' ) ) { $direct = get_page_by_path( basename( $path ), OBJECT, 'post' ); } if ( $direct && $this->is_post_language( $direct->ID, $language ) && $this->mapping->is_frontend_ready_translation( $direct->ID ) ) { return array( 'p' => $direct->ID, ); } if ( $direct && ! $this->mapping->is_translation( $direct->ID ) ) { $translation_id = $this->mapping->get_ready_translation_id( $direct->ID, $language ); if ( $translation_id ) { return array( 'p' => $translation_id, ); } } return null; } /** * Resolve page path. * * @param string $path Path. * @param string $language Language code. * @return array|null */ private function resolve_page_path( $path, $language ) { $direct = get_page_by_path( $path, OBJECT, 'page' ); if ( $direct && $this->is_post_language( $direct->ID, $language ) && $this->mapping->is_frontend_ready_translation( $direct->ID ) ) { return array( 'page_id' => $direct->ID ); } if ( $direct && ! $this->mapping->is_translation( $direct->ID ) ) { $translation_id = $this->mapping->get_ready_translation_id( $direct->ID, $language ); if ( $translation_id ) { return array( 'page_id' => $translation_id ); } } $with_endpoint = $this->resolve_page_path_with_endpoints( $path, $language ); if ( $with_endpoint ) { return $with_endpoint; } return null; } /** * Resolve page paths that include WooCommerce endpoints. * * Examples: /de/checkout/order-received/123/ and /de/my-account/orders/. * * @param string $path Path. * @param string $language Language code. * @return array|null */ private function resolve_page_path_with_endpoints( $path, $language ) { $parts = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) ); if ( count( $parts ) < 2 ) { return null; } for ( $length = count( $parts ) - 1; $length >= 1; --$length ) { $base_path = implode( '/', array_slice( $parts, 0, $length ) ); $remaining = array_slice( $parts, $length ); $direct = get_page_by_path( $base_path, OBJECT, 'page' ); if ( ! $direct ) { continue; } $page_id = 0; if ( $this->is_post_language( $direct->ID, $language ) && $this->mapping->is_frontend_ready_translation( $direct->ID ) ) { $page_id = $direct->ID; } elseif ( ! $this->mapping->is_translation( $direct->ID ) ) { $translation_id = $this->mapping->get_ready_translation_id( $direct->ID, $language ); $page_id = $translation_id ? $translation_id : $direct->ID; } if ( $page_id ) { return $this->add_endpoint_vars( array( 'page_id' => $page_id, 'pagename' => get_page_uri( $page_id ), ), $remaining ); } } return null; } /** * Add WooCommerce endpoint query vars to a resolved page request. * * @param array $vars Query vars. * @param array $segments Remaining path segments. * @return array */ private function add_endpoint_vars( array $vars, array $segments ) { if ( empty( $segments ) ) { return $vars; } $endpoint = sanitize_title( array_shift( $segments ) ); $value = ! empty( $segments ) ? sanitize_text_field( rawurldecode( implode( '/', $segments ) ) ) : ''; $map = $this->get_endpoint_map(); if ( isset( $map[ $endpoint ] ) ) { $vars[ $map[ $endpoint ] ] = $value; } else { $vars[ $endpoint ] = $value; } return $vars; } /** * Get endpoint slug to query-var map. * * @return array */ private function get_endpoint_map() { $map = array( 'order-pay' => 'order-pay', 'order-received' => 'order-received', 'orders' => 'orders', 'view-order' => 'view-order', 'downloads' => 'downloads', 'edit-account' => 'edit-account', 'edit-address' => 'edit-address', 'payment-methods' => 'payment-methods', 'add-payment-method' => 'add-payment-method', 'lost-password' => 'lost-password', 'customer-logout' => 'customer-logout', ); if ( function_exists( 'WC' ) && WC() && isset( WC()->query ) ) { foreach ( WC()->query->get_query_vars() as $key => $slug ) { if ( $slug ) { $map[ sanitize_title( $slug ) ] = $key; } $map[ sanitize_title( $key ) ] = $key; } } return $map; } /** * Resolve product path using WooCommerce product base. * * @param string $path Path. * @param string $language Language code. * @return array|null */ private function resolve_product_path( $path, $language ) { $product_bases = $this->get_product_bases(); foreach ( $product_bases as $base ) { $prefix = '' === $base ? '' : trailingslashit( $base ); if ( '' !== $base && 0 !== strpos( $path, $prefix ) ) { continue; } $slug = '' === $base ? basename( $path ) : substr( $path, strlen( $prefix ) ); $slug = trim( $slug, '/' ); if ( false !== strpos( $slug, '/' ) || '' === $slug ) { continue; } $direct = $this->get_product_by_slug_resilient( $slug ); if ( $direct && $this->is_post_language( $direct->ID, $language ) && $this->mapping->is_frontend_ready_translation( $direct->ID ) ) { return array( 'post_type' => 'product', 'p' => $direct->ID, ); } if ( $direct && ! $this->mapping->is_translation( $direct->ID ) ) { $translation_id = $this->mapping->get_ready_translation_id( $direct->ID, $language ); if ( $translation_id ) { return array( 'post_type' => 'product', 'p' => $translation_id, ); } } } return null; } /** * Resolve basic WooCommerce archives. * * @param string $path Path. * @return array|null */ private function resolve_product_archive_path( $path ) { $parts = explode( '/', trim( $path, '/' ) ); if ( function_exists( 'wc_get_page_id' ) ) { $shop_id = wc_get_page_id( 'shop' ); if ( $shop_id > 0 ) { $shop_page = get_post( $shop_id ); if ( $shop_page && $shop_page->post_name === $path ) { return array( 'post_type' => 'product' ); } } } if ( isset( $parts[0], $parts[1] ) && 'product-category' === $parts[0] ) { return array( 'post_type' => 'product', 'product_cat' => sanitize_title( end( $parts ) ), ); } if ( isset( $parts[0], $parts[1] ) && 'product-tag' === $parts[0] ) { return array( 'post_type' => 'product', 'product_tag' => sanitize_title( end( $parts ) ), ); } return null; } /** * Filter links to translations so they include the language prefix. * * @param string $url URL. * @param \WP_Post $post Post object. * @return string */ public function filter_post_link( $url, $post ) { return $this->filter_translation_link( $url, $post ); } /** * Filter page links. * * @param string $url URL. * @param int $post_id Post ID. * @return string */ public function filter_page_link( $url, $post_id ) { return $this->filter_translation_link( $url, get_post( $post_id ) ); } /** * Filter post type links. * * @param string $url URL. * @param \WP_Post $post Post object. * @return string */ public function filter_post_type_link( $url, $post ) { return $this->filter_translation_link( $url, $post ); } /** * Prefix links for translation posts. * * @param string $url URL. * @param \WP_Post|null $post Post object. * @return string */ private function filter_translation_link( $url, $post ) { if ( ! $post instanceof \WP_Post ) { return $url; } $row = $this->mapping->get_by_translation_id( $post->ID ); $lang = $row ? sanitize_key( (string) $row->language_code ) : ''; if ( $lang && $this->is_language_homepage( $post->ID, $row ) ) { return $this->preserve_url_suffix( $this->get_language_home_url( $lang ), $url ); } return $lang ? $this->prefix_url( $url, $lang ) : $url; } /** * Check whether a translated post is configured as the language homepage. * * @param int $post_id Post ID. * @param object|null $mapping_row Translation mapping row. * @return bool */ private function is_language_homepage( $post_id, $mapping_row = null ) { $post_id = absint( $post_id ); if ( ! $post_id || 'page' !== get_option( 'show_on_front' ) ) { return false; } $language = $mapping_row && isset( $mapping_row->language_code ) ? sanitize_key( (string) $mapping_row->language_code ) : ''; if ( $language ) { $settings = $this->languages->get_settings(); $homepages = isset( $settings['homepages'] ) && is_array( $settings['homepages'] ) ? $settings['homepages'] : array(); if ( ! empty( $homepages[ $language ] ) && $post_id === absint( $homepages[ $language ] ) ) { return true; } } $front_id = absint( get_option( 'page_on_front' ) ); if ( $front_id && $post_id === $front_id ) { return true; } return $mapping_row && ! empty( $mapping_row->original_id ) && $front_id === absint( $mapping_row->original_id ); } /** * Preserve an existing permalink query and fragment on a replacement URL. * * @param string $base_url Replacement URL. * @param string $source_url Original URL. * @return string */ private function preserve_url_suffix( $base_url, $source_url ) { if ( ! is_string( $source_url ) || '' === $source_url ) { return $base_url; } $parts = wp_parse_url( $source_url ); if ( ! is_array( $parts ) ) { return $base_url; } if ( ! empty( $parts['query'] ) ) { $base_url = add_query_arg( wp_parse_args( $parts['query'] ), $base_url ); } if ( ! empty( $parts['fragment'] ) ) { $base_url .= '#' . $parts['fragment']; } return $base_url; } /** * Redirect direct unprefixed translation URLs to the canonical prefixed URL. * * @return void */ public function redirect_unprefixed_translation() { if ( is_admin() || isset( $_REQUEST['elementor-preview'] ) || isset( $_REQUEST['elementor_library'] ) || ! is_singular() || get_query_var( 'sitelang_lang' ) ) { return; } $post_id = get_queried_object_id(); if ( ! $post_id ) { return; } $row = $this->mapping->get_by_translation_id( $post_id ); $lang = $row ? sanitize_key( (string) $row->language_code ) : ''; if ( $lang ) { wp_safe_redirect( $this->prefix_url( get_permalink( $post_id ), $lang ), 301 ); exit; } } /** * Build a URL for a target language and post. * * @param int $post_id Post ID. * @param string $language Language code. * @return string */ public function get_language_url_for_post( $post_id, $language ) { $language = $this->languages->sanitize_code( $language ); $default_code = $this->languages->get_default_code(); $root_id = $this->mapping->get_root_id( $post_id ); $previous_language = $this->languages->get_current(); // A permalink requested for another language must not inherit the current URL's language. $this->languages->set_current( $language ); try { if ( $language === $default_code ) { return get_permalink( $root_id ); } $target_id = $this->mapping->get_ready_translation_id( $root_id, $language ); if ( $target_id ) { if ( $this->is_language_homepage( $target_id, $this->mapping->get_by_translation_id( $target_id ) ) ) { return $this->get_language_home_url( $language ); } return get_permalink( $target_id ); } return $this->get_language_home_url( $language ); } finally { $this->languages->set_current( $previous_language ); } } /** * Get current page language URL. * * @param string $language Language code. * @return string */ public function get_current_language_url( $language ) { if ( is_singular() ) { return $this->get_language_url_for_post( get_queried_object_id(), $language ); } $language = $this->languages->sanitize_code( $language ); if ( $language === $this->languages->get_default_code() ) { return $this->get_unprefixed_home_url(); } return $this->get_language_home_url( $language ); } /** * Get prefixed language homepage URL. * * @param string $language Language code. * @return string */ public function get_language_home_url( $language ) { $language = $this->languages->sanitize_code( $language ); if ( $language === $this->languages->get_default_code() ) { return $this->get_unprefixed_home_url(); } return home_url( user_trailingslashit( $language ) ); } /** * Get the site's configured home URL without applying this router's language filter. * * @return string */ private function get_unprefixed_home_url() { $home = get_option( 'home' ); return trailingslashit( untrailingslashit( (string) $home ) ); } /** * Keep home links (logo/home button) in current language. * * @param string $url Full URL. * @param string $path Path passed to home_url(). * @param string|null $orig_scheme Original scheme. * @param int|null $blog_id Blog ID. * @return string */ public function filter_home_url( $url, $path, $orig_scheme, $blog_id ) { if ( $this->should_bypass_runtime_localization() ) { return $url; } $language = $this->languages->get_current(); if ( ! $language || $language === $this->languages->get_default_code() ) { return $url; } $path = is_string( $path ) ? trim( $path ) : ''; if ( '' !== $path && '/' !== $path ) { return $url; } return $this->get_language_home_url( $language ); } /** * Prefix an existing site URL with a language directory. * * @param string $url URL. * @param string $language Language code. * @return string */ public function prefix_url( $url, $language ) { $language = $this->languages->sanitize_code( $language ); if ( $language === $this->languages->get_default_code() || '' === $language ) { return $url; } $home = home_url( '/' ); $home_parts = wp_parse_url( $home ); $url_parts = wp_parse_url( $url ); if ( empty( $url_parts['host'] ) ) { $url_parts = wp_parse_url( home_url( $url ) ); } if ( empty( $home_parts['host'] ) || empty( $url_parts['host'] ) || strtolower( $home_parts['host'] ) !== strtolower( $url_parts['host'] ) ) { return $url; } $path = isset( $url_parts['path'] ) ? ltrim( $url_parts['path'], '/' ) : ''; if ( ! empty( $home_parts['path'] ) ) { $home_path = trim( $home_parts['path'], '/' ); if ( '' !== $home_path && 0 === strpos( $path, $home_path . '/' ) ) { $path = substr( $path, strlen( $home_path ) + 1 ); } } if ( $this->is_wordpress_system_path( $path ) ) { return $url; } if ( 0 === strpos( $path, $language . '/' ) ) { return $url; } $path_parts = explode( '/', $path ); if ( $this->languages->is_enabled( $path_parts[0] ?? '' ) ) { array_shift( $path_parts ); $path = implode( '/', $path_parts ); } $prefixed = home_url( user_trailingslashit( trim( $language . '/' . $path, '/' ) ) ); if ( ! empty( $url_parts['query'] ) ) { $prefixed .= '?' . $url_parts['query']; } if ( ! empty( $url_parts['fragment'] ) ) { $prefixed .= '#' . $url_parts['fragment']; } return $prefixed; } /** * Localize menu item URLs/titles for current language. * * @param array $items Menu items. * @param object $args Menu args. * @return array */ public function localize_menu_items( $items, $args ) { if ( $this->should_bypass_runtime_localization() || empty( $items ) || ! is_array( $items ) ) { return $items; } $language = $this->languages->get_current(); if ( ! $language || $language === $this->languages->get_default_code() ) { return $items; } foreach ( $items as $item ) { if ( ! $item instanceof \WP_Post ) { continue; } $menu_translation = $this->get_menu_item_translation( $item->ID, $language ); if ( ! empty( $menu_translation['title'] ) ) { $item->title = $menu_translation['title']; } // Post/page/product menu items: switch to linked translation when available. if ( isset( $item->type ) && 'post_type' === $item->type && ! empty( $item->object_id ) ) { $object_id = absint( $item->object_id ); $root_id = $this->mapping->get_root_id( $object_id ); if ( ! $root_id ) { $root_id = $object_id; } $target_id = $this->mapping->get_ready_translation_id( $root_id, $language ); if ( $target_id && 'publish' === get_post_status( $target_id ) ) { $item->url = get_permalink( $target_id ); $original_title = get_the_title( $root_id ); $current_title = isset( $item->title ) ? wp_strip_all_tags( (string) $item->title ) : ''; if ( $original_title && $current_title && 0 === strcasecmp( trim( $current_title ), trim( $original_title ) ) ) { $item->title = get_the_title( $target_id ); } continue; } } // Custom links and fallbacks: prefix local URLs with current language. if ( isset( $item->url ) && is_string( $item->url ) && '' !== $item->url ) { $item->url = ! empty( $menu_translation['url'] ) ? $menu_translation['url'] : $this->prefix_url( $item->url, $language ); } } return $items; } /** * Get saved menu item translation. * * @param int $menu_item_id Menu item ID. * @param string $language Language code. * @return array */ private function get_menu_item_translation( $menu_item_id, $language ) { $translations = get_option( 'sitelang_menu_translations', array() ); if ( ! is_array( $translations ) ) { return array(); } $menu_item_id = absint( $menu_item_id ); $language = $this->languages->sanitize_code( $language ); if ( empty( $translations[ $menu_item_id ][ $language ] ) || ! is_array( $translations[ $menu_item_id ][ $language ] ) ) { return array(); } $row = $translations[ $menu_item_id ][ $language ]; return array( 'title' => ! empty( $row['title'] ) ? sanitize_text_field( $row['title'] ) : '', 'url' => ! empty( $row['url'] ) ? esc_url_raw( $row['url'] ) : '', ); } /** * Remove a language prefix from a local URL. * * @param string $url URL. * @return string */ public function unprefix_url( $url ) { $home = home_url( '/' ); $url_parts = wp_parse_url( $url ); if ( empty( $url_parts['host'] ) ) { return $url; } $path = isset( $url_parts['path'] ) ? ltrim( $url_parts['path'], '/' ) : ''; $parts = explode( '/', $path ); if ( ! $this->languages->is_enabled( $parts[0] ?? '' ) ) { return $url; } array_shift( $parts ); $clean = home_url( user_trailingslashit( implode( '/', $parts ) ) ); if ( ! empty( $url_parts['query'] ) ) { $clean .= '?' . $url_parts['query']; } if ( ! empty( $url_parts['fragment'] ) ) { $clean .= '#' . $url_parts['fragment']; } return $clean ? $clean : $home; } /** * Whether a URL path belongs to WordPress/core/plugin system endpoints. * * These paths must never receive a language prefix because doing so can * break admin login cookies, AJAX, REST calls, static assets, and cron. * * @param string $path Relative URL path. * @return bool */ private function is_wordpress_system_path( $path ) { $path = ltrim( (string) $path, '/' ); if ( '' === $path ) { return false; } $first_segment = strtok( $path, '/' ); $blocked = array( 'wp-admin', 'wp-login.php', 'wp-json', 'wp-content', 'wp-includes', 'xmlrpc.php', 'wp-cron.php', 'wp-comments-post.php', ); return in_array( $first_segment, $blocked, true ); } /** * Is a post assigned to the language. * * @param int $post_id Post ID. * @param string $language Language. * @return bool */ private function is_post_language( $post_id, $language ) { $row = $this->mapping->get_by_translation_id( absint( $post_id ) ); return $row && $language === sanitize_key( (string) $row->language_code ); } /** * Resolve a product slug with a direct database fallback. * * Persistent object caches can occasionally retain a negative * get_page_by_path() result after a translated product is published. The * fallback only runs after the normal lookup misses and prevents a stale * cache entry from turning a valid translated URL into a long-lived 404. * * @param string $slug Product slug. * @return \WP_Post|null */ private function get_product_by_slug_resilient( $slug ) { $slug = sanitize_title( $slug ); if ( '' === $slug ) { return null; } $product = get_page_by_path( $slug, OBJECT, 'product' ); if ( $product instanceof \WP_Post ) { return $product; } global $wpdb; $product_id = absint( $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish' ORDER BY ID DESC LIMIT 1", $slug ) ) ); if ( ! $product_id ) { return null; } clean_post_cache( $product_id ); $product = get_post( $product_id ); return $product instanceof \WP_Post ? $product : null; } /** * Purge route-related caches when a translation is published or drafted. * * @param string $new_status New post status. * @param string $old_status Previous post status. * @param \WP_Post $post Post object. * @return void */ public function purge_translation_route_cache( $new_status, $old_status, $post ) { if ( $new_status === $old_status || ! $post instanceof \WP_Post ) { return; } $row = $this->mapping->get_by_translation_id( $post->ID ); if ( ! $row ) { return; } $this->mapping->invalidate_translation( $post->ID ); clean_post_cache( $post->ID ); clean_post_cache( (int) $row->original_id ); if ( function_exists( 'wc_delete_product_transients' ) && 'product' === $post->post_type ) { wc_delete_product_transients( $post->ID ); } if ( function_exists( 'rocket_clean_post' ) ) { rocket_clean_post( $post->ID ); } if ( function_exists( 'w3tc_flush_post' ) ) { w3tc_flush_post( $post->ID ); } if ( function_exists( 'sg_cachepress_purge_cache' ) ) { sg_cachepress_purge_cache(); } do_action( 'litespeed_purge_post', $post->ID ); $url = get_permalink( $post->ID ); if ( $url ) { do_action( 'litespeed_purge_url', $url ); } } /** * Get product bases. * * @return array */ private function get_product_bases() { $bases = array( 'product' ); if ( function_exists( 'wc_get_permalink_structure' ) ) { $structure = wc_get_permalink_structure(); if ( ! empty( $structure['product_base'] ) ) { $base = trim( preg_replace( '#%product_cat%#', '', $structure['product_base'] ), '/' ); $base = trim( $base, '/' ); if ( '' !== $base ) { array_unshift( $bases, $base ); } } } return array_values( array_unique( array_filter( $bases ) ) ); } /** * Get enabled language regex. * * @return string */ private function language_regex() { $codes = array_map( 'preg_quote', $this->languages->get_enabled_codes() ); return implode( '|', $codes ); } /** * Flush rewrite rules only when language or URL settings change. * * @return void */ public function maybe_flush_rewrites() { if ( get_option( 'sitelang_rewrite_flush_needed' ) ) { flush_rewrite_rules( false ); delete_option( 'sitelang_rewrite_flush_needed' ); } } /** * Send no-cache headers for translated frontend URLs. * * @return void */ public function send_language_no_cache_headers() { if ( ! get_query_var( 'sitelang_lang' ) ) { return; } nocache_headers(); header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0', true ); } /** * Tell common cache plugins not to cache translated pages. * * @return void */ private function maybe_disable_language_page_cache() { foreach ( array( 'DONOTCACHEPAGE', 'DONOTCACHEDB', 'DONOTCACHEOBJECT' ) as $sitelang_constant ) { if ( ! defined( $sitelang_constant ) ) { define( $sitelang_constant, true ); } } } /** * Localize fetched post objects (templates/blocks/layouts) to current language. * * This makes theme builders that load content by original post ID automatically * receive translated objects on language-prefixed URLs. * * @param \WP_Post|null $post Post object. * @param string $output Output type. * @param string $filter Filter context. * @return \WP_Post|null */ public function localize_post_object( $post, $output, $filter ) { if ( ! $post instanceof \WP_Post || $this->should_bypass_runtime_localization() ) { return $post; } $language = $this->languages->get_current(); if ( ! $language || $language === $this->languages->get_default_code() ) { return $post; } if ( ! in_array( $post->post_type, $this->localizable_post_types(), true ) ) { return $post; } static $resolving = array(); if ( ! empty( $resolving[ $post->ID ] ) ) { return $post; } $target_id = 0; if ( $this->mapping->is_translation( $post->ID ) ) { $current_language = get_post_meta( $post->ID, '_sitelang_language', true ); if ( $language === $current_language ) { return $post; } $root_id = $this->mapping->get_root_id( $post->ID ); if ( $root_id ) { $target_id = $this->resolve_runtime_translation_id( $root_id, $post->post_type, $language ); } } else { $target_id = $this->resolve_runtime_translation_id( $post->ID, $post->post_type, $language ); } if ( ! $target_id || $target_id === $post->ID || 'publish' !== get_post_status( $target_id ) ) { return $post; } $resolving[ $post->ID ] = true; $localized = get_post( $target_id ); unset( $resolving[ $post->ID ] ); return $localized instanceof \WP_Post ? $localized : $post; } /** * Resolve translation ID for runtime object localization. * * Frontend page/product routing remains strict (complete + publish). Builder * components should still localize when published, even if status is still * in progress, so layout pieces can render translated while editing. * * @param int $root_id Root/original post ID. * @param string $post_type Post type. * @param string $language Language code. * @return int */ private function resolve_runtime_translation_id( $root_id, $post_type, $language ) { $root_id = absint( $root_id ); if ( ! $root_id ) { return 0; } if ( $this->is_strict_ready_post_type( $post_type ) ) { return $this->mapping->get_ready_translation_id( $root_id, $language ); } $target_id = $this->mapping->get_translation_id( $root_id, $language ); return $target_id && 'publish' === get_post_status( $target_id ) ? (int) $target_id : 0; } /** * Post types that should auto-localize when fetched by ID on frontend. * * @return array */ private function localizable_post_types() { $types = array( 'page', 'post', 'product', 'elementor_library', 'cms_block', 'woodmart_layout', 'woodmart_slide', 'wd_popup', 'wd_floating_block', 'e-floating-buttons', 'wp_block', 'wp_template', 'wp_template_part', ); /** * Filter post types that should auto-localize on frontend object fetches. * * @param array $types Post types. */ $types = apply_filters( 'sitelang_localizable_post_types', $types ); return array_values( array_unique( array_filter( array_map( 'sanitize_key', (array) $types ) ) ) ); } /** * Post types that should require "complete" status for frontend replacement. * * @param string $post_type Post type. * @return bool */ private function is_strict_ready_post_type( $post_type ) { return in_array( sanitize_key( $post_type ), array( 'page', 'post', 'product' ), true ); } /** * Localize frontend query result posts for builder-controlled content. * * Woodmart sliders query `woodmart_slide` posts by taxonomy instead of * loading one known post ID. This filter swaps those query results to the * current language and prevents default-language sliders from showing their * translated duplicates. * * @param array $posts Query result posts. * @param \WP_Query $query Query object. * @return array */ public function localize_post_results( $posts, $query ) { if ( empty( $posts ) || ! is_array( $posts ) || ! $query instanceof \WP_Query || $this->should_bypass_runtime_localization() ) { return $posts; } if ( ! $this->query_contains_localizable_builder_posts( $posts, $query ) ) { return $posts; } $language = $this->languages->get_current(); $default_code = $this->languages->get_default_code(); $localized = array(); $seen_roots = array(); foreach ( $posts as $post ) { if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, $this->query_localizable_builder_post_types(), true ) ) { $localized[] = $post; continue; } $root_id = $this->mapping->get_root_id( $post->ID ); if ( ! $root_id ) { $root_id = $post->ID; } if ( isset( $seen_roots[ $root_id ] ) ) { continue; } $seen_roots[ $root_id ] = true; if ( $language === $default_code ) { if ( ! $this->mapping->is_translation( $post->ID ) ) { $localized[] = $post; } continue; } $target_id = $this->resolve_runtime_translation_id( $root_id, $post->post_type, $language ); if ( $target_id ) { $target = get_post( $target_id ); if ( $target instanceof \WP_Post ) { $localized[] = $target; continue; } } if ( ! $this->mapping->is_translation( $post->ID ) ) { $localized[] = $post; } } return $localized; } /** * Whether the query is for builder/slider content that can be localized. * * @param array $posts Query result posts. * @param \WP_Query $query Query object. * @return bool */ private function query_contains_localizable_builder_posts( array $posts, \WP_Query $query ) { $query_types = (array) $query->get( 'post_type' ); $builder_types = $this->query_localizable_builder_post_types(); if ( array_intersect( array_map( 'sanitize_key', $query_types ), $builder_types ) ) { return true; } foreach ( $posts as $post ) { if ( $post instanceof \WP_Post && in_array( $post->post_type, $builder_types, true ) ) { return true; } } return false; } /** * Builder post types that are commonly queried as reusable components. * * @return array */ private function query_localizable_builder_post_types() { return array( 'elementor_library', 'cms_block', 'woodmart_layout', 'woodmart_slide', 'wd_popup', 'wd_floating_block', 'e-floating-buttons', 'wp_block', 'wp_template', 'wp_template_part', ); } /** * Localize Woodmart option IDs that reference blocks/layouts/templates. * * @param mixed $options Option value. * @return mixed */ public function localize_woodmart_options( $options ) { if ( $this->should_bypass_runtime_localization() || ! is_array( $options ) ) { return $options; } $language = $this->languages->get_current(); if ( ! $language || $language === $this->languages->get_default_code() ) { return $options; } $this->localize_option_ids_recursively( $options, '', $language ); return $options; } /** * Localize Woodmart layout conditions map by current language. * * Woodmart stores layout IDs as array keys (e.g. single_product => [15997 => ...]). * We normalize these keys so frontend chooses the language-specific layout. * * @param mixed $conditions Option value. * @return mixed */ public function localize_woodmart_layout_conditions( $conditions ) { if ( $this->should_bypass_runtime_localization() || ! is_array( $conditions ) ) { return $conditions; } $language = $this->languages->get_current(); if ( ! $language ) { return $conditions; } $default_code = $this->languages->get_default_code(); foreach ( $conditions as $group_key => $group_conditions ) { if ( ! is_array( $group_conditions ) ) { continue; } $localized_group = array(); foreach ( $group_conditions as $layout_id_key => $rules ) { $layout_id = absint( $layout_id_key ); if ( ! $layout_id ) { $localized_group[ $layout_id_key ] = $rules; continue; } $layout = get_post( $layout_id ); if ( ! $layout || 'woodmart_layout' !== $layout->post_type ) { $localized_group[ $layout_id_key ] = $rules; continue; } $root_id = $this->mapping->get_root_id( $layout_id ); if ( ! $root_id ) { $root_id = $layout_id; } $target_id = $root_id; if ( $language !== $default_code ) { $resolved = $this->resolve_runtime_translation_id( $root_id, 'woodmart_layout', $language ); if ( $resolved ) { $target_id = $resolved; } } $target_key = (string) $target_id; if ( ! isset( $localized_group[ $target_key ] ) ) { $localized_group[ $target_key ] = array(); } if ( is_array( $rules ) ) { $localized_group[ $target_key ] = array_merge( $localized_group[ $target_key ], $rules ); } } $conditions[ $group_key ] = $localized_group; } return $conditions; } /** * Localize post meta values that store template/layout/block post IDs. * * Some theme builders (including WooCommerce/Elementor integrations) read * layout references from post meta instead of theme options, so we map those * IDs to language-specific translations on frontend requests. * * @param mixed $value Existing filtered value. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param bool $single Whether single meta value was requested. * @return mixed */ public function localize_post_meta_reference( $value, $object_id, $meta_key, $single ) { if ( $this->should_bypass_runtime_localization() || ! is_string( $meta_key ) || '' === $meta_key ) { return $value; } $language = $this->languages->get_current(); if ( ! $language || $language === $this->languages->get_default_code() ) { return $value; } if ( ! $this->is_option_key_likely_post_reference( $meta_key ) ) { return $value; } if ( ! function_exists( 'get_metadata_raw' ) ) { return $value; } static $resolving = false; if ( $resolving ) { return $value; } $resolving = true; $raw = get_metadata_raw( 'post', absint( $object_id ), $meta_key, $single ); $resolving = false; if ( null === $raw ) { return $value; } if ( $single ) { $localized = $this->localize_single_meta_reference_value( $raw, $language ); return null !== $localized ? $localized : $raw; } if ( ! is_array( $raw ) ) { return $raw; } foreach ( $raw as $index => $item ) { $localized = $this->localize_single_meta_reference_value( $item, $language ); if ( null !== $localized ) { $raw[ $index ] = $localized; } } return $raw; } /** * Map one post meta scalar reference to translated post ID when possible. * * @param mixed $value Meta value. * @param string $language Language code. * @return int|string|null */ private function localize_single_meta_reference_value( $value, $language ) { if ( ! is_numeric( $value ) ) { return null; } $original_id = absint( $value ); if ( ! $original_id ) { return null; } $post = get_post( $original_id ); if ( ! $post || ! in_array( $post->post_type, $this->localizable_post_types(), true ) ) { return null; } $root_id = $this->mapping->get_root_id( $original_id ); $target_id = $root_id ? $this->resolve_runtime_translation_id( $root_id, $post->post_type, $language ) : 0; if ( ! $target_id ) { return null; } return is_int( $value ) ? (int) $target_id : (string) $target_id; } /** * Recursively localize numeric post IDs in option arrays for relevant keys. * * @param array $node Option node by reference. * @param string $parent_key Parent key name. * @param string $language Language code. * @return void */ private function localize_option_ids_recursively( array &$node, $parent_key, $language ) { foreach ( $node as $key => &$value ) { $key_name = is_string( $key ) ? $key : (string) $parent_key; if ( is_array( $value ) ) { $this->localize_option_ids_recursively( $value, $key_name, $language ); continue; } if ( ! $this->is_option_key_likely_post_reference( $key_name ) ) { continue; } if ( ! is_numeric( $value ) ) { continue; } $original_id = absint( $value ); if ( ! $original_id ) { continue; } $post = get_post( $original_id ); if ( ! $post || ! in_array( $post->post_type, $this->localizable_post_types(), true ) ) { continue; } $root_id = $this->mapping->get_root_id( $original_id ); $target_id = $root_id ? $this->resolve_runtime_translation_id( $root_id, $post->post_type, $language ) : 0; if ( ! $target_id || 'publish' !== get_post_status( $target_id ) ) { continue; } $value = is_int( $value ) ? (int) $target_id : (string) $target_id; } } /** * Whether an option key likely stores a post ID reference. * * @param string $key Option key name. * @return bool */ private function is_option_key_likely_post_reference( $key ) { return (bool) preg_match( '/(block|layout|template|footer|header|popup|sidebar|html|static|slider|slide|carousel)/i', (string) $key ); } /** * Whether runtime localization should be bypassed for current request. * * @return bool */ private function should_bypass_runtime_localization() { if ( is_admin() ) { return true; } if ( $this->is_login_or_admin_path_request() ) { return true; } if ( defined( 'DOING_CRON' ) && DOING_CRON ) { return true; } if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { return true; } if ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() ) { return true; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( 'elementor' === $action || 'elementor_ajax' === $action ) { return true; } if ( isset( $_REQUEST['elementor-preview'] ) || isset( $_REQUEST['elementor_library'] ) ) { return true; } if ( class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance ) ) { try { if ( isset( \Elementor\Plugin::$instance->editor ) && method_exists( \Elementor\Plugin::$instance->editor, 'is_edit_mode' ) && \Elementor\Plugin::$instance->editor->is_edit_mode() ) { return true; } if ( isset( \Elementor\Plugin::$instance->preview ) && method_exists( \Elementor\Plugin::$instance->preview, 'is_preview_mode' ) && \Elementor\Plugin::$instance->preview->is_preview_mode() ) { return true; } } catch ( \Throwable $e ) { return true; } } return false; } /** * Detect login/admin URLs before WordPress has a normal admin context. * * wp-login.php is not is_admin(), but URL localization must never run * there because it can interfere with auth redirects and cookies. * * @return bool */ private function is_login_or_admin_path_request() { global $pagenow; if ( isset( $pagenow ) && in_array( $pagenow, array( 'wp-login.php', 'wp-register.php' ), true ) ) { return true; } $script = isset( $_SERVER['SCRIPT_NAME'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : ''; $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; return false !== strpos( $script, 'wp-login.php' ) || false !== strpos( $uri, '/wp-login.php' ) || false !== strpos( $uri, '/wp-admin/' ); } }