LocalePress
WordPress Multilingual Plugin
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/themewant/localepress/archive/refs/heads/master.zipLocalePress is a modular multilingual foundation for WordPress. This repository contains the free plugin and currently implements language management, post and taxonomy translation relationships, a central translation dashboard, a registered-string translation system, language-prefixed frontend routing, context-aware language switching, multilingual navigation, Gutenberg-safe manual translation workflows with two-phase copy and synchronization, optional media translation, basic Elementor document compatibility, and essential multilingual SEO metadata.
Requirements
- WordPress 6.4 or newer
- PHP 7.4 or newer
The requirements are declared in the plugin header and in the overridable LOCALEPRESS_MINIMUM_PHP_VERSION and LOCALEPRESS_MINIMUM_WP_VERSION constants. Compatibility is checked during activation and normal plugin loading.
Storage decision
Languages are stored in one non-autoloaded WordPress option named localepress_language_registry. Its value is a versioned registry containing:
schema_version
default_language_id
languages
<stable UUID>
name, native_name, locale, language_code, url_slug
is_rtl, enabled, order, created_at, updated_at
Language registries are small configuration datasets, so the Options API provides native caching, serialization, multisite per-site isolation, and simple backup behavior. A custom table would add schema and migration costs without a useful query benefit at this stage. A custom post type would incorrectly model languages as editable content.
Persistence sits behind LanguageRepositoryInterface. An addon can replace the repository through localepress_language_repository without changing validation, admin, or resolution code. The repository caches its normalized registry within the request, and operations that create an initial default language persist the record and default ID atomically in one option write.
Post translation relationships use two site-prefixed custom tables:
<prefix>localepress_translation_groups
group_id, source_post_id, created_at
<prefix>localepress_post_translations
post_id, group_id, language_id, created_at, updated_at
Relationship data grows with content and must support indexed group lookups. The assignment table therefore has a primary key on post_id and a unique key on group_id + language_id. These database constraints guarantee that one post cannot belong to multiple groups and one group cannot contain duplicate translations for a language, including during concurrent requests. The group table records the original source post and selects a surviving member if that source is permanently deleted.
Persistence sits behind TranslationRepositoryInterface and can be replaced through localepress_translation_repository. The default repository uses request-level caches and bulk-primes assignment, source-group, and group-member records for admin and frontend result sets. It intentionally avoids persistent object caching so writes from another request cannot leave stale relationships.
Dashboard reporting is a separate optional TranslationDashboardRepositoryInterface. The default database implementation runs one prepared count query and one prepared source-ID query against the existing post/group indexes, then the dashboard query service bulk-primes source posts, relationships, and translated posts. Keeping reporting outside the mutation repository avoids breaking custom relationship-storage implementations. A custom repository can supply compatible reporting through localepress_translation_dashboard_repository or return null to disable the screen safely.
Registered strings and their translations use two site-prefixed custom tables:
<prefix>localepress_strings
string_id, string_group, string_key, original_string, created_at, updated_at
<prefix>localepress_string_translations
string_id, language_id, translation, updated_at
Definitions must be searched, grouped, paginated, and joined to a growing language-specific dataset, so an option would require loading and rewriting the entire registry. The SHA-256 string_id is deterministic from the validated group and key. A unique string_group + string_key index and the translation table's string_id + language_id primary key enforce one definition and one value per language at the database boundary. Tables are installed and upgraded with WordPress's dbDelta() lifecycle.
Persistence is replaceable through StringRepositoryInterface and localepress_string_repository. Registrations are deduplicated in memory and flushed in one bounded definition check at shutdown; the admin editor flushes before querying. Definition and translation lookups use the WordPress Object Cache, including negative cache entries, while admin rows bulk-load one page of translations without N+1 queries. Re-registering a changed original updates the definition and preserves existing translations.
Term translation relationships use two separate site-prefixed tables:
<prefix>localepress_term_translation_groups
group_id, source_term_taxonomy_id, taxonomy, created_at
<prefix>localepress_term_translations
term_taxonomy_id, term_id, taxonomy, group_id, language_id, created_at, updated_at
Term data is keyed by term_taxonomy_id, WordPress's stable identity for a term inside one taxonomy. The assignment table enforces unique term_taxonomy_id, term_id + taxonomy, and group_id + language_id values. This prevents cross-taxonomy ambiguity, conflicting group membership, and duplicate language slots at the database boundary. Persistence is replaceable through localepress_term_translation_repository and primes taxonomy list screens in two relationship queries.
Navigation menus remain native nav_menu terms and nav_menu_item posts. A menu's language is stored as _localepress_language_id term meta. Per-language choices for registered theme locations are stored in the active theme's localepress_nav_menu_locations theme mod, alongside but separate from WordPress's normal nav_menu_locations theme mod. This keeps menus compatible with core menu editing, Customizer behavior, walkers, and theme switching while allowing each theme to retain its own language matrix.
Translated-draft workflow settings are stored in the non-autoloaded localepress_workflow_settings option. The settings control source content/excerpt copying, Elementor element JSON when applicable, and featured-image reuse; they do not enable automatic translation or copy arbitrary post metadata.
User-facing configuration is stored in the non-autoloaded, versioned localepress_settings option. Its normalized sections are url, content, switcher, seo, advanced, and setup. Unknown keys are discarded, enum values are allowlisted, booleans are normalized, and site-specific menu IDs remain in their existing WordPress term meta and theme-mod storage. PluginSettings is the shared schema and cache boundary, available through LocalePress\Plugin::instance()->settings() and replaceable at the value level through documented filters.
Architecture
localepress.php Plugin metadata, constants, compatibility checks, hooks
src/class-autoloader.php Namespace autoloader
src/class-plugin.php Service composition and module registration
src/class-assets.php Cache-busting versions for bundled CSS and JS
src/Contracts/ Repository and module contracts
src/Infrastructure/ Options and translation-table repositories
src/Language/ Catalog, validation, lifecycle service, current-language resolver, locale switching, Accept-Language negotiation
src/Content/ Post-type policy, relationship API, global lifecycle handling, stored-identifier translation
src/Taxonomy/ Taxonomy policy, term relationship API, hierarchy and lifecycle handling
src/Routing/ Current-language detection, rewrites, query mapping, URLs, canonicals, and visitor detection
src/SEO/ Language attributes, hreflang, canonicals, provider compatibility, and core sitemap constraints
src/Navigation/ Menu languages, theme-location mapping, and translated links
src/Switcher/ Shared switcher model/renderer and WordPress integrations
src/Settings/ Versioned plugin settings, copy and sync catalog, and safe transfer
src/Sync/ Two-phase translation copy and permission-aware synchronization
src/Media/ Shared-file media translation and attachment resolution
src/Rest/ Language-scoped REST collections for the block editor
src/StringTranslation/ Registered-string validation, retrieval, lifecycle handling, and the option value translator with its core catalog
src/Integrations/Elementor/ Optional Elementor document copy compatibility
src/Integrations/Wpml/ wpml-config.xml discovery, parsing, and option string translation
src/Admin/ Language screens, translation dashboard, editor UI, admin language filter, actions, and list columns
src/Lifecycle/ Installation, upgrades, activation, and deactivation
blocks/language-switcher/ Dynamic Gutenberg block metadata and editor controls
blocks/navigation-language-switcher/ Navigation block switcher metadata and editor controls
assets/ Minimal frontend switcher CSS and admin assets
includes/ Prefixed template functions and the public developer API
tests/ WordPress integration tests
The Plugin class composes shared services. LanguageManager is the language mutation boundary. PostTranslationManager and TermTranslationManager own assignments, groups, translated-copy creation, conflict validation, and deletion repair. Repositories own persistence. Admin modules handle WordPress UI and capability checks, while lifecycle modules run in every request context so REST, CLI, cron, and custom-code deletions cannot leave stale rows.
After a normal single-site activation, the first eligible administrator request is redirected to initial setup. Network, bulk, AJAX, cron, and CLI activation contexts do not interrupt their workflows. The short-lived redirect marker is consumed once and does not alter the language registry. When no languages exist, opening the main screen directly also displays setup.
Network activation, deactivation, and uninstall process existing multisite sites in bounded batches and always restore the active blog context. New sites install their per-site schema through the normal version check on first load. Network activation does not create per-site setup redirects.
LocalePress > Setup Wizard is always available and resumes from its last saved step. It selects the existing-content language, optionally adds a second catalog language, configures directory URLs, saves switcher defaults, and can insert one idempotent virtual switcher item into an existing classic menu. The catalog path uses the same language validator as normal administration and allocates a non-conflicting URL slug. Existing configured installations are marked complete during upgrade so activation does not reopen setup unexpectedly.
LocalePress > Settings uses tab-scoped, nonce-protected POST handlers over the versioned settings service. A custom handler is used instead of independent Settings API callbacks because General and Switcher saves coordinate language-registry invariants, term meta, theme mods, and workflow options in one validated operation. Each tab changes only its own fields.
Settings exports use stable locales instead of language UUIDs and exclude non-portable navigation menu and theme-location IDs. Import accepts JSON up to 1 MB, validates the complete schema and every referenced locale, post type, and taxonomy before mutation, and then maps locales to local language IDs. Imported languages must already be registered. URL changes produce a dedicated cache/link warning. Uninstall preserves all data by default; complete option, metadata, theme-mod, and custom-table cleanup runs only when explicitly enabled under Advanced before uninstalling.
Supported posts and terms without a stored assignment use the configured default language immediately. This is an effective fallback only and does not write while content is being read. A normal save persists the default assignment, and clicking a translation action materializes a legacy source assignment before creating the target. Explicit editor language selections always take precedence.
Post types and taxonomies become translatable by selection, not by discovery. Settings > Content offers every post type and taxonomy registered with public => true and an administrative UI, and a new site starts with post, page, category, and post_tag selected so the plugin is usable immediately. Everything else — including a plugin's or theme's own custom post types — stays untouched until a site owner chooses it, so registering a post type never silently adds a language control to it. An unreadable submitted policy falls back to the narrower one, so a malformed request cannot widen what is translatable.
Attachments are never listed there; media has its own switch, described under Media translation. Non-public editorial post types are not listed either, wp_block included: a site that wants each language to own its synced patterns adds that post type through localepress_supported_post_types, which is also the extension point for any other post type outside the public set. A detected WooCommerce product post type uses this generic core-field workflow only once selected. LocalePress Free does not copy product metadata, variations, stock, prices, or SEO metadata. Elementor metadata receives only the basic allowlisted behavior documented below.
Existing sites keep the policy they already stored. Upgrading does not narrow a site that was configured to translate every type, because that would orphan translations already created; switch the policy on the Content tab to adopt the selective default.
Categories, tags, and public custom taxonomies with an administrative UI are supported through one taxonomy policy. Hierarchical translated terms use the matching translated parent when it exists and remain at the root when it does not. Creating a missing parent translation later repairs descendant relationships. Only core term fields are copied; term metadata and WooCommerce-specific category or attribute behavior are not implemented.
Future free or Pro modules can implement ModuleInterface and join through localepress_modules. This remains the composition boundary for additional page-builder integrations, advanced SEO or Elementor behavior, and Pro-only commerce behavior. None of those later-phase features are implemented here.
Admin language filter
An admin bar menu lists every enabled language plus Show all languages, and the choice is stored per user in the localepress_admin_language user meta key. Because the choice is remembered, ordinary admin links do not need to carry it: the lang query argument only announces a change. An unknown or removed slug clears the filter rather than leaving a stale one in place.
The filter is a per-user display preference that writes no site state and changes nothing another user sees, which is why the lang argument is accepted without a form token. It is offered to users who can edit_posts, never in network admin, and localepress_enable_admin_language_filter disables it entirely.
While one language is selected:
| Screen | Effect |
|---|---|
| Posts, Pages, and translatable custom post types | The list table shows only that language. |
| Media library, list and grid | Filtered when media translation is enabled, including the query-attachments request the media modal uses. |
| Category, tag, and translatable taxonomy term lists | The term list table shows only that language. |
| New posts and terms | The language control starts on the filtered language instead of the site default. |
Filtering reuses LanguageQueryConstraint, the same indexed join frontend routing and the REST API use, so a filtered listing costs one extra join rather than a second query. Queries for the default language also match content with no stored assignment, so a site that installed LocalePress after publishing still lists everything.
A post saved without an explicit language while the filter is active is assigned the filtered language, on wp_after_insert_post before TranslationLifecycleModule would assign the site default. The guards are identical, so only the chosen language differs.
Two limits are deliberate. The status links above a list table (All, Published, Draft) keep WordPress's unfiltered counts, because filtering them means one extra counting query per status. A taxonomy control inside the post editor follows the language of the post being edited rather than the admin filter, which the block editor integration already supplies.
Translation dashboard
LocalePress > Translations presents one row per source post or translation group. Enabled languages become matrix columns: a completed public translation shows a check action, a draft or other non-public translation shows an edit action with its status in the accessible label, and an open language slot shows a nonce-protected add action. A trashed translation stops counting as one, so its language reads as an open slot again: a post its author has thrown away is not something a reader can be sent to, and leaving a completed check on it hid the add action and refused a replacement. The relationship row stays where it is, so restoring the post restores the translation with no repair step. Creating a replacement while the old one sits in the trash releases the slot to the new draft, and restoring the old post afterwards returns it without a language.
The content type, target language, translation status, and search controls execute in SQL before pagination. Without a language selection, Missing means at least one enabled language is absent, Completed means every enabled language has a public post, and Draft means at least one enabled language has a draft post. Selecting a language applies the status to that language slot. Search covers both source and translated titles.
The screen uses an isolated WP_List_Table adapter for native pagination, screen options, filters, row actions, and table markup. LocalePress Free does not define a mutating bulk operation, but addons can register an action through the documented bulk filters and receive nonce-verified, capability-filtered source IDs. Reporting query arguments and rows have separate filters so future modules do not need to replace the screen.
String translation
Developers register stable, LocalePress-controlled plain-text strings on init or later. Registration does not hook or replace WordPress gettext, scan source files, or read and write PO/MO catalogs.
add_action(
'init',
static function () {
localepress_register_string( 'theme', 'footer_notice', 'All rights reserved.' );
}
);
$notice = localepress_translate_string( 'theme', 'footer_notice', 'All rights reserved.' );
echo esc_html( $notice );
localepress_register_string() returns a deterministic string ID or WP_Error. localepress_translate_string() accepts an optional fourth language-ID argument, otherwise it uses LocalePress's current enabled language. A missing value falls back to the registered original. Passing a non-empty fallback also queues that definition for registration. Values are plain text and the caller must always apply output-context escaping.
LocalePress > String Translation provides native search, group and enabled-language filters, pagination, screen options, immutable originals, and editable values. Saving an empty translation deletes that value and restores the original fallback. Each submitted page is fully validated before any row changes.
The language filter offers one language at a time, which keeps the table narrow on a site with many languages, and All languages, which puts a field for every enabled language in the same row so a string can be finished in one pass while its meaning is fresh. Both views load their values in one query, and both post the same translations[language][string] shape, so one save path serves them.
The all-languages view caps its own page size. Its form posts one field per string per language, and PHP silently discards everything past max_input_vars, so a page that would exceed that limit is shortened instead. Fewer rows are visible and recoverable; a save that drops half its translations is neither.
What is translatable without any code
A site owner should not have to write PHP to translate their own site title, so LocalePress names the options every site has and registers their values itself. Nothing needs configuring; the strings appear the first time something reads the option.
| Group | What it covers |
|---|---|
WordPress |
blogname, blogdescription, date_format, time_format. Date and time patterns are text too: a language often writes the day before the month. |
Widgets |
Every widget instance's title, text, and content, through a widget_* wildcard. One rule covers the widget types a site has today and the ones it adds later; a widget's post counts, menu IDs, and feed URLs are not named and stay untouched. |
plugins/…, themes/… |
Whatever a plugin or theme declared under admin-texts in its own wpml-config.xml. |
localepress_core_string_catalog adds to the first two, so a site can name one more option without writing a module:
add_filter( 'localepress_core_string_catalog', function ( array $catalog ) {
$catalog['Acme'] = array(
'acme_settings' => array(
'header_text' => true,
'footer' => array( 'copyright' => true ),
),
);
return $catalog;
} );
Two limits apply to every option value, whichever source named it. Registered strings are plain text, so a value carrying markup is passed through untouched rather than flattened — a text widget holding a link keeps its link and is not offered for translation. And substitution happens on the front end only, so an administration form always shows the value it will save.
Classic widgets are covered by the wildcard because WordPress stores their instances in widget_{$id_base}. Block widgets keep their text inside block markup in widget_block, which is left alone for the reason above.
Frontend routing
A language reaches a URL in one of four ways, chosen under Settings → URL: a directory, a subdomain, a domain of its own, or a ?lang= query argument. The first three are described below and in the host resolver; the query argument is the only one that adds nothing to the path, which makes it the only mode a site without pretty permalinks can use, and the fallback for a site whose paths are already owned by something else:
/about/?lang=de -> German page, on any permalink structure
/?p=12&lang=de -> the same page on a plain-permalink site
/about/ -> 301 /about/?lang=en when the default prefix is enabled
The argument replaces any language a URL already named, in either form, so a site that changes its mode keeps producing exactly one language per URL. localepress_public_query_var renames it for a site whose theme or another plugin already owns lang; the internal localepress_lang variable is accepted alongside it in every mode.
In directory mode LocalePress requires a non-empty WordPress permalink structure and uses one directory for each non-default language. The default language can either keep its directory or use the unprefixed site root:
/ -> 301 /en/ when the default prefix is enabled
/ -> English home when the default prefix is hidden
/en/ -> English home or front-page translation
/de/ -> German home or front-page translation
/en/about/ -> English page
/de/about/ -> German member of the same post translation group
/de/category/reisen/ -> German category term
/de/search/route/ -> German search results
/de/page/2/ -> German pagination
LocalePress prepends a single (en|de|...) language capture to WordPress's generated public rewrite rules and shifts existing match indexes. Core non-prefixed rules remain available. With a prefixed default language, valid legacy URLs resolve before one permanent redirect to the default prefix. With a hidden default prefix, old default-prefixed requests resolve before one permanent redirect to the equivalent root URL; non-default prefixes remain unchanged. WordPress administration, login, REST, sitemaps, robots, favicon, content, and include paths are not prefixed. Unknown routes remain 404 responses and are not redirected.
Posts, pages, and public CPT translations use the original translation-group source path. For example, a German page stored with slug ueber still uses /de/about/ when its source page uses about; translated post slugs are outside Phase 4. Term translations use their own WordPress term slug and translated hierarchy. A missing singular or term translation resolves to a 404 instead of silently serving another language.
A static front page and a posts page are changed only for the active request through WordPress's option_page_on_front and option_page_for_posts filters, preserving is_front_page(), is_home(), and the native template hierarchy without changing the saved settings. Both options return their stored value while WordPress writes them or resets a trashed post's front-page settings. Both keep one shared route: /de/blog/ resolves the translated blog page, so themes read its translated title and content while the archive queries posts in the requested language, and a translation's own slug redirects back to the shared route. An untranslated front page or posts page falls back to the source page's route rather than a 404, because the switcher sends every untranslated language to its root. CPT, author, date, taxonomy, search, feed, embed, and pagination rules retain their native WordPress shape behind the language prefix. Preview URLs retain preview query parameters and use the previewed post's assigned language.
The main frontend post query and block-driven post queries receive the indexed language assignment join. Default-language queries include older unassigned content; other languages require an explicit assignment. Result-set relationship caches are primed in bulk to avoid permalink N+1 queries.
Block queries are recognized in two ways. Every block in the Query Loop family — post template, pagination, total, and the no-results fallback — builds its query vars through WordPress's query_loop_block_query_vars filter, so a single marker keeps a paginated loop and its counters consistent. Blocks that instantiate WP_Query directly, such as Latest Posts, are constrained while they render; localepress_post_query_block_names registers additional block names. A Query Loop set to inherit the template query keeps using the already-constrained main query.
Stored identifiers
A theme, page builder, or widget stores the identifier of the thing an editor picked: a featured page, a category to list, posts to exclude. That identifier names one language's record, so read back on a translated page it points at the wrong language — and where the language constraint also applies, at nothing at all, because a post__in naming English posts returns an empty German loop.
QueryIdTranslationModule rewrites those identifiers to the language being viewed, so code that knows nothing about LocalePress produces the right language anyway. It covers p, page_id, attachment_id, post_parent, post__in, post__not_in, post_parent__in, post_parent__not_in, cat including its leading-minus exclusions, tag_id, the category__* and tag__* lists, tax_query clauses matching on term_id including nested ones, and the include and exclude arguments of a term query.
Only identifiers are translated. A slug or a name is left exactly as it was asked for, because the router already maps the routes a visitor can request and a query naming a slug is naming one specific record. Three rules decide every rewrite: the post type or taxonomy must be translatable, the object must have a language that is not already the right one, and a translation must exist. An untranslated identifier keeps its stored value, so a query returns what it returned before the module existed rather than nothing.
A query that names a language through localepress_lang is rewritten to that language instead of the request's — the same opt-in the language constraint honors. Previews are never rewritten, so an editor sees the post they opened. localepress_skip_id_translation opts one query out, localepress_translate_query_ids filters the decision, and administration, AJAX, cron, REST, and CLI requests are untouched.
A block query is constrained only when every post type it targets is translatable. Navigation menus, templates, template parts, any queries, and other non-public types are left untouched, because they carry no language assignment and an unconditional join would return nothing. Queries outside block rendering keep their original clauses, so widgets, related-post loops, and other secondary queries behave as before.
Rewrite rules flush softly only when the routing schema, enabled language slugs, or permalink structure changes. Toggling the default prefix changes generated URLs and redirects but does not flush because the rewrite rule set is unchanged. Activation and deactivation invalidate the stored signature. LocalePress does not register competing global routing filters while Polylang or WPML is active; localepress_enable_frontend_routing can override this decision for a controlled integration.
Site language
Translating post content is not enough for a page to read as one language. Theme and plugin strings, date and number formatting, text direction, and the document language WordPress prints all derive from get_locale(), so a request under a language prefix has to answer with that language's locale.
This is not a setting. Running WordPress in the language being viewed is what makes the rest of the engine coherent, and a site with it switched off reports one language in its markup while rendering another — so it is always on. A theme that genuinely depends on a single site locale can still opt out per request through localepress_should_switch_locale, which is the audience such an exception actually has.
LocaleModule filters locale. It registers while LocalePress boots on plugins_loaded, which is before WordPress loads its default text domain and well before themes and plugins load theirs, so every normal text domain resolves in the request language.
The switch is bound to an explicit language prefix in the request URL rather than to a general current-language lookup. That single rule gives most of the safety for free: administration, login, REST, cron, and CLI requests carry no prefix, so they keep the site locale without depending on a guard running at the right moment. is_admin(), wp_doing_cron(), WP_CLI, and REST_REQUEST are still checked as defense in depth, and the whole module stands down while another multilingual plugin owns routing.
The stored locale is validated against the same pattern the language validator enforces before it reaches WordPress, so a malformed registry value falls back to the site locale instead of reaching translation file paths. Resolution runs once per request and is cached, because get_locale() is called many times. A reentrancy guard answers any nested get_locale() — an extension filtering localepress_registered_languages or home_url, for example — with the unfiltered locale rather than recursing.
Two filters adjust the result: localepress_should_switch_locale allows or blocks the switch for one request, and localepress_request_locale replaces the resolved locale, with an empty string restoring the one WordPress determined.
This is what makes the document language attribute robust rather than incidental. SeoMetadata::filter_language_attributes() still rewrites lang and dir from the request language, but with the locale switched, WordPress's own language_attributes() and get_bloginfo( 'language' ) are already correct, so a theme that prints the language attribute itself stays correct too.
Turn the setting off only when a theme genuinely depends on a single site locale for its own strings.
Visitor language detection
One optional setting under LocalePress > Settings > URL decides which language an undecided visitor lands on. It is off by default because it changes what the site root returns.
| Setting | Default | Behavior |
|---|---|---|
| Send a first-time visitor to the language their browser asks for | Off | Negotiates Accept-Language and redirects the site root once. |
Detection runs on exactly one kind of request: a plain GET for the front page with no language prefix in the URL, outside admin, AJAX, cron, REST, CLI, robots, feed, embed, preview, and 404 contexts, with an empty $_POST. Every prefixed URL is therefore authoritative — a shared link, a bookmark, a search result, and a switcher click all keep the language they name, and no interior page is ever redirected by detection.
The preferred language is resolved in a fixed order:
- The cookie, when the visitor already browsed the site and the remembered language is still enabled.
- Nothing, when the request carries an internal referrer. Following a theme's home link is deliberate navigation, so it must not bounce the visitor out of the language they were reading.
- The
Accept-Languageheader, negotiated against enabled languages. - Otherwise LocalePress does not act, and normal routing sends the request to the default language.
BrowserLanguageDetector parses the header as RFC 7231 section 5.3.5 describes. Comma-separated ranges are read with their optional q weight, wildcards and q=0 ranges are discarded, malformed ranges are ignored, and at most 20 ranges are kept so a hostile header stays cheap. Ranges are compared in descending weight, and equal weights keep the order the browser sent.
Each range is tried against three progressively looser comparisons before the next range is considered, so a lower-weighted exact match never beats a higher-weighted approximate one: the full locale (bn-BD matches bn_BD), then the language code or URL slug, then the primary subtag (pt-PT matches a pt_BR language). Underscores, casing, and surrounding whitespace are normalized on both sides.
The redirect is a 302 carrying Vary: Accept-Language, and it preserves the request's query string. The cookie is written before the redirect, so the negotiation happens once per visitor rather than on every visit.
Caching. A full page cache that stores the site home page can serve one visitor's detected language to every later visitor. Exclude the home page from caching, make the cache vary on Accept-Language, or return false from localepress_should_detect_language while a cache is active. The settings screen shows this warning whenever detection is enabled.
The cookie is written only while browser detection is on, because it exists to answer detection without reading the header again; nothing reads it otherwise, so nothing is stored. It holds only a language slug, is readable by JavaScript so cache-aware front ends can act on it, and is sent with SameSite=Lax and the secure flag on HTTPS. localepress_language_cookie_lifetime changes its lifetime, and returning zero makes it a session cookie.
Browser translation
A browser's own translation prompt is browser UI, not page content. No site can open it, read the language chosen in it, or be notified that it ran; there is no such API. What the prompt reacts to is the document language LocalePress already writes, so <html lang="bn-BD"> is the only lever over whether a visitor is offered a machine translation at all. A page still declaring the site language while showing translated content is the usual reason the prompt behaves unexpectedly.
Multilingual navigation
LocalePress > Settings lists every native navigation menu and registered theme location. Assign each menu one language, then select a menu for each location/language pair. When a theme calls wp_nav_menu() with a configured theme_location, LocalePress supplies the current language's menu through the normal menu argument. Explicit menu arguments remain untouched by default, and an unconfigured language/location falls back to WordPress's normal location selection.
Before a menu walker renders, post and taxonomy items resolve through existing translation groups. Same-site custom URLs receive the current prefix, external URLs and fragment links remain unchanged, and the LocalePress language-switcher item remains available. An unavailable object translation and its descendants are hidden by default; localepress_menu_missing_translation_behavior can choose home, current, or preserve instead. Translated current-item and ancestor classes are repaired after link resolution.
Only classic/native nav_menu menus are assigned in this phase. LocalePress does not replace wp_navigation entities or alter Navigation block storage.
Block themes build their header menu from core/navigation, which styles only its own child blocks, so the standalone switcher block cannot sit inside one. localepress/navigation-language-switcher fills that gap: it declares core/navigation as its parent and renders each language through core/navigation-link, or through one core/navigation-submenu in dropdown mode. The switcher therefore inherits the menu's spacing, colors, typography, and responsive overlay instead of reimplementing them.
Flags need one extra step. WordPress escapes the label passed to a navigation link, so an image cannot travel through it as markup. The label is rendered as a plain token, which survives escaping unchanged, and the token is replaced with the flag and the escaped label once the surrounding link markup exists.
In the editor the block previews the real language labels laid out as menu items rather than server-rendering itself, because the navigation editor would nest the rendered list inside its own.
Gutenberg workflow
LocalePress copies post_content exactly as WordPress stores it. It never parses and re-serializes blocks, so block comments, JSON attributes, nested blocks, inner HTML, media IDs, and Query block markup are preserved without text translation. This applies to Heading, Paragraph, Image, Gallery, Buttons, Columns, Cover, List, Table, Query-related, and other core blocks. On the frontend, Query Loop and Latest Posts blocks list only the requested language; the routing section describes how those block queries are recognized and constrained.
A new translation always starts from its source: the title, content and excerpt, featured image, page template, taxonomies, and public custom fields are copied for the editor to translate in place. The copy and synchronization section describes exactly what is carried over.
Synced p
This README is longer than the copy stored here. Read the rest on GitHub →