is_single || $wp_the_query->is_page || $wp_the_query->is_posts_page ) {
// Store and reset the queried_object and queried_object_id
// Otherwise, redirect_canonical() will redirect to home_url( '/' ) for show_on_front = page sites where home_url() is not all lowercase.
// Repro:
// 1. Set home_url = https://ExamPle.com/
// 2. Set show_on_front = page
// 3. Set page_on_front = something
// 4. Visit https://example.com/ !
$queried_object = $wp_the_query->queried_object ?? null;
$queried_object_id = $wp_the_query->queried_object_id ?? null;
try {
$post_obj = $wp_the_query->get_queried_object();
$post = $post_obj instanceof WP_Post ? $post_obj->ID : '0';
} finally {
$wp_the_query->queried_object = $queried_object;
$wp_the_query->queried_object_id = $queried_object_id;
}
} else {
$post = '0';
$is_not_post = true;
}
$view_data = compact( 'v', 'blog', 'post', 'tz', 'srv' );
// Batcache removes some of the UTM params from $_GET, we need to extract them from uri directly instead.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're sanitizing individual params in the loop.
$url_query = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_QUERY );
parse_str( (string) $url_query, $url_params );
foreach ( self::TRACKED_UTM_PARAMETERS as $utm_parameter ) {
if ( isset( $url_params[ $utm_parameter ] ) && is_scalar( $url_params[ $utm_parameter ] ) ) {
$view_data[ $utm_parameter ] = substr( sanitize_textarea_field( wp_unslash( $url_params[ $utm_parameter ] ) ), 0, 255 );
}
}
if ( $is_not_post ) {
if ( $wp_the_query->is_home() ) {
$view_data['arch_home'] = '1';
} elseif ( $wp_the_query->is_search() ) {
$search_term = $wp_the_query->query['s'] ?? $wp_the_query->query_vars['s'] ?? '';
$view_data['arch_search'] = sanitize_text_field( $search_term );
$view_data['arch_filters'] = sanitize_text_field( self::build_search_filters( $wp_the_query ) );
$view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
} elseif ( $wp_the_query->is_archive() ) {
if ( $wp_the_query->is_date ) {
$query = $wp_the_query->query;
$date_parts = array_filter( array( $query['year'] ?? null, $query['monthnum'] ?? null, $query['day'] ?? null ) );
$date = implode( '/', $date_parts );
$view_data['arch_date'] = $date;
}
if ( $wp_the_query->is_category ) {
$view_data['arch_cat'] = $wp_the_query->query['category_name'] ?? $wp_the_query->query_vars['category_name'] ?? '';
}
if ( $wp_the_query->is_tag ) {
$view_data['arch_tag'] = $wp_the_query->query['tag'] ?? $wp_the_query->query_vars['tag'] ?? '';
}
if ( $wp_the_query->is_author ) {
$view_data['arch_author'] = $wp_the_query->query['author_name'] ?? '';
}
if ( $wp_the_query->is_tax ) {
$query = $wp_the_query->query;
if ( is_array( $query ) && count( $query ) === 1 ) {
$view_data[ 'arch_tax_' . array_keys( $query )[0] ] = array_values( $query )[0];
}
}
$view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
} elseif ( $wp_the_query->is_404() ) {
$view_data['arch_err'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
} else {
$view_data['arch_other'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
}
}
return $view_data;
}
/**
* Collect the tracking data for a search page.
*
* @access private
* @param \WP_Query $query The WP_Query object to parse all the filters from.
* @return string The search filters in a URL query string format.
*/
private static function build_search_filters( $query ) {
$data = array(
'posts_per_page' => $query->get( 'posts_per_page' ),
'paged' => ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1,
'orderby' => $query->get( 'orderby' ),
'order' => $query->get( 'order' ),
);
if ( $query->get( 'author_name' ) ) {
$data['author_name'] = $query->get( 'author_name' );
}
$filters = http_build_query( $data );
$the_tax_query = $query->tax_query;
$terms = array();
if ( ! empty( $the_tax_query->queried_terms ) && is_array( $the_tax_query->queried_terms ) ) {
foreach ( $the_tax_query->queries as $tax_query ) {
if ( ! is_array( $tax_query ) || ! isset( $tax_query['taxonomy'] ) ) {
continue;
}
$taxonomy = $tax_query['taxonomy'];
if ( ! isset( $terms[ $taxonomy ] ) || ! is_array( $terms[ $taxonomy ] ) ) {
$terms[ $taxonomy ] = array();
}
$terms[ $taxonomy ] = array_merge( $terms[ $taxonomy ], $tax_query['terms'] );
}
}
if ( ! empty( $terms ) ) {
$filters .= '&terms=' . wp_json_encode( $terms, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
}
return $filters;
}
/**
* Build the Stats tracking details.
*
* @since 0.6.0
*
* @access private
* @param array $data Array of options about the site and page for the inline (non-AMP) tracker.
* @return string
*/
private static function build_stats_details( $data ) {
$data_stats_array = self::stats_array_to_string( $data );
$pushes = sprintf(
'_stq.push([ "view", %1$s ]);
_stq.push([ "clickTrackerInit", "%2$s", "%3$s" ]);',
$data_stats_array,
$data['blog'],
$data['post']
);
// OFF (default): byte-for-byte identical to the historical output.
if ( ! Options::get_option( 'honor_cookie_consent' ) ) {
return "_stq = window._stq || [];\n" . $pushes;
}
// Fail closed when the WP Consent API plugin is active (an unavailable client-side API
// means "wait", not "fire"); fail open otherwise to preserve historical tracking.
return self::build_consent_gate( $pushes, ! function_exists( 'wp_has_consent' ) );
}
/**
* Wrap the tracking pushes in a WP Consent API gate.
*
* The check runs in the browser because cached HTML is shared across visitors, deferred to
* DOMContentLoaded (and re-run on the `wp_consent_type_defined` readiness event) so a
* late-loading consent plugin is still honored. The check is idempotent.
*
* `_jpStatsFire.done` is set before the pushes, not after, so the gate is at-most-once even
* if a push throws. Retrying can't recover: the stats sender assigns the beacon `src` before
* any of its fallible DOM work, so a later exception means the view was already counted and
* a replay would double-count it.
*
* @access private
* @param string $pushes The `_stq.push(...)` statements to gate.
* @param bool $fail_open Whether to fire when the client-side WP Consent API is unavailable.
* @return string
*/
private static function build_consent_gate( $pushes, $fail_open ) {
$fail_open_literal = $fail_open ? 'true' : 'false';
return sprintf(
'_stq = window._stq || [];
function _jpStatsFire() {
if ( _jpStatsFire.done ) { return; }
_jpStatsFire.done = true;
%1$s
}
function _jpStatsCheck() {
if ( typeof window.wp_has_consent === "function" ) {
var consented;
try {
consented = window.wp_has_consent( "statistics" );
} catch ( e ) {
consented = %2$s;
}
if ( consented ) { _jpStatsFire(); }
return;
}
if ( %2$s ) { _jpStatsFire(); }
}
document.addEventListener( "wp_listen_for_consent_change", function ( event ) {
if ( event && event.detail && event.detail.statistics === "allow" ) { _jpStatsFire(); }
} );
document.addEventListener( "wp_consent_type_defined", _jpStatsCheck );
window.addEventListener( "wp_consent_type_defined", _jpStatsCheck );
if ( document.readyState === "loading" ) {
document.addEventListener( "DOMContentLoaded", _jpStatsCheck, { once: true } );
} else {
_jpStatsCheck();
}',
$pushes,
$fail_open_literal
);
}
/**
* Add fetchpriority="low" to the Stats script attributes.
*
* Reduces network contention with resources in the critical rendering path (e.g., the LCP
* element image). This benefits Safari and Firefox, which don't automatically assign low
* priority to async/defer scripts (unlike Chrome).
*
* @since 0.19.5
*
* @param array $attributes Script tag attributes.
* @return array Modified attributes.
*/
public static function add_low_fetchpriority( $attributes ) {
// WordPress derives the tag id from the enqueue handle as "{handle}-js", so the
// 'jetpack-stats' script (registered in enqueue_stats_script()) prints as
// 'jetpack-stats-js'. Keep this in sync if the handle is ever renamed.
if ( isset( $attributes['id'] ) && 'jetpack-stats-js' === $attributes['id'] ) {
$attributes['fetchpriority'] = 'low';
}
return $attributes;
}
/**
* Remove the dns-prefetch resource hint for stats.wp.com.
*
* WordPress automatically adds dns-prefetch hints for enqueued script hosts via
* wp_dependencies_unique_hosts(). Since we're deprioritizing the stats script,
* the dns-prefetch is counterproductive — it front-loads DNS resolution for a
* resource we're intentionally delaying.
*
* @since 0.19.5
*
* @param array $urls Array of resource hint URLs.
* @param string $relation_type The relation type (dns-prefetch, preconnect, etc.).
* @return array Filtered URLs.
*/
public static function remove_stats_dns_prefetch( $urls, $relation_type ) {
if ( 'dns-prefetch' !== $relation_type ) {
return $urls;
}
return array_filter(
$urls,
static function ( $url ) {
// Resource hints can be arrays that carry the URL under an 'href' key.
if ( is_array( $url ) ) {
$candidate = ( isset( $url['href'] ) && is_string( $url['href'] ) ) ? $url['href'] : '';
} elseif ( is_string( $url ) ) {
$candidate = $url;
} else {
return true; // Unknown entry shape; leave it untouched.
}
// dns-prefetch entries arrive in several shapes: WordPress core emits bare
// hosts ('stats.wp.com') via wp_dependencies_unique_hosts(), while other
// filters may add scheme-relative ('//stats.wp.com') or full URLs. Normalize
// each to a host so we drop stats.wp.com exactly without removing look-alike
// hosts such as 'mystats.wp.com' or 'stats.wp.com.evil.tld'.
if ( str_starts_with( $candidate, '//' ) ) {
$host = wp_parse_url( 'https:' . $candidate, PHP_URL_HOST );
} elseif ( str_contains( $candidate, '://' ) ) {
$host = wp_parse_url( $candidate, PHP_URL_HOST );
} else {
$host = $candidate; // Bare host form, e.g. 'stats.wp.com'.
}
return ! is_string( $host ) || 'stats.wp.com' !== strtolower( $host );
}
);
}
/**
* Enqueue the Stats pixel.
* Do not use this function directly, it is hooked into `wp_enqueue_scripts`.
*
* @access public
* @return void
*/
public static function enqueue_stats_script() {
if ( self::is_amp_request() ) {
return;
}
wp_enqueue_script(
'jetpack-stats',
'https://stats.wp.com/e-' . gmdate( 'YW' ) . '.js',
array(),
null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
array(
'in_footer' => true,
'strategy' => 'defer',
)
);
add_filter( 'wp_script_attributes', array( static::class, 'add_low_fetchpriority' ) );
add_filter( 'wp_resource_hints', array( static::class, 'remove_stats_dns_prefetch' ), 100, 2 );
$data = self::build_view_data();
/**
* Filter the parameters added to the JavaScript stats tracking code.
*
* @module stats
*
* @since-jetpack 10.9
*
* @param array $data Array of options about the site and page you're on.
*/
$data = (array) apply_filters( 'jetpack_stats_footer_js_data', $data );
$triggers = self::build_stats_details( $data );
wp_add_inline_script(
'jetpack-stats',
$triggers,
'before'
);
}
/**
* Gets the tracking pixel URL for AMP output.
*
* @access private
* @param array $data Array of data for the AMP pixel tracker.
* @return string Returns the URL for the Stats tracker in an AMP scenario.
*/
private static function get_amp_pixel_url( $data ) {
/**
* Filter the parameters added to the AMP pixel tracking code.
*
* @module stats
*
* @since-jetpack 10.9
*
* @param array $data Array of options about the site and page you're on.
*/
$data = (array) apply_filters( 'jetpack_stats_footer_amp_data', $data );
$data['host'] = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // input var ok.
$data['rand'] = 'RANDOM'; // AMP placeholder.
$data['ref'] = 'DOCUMENT_REFERRER'; // AMP placeholder.
$data = array_map( 'rawurlencode', $data );
return add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
}
/**
* Build an AMP pixel.
* Do not use this function directly, it is hooked into `wp_footer`.
*
* @access public
* @return void
*/
public static function add_amp_pixel() {
$data = self::build_view_data();
if ( ! self::is_amp_request() ) {
return;
}
printf( '', esc_url( self::get_amp_pixel_url( $data ) ) );
}
/**
* Stats Footer.
*
* @deprecated 0.6.0
*
* @access public
* @return void
*/
public static function add_to_footer() {
_deprecated_function( __METHOD__, '0.6.0' );
}
/**
* Gets the footer to add for the Stats tracker.
*
* @deprecated 0.6.0
*
* @access public
* @param array $data Array of data for the JS stats tracker.
* @return void
*/
public static function get_footer_to_add( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_deprecated_function( __METHOD__, '0.6.0' );
}
/**
* Render the stats footer. Kept for backward compatibility on legacy AMF views.
*
* @deprecated 0.6.0
*
* @access public
* @param array $data Array of data for the JS stats tracker.
*/
public static function render_footer( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_deprecated_function( __METHOD__, '0.6.0' );
}
/**
* Render the stats footer for AMP output. Kept for backward compatibility.
*
* @access public
* @param array $data Array of data for the AMP pixel tracker.
*/
public static function render_amp_footer( $data ) {
printf( '', esc_url( self::get_amp_pixel_url( $data ) ) );
}
/**
* Creates the "array" string used as part of the JS tracker.
*
* @access private
* @param array $kvs Array of options about the site and page you're on.
* @return string
*/
private static function stats_array_to_string( $kvs ) {
/**
* Filters the options added to the JavaScript Stats tracking code.
*
* @since-jetpack 1.1.0
*
* @param array $kvs Array of options about the site and page you're on.
*/
$kvs = (array) apply_filters( self::STATS_ARRAY_TO_STRING_FILTER, $kvs );
$kvs = array_map( 'strval', $kvs );
// Encode into JSON object for direct use in JS.
return wp_json_encode( $kvs, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
}
/**
* Does the page return AMP content.
*
* @return bool $is_amp_request Are we on AMP view.
*/
private static function is_amp_request() {
$is_amp_request = ( function_exists( 'amp_is_request' ) && amp_is_request() );
$is_amp_request = $is_amp_request || ( function_exists( 'ampforwp_is_amp_endpoint' ) && ampforwp_is_amp_endpoint() );
/**
* Returns true if the current request should return valid AMP content.
*
* @since 6.2.0
*
* @param boolean $is_amp_request Is this request supposed to return valid AMP content?
*/
return apply_filters( 'jetpack_is_amp_request', $is_amp_request );
}
}