This article is the code-level companion to the user-facing detection guide. It walks through how wpresidence-translate resolves the active language on every request, how it avoids canonical redirect loops, and which hooks it exposes for integration work on a multi-language real estate website.
Files & Entry Points
| File | Responsibility |
|---|---|
| includes/language-router.php | Request path prep, language resolution, rewrite rules, cookie persistence, canonical-redirect guards. |
| includes/language-manager.php | wpr_translate_resolve_preferred_language() – the cookie → browser → default fallback chain. |
| includes/language-manager-helpers.php | wpr_translate_match_browser_language() – Accept-Language parser and matcher. |
| includes/body-language-attribute.php | Prints the data-wpr-language attribute on <body>. |
| includes/seo-tags.php | Emits canonical and hreflang link tags; integrates Yoast SEO and Rank Math. |
Router Bootstrap
wpr_translate_language_router_bootstrap() registers the full hook set:
add_action( 'setup_theme', 'wpr_translate_language_router_prepare_request_path', 0 ); add_action( 'init', 'wpr_translate_language_router_prepare_request_path', 0 ); add_action( 'init', 'wpr_translate_language_router_register_rewrite_rules', 20 ); add_filter( 'query_vars', 'wpr_translate_language_router_register_query_vars' ); add_action( 'parse_request', 'wpr_translate_language_router_detect_language', 0 ); add_filter( 'redirect_canonical', 'wpr_translate_maybe_skip_front_page_canonical_redirect', 10, 2 ); add_filter( 'redirect_canonical', 'wpr_translate_maybe_skip_language_prefix_redirect', 2, 2 ); add_action( 'wp_ajax_wpr_translate_set_language_cookie', 'wpr_translate_handle_language_cookie_ajax' ); add_action( 'wp_ajax_nopriv_wpr_translate_set_language_cookie', 'wpr_translate_handle_language_cookie_ajax' );
Path preparation is bound to both setup_theme and init because some environments swap $_SERVER[‘REQUEST_URI’] between those hooks. A static $prepared flag inside the function ensures the work runs once per request.
Request Path Preparation
wpr_translate_language_router_prepare_request_path() is the first stage. It:
- Parses $_SERVER[‘REQUEST_URI’] into path and query pieces.
- Strips the home URL’s base-path segments (subdirectory installs).
- Compares the first remaining segment against every active language’s slug and code.
- On a match, stores the language in $GLOBALS[‘wpr_translate_language_router’][‘matched_language’], removes the prefix from the request URI, calls wpr_translate_set_current_language(), and rewrites $_SERVER[‘REQUEST_URI’] so the rest of WordPress sees the language-free path.
The function bails early when wpr_translate_language_router_should_skip_prepare() returns true – that covers admin, AJAX, CLI, and REST contexts. Both skip helpers are filterable via wpr_translate_language_router_skip_prepare and wpr_translate_language_router_skip_detection.
Query Vars & Rewrite Rules
Two public query vars are exposed:
- wpr_lang – the language slug matched by the rewrite rule.
- wpr_lang_path – the remainder of the path after the language prefix.
Rewrite rules are added at init priority 20, after all post types have registered. For each active language:
add_rewrite_tag( '%wpr_lang%', '([^/]+)' );
add_rewrite_tag( '%wpr_lang_path%', '(.*)' );
add_rewrite_rule(
'^' . preg_quote( $slug, '/' ) . '(?:/(.*))?/?$',
'index.php?wpr_lang=' . $slug . '&wpr_lang_path=$matches[1]',
'top'
);
Rules are inserted at the top of the list so they beat core archive rules. A rewrite flush is triggered from the activator; any runtime change to the language list must call flush_rewrite_rules() explicitly.
Language Detection on parse_request
wpr_translate_language_router_detect_language( $wp ) runs at priority 0 on parse_request and takes the following branches:
- If the path prep step already matched a language, use it.
- Else if the rewrite rule populated $wp->query_vars[‘wpr_lang’], resolve that via wpr_translate_get_language(), reconstruct the residual path from wpr_lang_path, and rewrite the request URI.
- Else call wpr_translate_get_default_language().
- As a final fallback, call wpr_translate_resolve_preferred_language(), which reads the cookie, then the browser header.
Whichever branch wins, wpr_translate_set_current_language() is called with reload_theme_translations => true, and WPR_TRANSLATE_CURRENT_LANGUAGE is defined as a convenience constant for templates.
The Cookie Persistence Contract
wpr_translate_language_router_store_language_cookie( $code ) writes the cookie named wpestate_translation_lang_pref with:
- Expiration: time() + MONTH_IN_SECONDS.
- Path: COOKIEPATH, mirrored to SITECOOKIEPATH when they differ.
- Domain: COOKIE_DOMAIN.
- Secure: is_ssl().
- HttpOnly: true.
Critical invariant: the cookie is written only when language_from_url is true. Browser-header or default-language fallbacks never overwrite an existing preference – this prevents the cookie from being destroyed by background requests for missing asset files.
AJAX Endpoint for the Switcher
The front-end language switcher hits wp-admin/admin-ajax.php?action=wpr_translate_set_language_cookie. The handler:
- check_ajax_referer( ‘wpr_translate_language_cookie’, ‘nonce’ ).
- Sanitises the incoming language with sanitize_key().
- Confirms the language is registered and is_active.
- Calls wpr_translate_language_router_store_language_cookie().
- Responds with wp_send_json_success( [ ‘language’ => $code ] ).
Redirect-Loop Safeguards
Two redirect_canonical filters run on different priorities:
- wpr_translate_maybe_skip_language_prefix_redirect (priority 2) – cancels redirects that only toggle a known language slug.
- wpr_translate_maybe_skip_front_page_canonical_redirect (priority 10) – when the front page is a static page, the active language is non-default, and no translation of the front page exists, the filter returns false to keep the URL stable.
The front-page guard reads wpr_translation_get_translation_group() to confirm a translation does not exist before short-circuiting the redirect. If a translation is present, the canonical redirect is allowed through.
Browser Matching Algorithm
wpr_translate_match_browser_language() (in language-manager-helpers.php):
- Parses $_SERVER[‘HTTP_ACCEPT_LANGUAGE’], splits on ,, extracts ;q= quality.
- Normalises underscores to hyphens in each candidate.
- arsort() by quality.
- For each candidate, also tries its two-character root (so en-GB matches a language with code en).
- Matches against each language’s locale (hyphen-normalised), code, and slug.
SEO Tag Emission
wpr_translate_bootstrap_seo_tags() hooks wp_head at priority 2. wpr_translate_render_seo_link_tags() pulls URLs from wpr_translate_seo_collect_urls() and emits:
<link rel="alternate" hreflang="..." href="..." /> <link rel="canonical" href="..." />
When Yoast or Rank Math are active, the same URL set is handed to their filter hooks: wpseo_hreflang_links, wpseo_canonical, rank_math/frontend/seo/hreflang, rank_math/frontend/canonical.
Body Language Attribute
wpr_translate_print_body_language_attribute() hooks wp_body_open, wp_footer, and admin_footer. It emits a tiny inline script that calls document.body.setAttribute(‘data-wpr-language’, code). Static print-guard prevents double emission. Primarily useful for automated tests and analytics segmentation.
Settings Key
The wpr_translate_settings option carries the detect_browser_language boolean (default true, seeded by wpr_translate_activator_ensure_default_settings()). Code paths that consult it should read the option via get_option( ‘wpr_translate_settings’ ) rather than caching it.
Extension Points
- apply_filters( ‘wpr_translate_language_router_skip_prepare’, $skip ) – force path prep to run in CLI tests.
- apply_filters( ‘wpr_translate_language_router_skip_detection’, $skip ) – same for detection.
- apply_filters( ‘wpr_translate_language_router_force_prepare’, false ) – bypass the static once-per-request guard.
- $GLOBALS[‘wpr_translate_language_router’] – read-only view of the parsed state if you need the current relative path without the language prefix.
Gotchas
- REST requests skip prep and detection. If you need language context in a REST handler, call wpr_translate_get_context_language() explicitly.
- The cookie is HttpOnly – JavaScript cannot read it. Use the AJAX endpoint to change it, not direct DOM writes.
- The two canonical filters sit at different priorities on purpose. Do not re-prioritise them without understanding the front-page case.
Related Reading
- URL Structure & Permalinks – outbound URL construction.
- Rewrite Rules & Query Vars – inbound request routing.
- Translation Linking (trid system) – how translation groups power the front-page guard.
Product context is on the multi-language real estate website page.