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 ] )
);
}
}
}