From 2e2fb23547ba6d7e0f53959d92f33a9a1df2b1fa Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:53:17 +0200 Subject: [PATCH 1/3] Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. --- readme.txt | 5 +- src/Admin/Settings/API.php | 2 +- src/Helpers.php | 148 +++++++++++++++++++++++++----- tests/integration/HelpersTest.php | 56 +++++++++++ 4 files changed, 186 insertions(+), 25 deletions(-) diff --git a/readme.txt b/readme.txt index 26cbe836..79d21940 100644 --- a/readme.txt +++ b/readme.txt @@ -126,7 +126,7 @@ Plausible fits naturally into how WordPress sites are built and managed: - Works with any WordPress theme - Compatible with caching and performance plugins -- Works with multilingual sites built with WPML, with each language domain on its own dashboard +- Works with multilingual sites built with WPML or TranslatePress, with each language domain on its own dashboard - No need to edit theme files or use tag managers - Simple setup with no coding required - View your stats directly in your WordPress dashboard @@ -278,6 +278,9 @@ Please make sure you make a backup of your database before updating any version == Changelog == += 2.6.2 = +* Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. + = 2.6.1 = * Added: info icons linking to the documentation for Cloaked Affiliate Links, Query Parameters, View Your Stats in Your WordPress Dashboard, Track Analytics for User Roles, Show Stats Dashboard to Additional User Roles and Disable Menu in Toolbar. diff --git a/src/Admin/Settings/API.php b/src/Admin/Settings/API.php index a719fcf0..a5f7b5d9 100644 --- a/src/Admin/Settings/API.php +++ b/src/Admin/Settings/API.php @@ -280,7 +280,7 @@ class="plausible-analytics-button mt-4 border-0 hover:cursor-pointer inline-flex } /** - * Render Domain Map field for WPML multilang domain mode. + * Render Domain Map field for multilang (WPML/TranslatePress) domain-per-language mode. * * @param array $field * diff --git a/src/Helpers.php b/src/Helpers.php index 5c2a6f8a..83b158da 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -15,6 +15,13 @@ * We use 'static' (late static binding) instead of 'self' (early binding), so we can mock (where needed) in our tests. */ class Helpers { + /** + * Supported multilingual plugins, as returned by @see Helpers::get_multilang_plugin(). + */ + const MULTILANG_PLUGIN_WPML = 'wpml'; + + const MULTILANG_PLUGIN_TRANSLATEPRESS = 'translatepress'; + /** * Returns the API token. * @@ -79,10 +86,32 @@ public static function get_settings() { return apply_filters( 'plausible_analytics_settings', $settings ); } + /** + * Returns the active multilingual plugin, so we know which of its APIs to talk to. + * + * @since v2.6.2 + * + * @return string One of the MULTILANG_PLUGIN_* constants, or an empty string when none is active. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_multilang_plugin() { + $plugin = ''; + + if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { + $plugin = static::MULTILANG_PLUGIN_WPML; + } elseif ( defined( 'TRP_PLUGIN_VERSION' ) ) { + $plugin = static::MULTILANG_PLUGIN_TRANSLATEPRESS; + } + + return (string) apply_filters( 'plausible_analytics_multilang_plugin', $plugin ); + } + /** * Returns the key of the currently used Language Domain. * * @since v2.6.0 + * @since v2.6.2 Added TranslatePress (Multiple Domains) support. * * @return string * @@ -93,20 +122,32 @@ public static function get_current_language_domain_key() { return 'default'; } - $language_domains = apply_filters( 'wpml_setting', [], 'language_domains' ); - $current_language = apply_filters( 'wpml_current_language', null ); + $language_domains = []; + $current_language = null; - if ( $current_language && isset( $language_domains[ $current_language ] ) ) { - return (string) apply_filters( 'plausible_analytics_current_language_domain_key', $current_language ); + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $language_domains = apply_filters( 'wpml_setting', [], 'language_domains' ); + $current_language = apply_filters( 'wpml_current_language', null ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $language_domains = static::get_translatepress_language_domains(); + $current_language = static::get_translatepress_current_language(); + break; } - return (string) apply_filters( 'plausible_analytics_current_language_domain_key', 'default' ); + $key = $current_language && isset( $language_domains[ $current_language ] ) ? $current_language : 'default'; + + return (string) apply_filters( 'plausible_analytics_current_language_domain_key', $key ); } /** - * Returns true only when WPML is active, its negotiation type is "different domain per language", AND at least one domain is configured. + * Returns true only when a supported multilingual plugin (WPML or TranslatePress) is active, it's configured to serve + * a "different domain per language", AND at least one domain is configured. * * @since v2.6.0 + * @since v2.6.2 Added TranslatePress (Multiple Domains) support. * * @return bool * @@ -123,16 +164,19 @@ public static function is_language_per_domain_mode() { return $is_language_per_domain; // @codeCoverageIgnore } - /** - * If WPML is not active, we can assume we're not in language per domain mode. - */ - if ( ! defined( 'ICL_SITEPRESS_VERSION' ) ) { - return $is_language_per_domain = (bool) apply_filters( 'plausible_analytics_language_per_domain_mode', false ); - } + $value = false; - $negotiation_type = (int) apply_filters( 'wpml_setting', 0, 'language_negotiation_type' ); - $domains = apply_filters( 'wpml_setting', [], 'language_domains' ); - $value = $negotiation_type === 2 && ! empty( $domains ); + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $negotiation_type = (int) apply_filters( 'wpml_setting', 0, 'language_negotiation_type' ); + $domains = apply_filters( 'wpml_setting', [], 'language_domains' ); + $value = $negotiation_type === 2 && ! empty( $domains ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $value = ! empty( static::get_translatepress_language_domains() ); + break; + } return $is_language_per_domain = (bool) apply_filters( 'plausible_analytics_language_per_domain_mode', $value ); } @@ -375,16 +419,28 @@ public static function get_js_url( $local = false ) { /** * @since v2.6.0 Provide compatibility with multilang plugins, like WPML. + * @since v2.6.2 Added TranslatePress (Multiple Domains) support. * * @return array * * @codeCoverageIgnore Because it depends on 3rd party plugins. */ public static function get_language_domains() { - $domains = apply_filters( 'wpml_setting', [], 'language_domains' ); - $main = wp_parse_url( home_url(), PHP_URL_HOST ) ?: home_url(); + $domains = []; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $domains = apply_filters( 'wpml_setting', [], 'language_domains' ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $domains = static::get_translatepress_language_domains(); + break; + } + + $main = wp_parse_url( home_url(), PHP_URL_HOST ) ?: home_url(); - // WPML's language_domains omits the default language; prepend the main WP domain. + // WPML/TranslatePress omit the default language; prepend the main WP domain. if ( ! in_array( $main, $domains, true ) ) { $domains = array_merge( [ 'default' => $main ], $domains ); } @@ -392,19 +448,65 @@ public static function get_language_domains() { return apply_filters( 'plausible_analytics_language_domains', $domains ); } + /** + * Returns TranslatePress' "Multiple Domains" mappings, keyed by language code (excluding the default language, to + * mirror WPML's language_domains behavior). + * + * @since v2.6.2 + * + * @return array + */ + protected static function get_translatepress_language_domains() { + $settings = function_exists( 'get_option' ) ? get_option( 'trp_settings', [] ) : []; + $mappings = $settings['trp-multiple-domains'] ?? []; + $default = $settings['default-language'] ?? ''; + $domains = []; + + if ( ! is_array( $mappings ) ) { + return $domains; + } + + foreach ( $mappings as $language_code => $mapping ) { + if ( $language_code === $default || empty( $mapping['enabled'] ) || empty( $mapping['domain'] ) ) { + continue; + } + + $domains[ $language_code ] = $mapping['domain']; + } + + return $domains; + } + + /** + * Returns the language code TranslatePress is currently serving. + * + * @since v2.6.2 + * + * @return string|null + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + protected static function get_translatepress_current_language() { + global $TRP_LANGUAGE; + + return ! empty( $TRP_LANGUAGE ) ? $TRP_LANGUAGE : null; + } + /** * Get the name of the active multilang plugin. * + * @since v2.6.2 Added TranslatePress support. + * * @return string * * @codeCoverageIgnore Because it depends on 3rd party plugins. */ public static function get_multilang_plugin_name() { - $name = ''; - - if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { - $name = 'WPML'; - } + $names = [ + static::MULTILANG_PLUGIN_WPML => 'WPML', + static::MULTILANG_PLUGIN_TRANSLATEPRESS => 'TranslatePress', + ]; + $name = $names[ static::get_multilang_plugin() ] ?? ''; return apply_filters( 'plausible_analytics_multilang_plugin_name', $name ); } diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 39ebe982..62550e27 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -344,4 +344,60 @@ public function testUpdateSetting() { $this->assertTrue( Helpers::get_settings()['test'] ); } + + /** + * TranslatePress' "Multiple Domains" mappings should be reduced to [ language_code => domain ], excluding the + * default language and any language that isn't enabled or lacks a domain. + * + * @see Helpers::get_translatepress_language_domains() + * @return void + * @throws \ReflectionException + */ + public function testGetTranslatePressLanguageDomains() { + update_option( + 'trp_settings', + [ + 'default-language' => 'en_US', + 'trp-multiple-domains' => [ + // Default language: always excluded, it maps to the main WP domain. + 'en_US' => [ 'enabled' => '1', 'domain' => 'https://example.com' ], + 'fr_FR' => [ 'enabled' => '1', 'domain' => 'https://example.fr' ], + 'de_DE' => [ 'enabled' => '1', 'domain' => 'https://example.de' ], + // Not enabled: excluded. + 'es_ES' => [ 'enabled' => '', 'domain' => 'https://example.es' ], + // No domain: excluded. + 'nl_NL' => [ 'enabled' => '1', 'domain' => '' ], + ], + ] + ); + + $method = new \ReflectionMethod( Helpers::class, 'get_translatepress_language_domains' ); + $method->setAccessible( true ); + + $domains = $method->invoke( null ); + + $this->assertEquals( + [ + 'fr_FR' => 'https://example.fr', + 'de_DE' => 'https://example.de', + ], + $domains + ); + } + + /** + * When no "Multiple Domains" mappings are configured, no language domains should be returned. + * + * @see Helpers::get_translatepress_language_domains() + * @return void + * @throws \ReflectionException + */ + public function testGetTranslatePressLanguageDomainsEmpty() { + update_option( 'trp_settings', [ 'default-language' => 'en_US', 'trp-multiple-domains' => [] ] ); + + $method = new \ReflectionMethod( Helpers::class, 'get_translatepress_language_domains' ); + $method->setAccessible( true ); + + $this->assertEquals( [], $method->invoke( null ) ); + } } From 3944b847a640146b9577dbd3bb6ff7e11e75c249 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:06:24 +0200 Subject: [PATCH 2/3] * Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. --- src/Compatibility.php | 7 ++- src/Helpers.php | 80 +++++++++++++++++++++++++++++++++- tests/integration/AjaxTest.php | 31 +++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/Compatibility.php b/src/Compatibility.php index 2163ba8f..5fa66217 100644 --- a/src/Compatibility.php +++ b/src/Compatibility.php @@ -256,9 +256,12 @@ public function exclude_js_by_handle( $excluded_handles ) { * Multilingual plugins, e.g., TranslatePress and WPML, override the REST API URL to include * the language 'subdirectory', which leads to 404 errors. * - * This filter forces it back to default behavior. + * This filter strips that subdirectory. In "domain per language" mode it then moves the endpoint to the + * language domain we're currently on, because pointing it at the default domain would make the tracker fire + * cross-origin (CORS) requests. * * @filter rest_url + * @since 2.6.2 Keep the endpoint on the current language domain. * * @param mixed $url * @@ -269,7 +272,7 @@ public function multilingual_compatibility( $url ) { $rest_endpoint = Helpers::get_rest_endpoint( false ); if ( str_contains( $url, $rest_endpoint ) ) { - return get_option( 'home' ) . $rest_endpoint; + return Helpers::maybe_use_current_language_domain( get_option( 'home' ) . $rest_endpoint ); } return $url; diff --git a/src/Helpers.php b/src/Helpers.php index 83b158da..3728a01a 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -411,7 +411,11 @@ public static function get_js_url( $local = false ) { * If the Avoid Ad Blockers option is enabled, return URL pointing to the local file. */ if ( $local && static::proxy_enabled() ) { - return esc_url( static::get_proxy_resource( 'cache_url' ) . $file_name . '.js' ); + /** + * The cache URL is stored as an absolute URL on the main domain, so it needs to be moved to the + * language domain we're currently on. + */ + return esc_url( static::maybe_use_current_language_domain( static::get_proxy_resource( 'cache_url' ) . $file_name . '.js' ) ); } return esc_url( static::get_hosted_domain_url() . "/js/$file_name.js" ); @@ -448,6 +452,80 @@ public static function get_language_domains() { return apply_filters( 'plausible_analytics_language_domains', $domains ); } + /** + * Returns the origin (scheme + host + port) of the language domain that's currently being served, or an empty + * string when we're not running in "domain per language" mode. + * + * @since v2.6.2 + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_current_language_domain_url() { + if ( ! static::is_language_per_domain_mode() ) { + return ''; + } + + $domains = static::get_language_domains(); + $domain = $domains[ static::get_current_language_domain_key() ] ?? ''; + + if ( empty( $domain ) ) { + return ''; + } + + // Language domains are stored as a bare host (WPML) or as a full URL (TranslatePress). + if ( strpos( $domain, '//' ) === false ) { + $domain = ( is_ssl() ? 'https://' : 'http://' ) . $domain; + } + + $parts = wp_parse_url( $domain ); + + if ( empty( $parts['host'] ) ) { + return ''; + } + + $scheme = $parts['scheme'] ?? ( is_ssl() ? 'https' : 'http' ); + $port = ! empty( $parts['port'] ) ? ':' . $parts['port'] : ''; + + return $scheme . '://' . $parts['host'] . $port; + } + + /** + * Moves $url to the language domain that's currently being served, keeping its path and query intact. + * + * Both the proxy endpoint and the locally cached JS file are built from the main WP domain. In "domain per + * language" mode that means the tracker would fire cross-origin requests (CORS) at the default domain, so we + * point them at the domain the visitor is actually on. + * + * @since v2.6.2 + * + * @param string $url + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function maybe_use_current_language_domain( $url ) { + $origin = static::get_current_language_domain_url(); + + if ( empty( $url ) || empty( $origin ) ) { + return $url; + } + + $parts = wp_parse_url( $url ); + + // Already on the right domain, or nothing we can rewrite. + if ( empty( $parts['host'] ) || strcasecmp( $parts['host'], (string) wp_parse_url( $origin, PHP_URL_HOST ) ) === 0 ) { + return $url; + } + + $path = $parts['path'] ?? ''; + $query = ! empty( $parts['query'] ) ? '?' . $parts['query'] : ''; + + return $origin . $path . $query; + } + /** * Returns TranslatePress' "Multiple Domains" mappings, keyed by language code (excluding the default language, to * mirror WPML's language_domains behavior). diff --git a/tests/integration/AjaxTest.php b/tests/integration/AjaxTest.php index e249962c..fcc5d089 100644 --- a/tests/integration/AjaxTest.php +++ b/tests/integration/AjaxTest.php @@ -71,6 +71,37 @@ public function testSaveKeyedOptionsSuccess() { $this->assertEquals( 'plausible-plugin-german-token', $settings['api_token'][ $language_domain ] ); } + /** + * TranslatePress keys its language domains by locale (e.g. nl_NL). The underscore must survive key + * sanitization, otherwise the setting is stored under a key that's never read back. + * + * @see OptionsParser::parse_keyed_options() + */ + public function testSaveKeyedOptionsKeepsUnderscoreInKey() { + $language_domain = 'nl_NL'; + $options = [ + [ 'name' => "domain_name[$language_domain]", 'value' => 'tp-dutch.example.com' ], + [ 'name' => "api_token[$language_domain]", 'value' => 'plausible-plugin-dutch-token' ], + ]; + + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = wp_json_encode( $options ); + + set_transient( 'plausible_analytics_valid_token', [ 'plausible-plugin-dutch-token' => true ] ); + + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + } + + $settings = Helpers::get_settings(); + + $this->assertArrayHasKey( $language_domain, $settings['domain_name'] ); + $this->assertEquals( 'tp-dutch.example.com', $settings['domain_name'][ $language_domain ] ); + $this->assertArrayHasKey( $language_domain, $settings['api_token'] ); + $this->assertEquals( 'plausible-plugin-dutch-token', $settings['api_token'][ $language_domain ] ); + } + /** * Test save_options with invalid JSON. */ From 6e18a0fdd0f3a0638ff3cf0ca65478cc485ee04d Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:06:38 +0200 Subject: [PATCH 3/3] Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key --- readme.txt | 5 ++++- src/Admin/Settings/OptionsParser.php | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/readme.txt b/readme.txt index 79d21940..435515cd 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: plausible, DaanvandenBergh Donate link: https://plausible.io/ Tags: analytics, privacy, google analytics alternative, woocommerce analytics, stats Requires at least: 5.9 -Tested up to: 7.0 +Tested up to: 7.1 Requires PHP: 7.2 Stable tag: 2.6.1 License: Massachusetts Institute of Technology (MIT) license @@ -280,6 +280,9 @@ Please make sure you make a backup of your database before updating any version = 2.6.2 = * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. +* Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. +* Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key, which meant they were never read back. +* Tested with WP 7.1. = 2.6.1 = * Added: info icons linking to the documentation for Cloaked Affiliate Links, Query Parameters, View Your Stats in Your WordPress Dashboard, Track Analytics for User Roles, Show Stats Dashboard to diff --git a/src/Admin/Settings/OptionsParser.php b/src/Admin/Settings/OptionsParser.php index e2349373..2fb421d2 100644 --- a/src/Admin/Settings/OptionsParser.php +++ b/src/Admin/Settings/OptionsParser.php @@ -28,8 +28,14 @@ public static function parse_keyed_options( array $options, array $settings ) { // Allow all keys, but distinguish them. $has_keyed = true; - // Sanitize key: allow only [A-Za-z0-9.-] - $key = preg_replace( '/[^A-Za-z0-9.\-]/', '', $key ); + /** + * Sanitize key: allow only [A-Za-z0-9._-] + * + * @since v2.6.2 Underscores are allowed, because TranslatePress keys its language domains by + * locale, e.g. nl_NL. Stripping the underscore would store the setting under a key + * that's never read back. + */ + $key = preg_replace( '/[^A-Za-z0-9._\-]/', '', $key ); if ( ! isset( $posted_values[ $array_name ] ) ) { $posted_values[ $array_name ] = $value;