Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
License: Massachusetts Institute of Technology (MIT) license
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -278,6 +278,12 @@ 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.
* 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
Additional User Roles and Disable Menu in Toolbar.
Expand Down
2 changes: 1 addition & 1 deletion src/Admin/Settings/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
10 changes: 8 additions & 2 deletions src/Admin/Settings/OptionsParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions src/Compatibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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;
Expand Down
228 changes: 204 additions & 24 deletions src/Helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
*
Expand All @@ -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;

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;

if ( $current_language && isset( $language_domains[ $current_language ] ) ) {
return (string) apply_filters( 'plausible_analytics_current_language_domain_key', $current_language );
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
*
Expand All @@ -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 );
}
Expand Down Expand Up @@ -367,24 +411,40 @@ 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" );
}

/**
* @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 );
}
Expand All @@ -393,19 +453,139 @@ public static function get_language_domains() {
}

/**
* Get the name of the active multilang plugin.
* 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_multilang_plugin_name() {
$name = '';
public static function get_current_language_domain_url() {
if ( ! static::is_language_per_domain_mode() ) {
return '';
}

if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {
$name = 'WPML';
$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).
*
* @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() {
$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 );
}

Expand Down
Loading
Loading