'Polylang', 'wpml' => 'WPML', 'translatepress' => 'TranslatePress', 'weglot' => 'Weglot', default => '', }; } public static function is_multilingual(): bool { return count( self::languages() ) > 1; } /** * Whether translations are separate posts (Polylang, WPML) or the same post * rendered in another language (TranslatePress, Weglot). * * Anything that walks the post table — the content list, the audit, bulk * jobs — needs to know, because on a per-post plugin every language is a * row and on a per-request plugin no language is. */ public static function has_post_translations(): bool { return in_array( self::plugin(), array( 'polylang', 'wpml' ), true ); } /** * Every language the site publishes. * * @return array */ public static function languages(): array { static $cache = null; if ( null !== $cache ) { return $cache; } $out = array(); try { switch ( self::plugin() ) { case 'polylang': $default = function_exists( 'pll_default_language' ) ? (string) pll_default_language() : ''; foreach ( (array) pll_the_languages( array( 'raw' => 1, 'hide_if_empty' => 0 ) ) as $language ) { $code = (string) ( $language['slug'] ?? '' ); if ( '' === $code ) { continue; } $out[ $code ] = array( 'code' => $code, 'locale' => str_replace( '_', '-', (string) ( $language['locale'] ?? $code ) ), 'label' => (string) ( $language['name'] ?? $code ), 'url' => (string) ( $language['url'] ?? '' ), 'default' => $code === $default, ); } break; case 'wpml': $default = (string) apply_filters( 'wpml_default_language', null ); $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) ); foreach ( (array) $languages as $code => $language ) { $code = (string) ( $language['language_code'] ?? $code ); if ( '' === $code ) { continue; } $out[ $code ] = array( 'code' => $code, 'locale' => str_replace( '_', '-', (string) ( $language['default_locale'] ?? $code ) ), 'label' => (string) ( $language['translated_name'] ?? $language['native_name'] ?? $code ), 'url' => (string) ( $language['url'] ?? '' ), 'default' => $code === $default, ); } break; case 'translatepress': $settings = get_option( 'trp_settings', array() ); $all = (array) ( $settings['translation-languages'] ?? array() ); $names = (array) ( $settings['publish-languages'] ?? $all ); $default = (string) ( $settings['default-language'] ?? '' ); $converter = class_exists( 'TRP_Language_Converter' ) ? null : null; unset( $converter, $names ); foreach ( $all as $locale ) { $locale = (string) $locale; if ( '' === $locale ) { continue; } $code = strtolower( (string) strtok( $locale, '_' ) ); $out[ $code ] = array( 'code' => $code, 'locale' => str_replace( '_', '-', $locale ), 'label' => $locale, 'url' => self::translatepress_url( $locale, $default ), 'default' => $locale === $default, ); } break; case 'weglot': $original = function_exists( 'weglot_get_original_language' ) ? (string) weglot_get_original_language() : ''; $codes = function_exists( 'weglot_get_destination_languages' ) ? (array) weglot_get_destination_languages() : array(); if ( '' !== $original ) { array_unshift( $codes, $original ); } foreach ( $codes as $code ) { // Weglot returns either a string code or a language object. $code = is_array( $code ) ? (string) ( $code['language_to'] ?? '' ) : (string) $code; if ( '' === $code ) { continue; } $out[ $code ] = array( 'code' => $code, 'locale' => $code, 'label' => strtoupper( $code ), 'url' => function_exists( 'weglot_get_full_url_no_lang' ) ? (string) weglot_get_full_url_no_lang() : '', 'default' => $code === $original, ); } break; } } catch ( \Throwable $e ) { $out = array(); } if ( empty( $out ) ) { $locale = (string) get_bloginfo( 'language' ); $code = strtolower( (string) strtok( str_replace( '-', '_', $locale ), '_' ) ); $out[ $code ] = array( 'code' => $code, 'locale' => $locale, 'label' => $locale, 'url' => (string) home_url( '/' ), 'default' => true, ); } $cache = $out; return $out; } /** * TranslatePress puts the language in a path prefix, and the default * language may or may not have one depending on a setting. */ private static function translatepress_url( string $locale, string $default ): string { $home = (string) home_url( '/' ); if ( $locale === $default ) { return $home; } $settings = get_option( 'trp_settings', array() ); $slugs = (array) ( $settings['url-slugs'] ?? array() ); $slug = (string) ( $slugs[ $locale ] ?? strtolower( (string) strtok( $locale, '_' ) ) ); return trailingslashit( $home ) . $slug . '/'; } /** * The language code of the current request. */ public static function current(): string { try { switch ( self::plugin() ) { case 'polylang': return function_exists( 'pll_current_language' ) ? (string) pll_current_language() : ''; case 'wpml': return (string) apply_filters( 'wpml_current_language', null ); case 'translatepress': $settings = get_option( 'trp_settings', array() ); $locale = (string) ( $GLOBALS['TRP_LANGUAGE'] ?? ( $settings['default-language'] ?? '' ) ); return strtolower( (string) strtok( $locale, '_' ) ); case 'weglot': return function_exists( 'weglot_get_current_language' ) ? (string) weglot_get_current_language() : ''; } } catch ( \Throwable $e ) { return ''; } $languages = self::languages(); return (string) ( array_key_first( $languages ) ?? '' ); } /** * The language a specific post is written in. * * Empty on the per-request plugins, where a post has no single language. */ public static function post_language( int $post_id ): string { try { switch ( self::plugin() ) { case 'polylang': return function_exists( 'pll_get_post_language' ) ? (string) pll_get_post_language( $post_id ) : ''; case 'wpml': $info = apply_filters( 'wpml_post_language_details', null, $post_id ); return is_array( $info ) ? (string) ( $info['language_code'] ?? '' ) : ''; } } catch ( \Throwable $e ) { return ''; } return ''; } /** * hreflang alternates for the current request, including x-default. * * Google treats a set of alternates as valid only when every version links * to every other one and to itself, so the current language is included * deliberately rather than skipped. * * @return array locale => URL */ public static function alternates( ?WP_Post $post = null ): array { $languages = self::languages(); if ( count( $languages ) < 2 ) { return array(); } $out = array(); $default = ''; foreach ( $languages as $language ) { $url = (string) $language['url']; // On per-post plugins the alternate is the translated post, not the // language home page — linking every article to the front page is // the single most common hreflang mistake. if ( $post instanceof WP_Post && self::has_post_translations() ) { $translated = self::translated_id( $post->ID, (string) $language['code'] ); if ( $translated > 0 ) { $url = (string) get_permalink( $translated ); } else { continue; // No translation of this post: no alternate to claim. } } if ( '' === $url ) { continue; } $out[ (string) $language['locale'] ] = $url; if ( ! empty( $language['default'] ) ) { $default = $url; } } if ( '' !== $default && count( $out ) > 1 ) { $out['x-default'] = $default; } return $out; } /** * The URL of a page in another language, for plugins that translate URLs * rather than posts (TranslatePress, Weglot) — and, via the plugin's own * filter, for WPML. Returns '' when the plugin cannot say. */ public static function url_for_language( string $url, string $code ): string { if ( '' === $url || '' === $code ) { return ''; } $plugin = self::plugin(); if ( 'translatepress' === $plugin && class_exists( 'TRP_Translate_Press' ) ) { try { $trp = \TRP_Translate_Press::get_trp_instance(); $converter = $trp ? $trp->get_component( 'url_converter' ) : null; if ( $converter && method_exists( $converter, 'get_url_for_language' ) ) { return (string) $converter->get_url_for_language( $code, $url, '' ); } } catch ( \Throwable $e ) { return ''; } } if ( 'wpml' === $plugin ) { return (string) apply_filters( 'wpml_permalink', $url, $code, true ); } if ( 'weglot' === $plugin ) { $p = wp_parse_url( $url ); if ( ! $p || empty( $p['host'] ) ) { return ''; } $path = '/' . ltrim( (string) ( $p['path'] ?? '/' ), '/' ); return ( $p['scheme'] ?? 'https' ) . '://' . $p['host'] . '/' . $code . $path . ( isset( $p['query'] ) ? '?' . $p['query'] : '' ); } return ''; } /** * The post ID of a translation, or 0. */ public static function translated_id( int $post_id, string $language ): int { try { switch ( self::plugin() ) { case 'polylang': return function_exists( 'pll_get_post' ) ? (int) pll_get_post( $post_id, $language ) : 0; case 'wpml': $type = (string) get_post_type( $post_id ); return (int) apply_filters( 'wpml_object_id', $post_id, $type, false, $language ); } } catch ( \Throwable $e ) { return 0; } return 0; } /** * Restrict a WP_Query to one language, in whatever way the plugin wants. * * @param array $args Query args. * @param string $language Language code, or '' for all. * @return array */ public static function scope_query( array $args, string $language ): array { if ( '' === $language ) { return $args; } if ( ! self::has_post_translations() ) { // URL-based site: restrict to posts that have a URL in this // language, through a join on the URL inventory rather than an // id list. Registered once and removed after the query runs. if ( ! self::urls_have_languages() ) { return $args; } $lang = sanitize_key( $language ); $join = static function ( string $clause ) use ( $lang ): string { global $wpdb; $urls = \AiRankSuite\Support\Database::urls(); return $clause . $wpdb->prepare( " INNER JOIN {$urls} airanksuite_u ON airanksuite_u.post_id = {$wpdb->posts}.ID AND airanksuite_u.lang = %s", $lang ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared }; // One row per post however many URL rows matched. $group = static function ( string $groupby ): string { global $wpdb; return '' === trim( $groupby ) ? "{$wpdb->posts}.ID" : $groupby; }; add_filter( 'posts_join', $join ); add_filter( 'posts_groupby', $group ); // Both filters come off again the moment this query has run — // they must never touch the next query on the request, and // removing them by reference leaves other plugins' filters alone. $cleanup = static function ( $posts ) use ( $join, $group, &$cleanup ) { remove_filter( 'posts_join', $join ); remove_filter( 'posts_groupby', $group ); remove_filter( 'the_posts', $cleanup ); return $posts; }; add_filter( 'the_posts', $cleanup ); return $args; } if ( 'polylang' === self::plugin() ) { $args['lang'] = $language; return $args; } // WPML switches globally rather than per query; the caller wraps the // query in switch_language() instead. return $args; } /** * Run a callback with WPML switched to a language, then switch back. * * @template T * @param callable(): T $callback The work. * @return T */ public static function in_language( string $language, callable $callback ) { if ( '' === $language || 'wpml' !== self::plugin() ) { return $callback(); } $previous = (string) apply_filters( 'wpml_current_language', null ); do_action( 'wpml_switch_language', $language ); try { return $callback(); } finally { do_action( 'wpml_switch_language', $previous ); } } /** * The arguments that scope a raw query to one language. * * The list screens query their own tables directly, so WP_Query filters are * no help — and pulling every post id of a language into an IN clause stops * working at exactly the size this plugin now claims to handle. Every * source keeps the language next to the post, so a keyed lookup is the * scalable answer: * * - Polylang keeps it as a `language` taxonomy term. * - WPML keeps it in its own icl_translations table. * - TranslatePress and Weglot keep it in the URL, which the URL inventory * (airanksuite_urls) records — one row per URL per language, so a language * filter on a per-post list means "posts that have a page in this * language". * * Nothing is assembled into the caller's statement. The caller writes both * lookups out as literals, each behind its own flag, and binds the nine * values returned here in this order (`p.ID` being its post id column): * * AND ( %d = 0 OR EXISTS ( SELECT 1 FROM %i airanksuite_x * WHERE airanksuite_x.%i = p.ID AND airanksuite_x.%i = %s AND airanksuite_x.%i LIKE %s ) ) * AND ( %d = 0 OR EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} airanksuite_lr * INNER JOIN {$wpdb->term_taxonomy} airanksuite_lt ON airanksuite_lt.term_taxonomy_id = airanksuite_lr.term_taxonomy_id AND airanksuite_lt.taxonomy = 'language' * INNER JOIN {$wpdb->terms} airanksuite_lg ON airanksuite_lg.term_id = airanksuite_lt.term_id * WHERE airanksuite_lr.object_id = p.ID AND airanksuite_lg.slug = %s ) ) * * The first lookup serves both single-table sources, so its table and * columns travel as identifiers. MySQL resolves every table a statement * names, switched off or not — so an unused lookup points at wp_posts, * which is always there, rather than at a WPML table that may not be. * * @param string $language Language code, or '' for no scoping. * @return array{active: bool, args: array} */ public static function sql_scope_args( string $language ): array { global $wpdb; $off = array( 'active' => false, 'args' => array( 0, $wpdb->posts, 'ID', 'post_status', '', 'post_type', '%', 0, '' ), ); $language = sanitize_key( $language ); if ( '' === $language || 'all' === $language ) { return $off; } if ( ! self::has_post_translations() ) { if ( ! self::urls_have_languages() ) { return $off; } return array( 'active' => true, 'args' => array( 1, \AiRankSuite\Support\Database::urls(), 'post_id', 'lang', $language, 'lang', '%', 0, '' ), ); } switch ( self::plugin() ) { case 'polylang': return array( 'active' => true, 'args' => array( 0, $wpdb->posts, 'ID', 'post_status', '', 'post_type', '%', 1, $language ), ); case 'wpml': return array( 'active' => true, 'args' => array( 1, $wpdb->prefix . 'icl_translations', 'element_id', 'language_code', $language, 'element_type', 'post_%', 0, '' ), ); } return $off; } /** * Whether the URL inventory holds more than one language — the condition * under which a per-post screen can offer a language filter on a site whose * translation plugin keeps the language in the URL. */ public static function urls_have_languages(): bool { static $has = null; if ( null !== $has ) { return $has; } global $wpdb; $table = \AiRankSuite\Support\Database::urls(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { $has = false; return $has; } $has = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT lang) FROM %i WHERE lang <> ''", $table ) ) > 1; // phpcs:enable return $has; } /** * Summary for the admin app. * * @return array */ public static function state(): array { $languages = self::languages(); return array( 'plugin' => self::plugin(), 'label' => self::label(), 'multilingual' => count( $languages ) > 1, 'perPost' => self::has_post_translations(), // A URL-based site can still be filtered by language once the URL // inventory has been discovered; the screens say where the number // comes from rather than pretending the post has a language. 'urlLangs' => self::urls_have_languages(), 'current' => self::current(), 'languages' => array_values( $languages ), ); } }