> Each entry has 'code', 'locale' and 'name'. */ public static function get_languages() { switch ( self::get_plugin() ) { case 'wpml': return self::get_wpml_languages(); case 'polylang': return self::get_polylang_languages(); default: return array(); } } /** * Active languages excluding the site default. * * @return array> */ public static function get_secondary_languages() { $default = self::get_default_language(); if ( '' === $default ) { return array(); } $secondary = array(); foreach ( self::get_languages() as $language ) { if ( $language['code'] !== $default ) { $secondary[] = $language; } } return $secondary; } /** * Published pages in every active language, default language first. * * EVERY PASS IS PINNED TO A NAMED LANGUAGE, INCLUDING THE DEFAULT ONE, AND * THAT IS THE WHOLE POINT OF THIS METHOD. The collector used to take the * default-language list from a bare get_pages() call and append * translations to it. A bare call has no language of its own: it answers in * whatever language the current request happens to be in. On the front end * that is the site default and the result looks right. In wp-admin it is * whatever WPML's language switcher was last set to, so a publisher who * saved plugin settings while working on Arabic content got a file listing * Arabic pages, untagged, with the English ones absent entirely: the * secondary pass then asked for Arabic, found the same URLs already * present, and deduplicated every one of them away. Reported within hours * of the 2.2.0 release. * * The deeper problem was that the file's contents depended on where it was * generated from. For a project whose subject is deterministic * machine-readable identity, output that changes with an admin's UI state * is the wrong kind of surprise, so ambient context is now never consulted. * * Default-language rows carry no 'lang' key, because the specification says * an unannotated link is in the language declared by Lang: and publishers * SHOULD NOT annotate default-language links. Ordering default-first also * makes deduplication correct rather than incidental: an untranslated page * resolves to the default copy in both plugins, and the copy that survives * should be the untagged one. * * @return array> Empty when unavailable, so callers can fall back. */ public static function get_all_pages() { $default = self::get_default_language(); if ( '' === $default ) { return array(); } $languages = self::get_languages(); if ( empty( $languages ) ) { return array(); } // Default first, whatever order the multilingual plugin listed them in. $ordered = array(); foreach ( $languages as $language ) { if ( $language['code'] === $default ) { array_unshift( $ordered, $language ); } else { $ordered[] = $language; } } $rows = array(); $seen = array(); foreach ( $ordered as $language ) { $is_default = ( $language['code'] === $default ); $locale = $is_default ? '' : $language['locale']; foreach ( self::get_pages_in_language( $language['code'], $locale ) as $row ) { if ( '' === $row['url'] || isset( $seen[ $row['url'] ] ) ) { continue; } $seen[ $row['url'] ] = true; if ( '' === $row['lang'] ) { unset( $row['lang'] ); } $rows[] = $row; } } return $rows; } /** * Published pages in one language, as title/url/lang rows. * * @param string $code Language code. * @param string $locale BCP 47 tag recorded against each page. * @return array> */ public static function get_pages_in_language( $code, $locale ) { switch ( self::get_plugin() ) { case 'wpml': return self::get_wpml_pages( $code, $locale ); case 'polylang': return self::get_polylang_pages( $code, $locale ); default: return array(); } } /** * Translate one of WordPress's own option strings into a language. * * Used for the site title and tagline, which both plugins hold in their * string-translation store rather than as translated posts. * * @param string $value Untranslated value. * @param string $name String name in the multilingual plugin's registry. * @param string $code Target language code. * @return string The translation, or $value when there is not one. */ public static function translate_string( $value, $name, $code ) { if ( '' === $value || '' === $code ) { return $value; } switch ( self::get_plugin() ) { case 'wpml': // Context 'WP' with the names WPML registers core options under. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own public API, consumed not defined; the name belongs to WPML and prefixing it would call a hook that does not exist. $translated = apply_filters( 'wpml_translate_single_string', $value, 'WP', $name, $code ); return is_string( $translated ) && '' !== $translated ? $translated : $value; case 'polylang': if ( function_exists( 'pll_translate_string' ) ) { $translated = pll_translate_string( $value, $code ); return is_string( $translated ) && '' !== $translated ? $translated : $value; } return $value; default: return $value; } } /** * Build a *Localized map for one value, keyed by BCP 47 tag. * * Returns an empty array unless at least one secondary language actually * translates the value. A map whose entries all repeat the canonical value * is noise: it makes the file longer, tells a consumer nothing it could * not infer, and would appear on every monolingual-in-practice site that * happens to have a second language switched on. * * The primary language's own entry is always included when the map is * emitted at all, and always carries the canonical value, because the * specification requires the two to match. * * @see https://www.ai-visibility.org.uk/specifications/i18n-a11y/ Section 2. * * @param string $canonical Canonical (primary-language) value. * @param string $string_name String name in the plugin's registry. * @param string $primary_locale BCP 47 tag for the canonical value. * @return array Empty when nothing is translated. */ public static function build_localized_map( $canonical, $string_name, $primary_locale ) { if ( ! self::is_enabled() || '' === $canonical || '' === $primary_locale ) { return array(); } $map = array(); $found = false; foreach ( self::get_secondary_languages() as $language ) { $translated = self::translate_string( $canonical, $string_name, $language['code'] ); if ( $translated === $canonical || '' === $language['locale'] ) { continue; } $map[ $language['locale'] ] = $translated; $found = true; } if ( ! $found ) { return array(); } // Specification section 2: the entry at the primary language tag MUST // match the canonical value. Placed first so the file reads in the // order a person would expect. return array_merge( array( $primary_locale => $canonical ), $map ); } /** * Convert a WordPress locale to a BCP 47 language tag. * * WordPress writes locales with an underscore ("pt_BR", "ar"); BCP 47 uses * a hyphen. A third segment is a WordPress formality or spelling variant * ("de_DE_formal") with no BCP 47 equivalent, so it is dropped rather than * emitted as an invalid tag. * * @param string $locale WordPress locale. * @return string BCP 47 tag, or '' when the input is unusable. */ public static function locale_to_bcp47( $locale ) { if ( ! is_string( $locale ) || '' === $locale ) { return ''; } $parts = explode( '_', $locale ); if ( count( $parts ) < 2 ) { return $parts[0]; } return $parts[0] . '-' . $parts[1]; } /** * WPML's active languages. * * @return array> */ private static function get_wpml_languages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own public API, consumed not defined; the name belongs to WPML and prefixing it would call a hook that does not exist. $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) ); if ( ! is_array( $languages ) ) { return array(); } $out = array(); foreach ( $languages as $code => $language ) { if ( ! is_array( $language ) ) { continue; } $code = isset( $language['language_code'] ) ? $language['language_code'] : $code; if ( ! is_string( $code ) || '' === $code ) { continue; } $locale = isset( $language['default_locale'] ) ? self::locale_to_bcp47( $language['default_locale'] ) : ''; $out[] = array( 'code' => $code, 'locale' => '' !== $locale ? $locale : $code, 'name' => isset( $language['translated_name'] ) ? (string) $language['translated_name'] : $code, ); } return $out; } /** * Polylang's active languages. * * @return array> */ private static function get_polylang_languages() { if ( ! function_exists( 'pll_languages_list' ) ) { return array(); } $slugs = pll_languages_list( array( 'fields' => 'slug' ) ); $locales = pll_languages_list( array( 'fields' => 'locale' ) ); $names = pll_languages_list( array( 'fields' => 'name' ) ); if ( ! is_array( $slugs ) ) { return array(); } $locales = is_array( $locales ) ? $locales : array(); $names = is_array( $names ) ? $names : array(); $out = array(); foreach ( $slugs as $index => $slug ) { if ( ! is_string( $slug ) || '' === $slug ) { continue; } $locale = isset( $locales[ $index ] ) ? self::locale_to_bcp47( $locales[ $index ] ) : ''; $out[] = array( 'code' => $slug, 'locale' => '' !== $locale ? $locale : $slug, 'name' => isset( $names[ $index ] ) ? (string) $names[ $index ] : $slug, ); } return $out; } /** * Pages in one WPML language. * * WPML resolves permalinks against the language that is current at the * moment get_permalink() runs, not against the language of the post being * asked about. So the URLs are built inside the switch and the language is * only restored afterwards: hoist the restore any earlier and every URL * comes back pointing at the default-language copy, which looks correct in * a code review and is wrong in the published file. * * @param string $code Language code. * @param string $locale BCP 47 tag. * @return array> */ private static function get_wpml_pages( $code, $locale ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own public API, consumed not defined; the name belongs to WPML and prefixing it would call a hook that does not exist. $original = apply_filters( 'wpml_current_language', null ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own public API, consumed not defined; the name belongs to WPML and prefixing it would call a hook that does not exist. do_action( 'wpml_switch_language', $code ); $rows = self::rows_from_pages( self::query_pages( $code ), $locale ); if ( is_string( $original ) && '' !== $original ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own public API, consumed not defined; the name belongs to WPML and prefixing it would call a hook that does not exist. do_action( 'wpml_switch_language', $original ); } return $rows; } /** * Pages in one Polylang language. * * Polylang accepts a 'lang' query argument, so no global switch is needed * and permalinks resolve from the post's own language. * * @param string $code Language code. * @param string $locale BCP 47 tag. * @return array> */ private static function get_polylang_pages( $code, $locale ) { return self::rows_from_pages( self::query_pages( $code ), $locale ); } /** * Published pages in one language. * * NOT get_pages(). THE LANGUAGE IS NOT PART OF ITS CACHE KEY, AND THAT IS * THE WHOLE BUG. get_pages() caches on an md5 of its own arguments plus the * posts group's last_changed stamp. Neither moves when a multilingual * plugin switches language, so a second call with the same arguments in the * same request is answered from cache with the FIRST language's pages. The * collector has already asked for the default language by the time this * runs, so an identical call here returns default-language pages: the * switch works, the query never re-runs, and the result looks like the * switch silently failed. It reports no error because nothing has gone * wrong as far as WordPress is concerned. A support snippet built on * get_pages() was defeated by exactly this and looked correct while doing * nothing, which is what sent us looking. * * Two independent defences, because one of them is version-dependent: * the language code goes into the arguments so the cache key differs per * language whatever else happens, and query caching is switched off for * these reads. This runs a handful of times when a file is regenerated, * not per page view, so the cost is irrelevant. * * `lang` is also the documented way to scope a query in Polylang, so the * argument is doing real work there rather than only perturbing a hash. * * @param string $code Language code. * @return array */ private static function query_pages( $code ) { $query = new WP_Query( array( 'post_type' => 'page', 'post_status' => 'publish', // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Generation-time only, a few times per settings save rather than per request, and 200 mirrors the default-language limit in AIDF_Data_Collector so the two lists cannot diverge. 'posts_per_page' => 200, 'orderby' => 'menu_order title', 'order' => 'ASC', 'lang' => $code, 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'suppress_filters' => false, 'cache_results' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); return is_array( $query->posts ) ? $query->posts : array(); } /** * Convert page objects to the title/url/lang rows templates render. * * @param array $pages Page objects. * @param string $locale BCP 47 tag. * @return array> */ private static function rows_from_pages( $pages, $locale ) { $rows = array(); if ( ! is_array( $pages ) ) { return $rows; } foreach ( $pages as $page ) { if ( ! isset( $page->ID ) ) { continue; } $url = get_permalink( $page ); if ( ! is_string( $url ) || '' === $url ) { continue; } $rows[] = array( 'title' => isset( $page->post_title ) ? (string) $page->post_title : '', 'url' => untrailingslashit( $url ), 'lang' => $locale, ); } return $rows; } }