query_vars['lang'] ) && $wp->query_vars['lang'] === $secondary ) { self::$current = $secondary; } else { self::$current = self::detect_from_url(); } } /* ------------------------------------------------------------------ * Locale * ---------------------------------------------------------------- */ public function filter_front_locale( $locale ) { if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return $locale; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return $locale; } $lang = duolangue_get_language( self::current_lang() ); return ( $lang && ! empty( $lang['locale'] ) ) ? $lang['locale'] : $locale; } /* ------------------------------------------------------------------ * Front-end query filtering * ---------------------------------------------------------------- */ public function filter_front_queries( $query ) { if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } // Escape hatch: ?lang=all inside WP_Query args. if ( 'all' === $query->get( 'duolangue_lang' ) ) { return; } // Single-object queries resolve by ID/slug; no language filter needed. // Exception: the posts page ("Page for posts" in Reading settings) // carries a pagename/page_id yet lists ALL posts — WP_Query flags it // via is_posts_page and drops the ID constraint, so the language // filter MUST apply or the blog mixes both languages. if ( ! $query->is_posts_page && ( $query->get( 'p' ) || $query->get( 'page_id' ) || $query->get( 'name' ) || $query->get( 'pagename' ) ) ) { return; } $post_type = $query->get( 'post_type' ); $types = duolangue_post_types(); if ( $post_type && 'any' !== $post_type ) { $requested = (array) $post_type; if ( ! array_intersect( $requested, $types ) ) { return; // Not a translated post type. } } $meta_query = (array) $query->get( 'meta_query' ); $meta_query[] = duolangue_lang_meta_clause( self::current_lang() ); $query->set( 'meta_query', $meta_query ); } /** * Front-end translation of the page_for_posts option. * * WordPress compares resolved page IDs against this option to decide * is_posts_page / is_home. The option can only hold ONE page, so the * translated blog page would otherwise be treated as a plain static * page: template applied, but no posts loop. Swapping the option for * the linked translation makes the secondary-language blog behave * natively (query, pagination, feeds). */ public function filter_page_for_posts( $value ) { if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return $value; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return $value; } if ( ! $value || self::current_lang() === duolangue_default_lang()['code'] ) { return $value; } $translation = duolangue_get_post_translation( (int) $value ); if ( $translation && 'publish' === get_post_status( $translation ) && duolangue_get_post_lang( $translation ) === self::current_lang() ) { return $translation; } return $value; } /* ------------------------------------------------------------------ * Links * ---------------------------------------------------------------- */ /** * Prefix a URL with the secondary-language directory (or ?lang=). */ public static function localize_url( $url, $lang_code ) { $settings = duolangue_get_settings(); $secondary = duolangue_secondary_lang()['code']; if ( $lang_code !== $secondary ) { return $url; } if ( 'param' === $settings['url_mode'] ) { return add_query_arg( 'lang', $lang_code, $url ); } $home_path = self::raw_home_path(); $path = (string) wp_parse_url( $url, PHP_URL_PATH ); // Already prefixed? $rel = ltrim( substr( $path, strlen( $home_path ) ), '/' ); if ( $rel === $lang_code || 0 === strpos( $rel, $lang_code . '/' ) ) { return $url; } $prefixed_path = rtrim( $home_path, '/' ) . '/' . $lang_code . '/' . $rel; // Rebuild by components — a str_replace on the path would also hit // the '//' of the scheme when the path is just '/'. $parts = wp_parse_url( $url ); $base = ( isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '' ) . ( $parts['host'] ?? '' ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); $query = isset( $parts['query'] ) ? '?' . $parts['query'] : ''; $frag = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; return $base . $prefixed_path . $query . $frag; } public function filter_post_link( $permalink, $post ) { $post = get_post( $post ); if ( ! $post || ! in_array( $post->post_type, duolangue_post_types(), true ) ) { return $permalink; } $lang = duolangue_get_post_lang( $post->ID ); $secondary = duolangue_secondary_lang()['code']; // The front page and its translation live at the language roots — // no slug (e.g. / and /en/), regardless of their stored slug. $front = (int) get_option( 'page_on_front' ); if ( $front && 'page' === get_option( 'show_on_front' ) && ( $post->ID === $front || (int) duolangue_get_post_translation( $front ) === $post->ID ) ) { $home = trailingslashit( (string) get_option( 'home' ) ); return $lang === $secondary ? self::localize_url( $home, $lang ) : $home; } if ( $lang !== $secondary ) { return $permalink; } $permalink = Duolangue_Slugs::swap_base_in_url( $permalink, $post->post_type, 'post_type' ); return self::localize_url( $permalink, $lang ); } /** * Front-page routing per language: / serves the front page of the * default language, /en/ (or /fr/) serves its linked translation. * Runs on the 'request' filter so WP_Query builds a clean page query. */ public function filter_front_request( $vars ) { if ( 'page' !== get_option( 'show_on_front' ) ) { return $vars; } $front = (int) get_option( 'page_on_front' ); if ( ! $front ) { return $vars; } // Only act on "home" requests: nothing significant besides lang/paging. $ignorable = array( 'lang', 'preview', 'page', 'paged', 'cpage', 'embed' ); $significant = array_diff_key( $vars, array_flip( $ignorable ) ); if ( ! empty( $significant ) ) { return $vars; } $lang = self::current_lang(); $target = 0; if ( duolangue_get_post_lang( $front ) === $lang ) { $target = $front; } else { $translation = (int) duolangue_get_post_translation( $front ); if ( $translation && 'publish' === get_post_status( $translation ) && duolangue_get_post_lang( $translation ) === $lang ) { $target = $translation; } } if ( $target ) { $vars['page_id'] = $target; unset( $vars['pagename'] ); } return $vars; } /** * When both languages share a slug (/contact/ and /en/contact/), WP's * path resolution picks an arbitrary one; steer it to the version * matching the current language. */ public function resolve_shared_slug( $vars ) { $lang = self::current_lang(); // Hierarchical (pages). if ( ! empty( $vars['pagename'] ) ) { $page = get_page_by_path( $vars['pagename'], OBJECT, 'page' ); if ( $page && duolangue_get_post_lang( $page->ID ) !== $lang ) { $translation = (int) duolangue_get_post_translation( $page->ID ); if ( $translation && duolangue_get_post_lang( $translation ) === $lang && get_page_uri( $translation ) === $vars['pagename'] ) { $vars['page_id'] = $translation; unset( $vars['pagename'] ); } } return $vars; } // Flat post types (posts & CPTs) resolved by name. if ( ! empty( $vars['name'] ) ) { $post_type = ! empty( $vars['post_type'] ) ? $vars['post_type'] : 'post'; if ( is_array( $post_type ) || ! in_array( $post_type, duolangue_post_types(), true ) ) { return $vars; } $matches = get_posts( array( 'name' => $vars['name'], 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => 2, 'duolangue_lang' => 'all', ) ); if ( count( $matches ) > 1 ) { foreach ( $matches as $match ) { if ( duolangue_get_post_lang( $match->ID ) === $lang ) { $vars['p'] = $match->ID; if ( 'post' !== $post_type ) { $vars['post_type'] = $post_type; } unset( $vars['name'] ); break; } } } } return $vars; } /** * Canonical: the front page and its translation are only served at the * language roots — their slug URLs 301 to / or /xx/. */ public function redirect_front_slug() { if ( is_admin() || 'page' !== get_option( 'show_on_front' ) || ! is_page() ) { return; } $front = (int) get_option( 'page_on_front' ); if ( ! $front ) { return; } $object_id = (int) get_queried_object_id(); if ( $object_id !== $front && $object_id !== (int) duolangue_get_post_translation( $front ) ) { return; } $expected = (string) wp_parse_url( get_permalink( $object_id ), PHP_URL_PATH ); $request = (string) wp_parse_url( self::request_uri(), PHP_URL_PATH ); if ( untrailingslashit( $request ) !== untrailingslashit( $expected ) ) { wp_safe_redirect( get_permalink( $object_id ), 301 ); exit; } } public function filter_page_link( $permalink, $post_id ) { return $this->filter_post_link( $permalink, get_post( $post_id ) ); } /** * Root home_url() follows the current language on the front end. * Non-root paths are left alone — permalinks are localized per object. */ public function filter_home_url( $url, $path ) { if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return $url; } if ( '' !== $path && '/' !== $path ) { return $url; } if ( did_action( 'parse_request' ) && self::current_lang() === duolangue_secondary_lang()['code'] ) { // Avoid recursion: build the prefix manually. $settings = duolangue_get_settings(); if ( 'param' === $settings['url_mode'] ) { return add_query_arg( 'lang', self::current_lang(), $url ); } return trailingslashit( $url ) . self::current_lang() . '/'; } return $url; } /* ------------------------------------------------------------------ * hreflang + language URLs * ---------------------------------------------------------------- */ /** * URL of the current view in the given language (used by the switcher). */ public static function url_for_lang( $lang_code ) { $current = self::current_lang(); if ( is_singular() ) { $post_id = get_queried_object_id(); if ( duolangue_get_post_lang( $post_id ) === $lang_code ) { return get_permalink( $post_id ); } $translation = duolangue_get_post_translation( $post_id ); if ( $translation && 'publish' === get_post_status( $translation ) && duolangue_get_post_lang( $translation ) === $lang_code ) { return get_permalink( $translation ); } } elseif ( is_home() && ! is_front_page() ) { // Posts page: queried object is the page assigned in Reading // settings (already language-resolved by filter_page_for_posts). $post_id = (int) get_queried_object_id(); if ( $post_id ) { if ( duolangue_get_post_lang( $post_id ) === $lang_code ) { return get_permalink( $post_id ); } $translation = duolangue_get_post_translation( $post_id ); if ( $translation && 'publish' === get_post_status( $translation ) && duolangue_get_post_lang( $translation ) === $lang_code ) { return get_permalink( $translation ); } } } elseif ( is_post_type_archive() ) { $object = get_queried_object(); $post_type = ( $object instanceof WP_Post_Type ) ? $object->name : (string) get_query_var( 'post_type' ); if ( $post_type && in_array( $post_type, duolangue_post_types(), true ) ) { $link = get_post_type_archive_link( $post_type ); if ( $link ) { // The registered rewrite slug IS the default-language base // (injected by Duolangue_Slugs), so this link already points // at the default language; the secondary one needs the // translated base plus the language prefix. if ( $lang_code === duolangue_secondary_lang()['code'] ) { $link = Duolangue_Slugs::swap_base_in_url( $link, $post_type, 'post_type' ); return self::localize_url( $link, $lang_code ); } return $link; } } // Untranslated post type: same archive, other language directory. return self::mirror_current_url( $lang_code ); } elseif ( is_date() || is_author() || is_search() ) { // No translatable object behind these views, so the same path under // the other language prefix is the right target: the secondary // language's rewrite rules are clones of every root rule, and the // posts listed get language-filtered on their own. return self::mirror_current_url( $lang_code ); } /** * URL of the current view in a given language, for views the core * router does not resolve itself. Return a non-empty string to use it. * * @param string|null $url Null to fall back to the language home. * @param string $lang_code Target language. */ $filtered = apply_filters( 'duolangue_url_for_lang', null, $lang_code ); if ( is_string( $filtered ) && '' !== $filtered ) { return $filtered; } // Fallback: home of the requested language (unfiltered base). $home = trailingslashit( (string) get_option( 'home' ) ); return self::localize_url( $home, $lang_code ); } /** * Current request URI (path + query), sanitized. Percent-encoding is * preserved so translated slugs with accents still match. * * @return string */ private static function request_uri() { if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! is_string( $_SERVER['REQUEST_URI'] ) ) { return '/'; } $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); return '' === $uri ? '/' : $uri; } /** * The current URL rebuilt for another language: same path, other language * directory (or ?lang=). Used for views with no translatable object behind * them — post type archives of untranslated types, date, author, search. * * Pagination is deliberately dropped: post counts differ per language, so * page 7 of the French archive rarely maps onto page 7 of the English one. * * @param string $lang_code Target language. * @return string */ private static function mirror_current_url( $lang_code ) { $request = self::request_uri(); $path = (string) wp_parse_url( $request, PHP_URL_PATH ); $query = (string) wp_parse_url( $request, PHP_URL_QUERY ); // Path relative to the site root (subdirectory installs included). $home_path = self::raw_home_path(); if ( $home_path && 0 === strpos( $path, $home_path ) ) { $path = substr( $path, strlen( $home_path ) ); } $path = ltrim( $path, '/' ); // Drop the current language prefix; localize_url() adds the target one. $secondary = duolangue_secondary_lang()['code']; if ( $path === $secondary ) { $path = ''; } elseif ( 0 === strpos( $path, $secondary . '/' ) ) { $path = substr( $path, strlen( $secondary ) + 1 ); } // Strip pagination and any existing lang parameter. $path = preg_replace( '#(^|/)page/\d+/?$#', '/', $path ); $path = ltrim( (string) $path, '/' ); $args = array(); if ( $query ) { wp_parse_str( $query, $args ); unset( $args['lang'], $args['paged'], $args['page'] ); } $url = untrailingslashit( (string) get_option( 'home' ) ) . '/' . $path; if ( $args ) { $url = add_query_arg( $args, $url ); } return self::localize_url( $url, $lang_code ); } public function output_hreflang() { $pairs = array(); if ( is_front_page() || is_home() || is_post_type_archive() ) { foreach ( duolangue_get_languages() as $lang ) { $pairs[ $lang['code'] ] = self::url_for_lang( $lang['code'] ); } } elseif ( is_singular() ) { $post_id = get_queried_object_id(); $lang = duolangue_get_post_lang( $post_id ); $translation = duolangue_get_post_translation( $post_id ); $pairs[ $lang ] = get_permalink( $post_id ); if ( $translation && 'publish' === get_post_status( $translation ) ) { $pairs[ duolangue_get_post_lang( $translation ) ] = get_permalink( $translation ); } } /** * Alternate URLs (lang code => URL) printed as hreflang tags. * * @param array $pairs */ $pairs = (array) apply_filters( 'duolangue_hreflang_pairs', $pairs ); if ( count( $pairs ) < 2 ) { return; } $default_code = duolangue_default_lang()['code']; foreach ( $pairs as $code => $url ) { $lang = duolangue_get_language( $code ); if ( ! $lang || is_wp_error( $url ) ) { continue; } printf( '' . "\n", esc_attr( strtolower( str_replace( '_', '-', $lang['locale'] ) ) ), esc_url( $url ) ); } if ( isset( $pairs[ $default_code ] ) && ! is_wp_error( $pairs[ $default_code ] ) ) { printf( '' . "\n", esc_url( $pairs[ $default_code ] ) ); } } }